Clojure持久化数据结构的底层实现

文章目录

一、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." (持久化数据结构是函数式纯粹性与实际效率之间的桥梁。)

相关推荐
艾莉丝努力练剑1 小时前
【数据结构与算法】数据结构初阶:详解顺序表和链表(四)——单链表(下)
c语言·开发语言·数据结构·学习·算法·链表
笑衬人心。3 小时前
Hashtable 与 HashMap 的区别笔记
java·数据结构·笔记
秋说4 小时前
【PTA数据结构 | C语言版】根据层序序列重构二叉树
c语言·数据结构·算法
秋说5 小时前
【PTA数据结构 | C语言版】前序遍历二叉树
c语言·数据结构·算法
hy.z_7776 小时前
【数据结构】反射、枚举 和 lambda表达式
android·java·数据结构
秋说6 小时前
【PTA数据结构 | C语言版】二叉树层序序列化
c语言·数据结构·算法
xiaofann_6 小时前
【数据结构】单链表练习(有环)
数据结构
NuyoahC7 小时前
笔试——Day9
数据结构·c++·笔试
共享家95277 小时前
排序算法实战(上)
数据结构·算法·排序算法
abigale039 小时前
JavaScript数据结构&算法
javascript·数据结构·算法