持久化与不可变结构 · 从 path copying 到 RRB-tree
一个数据结构叫 persistent,是说它的每次修改都产出一个新版本,而全部旧版本仍可查询。朴素做法是每次全量复制,代价 ;实测里这个代价大得离谱——10 万元素的数组做 1 万次「改一格并留档」,光格子写入就是 次,按每格 8 字节算 7.6 GB。
path copying 把这笔账压到 :树形结构里改一个叶子,只有根到该叶子这一条路径上的节点需要新建,其余子树两个版本共用同一批对象。10 万元素的 32 叉 trie 树深只有 4,于是一次修改新建 4 个节点、共享 3223 个,共享率 99.88%。本系列前两页把这件事画出来,并用它做出主席树的区间第 小。
后半是工程实现。HAMT 用 bitmap 加 popcount 把 32 槽节点压到只存实际存在的孩子,是 Clojure、Scala 与 Immutable.js 的 Map 底层;bit-partitioned vector 换成按下标的二进制位分段,加一个 tail 让尾部 append 摊还常数;transient 用所有权标记临时放弃持久性,把 10 万次 push 的新建节点从 11441 个降到 3228 个;RRB-tree 给节点挂 size table,让 concat 与 slice 也进对数级。
地基:路径复制与三个持久化级别
partial / full / confluent 三级的分野是「哪些版本能被改、能不能合并」,而三级共用同一个手法。把线段树的一次单点修改改写成 path copying 之后,旧根一个字节都不动,新根与旧根共享 个节点——这个共享量就是主席树能存下 个前缀版本的全部理由。
路径复制与持久化的分级
持久化按「哪些版本可改、能否合并」分成 partial、full、confluent 三级,三级共用同一个手法。path copying 改一个叶子只重建根到该叶子的路径,其余子树按引用共享,一次修改的额外空间因此是 O(log n)。
可持久化线段树与区间第 k 小
把 path copying 用到值域计数线段树上,每插入一个数就留下一个版本。两个前缀版本相减即得任意区间的值分布,于是区间第 k 小只需一次自顶向下的下探。实测 10 万数据 1 万次查询 21.7 ms。
为什么持久化几乎总是从树形结构起手
持久化的理论一侧 · 延伸阅读
- Driscoll, Sarnak, Sleator & Tarjan · Making Data Structures Persistent (1989) dl.acm.org 这个领域的奠基论文:定义 partial / full persistence,给出 path copying 与 fat node 两条路线,并用 node splitting 把单次修改的摊还代价压到 。
- Fiat & Kaplan · Making Data Structures Confluently Persistent (2003) erikdemaine.org confluent persistence 的正式处理:版本图从树变成 DAG 之后,为什么朴素的 path copying 会指数爆炸。
- MIT 6.851 · Persistent Data Structures (讲义与录像) courses.csail.mit.edu Erik Demaine 的课,一节讲完四个级别与各自的上界,是本系列前两页的骨架来源。
- 可持久化线段树 — OI Wiki oi-wiki.org 主席树的中文实现指南:前缀版本、区间第 小、以及常见的空间估算口径。
32 叉 trie 的两种索引来源
HAMT 与 persistent vector 的骨架相同:分支因子 32、树深不超过 7、一次修改只复制一条路径。分歧只在「第几层看哪几位」——HAMT 看 hash 的 5 位一段,故节点稀疏、要 bitmap 压缩;vector 看下标的 5 位一段,故节点致密、可纯位运算。RRB-tree 是第三种:允许节点不满,代价是索引要查 size table。
HAMT:bitmap 加 popcount 的 32 叉 trie
Clojure 与 Immutable.js 的不可变 Map 底层。32 叉 trie 每层吃 hash 的 5 位,节点用一个 32 位 bitmap 加一个紧凑数组表示,孩子下标由 popcount 算出。
bit-partitioned persistent vector
Clojure 的 vector 与 Immutable.js 的 List 底层。同样是 32 叉 trie,索引却来自下标的二进制位,再加一个 tail 缓冲让尾部 append 摊还常数。10 万元素树深 4。
RRB-tree:让 concat 与 slice 也进对数级
严格 radix 树要求除末尾外每个节点都装满,而 concat 与 slice 必然破坏这个前提。RRB 允许节点松弛,用一张 size table 换来对数级的拼接与切片。实测 20 万元素拼接只新建 5 个节点。
分支因子 32 不是「树深最小」算出来的
bitmap 刚好索引 32 个槽( 就要 64 位掩码,JS 的位运算只有 32 位);32 个指针在 64 位机上是 256 字节,正好四条 cache line。Bagwell 在 2001 年的论文里试过 16 与 32,选了后者。HAMT / vector / RRB · 一手资料
-
Bagwell · Ideal Hash Trees (2001)
lampwww.epfl.ch
HAMT 的原始论文:
bitmap加紧凑数组的稀疏节点、分支因子 32 的取舍、以及碰撞处理。 - Bagwell & Rompf · RRB-Trees: Efficient Immutable Vectors (2011) infoscience.epfl.ch RRB 的原始论文:size table、concat 的再平衡不变量、以及 search step 的上界证明。
-
Understanding Clojure's Persistent Vectors — Jean Niklas L'orange
hypirion.com
三篇连载,把
tail、shift、transient 的 owner 标记逐行讲透,配图极清楚。 -
clojure/lang/PersistentVector.java
github.com
一手实现:
pushTail、popTail、TransientVector的ensureEditable,本系列引擎的写法参照。 -
Immutable.js · List 与 withMutations
immutable-js.com
JS 一侧的对应实现与 API 形态,
withMutations就是 transient。
落到工程:批量构建与共享的代价
不可变结构在生产里能用,靠的是两件正文常常略过的事。transient 让批量构建期间的中间版本不必真的存在;引用相等让「这棵子树变没变」的判断从逐项比较降成一次指针比较——实测 10 万项的深比较单次 45.5 µs,指针比较 17.2 ns。代价也有:只保留最新版本时,91.9% 的新建节点当即成为垃圾。
transient:临时放弃持久性的可变窗口
批量构建时每一步都造一个不可变中间版本,代价全部浪费。transient 给节点打上所有权标记,标记对得上就原地改,构建完再冻结。实测 10 万次 set 的新建节点从 399872 降到 3227。
结构共享的代价与收益
结构共享把「改一格并留档」的写入量降了四个数量级,但墙钟时间只降一个量级,而且九成新建节点当场成垃圾。真正无法用别的手段换来的收益是引用相等:一次指针比较回答关于十万个元素的问题。
不可变数据在前端 · 延伸阅读
- Immer · 用 draft 写不可变更新 immerjs.github.io 另一条路线:不换数据结构,用 Proxy 记录改动再做结构共享的浅复制。与 HAMT 的取舍对照见本系列的代价一页。
- React · memo 与 props 的浅比较 react.dev 引用相等为什么能省掉整棵子树的重渲染,以及浅比较的边界。
- Structural sharing 在函数式语言里的位置 tomasp.net 把不可变结构放回语言设计的语境:持久性不是性能技巧,而是等式推理的前提。