Ternary-tree: 不可变数据结构复用的一个尝试

题叶

项目地址 https://github.com/calcit-lan...

这里说的不可变数据结构主要是指 Clojure 的 Persistent Data Structure.
有个系列文章介绍得比较详细了: Understanding Clojure's Persistent Vectors, pt. 1
Clojure 具体实现考虑到了很多的事情, 源码可以看到一些细节:
https://github.com/clojure/cl...

我的主要精力是在 TypeScript 跟 ClojureScript 这边, 对 C 了解很少,
我介绍的这个项目是用 Nim 写的, Nim 内置了 GC 功能, 用起来比较顺手.

Clojure 里用的是 32 分支的 B+ 树来存储数据的.
数据都在叶子节点上, 每次要填入数据的时候, 都会展开对应的分支.
我看源码的时候, 感觉 Clojure 为了性能上的优势, 具体实现是比较简单粗暴的.
没有很精细去做每个操作的结构共享, 所以说只有从尾部写入数据才是比较快的.
我当时尝试自己去试验的时候, 想着结构复用方便, 我就用了 3 个分支的树形结构.
这样也有好处, 就是从前面后面写入数据, 都是一样的, 而且复用这个思路比较清晰.
另外就是考虑 trie 这个结构, 实现 HashMap 的话好像 3 个分支比较容易吧.
这个性能上优化估计是不如 32 分支的, 不过简单场景还是可以跑跑的.

这篇文章里, 主要还是关于试验过程当中遇到的有意思的一些发现.

这个项目当中的 TernaryTreeList 是用 B+ 树实现的, 叶子节点存储数据.
内部节点存储分支包含的数据的大小, 这样索引的时候就能快速查询位置了.
Clojure 的实现当中索引是用 i >>> 5 这样查找的, 一层层在 32 分支当中定位, 很快.
TernaryTreeList 索引查找数据就需要不断计算 size 然一层层查找下去了, 慢一些.

TernaryTreeList 初始化的时候, 会尝试大致均匀分布开来, 至少保证树的深度尽量小.
当然这样其中可能会残留很多的空穴, 空间的利用率不是最高的.

紧凑记法

这里为了快速展示 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 个内部节点被创建出来, 这效果还是可以的.

所以这中间的缺陷就是树形结构是做不到自平衡的. 大部分时候, 树都是不平衡的.
这样效率也就不是最优的. 不过, 考虑到复用节点的需求, 还是不能经常对树进行平衡.

拼接和裁剪

然后还有一些常用的操作比如 concatslice.
如果不在乎平衡不平衡的话, 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 这个库, 并做了一些性能优化.
现在主要来说还是自己实现了这样的数据结构, 有了更深的理解.

阅读 739

题叶
ClojureScript 爱好者.

ClojureScript 爱好者.

17.2k 声望
2k 粉丝
0 条评论
你知道吗?

ClojureScript 爱好者.

17.2k 声望
2k 粉丝
宣传栏