关于 ternary-tree 不可变数据结构复用方案的一些解释

前面一篇讲 ternary-tree 模块的文章是丢给 Clojure 论坛用的, 写比较死板.
关于 ternary-tree 开发本身的过程还有其中的一些考虑, 单独记录一下.
中间涉及到的一些例子不再详细跑代码录了, 看之前那篇文章应该差不多了.

首先 structural sharing 的概念, 在看 Clojure Persistent Data 那篇文章之前, 我也是模糊的.
常规的, 如果按照 C 学习的话, 一个 struct 对应的是连续的内存,
然后要不可变数据结构, 就是要复制才可以, 当然这样就无法达到 sharing 的概念了.
而具体到 Clojure 那个 Persistent Data, 他是用 B+ 树实现的, 才能复用结构.
那个系列文章其实讲得蛮详细了, 就差对着代码分析每个操作了.

我刚开始弄 ternary-tree 模块的时候, 只是看了文章前几篇,
后面几篇关于位操作还有性能方面的, 看得迷糊就没仔细读了.
Clojure 关于 vector 操作的源码, 我也是后来再去看了下. 其他部分也没去看.
所以当时对 Clojure 具体的实现, 心里还是有点茫然的.
当然, 从前面的文章当中, 我知道, 那是要的 B+ 树, 然后 32 分支, 然后结构复用.

我为了简化问题, 就考虑直接用比较少的分支, 比如 2 个 3 个这样,
选择 3 的原因首先还是考虑到数据插入有从开头插入, 从结尾插入, 都有,
设定 3 个分支的话, 操作应该会比较平衡, 所以我就用 3 来尝试了.
当然 3 有个问题, 计算机是二进制, 那么"除以2"这个操作就比较快, 而 3 会慢.
当时就没管这么多了, 并没有指望性能追上那个 Clojure 的实现.

树的初始化

树形结构存储数据, 从基本的就能知道, 要访问数据需要一层层从根节点访问进去,
我设计每个内部节点上有 size 树形, 记录当前分支的大小,
然后访问 idx 位置的话, 按照子节点的 3 个 size 分别算就行了, 这个而简单,
那么要性能快, 就是要查的次数尽量少了, 也就是树的深度尽量少.
这样很容易就有一个方案, 初始化时候每个分支数据尽量平分, 这样深度就会尽量少.
那么到每个节点来说, 个数除以 3, 余数可能是 0, 1, 2, 那么只能说尽量平均吧.
我当前的是按照平衡来的, 多一个放中间, 多两个放两边, 这样尽量是平衡的.

使用以后, 发现这个方案也不是最优的, 因为我打印一看就知道很多的空穴.
除了性能, 整个树也是有储存空间的消耗的, 叶子节点是数据, 肯定是需要的,
然后数量的区别就是不同的结构, 导致的中间节点数量不同.
比如说 [1 2 3 4 5 6] 这个序列, 就可能不同的结构,
首先是我按照平衡分配的方案, 先 3 等分, 然后再左右均分:

((1 _ 2) (3 _ 4) (5 _ 6))

这个例子当中内部节点, 4 对括号对应 4 个节点, 加上 3 个空穴.

或者我手动紧凑一点, 但不按照平衡的逻辑来:

((1 2 3) (4 5 6) _)

可以看到是 3 对括号就是 4 个内部节点, 加上 1 个空穴.
明显, 这个比起上面是更加紧凑的, 当然这个是手动排列出来的.
可以设想, 数量更大的列表, 结构的可能性会更多, 空穴也会更多.

当然, 极端一点, 比如我每次新增元素都在当前节点右边, 那结果就更夸张了:

((_ (_ (_ (_ 1 2) 3) 4) 5) 6)

5 对括号了, 空穴也有 4 个, 就比较浪费, 每增加一个元素就增加一对括号, 一个节点.
当然这个明显有问题, 就是树的深度, 数据到 N 就就会有 N 层, 性能肯定不行,
最少也要保证, 至少初始化的时候, 树的深度要尽量小.

空穴的多少, 其实也还有一个考虑, 就是后续插入数据的时候, 空穴增加还是减少.
比如说平衡的那个, 我需要往中间插入数据的话, 就有可能利用空穴.
注意, ternary-tree 这个还是不可变数据, 插入数据并不是说直接填上去,
从实现来说是复用部分的分支, 能复用越多越好, 某种程度上, 填空穴也能认为复用多.
空穴这个, 主要是优化存储的效率.

