前面一篇讲 ternary-tree 模块的文章是丢给 Clojure 论坛用的, 写比较死板.
关于 ternary-tree 开发自己的过程还有其中的一些考虑, 单独记录一下.
中间涉及到的一些例子再也不详细跑代码录了, 看以前那篇文章应该差很少了.java
首先 structural sharing 的概念, 在看 Clojure Persistent Data 那篇文章以前, 我也是模糊的.
常规的, 若是按照 C 学习的话, 一个 struct 对应的是连续的内存,
而后要不可变数据结构, 就是要复制才能够, 固然这样就没法达到 sharing 的概念了.
而具体到 Clojure 那个 Persistent Data, 他是用 B+ 树实现的, 才能复用结构.
那个系列文章其实讲得蛮详细了, 就差对着代码分析每一个操做了.git
我刚开始弄 ternary-tree 模块的时候, 只是看了文章前几篇,
后面几篇关于位操做还有性能方面的, 看得迷糊就没仔细读了.
Clojure 关于 vector 操做的源码, 我也是后来再去看了下. 其余部分也没去看.
因此当时对 Clojure 具体的实现, 内心仍是有点茫然的.
固然, 从前面的文章当中, 我知道, 那是要的 B+ 树, 而后 32 分支, 而后结构复用.github
我为了简化问题, 就考虑直接用比较少的分支, 好比 2 个 3 个这样,
选择 3 的缘由首先仍是考虑到数据插入有从开头插入, 从结尾插入, 都有,
设定 3 个分支的话, 操做应该会比较平衡, 因此我就用 3 来尝试了.
固然 3 有个问题, 计算机是二进制, 那么"除以2"这个操做就比较快, 而 3 会慢.
当时就没管这么多了, 并无期望性能追上那个 Clojure 的实现.算法
树形结构存储数据, 从基本的就能知道, 要访问数据须要一层层从根节点访问进去,
我设计每一个内部节点上有 size 树形, 记录当前分支的大小,
而后访问 idx 位置的话, 按照子节点的 3 个 size 分别算就好了, 这个而简单,
那么要性能快, 就是要查的次数尽可能少了, 也就是树的深度尽可能少.
这样很容易就有一个方案, 初始化时候每一个分支数据尽可能平分, 这样深度就会尽可能少.
那么到每一个节点来讲, 个数除以 3, 余数多是 0, 1, 2, 那么只能说尽可能平均吧.
我当前的是按照平衡来的, 多一个放中间, 多两个放两边, 这样尽可能是平衡的.数据库
使用之后, 发现这个方案也不是最优的, 由于我打印一看就知道不少的空穴.
除了性能, 整个树也是有储存空间的消耗的, 叶子节点是数据, 确定是须要的,
而后数量的区别就是不一样的结构, 致使的中间节点数量不一样.
好比说 [1 2 3 4 5 6]
这个序列, 就可能不一样的结构,
首先是我按照平衡分配的方案, 先 3 等分, 而后再左右均分:segmentfault
((1 _ 2) (3 _ 4) (5 _ 6))
这个例子当中内部节点, 4 对括号对应 4 个节点, 加上 3 个空穴.性能优化
或者我手动紧凑一点, 但不按照平衡的逻辑来:数据结构
((1 2 3) (4 5 6) _)
能够看到是 3 对括号就是 4 个内部节点, 加上 1 个空穴.
明显, 这个比起上面是更加紧凑的, 固然这个是手动排列出来的.
能够设想, 数量更大的列表, 结构的可能性会更多, 空穴也会更多.app
固然, 极端一点, 好比我每次新增元素都在当前节点右边, 那结果就更夸张了:jvm
((_ (_ (_ (_ 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 跟前面的尾部增长数据类似, 只不过如今换成了增长的是一串数据,
简单的 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 的源码, 就 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 广泛更深, 也就意味着可能有更屡次判断.
再想一想, Clojure 用 List 是有好处的, 若是从头部一个个取,
好比用 rest
获取后续的序列, 链表的话每次引用都是同样的.
然而用 ternary-tree 的方案, 绝大部分状况都是产生新的引用,
若是程序当中使用了 memoization, 根据引用作判断的话, Clojure 代码性能就更高了.
ternary-tree 就会产生新的引用, 至少 identical?
的操做是不够了.
就已有的 ternary-tree 实现, 我用 nimprof 定位看了看,明显性能问题的地方已经被我优化掉了, 稍微深层的一些, 棘手的都没有去深刻处理.等到 ternary-tree 后续若是遇到真实场景有明显的问题, 我再着手处理一下.