項目地址 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 個內部節點被建立出來, 這效果仍是能夠的.
因此這中間的缺陷就是樹形結構是作不到自平衡的. 大部分時候, 樹都是不平衡的.
這樣效率也就不是最優的. 不過, 考慮到複用節點的需求, 仍是不能常常對樹進行平衡.
而後還有一些經常使用的操做好比 concat
和 slice
.
若是不在意平衡不平衡的話, 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 這個庫, 並作了一些性能優化.如今主要來講仍是本身實現了這樣的數據結構, 有了更深的理解.