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

文章目录

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/web/88815.shtml
繁体地址,请注明出处:http://hk.pswp.cn/web/88815.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

面试150 二叉树展开为链表

思路 思路:使用列表存储先序遍历的相关节点。然后遍历列表,分别获取前驱节点和当前节点,将前驱节点的左指针指向空,前驱节点的右指针指向当前节点。 # Definition for a binary tree node. # class TreeNode: # def __init__(self, val0, leftNone, …

代码随想录算法训练营第十七天

目录 LeetCode.654 最大二叉树 题目链接 最大二叉树 题解 解题思路 LeetCode.617 合并二叉树 题目链接 合并二叉树 题解 解题思路 LeetCode.700 二叉搜索树中的搜索 题目链接 二叉搜索树中的搜索 题解 解题思路 解题思路 LeetCode.98 验证二叉搜索树 题目链接 验…

pycharm无法识别pip安装的包

在使用conda创建一个新的环境后,有些包通过pip的方式安装更方便有效,若在pip安装后,遇到该环境没有此包,或pycharm监测不到此包,通常是pip的环境指向有问题。 解决措施: # 首先检查当前pip的指向 which pip…

Elasticsearch 的 `modules` 目录

Elasticsearch 的 modules 目录是存放**核心功能模块**的目录,这些模块是 Elasticsearch 运行所必需的基础组件,**随官方发行版一起提供**,但设计上允许通过移除或替换模块来**定制化部署**(比如构建一个最小化的 Elasticsearch 实…

https——TCP+TLS

https——TCPTLS主题:基于mbedtls-2.16.0,验证TLS会话复用功能验证环境:1.TLS服务端2.TLS客户端2.1 基于Sesssion ID2.1.1mbedtls-2.16.0库的宏配置2.1.2 初始化配置2.1.3 TCP连接2.1.4 首次TLS连接2.1.4.1 发送加密算法列表2.1.4.2 选择加密…

uni-app uni-push 2.0推送图标不展示问题

问题现象:我在uni-app的配置文件,配置了推送的大图标小图标发现在真机测试无法展示配置的推送图标问题 官网文档:开通 | uni-app官网 解决方法: 在uni-app官网中说的并不是很清楚只给了一个简单的示例,配置并没有告诉我…

scp:上传大型数据集到实验室服务器

我通过百度网盘下载了大概200GB的LUNA-2016的肺结节CT数据。实验是在实验室服务器上进行的,我现在需要将本地的数据集传输到实验室的服务器上。我已经通过remote-ssh连接上了实验室的服务器,但是如果通过这个插件上传数据的话,一方面不支持上…

量子计算突破:8比特扩散模型实现指数级加速

目录 一、量子扩散模型(Quantum Diffusion) 二、DNA存储生成(Biological-GAN) 三、光子计算加速 四、神经形态生成 五、引力场渲染 六、分子级生成 七、星际生成网络 八、元生成系统 极限挑战方向 一、量子扩散模型&…

Flask3.1打造极简CMS系统

基于Flask 3.1和Python 3.13的简易CMS以下是一个基于Flask 3.1和Python 3.13的简易CMS管理系统实现方案,包含核心功能和可运行代码示例。环境准备安装Flask和其他依赖库:pip install flask3.1.0 flask-sqlalchemy flask-login配置数据库在config.py中设置…

用 Node.js 构建模块化的 CLI 脚手架工具,从 GitHub 下载远程模板

本文将手把手带你构建一个支持远程模板下载、自定义项目名称,并完成模块化拆分的 CLI 脚手架工具,适用于初创项目、团队内部工具或者开源项目快速初始化。🧩 为什么要自己造一个 CLI 脚手架? 在日常开发中,我们常用脚手…

08.如何正确关闭文件

如何正确关闭文件(File Handling Best Practices) 文件操作是日常开发中非常常见的任务,正确关闭文件对于避免资源泄漏尤为关键。错误的文件关闭方式可能导致文件未保存、锁定或其他异常。 1. 常见的错误方式:手动 close() 许多初学者会手动调用 close() 关闭文件,这在异…

算法入门--动态规划(C++)

深入浅出掌握动态规划核心思想,图文并茂实战代码 什么是动态规划? 动态规划(Dynamic Programming, DP) 是一种高效解决多阶段决策问题的方法。它通过将复杂问题分解为重叠子问题,并存储子问题的解(避免重…

[2025CVPR]GNN-ViTCap:用于病理图像分类与描述模型

论文结构解析​ 本文采用经典学术论文结构: ​引言​:阐述病理图像分析的挑战与现有方法局限性​相关工作​:系统梳理MIL、视觉语言预训练和生物医学语言模型三大领域​方法​:详细阐述GNN-ViTCap四阶段架构​实验​:在BreakHis和PatchGastric数据集验证性能​讨论​:通…

Java SE--图书管理系统模拟实现

一.设计思路首先这个系统可以由俩种用户使用,分别为管理者用户和普通者用户,根据不同的用户有不同的界面,每个界面有不同的功能。二.代码实现创建三个包和一个类book包:包括Book类和Booklist类Book类:package book; pu…

[RPA] 批量数据抓取指定商品名称信息

影刀RPA案例:批量数据抓取指定商品名称信息流程图:操作步骤:涉及的影刀RPA大致指令: 1. 打开影刀商城页面 2. 使用【填写输入框(web)】指令输入用户名和密码,并点击"登录"按钮 3. 切换到"订单管理"…

我的世界Java版1.21.4的Fabric模组开发教程(十四)方块实体

这是适用于Minecraft Java版1.21.4的Fabric模组开发系列教程专栏第十四章——方块实体。想要阅读其他内容,请查看或订阅上面的专栏。 方块实体(Block Entity) 指的是一种用于存储方块额外数据的方法。但这种数据和为了控制方块状态而在自定义方块类中创建的属性不太…

【UE教程/进阶】UE中的指针与引用

目录直接属性引用共享指针 TSharedPtr实现原理共享引用 TSharedRef弱引用指针 TWeakPtrObject弱指针 FWeakPtr实现原理Object软指针 FSoftObjectPtr原理直接属性引用 在c通过UPROPERTY()宏将属性公开,蓝图中属性类型中的Object Reference。 对一个类型及其子类型的…

早期 CNN 的经典模型—卷积神经网络(LeNet)

目录 LeNet 的设计背景与目标 LeNet 的网络结构(经典 LeNet-5) 局部感受野详解 一、局部感受野和全连接网络的区别 1. 传统全连接网络的问题 2. 局部感受野的解决方案 二、局部感受野的优势 1. 参数大幅减少 2. 提取局部特征 3. 平移不变性 参数…

RabbitMQ 高级特性之延迟队列

1. 简介 在某些场景下,当生产者发送消息后,可能不需要让消费者立即接收到,而是让消息延迟一段时间后再发送给消费者。 2. 实现方式 2.1 TTL 死信队列 给消息设置过期时间后,若消息在这段时间内没有被消费,就会将消…

uniapp app安卓下载文件 图片 doc xls 数据流文件 app安卓本地路径下载保存

//下载图片 downloadToLocal() {plus.android.requestPermissions([android.permission.WRITE_EXTERNAL_STORAGE],(success) > {uni.saveImageToPhotosAlbum({filePath: /static/x.png,//本地地址success: () > {this.$refs.uToast.show({message: "模版下载成功&am…