關於 ternary-tree 不可變數據結構複用方案的一些解釋

前面一篇講 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 和 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 廣泛更深, 也就意味着可能有更屢次判斷.

再想一想, Clojure 用 List 是有好處的, 若是從頭部一個個取,
好比用 rest 獲取後續的序列, 鏈表的話每次引用都是同樣的.
然而用 ternary-tree 的方案, 絕大部分狀況都是產生新的引用,
若是程序當中使用了 memoization, 根據引用作判斷的話, Clojure 代碼性能就更高了.
ternary-tree 就會產生新的引用, 至少 identical? 的操做是不夠了.

就已有的 ternary-tree 實現, 我用 nimprof 定位看了看,明顯性能問題的地方已經被我優化掉了, 稍微深層的一些, 棘手的都沒有去深刻處理.等到 ternary-tree 後續若是遇到真實場景有明顯的問題, 我再着手處理一下.

相關文章
相關標籤/搜索