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

项目地址 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 个内部节点被建立出来, 这效果仍是能够的.

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

拼接和裁剪

而后还有一些经常使用的操做好比 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 这个库, 并作了一些性能优化.如今主要来讲仍是本身实现了这样的数据结构, 有了更深的理解.

相关文章
相关标签/搜索