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 這個庫, 並作了一些性能優化.如今主要來講仍是本身實現了這樣的數據結構, 有了更深的理解.

相關文章
相關標籤/搜索