contributed by < WangHanChi
>
經過了老師的一對一面談後,發現自己對於紅黑數的理解完全不足,因此打算重新補足相關知識。會先以老師的書以及教材為主。
先從 header rb.h 看起,會列出較為複雜或是重要的部份程式碼來筆記,避免自己讀過就忘。
這段程式碼是為了限制存儲紅黑樹時可能出現的問題。通過限制每個節點佔用的最小空間和限制樹的深度 ( 128 個深度),可以確保樹不會佔用太多的空間,並且可以正常運作。
這邊主要在設定左右節點的資訊,其中用到了 do-while(0) 語法,可以參見你所不知道的 C 語言: goto 和流程控制篇 (dangling else)
再來這邊左旋或是右旋的設定,以右旋來進行舉例,搭配著下面這張圖。
右旋的操作可以分成三個部份
左旋的想法也是類似的。透過組合幾個巨集,完成了樹的旋轉操作。
上面的程式碼是運用到了 C 語言:前置處理器應用篇 這篇裡面的技巧。
##
: concatenation (連結,接續)這樣就會使得傳入的 x_prefix
可以連接,變成有意義的宣告。
像是在 quiz4 測驗一裡面的 C++ 測試程式碼 中,就使用了
來進行命名,也就會使得函式名稱為 tree_new
, tree_search
等等的
而其他的參數也可以從下方的註解觀察到
typedef
typedef
再來會是比較長的部份,先從註解了結程式碼內容
可以看到它說明了這些巨集程式的描述以及引數,還有他的函式屬性
接著會一個註解搭配一段程式碼說明
可以得出
在註解裡面有些部份是假設我們已經完成的
可以從 C++ 測試程式中找到對應的程式碼
可以看到在 node_t
這個結構體裡面,透過了巨集 #define rb_node(x_type) struct { x_type *left, *right_red; }
來展開變成 struct { node_t *left, *right_red; }
,並且把它命名為 link 。
並且也展開了 #define rb_tree(node_t) tree_t
變成 struct { node_t *root; }
。
等待這些程式碼都完成後,就將這些變成引數傳入 rb_gen
裡面產生更多的程式碼
這邊定義了一個結構體,目前無法得知他是用在何處的,要繼續觀察程式碼才能弄清楚。
這邊通過了巨集設定了 rbtree->root = NULL
這邊使用了三個數值來進行分類。可以從比較的函式回傳值得到節點的比較結果 (cmp = (x_cmp) (key, ret))
。
以下是三種結果
根據 key 值找到節點之後就會進行回傳,也有可能沒有找到,就會回傳 NULL。
這邊先宣告了 tree_path_entry_t 的陣列 path 與指標 *pathp
並且使用了跟 search
很像的作法,比較了 node
與 pathp->node
的 key 後再決定往右邊或是左邊尋找。在這邊使用了 assert(cmp != 0);
代表了在這棵紅黑樹裡面沒有一個 key 值與要插入的節點 node
擁有相同的 key 值。
以下面這張圖來舉例。
現在要插入的節點 key 為 90
。那麼找尋的過程可以寫成下面這樣
50
進行比較,得到 cmp = 1 後,就會往右邊進行下一層的尋找,並且把 cmp 紀錄著 (這就是老師留言說明與 parent 有關的原因)70
進行比較,一樣得到了 cmp = 1 後,就會往右邊進行下一層的尋找,並且把 cmp 紀錄著80
進行比較,得到 cmp = 1 後,就會往右邊進行下一層的尋找,這時會得到 NULL,並且把 cmp 紀錄著透過這樣的方式,就可以找到插入的路徑了,也難怪會將陣列命名成 path
接下來再將陣列 path
反向回去
可以看到它判斷了每個 pathp
的 cmp 值來決定要進到
cmp < 0
或是
cmp > 0
CCCC
應該是要將 cnode
設定顏色,而它變成了最底部的葉子節點,因此推測 CCCC
為 rbtn_black_set
。
不用抄寫答案,只要專注解析程式碼並討論後續改進。
好的,學生會再去參考其他同學的筆記寫法,謝謝老師
最後再去將樹根節點 root 設定為黑色
來遵守紅黑樹規則中的第二點
- All nodes are either red or black
- Leaf nodes are black (root’s color)
- Leaf nodes do not contain data (NULL)
- All non-leaf nodes have two children
- If a node is red, both its children are black
- When traversing from the root node to a leaf, each path contains the same number of black nodes
上面這段程式碼與 insert
的找尋路徑的方式相似,但是這邊額外新加了判斷 cmp == 0
的狀況,這代表著兩個的 key 值一致,也就是找到了要移除的節點。
在 Red/Black Tree visualization 上以這棵樹為例
假如現在要移除的節點為 0074
這個節點,可以得到這個 path 陣列
node | 0060 | 0072 | 0075 | 0074 | 0073 |
---|---|---|---|---|---|
cmp | 1 | 1 | -1 | 0 | -1 |
而在 cmp == 0
的時候,會把 pathp->cmp = 1
,所以上面 path 陣列會改成
node | 0060 | 0072 | 0075 | 0074 | 0073 |
---|---|---|---|---|---|
cmp | 1 | 1 | -1 | 1 | -1 |
此時 pathp
會是指向 0074
,而 left
會是指向 0073
接著會因為 left
不等於 NULL,進到 if-statement 裡面,最後會進到這
接著因為 pathp[-1]
是 0075
,而他的 cmp 是 -1,因此會執行 rbtn_left_set
這個巨集使得 0075
這個節點的左邊節點變成 left
也就是 0073
,這樣就完成了把 0074
移出的動作。
接下來就倒回去進行平衡。
總共可以分成兩個部份,以 pathp->cmp < 0
為條件,在上述的例子中,這個條件是 True。
就會再進行各個條件判斷
可以知道最後會進到迴圈就是下面這段程式碼的部份
可以看到上面的圖形就跟這邊的圖形一致。
經過這些調整之後,就可以讓樹變回平衡。
可以看到這段程式碼在 Remove 節點的方法是使用遞迴的方式來執行的,它會先將根節點左半邊的全部節點清完後,設置根節點的左節點為 NULL,右邊也是一樣的方法。
而這邊的 cb 是函式指標,代表著 callback function,如果傳進來的參數不為 NULL 的,它就會去呼叫 cb
並執行相對應的功能,而 arg 是 cb 函式的額外參數。
這項實驗設計不恰當之處是:
學生設計這個實驗原先是發想自 quiz3/problem2 延伸問題 1 ,想與 quiz3/problem1 的紅黑樹與 AVL 樹實作的 priority queue 進行比較,只是後者的實作尚未完成,因此想先知道跟 C++ STL 的 priority queue相差多少,才會有這個實驗。
經過老師的題點後,學生會著重於 quiz3 與 quiz4 的紅黑樹比較的,謝謝老師。
Priority queue 可以有多種的實作方式,例如利用 heap 等等的方式,參見〈Priority Queue:Intro〉,我利用此題的紅黑樹實作了簡單的 priority queue,並與 C++ STL 提供的 Priority queue 進行插入與移除節點的比較
Pirority queue 的實作 API 比較少,而因此我的類別如下
以 10 萬筆資料到 100 萬筆資料
data 數 | my_pq(第一次) | my_pq(第二次) | my_pq(第三次) | my_pq(平均) |
---|---|---|---|---|
100000 | 0.03006 | 0.02919 | 0.031959 | 0.03028 |
200000 | 0.03993 | 0.036198 | 0.056237 | 0.044121 |
400000 | 0.050892 | 0.068313 | 0.070066 | 0.063090 |
600000 | 0.085633 | 0.098455 | 0.087791 | 0.090626 |
800000 | 0.124831 | 0.108754 | 0.110198 | 0.114594 |
1000000 | 0.149021 | 0.149008 | 0.124445 | 0.140824 |
data 數 | stl_pq(第一次) | stl_pq(第二次) | stl_pq(第三次) | stl_pq(平均) |
---|---|---|---|---|
100000 | 0.001908 | 0.002878 | 0.001651 | 0.002145 |
200000 | 0.002524 | 0.002429 | 0.003418 | 0.002790 |
400000 | 0.006056 | 0.004783 | 0.004756 | 0.005198 |
600000 | 0.007759 | 0.008056 | 0.010521 | 0.008778 |
800000 | 0.009296 | 0.009366 | 0.009465 | 0.009375 |
1000000 | 0.011264 | 0.011689 | 0.011261 | 0.011404 |
接下來將比較兩種不同的紅黑樹實作,分別進行插入節點的比較,而節點的會分成順序排序的節點還有亂數排序的節點兩種資料及進行比較,主要的操作內容會是插入所有節點之後,確保樹是平衡的。
以下實驗稱 quiz3 中紅黑樹為 Original,而 quiz4 中的紅黑樹為 Innovative
以下實驗稱 quiz3 中紅黑樹為 rbt-3nodes,而 quiz4 中的紅黑樹為 rbt-2nodes
data 數 | rbt-3nodes(第一次) | rbt-3nodes(第二次) | rbt-3nodes(第三次) | rbt-3nodes(平均) |
---|---|---|---|---|
10000 | 0.002138 | 0.002149 | 0.002102 | 0.002130 |
40000 | 0.009702 | 0.009803 | 0.008167 | 0.009224 |
70000 | 0.014931 | 0.016541 | 0.014661 | 0.015378 |
100000 | 0.021267 | 0.020736 | 0.020204 | 0.020735 |
data 數 | rbt-2nodes(第一次) | rbt-2nodes(第二次) | rbt-2nodes(第三次) | rbt-2nodes(平均) |
---|---|---|---|---|
10000 | 0.000807 | 0.000798 | 0.000825 | 0.000812 |
40000 | 0.003777 | 0.003751 | 0.003738 | 0.003755 |
70000 | 0.007339 | 0.005851 | 0.006031 | 0.006407 |
100000 | 0.008507 | 0.009132 | 0.009699 | 0.009113 |
為了了解為何效能究竟會差距如此大,使用 perf 來進行分析,這邊所使用的插入資料集都是亂數排序的,而這個實驗要採用插入亂數的原因在於,假如插入連續的數值的話,就會導致整顆紅黑樹的平衡性可能會受到嚴重影響,導致性能下降。例如插入連續數值會導致頻繁的旋轉操作,進而降低插入的效率。
可以看到在 quiz4 中所設計的紅黑樹在 cache-misses 的部份明顯的小於 quiz3 中的紅黑樹,因此在差不多的 instructions 總數下, quiz4 的 rbt-2nodes 版紅黑樹的 IPC性能 明顯優於 quiz3 版的。
這樣的結果也符合實作的預期,精簡了紅黑樹節點所佔用的空間,也符合老師的說明。
考慮到 cacheline 在 x86(-64) 架構為 64 bytes,能夠放入 cache 的節點數量因為這樣的記憶體佈局 (memory layout),變得更多,且紀錄 cmp 的成本相對低並對 cache 友善。
TODO: 引入 rb-bench 進行評比和分析
該程式的走訪是以迭代的方式來進行的,而第一題的走訪是以遞迴來進行的。
以上面這棵樹為例,
在第一次的 while-loop 會把 node 從 0004
移動到 0002
,在藉由下一次的 while-loop,找到 0001
,就會移動到第 273 行的 do_print 這個 label,關於這些 goto 的使用方法可以參考 你所不知道的 C 語言: goto 和流程控制篇。
因為這些節點在進行插入的時候就已經進行排序了,所以印出節點的時候必定是按照比較的函式 (在此例是用 node_less,比小的函式)的順序印出。
因此就會得到這樣的輸出
若要將測驗 1 對節點的紅色和黑色標注的技巧嵌入到指標變數中,需要修改結構以及一些巨集。
如以下所示
將結構體換成測驗題一的形式,將顏色標注嵌入到 右邊節點的指標變數當中
接著引入及修改巨集成測驗題一的形式
最後需要修改 sum_subtree
這個函式