比如深度为 3 的话(根节点也算进去), 最终是容纳 3 * 3 总共 9 个节点, 4 就是 27,
这样空间利用的效率, 同个深度就是最高的. 4 层能存 27 个数据.
主要就是不满 27 个数据时, 4 层以内, 中间的数据怎么排列?
也大致可以知道, 手动设计每个节点 3 个位置尽量填满, 利用率是最大的.

所以前面文章我想到一个方案是尽量放到中间去, 一定程度上减小生成的体积.
不过实际试了一下, 那样填的话, 要算中间取多少个, 计算就挺复杂了,
复杂的计算对性能有点影响, 而且数据集中在中间, 中间就是满的,
结果就是新的数据在中间插入的话, 肯定很容易增加深度, 也未必是好的.
所以没有想清楚到底好坏这个结果. 我没有切换掉方案.

头尾增加数据

就数据的高频操作来说, 从头部和尾部追加数据是高频的, 特别 Clojure 这种依赖尾递归的场景.
就前面来说, 数据初始化的时候集中在中间的话, 那么后续从头尾加, 也方便紧凑.
直观理解的话要看几个简单的场景了. 我就拿这个数据做例子, 在后面增加 7:

((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))
 ((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))

挺复杂的, 判断逻辑就多出来很多了. 实际的代码规则其实也比较绕了...
这样做的话, 可以想象, 左边的一些分支是复用的, 右边就不一定了.
然后往上的那些根节点都是会被查新创建的, 为了不可变数据嘛, 会有大约 log3(N) 的一个消耗.

这个是当前 ternary-tree 代码当中使用方案, 实现起来还没很复杂.
应该说是一个兼顾了内存使用效率和数据复用的一个方案. 偏向于内存使用效率.
同时由于前面的部分一般是复用的, 可以看到空穴就是留着没动.

由于 ternary-tree 这个对称的特性, 如果换成从头部插入数据, 这个基本也是一样的.

内部插入数据

然后是在内部插入数据的情况, 当然这边不可变数据, 其实还是从根节点开始创建索引的,
那么, 左边和右边的一些数据 还是有可能复用的, 比如说下面这个例子,
在 after 2(对应到元素 3 的位置)的位置插入一个数据 88,

((1 _ 2) (3 _ 4) (5 _ 6))
((1 _ 2) (3 88 4) (5 _ 6))

可以看到最左和最右的分支可以被继续使用, 然后中间相近的还是要重新创建索引了.
大致是这么一个情况.
然后再看一个如果没有空穴的情况呢? 在 after 4(对应元素 5 的后面):

((1 2 3) (4 5 6) (7 8 9))
((1 2 3) (4 (5 88 _) 6) (7 8 9))

可以看到, 这种情况为了复用左后, 就是中间直接增加和展开, 也就是增加了深度.
也就意味着如果持续在中间的某些位置增加的话是很容易增加深度的的,
这不像是在头尾连续增加, 头尾的话可以对元素做一些位移, 然后复用的时候控制一下位置,
中间的话能调整的空间就不多了, 中间分支增加深度以后, 周围那是没有增加深度的.
但是从访问中间的数据来说, 访问的深度就容易增加很多了. 性能隐患.

concat 和 slice 操作

concat 跟前面的尾部增加数据相似, 只不过现在换成了增加的是一串数据,
简单的 concat 方式就是增加一个共同的父节点了. (A _ B) 这样子. 访问是不影响的.
这样的隐患也明显, 就是多次之后树的深度增加也是很快.
现在 ternary-tree 的方案是设定一个深度的范围, 增加到超出了, 再考虑是不是处理一下.

slice 操作复杂一点, 就是要提取中间一段范围的数据.
可以想象, 范围内的完整分支, 当然是可以直接复用的, 边缘的就只能部分部分复用了.
这部分原理比较清晰, 没有什么需要犹豫的地方, 优化的途径也比较容易定位.

具体不深入了.

树的平衡

