项目地址 https://github.com/calcit-lan...java
这里说的不可变数据结构主要是指 Clojure 的 Persistent Data Structure.
有个系列文章介绍得比较详细了: Understanding Clojure's Persistent Vectors, pt. 1
Clojure 具体实现考虑到了不少的事情, 源码能够看到一些细节:
https://github.com/clojure/cl...git
个人主要精力是在 TypeScript 跟 ClojureScript 这边, 对 C 了解不多,
我介绍的这个项目是用 Nim 写的, Nim 内置了 GC 功能, 用起来比较顺手.github
Clojure 里用的是 32 分支的 B+ 树来存储数据的.
数据都在叶子节点上, 每次要填入数据的时候, 都会展开对应的分支.
我看源码的时候, 感受 Clojure 为了性能上的优点, 具体实现是比较简单粗暴的.
没有很精细去作每一个操做的结构共享, 因此说只有从尾部写入数据才是比较快的.
我当时尝试本身去试验的时候, 想着结构复用方便, 我就用了 3 个分支的树形结构.
这样也有好处, 就是从前面后面写入数据, 都是同样的, 并且复用这个思路比较清晰.
另外就是考虑 trie 这个结构, 实现 HashMap 的话好像 3 个分支比较容易吧.
这个性能上优化估计是不如 32 分支的, 不过简单场景仍是能够跑跑的.编程
这篇文章里, 主要仍是关于试验过程中遇到的有意思的一些发现.性能优化
这个项目当中的 TernaryTreeList
是用 B+ 树实现的, 叶子节点存储数据.
内部节点存储分支包含的数据的大小, 这样索引的时候就能快速查询位置了.
Clojure 的实现当中索引是用 i >>> 5
这样查找的, 一层层在 32 分支当中定位, 很快.TernaryTreeList
索引查找数据就须要不断计算 size 然一层层查找下去了, 慢一些.数据结构
TernaryTreeList
初始化的时候, 会尝试大体均匀分布开来, 至少保证树的深度尽可能小.
固然这样其中可能会残留不少的空穴, 空间的利用率不是最高的.jvm
这里为了快速展现 TernaryTreeList
树的结构, 我用一个记法,
好比 3 个数据, [1 2 3]
结构是:函数
^ / | \ 1 2 3
紧凑的记法就是:性能
(1 2 3)
当中间有空穴的时候, 就会空出对应的位置, 好比 [1 3]
的结构:优化
^ / | \ 1 3
就记为:
(1 _ 3)
而后数据更多有多层的数据 [1 4 5 6]
:
^ / | \ 1 ^ / | \ 4 5 6
就记成:
(1 _ (4 5 6))
这个紧凑的结构就可以展现出更多的信息了.
文章后面, 看到括号就要对应的一个树的分支上去, 并且算上空穴之后分支都是 3.
对于长度为 0 到 20 的序列, 建立出来的数据的结构是这样的:
(_ _ _) 1 (1 _ 2) (1 2 3) (1 (2 _ 3) 4) ((1 _ 2) 3 (4 _ 5)) ((1 _ 2) (3 _ 4) (5 _ 6)) ((1 _ 2) (3 4 5) (6 _ 7)) ((1 2 3) (4 _ 5) (6 7 8)) ((1 2 3) (4 5 6) (7 8 9)) ((1 2 3) (4 (5 _ 6) 7) (8 9 10)) ((1 (2 _ 3) 4) (5 6 7) (8 (9 _ 10) 11)) ((1 (2 _ 3) 4) (5 (6 _ 7) 8) (9 (10 _ 11) 12)) ((1 (2 _ 3) 4) ((5 _ 6) 7 (8 _ 9)) (10 (11 _ 12) 13)) (((1 _ 2) 3 (4 _ 5)) (6 (7 _ 8) 9) ((10 _ 11) 12 (13 _ 14))) (((1 _ 2) 3 (4 _ 5)) ((6 _ 7) 8 (9 _ 10)) ((11 _ 12) 13 (14 _ 15))) (((1 _ 2) 3 (4 _ 5)) ((6 _ 7) (8 _ 9) (10 _ 11)) ((12 _ 13) 14 (15 _ 16))) (((1 _ 2) (3 _ 4) (5 _ 6)) ((7 _ 8) 9 (10 _ 11)) ((12 _ 13) (14 _ 15) (16 _ 17))) (((1 _ 2) (3 _ 4) (5 _ 6)) ((7 _ 8) (9 _ 10) (11 _ 12)) ((13 _ 14) (15 _ 16) (17 _ 18))) (((1 _ 2) (3 _ 4) (5 _ 6)) ((7 _ 8) (9 10 11) (12 _ 13)) ((14 _ 15) (16 _ 17) (18 _ 19)))
由于元素是大体均匀分散开的, 分支都是 3, 因此初始的时候空穴也是大体平均分散开.
能够看到这不是最密的一种堆积方式. 因此在内存占用上也不是最经济的.
理论上说, 基于此方案能够作一下改良, 把元素尽量往中间靠拢, 而深度依然尽可能最小.
这样能够获得一个空穴更少的堆积方式, 大体效果像下面这样子:
(_ _ _) 1 (1 _ 2) (1 2 3) ((1 _ 2) 3 4) ((1 _ 2) 3 (4 _ 5)) ((1 _ 2) (3 4 5) 6) ((1 _ 2) (3 4 5) (6 _ 7)) ((1 2 3) (4 5 6) (7 _ 8)) ((1 2 3) (4 5 6) (7 8 9)) (((1 _ 2) 3 4) (5 6 7) (8 9 10)) (((1 _ 2) 3 4) (5 6 7) ((8 _ 9) 10 11)) ((1 _ 2) ((3 4 5) (6 7 8) (9 10 11)) 12) ((1 _ 2) ((3 4 5) (6 7 8) (9 10 11)) (12 _ 13)) ((1 2 3) ((4 5 6) (7 8 9) (10 11 12)) (13 _ 14)) ((1 2 3) ((4 5 6) (7 8 9) (10 11 12)) (13 14 15)) (((1 _ 2) 3 4) ((5 6 7) (8 9 10) (11 12 13)) (14 15 16)) (((1 _ 2) 3 4) ((5 6 7) (8 9 10) (11 12 13)) ((14 _ 15) 16 17)) (((1 _ 2) 3 (4 _ 5)) ((6 7 8) (9 10 11) (12 13 14)) ((15 _ 16) 17 18)) (((1 _ 2) 3 (4 _ 5)) ((6 7 8) (9 10 11) (12 13 14)) ((15 _ 16) 17 (18 _ 19)))
很少这种方案的话我就须要比较准确找到中间分支知足 3 个某个倍数的大小了,
这个反复查找数值的操做, 在二进制的计算机当中仍是不那么经济的.
而后是插入数据的时候, 若是数据从零开始一直从尾部写入, 通过优化后的效果是这样的:
(_ _ _) 0 (0 1 _) (0 1 2) ((0 1 2) 3 _) ((0 1 2) (3 4 _) _) ((0 1 2) (3 4 5) _) ((0 1 2) (3 4 5) 6) ((0 1 2) (3 4 5) (6 7 _)) ((0 1 2) (3 4 5) (6 7 8)) (((0 1 2) (3 4 5) (6 7 8)) 9 _) (((0 1 2) (3 4 5) (6 7 8)) (9 10 _) _) (((0 1 2) (3 4 5) (6 7 8)) (9 10 11) _) (((0 1 2) (3 4 5) (6 7 8)) ((9 10 11) 12 _) _) (((0 1 2) (3 4 5) (6 7 8)) ((9 10 11) (12 13 _) _) _) (((0 1 2) (3 4 5) (6 7 8)) ((9 10 11) (12 13 14) _) _) (((0 1 2) (3 4 5) (6 7 8)) ((9 10 11) (12 13 14) 15) _) (((0 1 2) (3 4 5) (6 7 8)) ((9 10 11) (12 13 14) (15 16 _)) _) (((0 1 2) (3 4 5) (6 7 8)) ((9 10 11) (12 13 14) (15 16 17)) _) (((0 1 2) (3 4 5) (6 7 8)) ((9 10 11) (12 13 14) (15 16 17)) 18) (((0 1 2) (3 4 5) (6 7 8)) ((9 10 11) (12 13 14) (15 16 17)) (18 19 _))
咱们看一下其中对应 (range 10)
的列表, 包含 9 + 1
个数据:
(((0 1 2) (3 4 5) (6 7 8)) 9 _)
能够看到它有两个分支(以及一个空穴), 总共 10 个元素.
那么访问这其中的 9
就很快, 由于只有一层, 深度很是小, 不须要跟前面的数据同样查找三层.
在前方写入数据的话, 效果跟上面相似, 可是反过来一下:
(_ _ _) 0 (_ 1 0) (2 1 0) (_ 3 (2 1 0)) (_ (_ 4 3) (2 1 0)) (_ (5 4 3) (2 1 0)) (6 (5 4 3) (2 1 0)) ((_ 7 6) (5 4 3) (2 1 0)) ((8 7 6) (5 4 3) (2 1 0)) (_ 9 ((8 7 6) (5 4 3) (2 1 0))) (_ (_ 10 9) ((8 7 6) (5 4 3) (2 1 0))) (_ (11 10 9) ((8 7 6) (5 4 3) (2 1 0))) (_ (_ 12 (11 10 9)) ((8 7 6) (5 4 3) (2 1 0))) (_ (_ (_ 13 12) (11 10 9)) ((8 7 6) (5 4 3) (2 1 0))) (_ (_ (14 13 12) (11 10 9)) ((8 7 6) (5 4 3) (2 1 0))) (_ (15 (14 13 12) (11 10 9)) ((8 7 6) (5 4 3) (2 1 0))) (_ ((_ 16 15) (14 13 12) (11 10 9)) ((8 7 6) (5 4 3) (2 1 0))) (_ ((17 16 15) (14 13 12) (11 10 9)) ((8 7 6) (5 4 3) (2 1 0))) (18 ((17 16 15) (14 13 12) (11 10 9)) ((8 7 6) (5 4 3) (2 1 0))) ((_ 19 18) ((17 16 15) (14 13 12) (11 10 9)) ((8 7 6) (5 4 3) (2 1 0)))
一样来看 (range 10)
对应的数据, 就是上一个例子反过来:
(_ 9 ((8 7 6) (5 4 3) (2 1 0)))
这是通过刻意的优化的, 由于在编程当中列表头部尾部增长数据的状况比较多.
这样优化以后, 树当中的空穴就会尽可能少.
那么, 若是在中间某个位置插入数据呢, 随机地插入, 用 assocAfter
?
能够用这样的一个例子(开头我用数字标记了树的深度):
2 : (0 1 _) 2 : (0 1 2) 3 : ((0 3 _) 1 2) 3 : ((0 3 _) 1 (2 4 _)) 4 : (((0 3 _) 1 (2 4 _)) 5 _) 4 : (((0 3 _) (1 6 _) (2 4 _)) 5 _) 4 : (((0 3 _) (1 6 7) (2 4 _)) 5 _) 4 : (((0 3 _) (1 6 7) (2 4 _)) (5 8 _) _) 5 : (((0 3 _) ((1 6 7) 9 _) (2 4 _)) (5 8 _) _) 6 : (((0 3 _) (((1 10 _) 6 7) 9 _) (2 4 _)) (5 8 _) _) 6 : (((0 3 11) (((1 10 _) 6 7) 9 _) (2 4 _)) (5 8 _) _) 6 : (((0 3 11) (((1 10 12) 6 7) 9 _) (2 4 _)) (5 8 _) _) 6 : (((0 3 11) (((1 10 12) 6 7) 9 _) (2 4 _)) (5 8 13) _) 6 : (((0 3 11) (((1 10 12) 6 7) 9 _) (2 4 14)) (5 8 13) _) 7 : (((0 3 11) ((((1 15 _) 10 12) 6 7) 9 _) (2 4 14)) (5 8 13) _) 7 : (((0 3 11) ((((1 15 _) 10 12) 6 7) 9 _) (2 4 14)) ((5 16 _) 8 13) _) 7 : (((0 3 11) ((((1 15 _) 10 12) 6 7) 9 _) (2 (4 17 _) 14)) ((5 16 _) 8 13) _) 7 : (((0 3 11) ((((1 15 _) 10 12) 6 7) 9 _) ((2 18 _) (4 17 _) 14)) ((5 16 _) 8 13) _) 7 : (((0 3 11) ((((1 15 _) 10 12) 6 (7 19 _)) 9 _) ((2 18 _) (4 17 _) 14)) ((5 16 _) 8 13) _) 7 : (((0 3 11) ((((1 15 _) 10 12) 6 (7 19 _)) 9 _) ((2 18 _) (4 17 20) 14)) ((5 16 _) 8 13) _) ; after balanced 4 : (((0 _ 3) (11 1 15) (10 _ 12)) ((6 _ 7) (19 9 2) (18 _ 4)) ((17 _ 20) (14 5 16) (8 _ 13)))
随着数据增长, 有时候会生成新的分支, 有时候会填充进已有的空穴当中.
树的深度在这个过程中增长是比较快的, 立刻就到了 7 层, 这样访问就会变慢了.
固然这个操做的过程也有好处的, 分支是尽可能会去复用.
我在代码里提供了一个 forceInplaceBalancing
函数用来压缩深度.
上边的例子当中深度从 7 降到 4. 不过空穴这时候不必定就是减小的.
能够注意到, 随机插入的状况当中, 分支仍是会被复用的, 兄弟节点的分支.
而被操做到的位置, 已经所有的父节点, 将被从新生成.
好比插入数据 13
的这个例子, 位置恰好在尾部, 因此开头的分支是被复用的.
这样就是 2 个内部节点被插件, 7 个内部节点被复用了,
6 : (((0 3 11) (((1 10 12) 6 7) 9 _) (2 4 _)) (5 8 _) _) 6 : (((0 3 11) (((1 10 12) 6 7) 9 _) (2 4 _)) (5 8 13) _)
再看好比 7
被插入的时候, 2 个内部节点被建立, 2 个内部节点被复用.
这个效果就比较通常了..
4 : (((0 3 _) (1 6 _) (2 4 _)) 5 _) 4 : (((0 3 _) (1 6 7) (2 4 _)) 5 _)
不过, 好比说在一个平衡分配的列表当中任意位置插入数据的话,
好比在 (range 17)
当中用 assocAfter
插入 888
这个数据,
4 : (((0 888 1) (2 _ 3) (4 _ 5)) ((6 _ 7) (8 _ 9) (10 _ 11)) ((12 _ 13) (14 _ 15) (16 _ 17))) 4 : (((0 1 888) (2 _ 3) (4 _ 5)) ((6 _ 7) (8 _ 9) (10 _ 11)) ((12 _ 13) (14 _ 15) (16 _ 17))) 4 : (((0 _ 1) (2 888 3) (4 _ 5)) ((6 _ 7) (8 _ 9) (10 _ 11)) ((12 _ 13) (14 _ 15) (16 _ 17))) 4 : (((0 _ 1) (2 3 888) (4 _ 5)) ((6 _ 7) (8 _ 9) (10 _ 11)) ((12 _ 13) (14 _ 15) (16 _ 17))) 4 : (((0 _ 1) (2 _ 3) (4 888 5)) ((6 _ 7) (8 _ 9) (10 _ 11)) ((12 _ 13) (14 _ 15) (16 _ 17))) 5 : ((((0 _ 1) (2 _ 3) (4 _ 5)) 888 _) ((6 _ 7) (8 _ 9) (10 _ 11)) ((12 _ 13) (14 _ 15) (16 _ 17))) 4 : (((0 _ 1) (2 _ 3) (4 _ 5)) ((6 888 7) (8 _ 9) (10 _ 11)) ((12 _ 13) (14 _ 15) (16 _ 17))) 4 : (((0 _ 1) (2 _ 3) (4 _ 5)) ((6 7 888) (8 _ 9) (10 _ 11)) ((12 _ 13) (14 _ 15) (16 _ 17))) 4 : (((0 _ 1) (2 _ 3) (4 _ 5)) ((6 _ 7) (8 888 9) (10 _ 11)) ((12 _ 13) (14 _ 15) (16 _ 17))) 4 : (((0 _ 1) (2 _ 3) (4 _ 5)) ((6 _ 7) (8 9 888) (10 _ 11)) ((12 _ 13) (14 _ 15) (16 _ 17))) 4 : (((0 _ 1) (2 _ 3) (4 _ 5)) ((6 _ 7) (8 _ 9) (10 888 11)) ((12 _ 13) (14 _ 15) (16 _ 17))) 5 : (((0 _ 1) (2 _ 3) (4 _ 5)) (((6 _ 7) (8 _ 9) (10 _ 11)) 888 _) ((12 _ 13) (14 _ 15) (16 _ 17))) 4 : (((0 _ 1) (2 _ 3) (4 _ 5)) ((6 _ 7) (8 _ 9) (10 _ 11)) ((12 888 13) (14 _ 15) (16 _ 17))) 4 : (((0 _ 1) (2 _ 3) (4 _ 5)) ((6 _ 7) (8 _ 9) (10 _ 11)) ((12 13 888) (14 _ 15) (16 _ 17))) 4 : (((0 _ 1) (2 _ 3) (4 _ 5)) ((6 _ 7) (8 _ 9) (10 _ 11)) ((12 _ 13) (14 888 15) (16 _ 17))) 4 : (((0 _ 1) (2 _ 3) (4 _ 5)) ((6 _ 7) (8 _ 9) (10 _ 11)) ((12 _ 13) (14 15 888) (16 _ 17))) 4 : (((0 _ 1) (2 _ 3) (4 _ 5)) ((6 _ 7) (8 _ 9) (10 _ 11)) ((12 _ 13) (14 _ 15) (16 888 17))) 5 : ((((0 _ 1) (2 _ 3) (4 _ 5)) ((6 _ 7) (8 _ 9) (10 _ 11)) ((12 _ 13) (14 _ 15) (16 _ 17))) 888 _)
不少状况下, 12~13 个内部节点当中就 2~3 个内部节点被建立出来, 这效果仍是能够的.
因此这中间的缺陷就是树形结构是作不到自平衡的. 大部分时候, 树都是不平衡的.
这样效率也就不是最优的. 不过, 考虑到复用节点的需求, 仍是不能常常对树进行平衡.
而后还有一些经常使用的操做好比 concat
和 slice
.
若是不在意平衡不平衡的话, concat
操做是很是简单的, 只是说深度会每次增长:
(1 (2 _ 3) 4) ; a (5 (6 _ 7) 8) ; b (9 (10 _ 11) 12) ; c ((1 (2 _ 3) 4) _ (5 (6 _ 7) 8)) ; a b (((1 (2 _ 3) 4) _ (5 (6 _ 7) 8)) _ (9 (10 _ 11) 12)) ; a b c
实际的代码当中, 有时候会触发逻辑强行进行一下平衡.
至于 slice, 当前的实现当中仍是尝试去复用分支, 只是效果上并不很好.
好比我临时生成的一个例子, 从这个结构 slice 出不一样的片断:
; original structure ((1 2 3) (4 _ 5) (6 7 8))
# part of nim code for i in 0..<8: for j in i..<9: echo fmt"{i}-{j} ", d.slice(i, j).formatInline
0-0 (_ _ _) 0-1 1 0-2 (1 _ 2) 0-3 (1 2 3) 0-4 ((1 2 3) _ 4) 0-5 ((1 2 3) _ (4 _ 5)) 0-6 (((1 2 3) _ (4 _ 5)) _ 6) 0-7 (((1 2 3) _ (4 _ 5)) _ (6 _ 7)) 0-8 ((1 2 3) (4 _ 5) (6 7 8)) 1-1 (_ _ _) 1-2 2 1-3 (2 _ 3) 1-4 ((2 _ 3) _ 4) 1-5 ((2 _ 3) _ (4 _ 5)) 1-6 (((2 _ 3) _ (4 _ 5)) _ 6) 1-7 (((2 _ 3) _ (4 _ 5)) _ (6 _ 7)) 1-8 (((2 _ 3) _ (4 _ 5)) _ (6 7 8)) 2-2 (_ _ _) 2-3 3 2-4 (3 _ 4) 2-5 (3 _ (4 _ 5)) 2-6 ((3 _ (4 _ 5)) _ 6) 2-7 ((3 _ (4 _ 5)) _ (6 _ 7)) 2-8 ((3 _ (4 _ 5)) _ (6 7 8)) 3-3 (_ _ _) 3-4 4 3-5 (4 _ 5) 3-6 ((4 _ 5) _ 6) 3-7 ((4 _ 5) _ (6 _ 7)) 3-8 ((4 _ 5) _ (6 7 8)) 4-4 (_ _ _) 4-5 5 4-6 (5 _ 6) 4-7 (5 _ (6 _ 7)) 4-8 (5 _ (6 7 8)) 5-5 (_ _ _) 5-6 6 5-7 (6 _ 7) 5-8 (6 7 8) 6-6 (_ _ _) 6-7 7 6-8 (7 _ 8) 7-7 (_ _ _) 7-8 8
这里主要仍是数据太少, 完成复用的状况就不那么多了.
若是数据大的话, 能够想见, 中间的分支是极可能整个被复用的.
我另外也试了一下 Persistent Map 用 ternary-tree 这个库实现的效果.
用的 trie 结构, 而后用的 hash(实际上 Nim 当中用 int 表示), 具体实现就差很少了.
结果 Map 的深度是很容易变得很是深的, 由于 hash 的数值就是设计成很是随机的.
虽然我能够强行进行平衡, 可是随着数据插入, 很容易就出现很是多不平衡的状况了.
对这部分的数据个人经验比较少, 再看了...
目前在我其余像是当中引用了一下 ternary-tree 这个库, 并作了一些性能优化.如今主要来讲仍是本身实现了这样的数据结构, 有了更深的理解.