文章目录
- 一、Clojure的持久化数据结构
- 二、向量(Vector)/Map的底层结构
- 1. HAMT 哈希数组映射字典树
- (1)简介
- (2)HAMT 的核心思想
- (3)HAMT 的结构
- a. 基本组成
- b. 树的分支因子
- (4)HAMT 的操作
- a. 查询(Get)
- b. 插入/更新(Assoc)
- c. 删除(Dissoc)
- (5)HAMT 的优化技术
- a.位图(Bitmap)优化
- b. 路径压缩
- c.惰性哈希计算
- (6)HAMT 在 Clojure 中的应用
- a.不可变哈希表(PersistentHashMap)
- b.不可变向量(PersistentVector)
- (7)总结
- 2. HAMT 的 32 路分支
- (1)HAMT 的 32 路分支(32-way branching)简介
- (2)示例 [1 2 3 4 5] 的树形结构
- a. 可能的实际存储形式
- b. 插入更多元素后的变化
- c. 为什么示例中节点元素未达到阈值?
- (3)动态调整规则
- (4)为什么选择 32?
- (5)32路分支总结
- 2. 修改向量
- 3. assoc 函数详解
- (1)基本语法
- (2)对不同数据结构的操作
- a. 操作 Map(哈希表)
- b. 操作 Vector(向量)
- c. 操作 Record
- (3)不可变性保证
- (4)底层实现原理
- a. 对 Map 的实现
- b. 对 Vector 的实现
- (5) 总结
- 4. (assoc v1 2 99) 的具体过程
- (1)定位修改路径
- (2)路径复制
- (3)结构共享
一、Clojure的持久化数据结构
Clojure 的持久化数据结构(Persistent Data Structures)在"修改"时会保留原版本的完整性,同时高效生成新版本。其核心是通过结构共享(Structural Sharing)和路径复制(Path Copying)实现,而非完整拷贝数据。
二、向量(Vector)/Map的底层结构
1. HAMT 哈希数组映射字典树
(1)简介
HAMT(Hash Array Mapped Trie)是一种高效的持久化数据结构,广泛应用于函数式编程语言(如 Clojure、Scala)中,用于实现高性能的不可变哈希表(Hash Map)和向量(Vector)。它通过哈希值的位分割和路径压缩技术,在保证不可变性的同时,实现了接近 O(1) 时间复杂度的查询、插入和删除操作。
(2)HAMT 的核心思想
HAMT 的核心是通过 哈希值的位分段 和 树形结构 来组织数据,结合 位图(Bitmap) 和 路径压缩 优化存储效率。其核心特性包括:
- 不可变性:所有操作生成新版本,原数据不变。
- 高效查询:平均时间复杂度为 O(log₃₂n),实际接近 O(1)。
- 结构共享:新旧版本共享未修改的部分,减少内存开销。
- 动态扩展:根据数据量自动调整树深度。
(3)HAMT 的结构
a. 基本组成
HAMT 的每个节点分为两种:
- 内部节点(Branch Node)
- 存储 位图(Bitmap) 和 子节点数组。
- 位图标记哪些子节点存在(32 位,对应 32 个子节点)。
- 子节点可以是内部节点或叶子节点。
- 子节点数组存储的是对子节点的引用,类似于指针或引用(具体实现可能是内存地址、对象引用等)。
- 子节点数组是 动态分配 的,仅存储实际存在的子节点。子节点数组的长度 = 位图中 1 的个数(即实际存在的子节点数),通过位图的 popcount(统计 1 的数量)快速定位子节点。
- 叶子节点(Leaf Node)
- 存储实际的键值对(Key-Value)。
- 如果哈希冲突(多个键哈希到同一位置),使用链表或进一步扩展。
b. 树的分支因子
32 路分支(32-way branching):每个内部节点最多 32 个子节点。
- 原因:32 = 2⁵,哈希值按 5 位分段决定路径。
- 例如,哈希值 0x1A3B 的分段:
第 1 层:取 0x1A3B & 0x1F(低 5 位)→ 分支索引。
第 2 层:取 (0x1A3B >> 5) & 0x1F → 下一层分支索引。
(4)HAMT 的操作
a. 查询(Get)
第一步:计算键的哈希值(如 hash(“key”))。
第二步:使用 哈希值的分段(5 位一组) 逐层向下查找,每一层对应哈希值的一部分:
- 第 1 层:hash & 0x1F(取最低 5 位)→ 索引 i₁。
- 第 2 层:(hash >> 5) & 0x1F → 索引 i₂。
- 第 3 层:(hash >> 10) & 0x1F → 索引 i₃。
- … 依此类推,直到找到目标叶子节点或返回 nil。
时间复杂度:O(log₃₂n) ≈ O(1)(通常 2-4 层即可定位)。
为什么查询时间复杂度是O(log₃₂n) ?
因为HAMT的结构类似于 32 叉树,层数与 n 的对数相关。
实际应用中,由于层数极少(2-4 层),可以近似看作 O(1),比传统哈希表更稳定(无冲突退化问题)。
b. 插入/更新(Assoc)
第一步:计算哈希值,按路径查找目标位置。
第二步:
- 如果目标位置为空:
- 创建新叶子节点,更新位图和子节点数组。
- 如果目标位置已有数据:
- 哈希冲突:扩展为新的内部节点,重新分布数据。
第三步:路径复制:复制受影响路径上的节点,未修改部分共享。
示例:
(def m {:a 1}) ; 创建映射 m = {:a 1}
(def m2 (assoc m :b 2)) ; 创建新映射 m2 = {:a 1, :b 2},m 保持不变
c. 删除(Dissoc)
第一步:查找目标键的位置。
第二步:移除对应叶子节点,更新位图。
第三步:如果子节点数组为空,向上收缩树结构。
(5)HAMT 的优化技术
a.位图(Bitmap)优化
传统 Trie 需要预分配 32 个子节点指针(内存浪费)。
HAMT 使用 位图标记有效子节点,只存储存在的分支。例如,位图 0b101 表示只有第 0 和第 2 个子节点存在。
b. 路径压缩
如果某条路径上只有一个子节点,可以压缩存储(跳过中间节点)。
减少树深度,提高查询速度。
c.惰性哈希计算
哈希值按需计算(避免预计算所有键的哈希)。
在冲突时再计算更深的哈希位。
(6)HAMT 在 Clojure 中的应用
a.不可变哈希表(PersistentHashMap)
Clojure 的 {} 和 hash-map 基于 HAMT 实现。
支持高效的 assoc、dissoc 和 get。
示例:
(def m {:a 1, :b 2})
(get m :a) ; => 1
(assoc m :c 3) ; => {:a 1, :b 2, :c 3}
b.不可变向量(PersistentVector)
Clojure 的 [] 和 vector 也使用类似结构(但按索引而非哈希分布)。
支持高效的 nth 和 assoc。
(def v [1 2 3])
(nth v 1) ; => 2
(assoc v 1 99) ; => [1 99 3]
(7)总结
- HAMT 是什么:基于哈希的不可变树形结构,支持高效查询和持久化修改。
- 核心优化:位图压缩、路径复制、惰性哈希。
- 在Clojure 中的应用:PersistentHashMap 和 PersistentVector 的底层实现。
- 适用场景:需要高性能、线程安全的不可变数据结构。
HAMT 是函数式编程中 “通过不可变性实现并发安全” 的经典实践,也是 Clojure 高效数据结构的基石
2. HAMT 的 32 路分支
Clojure 的向量是基于 HAMT实现的,这是一种高度优化的树形结构:
- 每个节点包含 32 个子节点(32-way branching)
- HAMT 的 32 个子节点是每个内部节点的最大容量,但实际存储时会根据数据分布动态调整。
- 叶子节点存储实际数据
- 内部节点存储子节点引用
示例 :
向量 [1 2 3 4 5] 的简化树形表示:Root/ | \[1,2] [3,4] [5]
(1)HAMT 的 32 路分支(32-way branching)简介
- 理论设计:
- 每个内部节点最多有 32 个子节点(对应哈希值的 5 位片段,因为 2的5次方=32)。
- 哈希值被分割为多个 5 位片段,每段决定一层树的选择路径。
- 内存优化:
- 实际实现中,节点会压缩存储非空分支(避免分配 32 个指针的固定数组)。
- 通过位图(bitmap)标记存在的子节点,按需分配内存。
(2)示例 [1 2 3 4 5] 的树形结构
a. 可能的实际存储形式
Root (bitmap: 0b111) ; 标记前3个分支非空/ | \[1,2] [3,4] [5] ; 叶子节点合并存储(优化小数据)
为什么只有 3 个子节点?
- 数据局部性优化:当元素较少时,Clojure 会合并相邻叶子节点以减少树深度。
- 惰性拆分:只有插入新元素导致节点溢出时(如超过 32 个元素),才会分裂为完整 32 路分支。
b. 插入更多元素后的变化
若继续添加元素直到节点超过合并阈值:
;; 假设阈值为 5(实际由 Clojure 内部决定)
(def v [1 2 3 4 5 6 7 8 9 10]);; 树可能变为:Root (bitmap: 0b11111111)/ | ... | \[1,2] [3,4] ... [9,10] ; 更多叶子节点
c. 为什么示例中节点元素未达到阈值?
正如上面的例子中,阈值为5,为什么每个节点有两个元素,共5个叶子节点。而不是每个节点有5个元素,一共两个叶子节点呢?
澄清概念:阈值的作用
阈值(Threshold) 是触发节点分裂的上限,但实际合并的叶子节点大小取决于数据插入顺序和哈希分布。
关键规则:
- 插入时检查:当向叶子节点插入元素后,若其大小超过阈值,则分裂为 32 个子节点(最多)。
- 初始构建:向量在构造时可能直接生成优化后的结构(不一定填满阈值)。
正确的阈值示例 :
; 叶子节点合并(≤阈值)
(def v [1 2 3 4 5]) ; 树结构:[1,2,3,4,5] ; 单叶子节点(未分裂); 叶子节点分裂(>阈值)
(def v [1 2 3 4 5 6]) ; 树结构:Root/ | \[1,2] [3,4] [5,6] ; 分裂为3个叶子节点(具体数量依赖哈希分布)
为什么有时分支元素较少?
- 哈希分布不均:某些哈希路径可能只有少量元素。
- 惰性分裂:未达到全局阈值时,局部可能先分裂。
- 优化策略:Clojure可能牺牲部分密度换取更平衡的树。
正如 Clojure 源码注释所述:
“The trie dynamically balances node density based on insertion
patterns, not just a fixed threshold.” (字典树根据插入模式动态平衡节点密度,而非仅依赖固定阈值。)
(3)动态调整规则
场景 | 节点行为 | 示例 |
---|---|---|
元素少(≤阈值) | 合并为单个叶子节点 | [1,2,3] → 不分裂 |
元素多(>阈值) | 拆分为 32 路分支 | [1…33] → 分裂为两层树 |
哈希冲突 | 链表或进一步哈希扩展 | 罕见,需处理冲突 |
(4)为什么选择 32?
- CPU 缓存友好:32 个子节点(每个指针 4-8 字节)可填充常见缓存行(64-256 字节)。
- 时间效率:log₃₂n 的深度平衡了查找速度和内存占用。
对比:32 路 vs 2 路(二叉树)- 32 路:log₃₂1M≈3.3 层
- 2 路:log₂1M≈20 层
(5)32路分支总结
- 32 是理论最大值,实际存储会动态合并叶子节点优化小数据。
- 设计目标:在内存效率(小数据)和查询速度(大数据)间平衡。
- 用户无需关心:Clojure 隐藏了这些优化,保证一致的 O(1) 访问语义。
正如 Clojure 的持久化数据结构论文所述:
“The trie adapts its branching factor based on data density, ensuring
compactness without sacrificing speed.”
(字典树根据数据密度自适应分支因子,在保证速度的同时实现紧凑存储。)
2. 修改向量
修改向量时:只复制受影响的部分节点,未变部分共享引用。
示例:
(def v1 [1 2 3 4 5])
(def v2 (assoc v1 2 99)) ; 仅复制索引 2 的路径
v1 和 v2 共享未被修改的部分(如索引 0,1,3,4)。
3. assoc 函数详解
assoc 是 Clojure 中用于关联性数据结构(如 Map、Vector、Record)的核心函数,其作用是根据指定的键(或索引)添加、替换或更新值,并返回一个新的不可变数据结构,而原数据保持不变。
(1)基本语法
(assoc 数据结构 键/索引 新值)
(assoc 数据结构 键1 值1 键2 值2 ...) ; 多键值对操作
(2)对不同数据结构的操作
a. 操作 Map(哈希表)
; 添加或替换键值对:
(def m {:a 1, :b 2})
(assoc m :c 3) ; => {:a 1, :b 2, :c 3} (添加)
(assoc m :b 99) ; => {:a 1, :b 99} (替换); 多键值操作:
(assoc m :x 10 :y 20) ; => {:a 1, :b 2, :x 10, :y 20}
b. 操作 Vector(向量)
; 按索引替换值(索引必须存在):
(def v [1 2 3])
(assoc v 1 "two") ; => [1 "two" 3]; 越界会报错:
(assoc v 5 :x) ; => IndexOutOfBoundsException
c. 操作 Record
; 类似 Map,但字段需预定义:
(defrecord Person [name age])
(def p (->Person "Alice" 30)) ; 创建Person类型示例p
(assoc p :age 31) ; => #user.Person{:name "Alice", :age 31}
以上的assoc操作结果没有绑定到变量,所以下次访问需要重新调用assoc生成。
- Clojure 中数据是值(values),而非可变对象。如果不对 (assoc p :age 31)的结果绑定变量或传递到其他函数,该结果会被垃圾回收,且无法再次访问。
- 如果需要多次使用修改后的数据,必须显式保存它(通过变量绑定、函数参数传递等)。
assoc 主要支持:Map、Vector、Record。
不支持:List、Set、基础类型、Java 集合(需转换)。
设计原则:通过限制类型保证操作的可预测性和性能。
(3)不可变性保证
原数据始终不变,生成新版本:
(def original {:a 1})
(def modified (assoc original :b 2))
original ; => {:a 1} (未被修改)
modified ; => {:a 1, :b 2}
(4)底层实现原理
a. 对 Map 的实现
基于 HAMT(Hash Array Mapped Trie):
- 计算键的哈希值,定位到树中对应路径。
- 复制路径上的节点并更新值,未修改的分支共享。
- 时间复杂度:平均 O(log₃₂ n),近似O(1)。
b. 对 Vector 的实现
基于 持久化向量树:
- 类似 HAMT,但使用索引而非哈希值定位路径。
- 复制修改路径的节点,共享未修改部分。
- 时间复杂度:O(log₃₂ n)。
(5) 总结
- assoc 是 Clojure 不可变数据操作的核心工具,适用于 Map/Vector/Record。
- 特点:无副作用、结构共享、高效生成新版本。
- 哲学:通过值语义(Value Semantics)简化并发和状态管理。
正如 RichHickey 所说:
“Assoc is the gateway to functional updates.” (assoc 是通向函数式更新的门户。)
4. (assoc v1 2 99) 的具体过程
当执行 (assoc [1 2 3 4 5] 2 99) 时:
(1)定位修改路径
计算索引 2 的位置(第 3 个元素)
从根节点向下找到包含该元素的叶子节点
(2)路径复制
复制从根节点到目标叶子节点的整条路径
新路径指向新值(99),其他分支保持原引用
修改后的树结构:Root' (新根节点)/ | \[1,2] [99,4] [5] (仅修改了第二个分支)↑ ↑ ↑共享 新节点 共享
(3)结构共享
未被修改的分支(如 [1,2] 和 [5])仍被新旧版本共享
只有被修改的路径(图中 [3,4] → [99,4])需要创建新节点
这种设计使得"修改"操作在逻辑上生成新数据,在物理上却智能共享不变部分,完美平衡了函数式编程的纯粹性与实际性能需求。正如 Rich Hickey 所说:
“Persistent data structures are the bridge between functional purity
and practical efficiency.” (持久化数据结构是函数式纯粹性与实际效率之间的桥梁。)