前面也提到了说, 插入或者 concat 的情况, 会增加树的深度,
而为了复用树的结构, 尽量是不应该对已有的数据的结构进行破坏的.
这两个当然就存在着冲突, 只能权衡了.
现在 ternary-tree 实现当中, 考虑的是尽量在局部重建, 远处的分支尽量复用,
然后等到发现深度大, 真的需要处理的时候, 就一次性重新初始化, 降低深度.
这个策略不算很好, 因为重新初始化树结构的消耗是比较大的, 特别是内存.
其次, 真的要我写一个算法, 重建树的结构, 还要部分部分复用, 这难度也大很多了.

我网上翻的时候, 发现红黑树做了自平衡的事情, 用在数据库的场景里边.
老实说我大致看明白了自旋, 但是也没搞明白为什么要区分颜色,
同样也有一个问题, 二叉树空间利用率更高, 我用三叉树反而增加复杂度了.
当然二叉树的话, 节点容纳的效率也有区别, 可以做一个对比,

((1 2 3) (4 5 6) (7 8 9))

(((1 2) (3 4)) ((5 6) (7 8)))

分支为 3 的时候, 9 个元素, 用到 4 个内部节点进行索引,
分支为 2 的时候, 8 个元素, 用到 7 个内部节点进行索引,
这样一比, 3 个分支的话, 内部节点的使用效率还是高一点的... 32 分支还更高.

理想情况下, 以后出于性能优化的需要, 可能也找一找三叉树进行快速自旋的方案,
如果能智能地在树的结构改变的时候做一下局部的自旋维持平衡, 效率应该还是不错的.
就触发的时机来说, 树不平衡的话, 访问的性能有影响,
但是总是触发进行平衡的话, 重建树的结构性能的开销一次也很大.
除非真的能找到一个低成本的重建的方案, 不然现在也只能做一定的容忍.

跟 Clojure 方案作对比

我后面翻了一下 Clojure 的源码, 就 Vector conj 这部分,
除了 32 分支那个事情, 如果用 ternary-tree 这个表示的话, 堆积的方式是这样的,

  (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 _ _) _ _) _)

可以看到就是从左边开始堆积, 然后元素的深度始终是维持一致的.
这个结构, 查找访问的位置就很容易了, 位操作算一算, 马上就知道, 而且深度稳定的.

问题也能看出来, Clojure 常说的, Vector 进行 conj 操作最快,
conj 就是说在尾部追加元素了, 这个当然快, 尾部就是留着位置的.
如果我要在头部加数据就麻烦点了, 说不得还得重新创建一棵树.
如果要取出局部的数据的话, 结构复用这个事情就不一定了.
翻了一下 subvec 倒是用虚拟的 index 计算的, 性能应该也还快:
https://github.com/clojure/cl...
就真实的场景来说, Clojure 真的头部尾部访问, 占了绝大多数了,
而且 Vector 的 rest 调用之后直接得到 List, 变成方便从头部读取, 也没毛病,
谁有事没事总从后面取啊, 实在不行通过 index 自己去取, 也不是不行.

真要说好处的话, ternary-tree 这个方案, 一个结构有 List Vector 两者的用法,
就是支持头部尾部较为高效添加, 也支持随机访问, 甚至随机操作,
同时总体上结构复用的还比较多... 倒是可以避免像学习 Clojure 的时候那么的困惑,
毕竟在 Clojure 当中两个数据动不动要转换, 而且默认是自动转换 List 的, 也不方便.
其他的, 就是研究和试验的意义比较多了.

性能方面...

没有对比的测试... 如果有人想要试试的话, 搜是有搜到 Nim 的实现的, 没细看过,
https://github.com/PMunch/nim...
从原理估计, ternary-tree 访问速度肯定是慢的,
至于说 append 的性能, 我估计 ternary-tree 不稳定,
遇到刚才复用比较多的时候, 创建的新数据成本是很低的, 前面可以看到某些节点深度很小,
而遇到大部分情况, 由于 ternary-tree 普遍更深, 也就意味着可能有更多次判断.

就已有的 ternary-tree 实现, 我用 nimprof 定位看了看,
明显性能问题的地方已经被我优化掉了, 稍微深层的一些, 棘手的都没有去深入处理.
等到 ternary-tree 后续如果遇到真实场景有明显的问题, 我再着手处理一下.

你可能感兴趣的:(immutable-js,clojure,nim)