講究套路之前,先來回答三個問題。
為什麼要列印樹形結構
樹形結構是演算法裡很常見的一種資料結構,從二叉樹到多叉樹,還有很多變種。很多涉及到演算法的工作,就需要程式設計師自己手動實現樹形結構,但出於結構本身複雜性,不太容易做對,需要一種除錯工具來檢測正確性。一般的除錯手段無非就是加列印,GDB上斷點,寫測試用例等,但這些區域性以及外部的除錯資訊對於資料結構的整體把握提供的幫助十分有限,經驗不足的程式設計師甚至可能會迷失在一大片除錯資訊的汪洋大海中找不著北。理解演算法本身是一回事,自己動手是另一回事了,這跟我們理解演算法的思維方式有關——對於資料結構而言,我們的感知是形象化的,比方腦海中自動出現一幅圖,動態的插入刪除,每個節點是如何變動的,平衡的時候區域性是怎麼旋轉的等等,對智力正常的人來說不是什麼難事。但對機器來說,它要面對的是隻是一堆基於狀態的指令而已,將人的形象思維轉化為狀態機,本身是一件艱難的工作,因為我們很難感知並儲存這麼多狀態,這就需要工具來輔助,最好是畫出整個形狀結構,以直觀地提醒我們哪裡出錯了,所謂“觀其形,見其義”。
我們知道Linux有個tree命令用來列印樹狀目錄列表,可以將某個目錄下的所有檔案和子目錄一覽無遺,非常直觀,本文可以說就是為了實現這個效果,並給出原始碼實現。
為什麼用深度優先遍歷
主要是方便輸出。在終端輸出一般都是從左至右,從上到下,對於樹形結構來說,前者自然表達的是從根節點到葉子節點,後者自然表達的是相鄰分支,深度優先遍歷符合輸出次序。
實際上廣度優先遍歷實現起來更簡單,只要在每一層左端建立一個連結串列頭,將同一層的節點橫向串聯起來,從上到下遍歷連結串列頭陣列就可以了。但考慮以下幾點:
- 我們的螢幕沒有這麼寬,足以容納整棵樹,而且我們更趨向於縱向滾動瀏覽;
- 層次關係很難表示,光實現對齊就很麻煩;
- 每個節點需要維護一個額外next指標,如果這不是資料結構本身所需要的成員,對於儲存空間來說是個額外的負擔。
這也說明深度優先遍歷第二個優點,它的實現對於資料結構本身是非侵入式的。
為什麼使用非遞迴遍歷
其實這是一個見仁見智的問題。遞迴還是非遞迴,不過是兩種不同的遍歷形式,不存在絕對的優劣,而且一般情況下可以相互補充。我個人選擇非遞迴出於以下幾種因素:
- 避免樹層次過多導致函式呼叫堆疊溢位;
- 避免C語言函式呼叫開銷;
- 所有狀態可見可控。
當然以上因素並不重要,開心就好。
一切皆套路,不變應萬變
既然本文講究套路,那麼幹脆現在就把套路給出來好了,虛擬碼形式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
/* log物件 */ typedef struct node_backlog { node指標; 回溯點位置(索引); }; /* Dump */ void dump(tree) { 從根節點開始迭代; 初始化log堆疊; for (; ;) { if (節點指標為空) { 從log物件中獲取回溯點位置; if (不存在,或無效的回溯點) { 壓棧空節點指標; } else { 壓棧當前節點指標,同時記錄下一個回溯點位置; } if (回溯點位置索引為0) { 輸出層次縮排、畫路徑,列印節點內容; } 進入下一層; } else { if (log堆疊為空) return; 彈出log物件,獲取最近記錄的節點指標; } } } |
簡單吧?而且我敢說,這個套路對於所有樹形結構都是通用的,只要能夠深度遍歷。
不信我給出三個實戰例子。
目錄樹或字典樹
程式碼在gist。這是個MIB樹,是管理網路節點(裝置)用的。簡要地講,它具有兩重特性:
- 節點之間的層次巢狀關係,決定了它屬於目錄層次結構;
- 節點的key具有公共字首,使得它也類似於(或可用於)字典結構。
我們不需要關心其CRUD實現,只需要知道有一棵現成的目錄樹或者字典樹,我們如何在終端輸出它的形狀。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
#define OID_MAX_LEN 64 struct node_backlog { /* node to be backlogged */ struct mib_node *node; /* the backtrack point, next to the orignal sub-index of the node, valid when >= 1, invalid == 0 */ int next_sub_idx; }; static inline void nbl_push(struct node_backlog *nbl, struct node_backlog **top, struct node_backlog **bottom) { if (*top - *bottom< OID_MAX_LEN) { (*(*top)++) = *nbl; } } static inline struct node_backlog * nbl_pop(struct node_backlog **top, struct node_backlog **bottom) { return *top > *bottom? --*top : NULL; } void mib_tree_dump(void) { int level = 0; oid_t id = 0; struct mib_node *node = *dummy_root; struct node_backlog nbl, *p_nbl = NULL; struct node_backlog *top, *bottom, nbl_stack[OID_MAX_LEN]; top = bottom = nbl_stack; for (; ;) { if (node != NULL) { /* Fetch the pop-up backlogged node's sub-id. If not backlogged, set 0. */ int sub_idx = p_nbl != NULL ? p_nbl->next_sub_idx : 0; /* Reset backlog for the node has gone deep down */ p_nbl = NULL; /* Backlog the node */ if (is_leaf(node) || sub_idx + 1 >= node->sub_id_cnt) { nbl.node = NULL; nbl.next_sub_idx = 0; } else { nbl.node = node; nbl.next_sub_idx = sub_idx + 1; } nbl_push(*nbl, *top, *bottom); level++; /* Draw lines as long as sub_idx is the first one */ if (sub_idx == 0) { int i; for (i = 1; i < level; i++) { if (i == level - 1) { printf("%-8s", "+-------"); } else { if (nbl_stack[i - 1].node != NULL) { printf("%-8s", "|"); } else { printf("%-8s", " "); } } } printf("%s(%d)\n", node->name, id); } /* Go deep down */ id = node->sub_id[sub_idx]; node = node->sub_ptr[sub_idx]; } else { p_nbl = nbl_pop(*top, *bottom); if (p_nbl == NULL) { /* End of traversal */ break; } node = p_nbl->node; level--; } } } |
程式碼不算複雜,就講幾個要點
深度優先遍歷要利用回溯點,就是走到一個分支的盡頭後,上溯到原先路過的某個位置,從另一個分支繼續遍歷,如果回溯到根節點,就說明遍歷結束了,所以,回溯點是必須要記錄的。問題是記錄哪個位置呢?以二叉樹為例,遍歷了左子樹後,接下來遍歷的就是右子樹,所以回溯點是右孩子;對於多叉樹,遍歷第N個分支後,接下來要遍歷N+1分支,所以回溯點是N+1;如果遍歷完最後一個分支,則需要繼續上溯尋找回溯點了。所以呢,我們就用sub_idx + 1來記錄回溯點,我們還可以利用這個屬性做個分類,值大於等於1時,回溯點有效,值等於0,回溯點無效。
關於log堆疊操作,這裡使用了二級指標的技巧。這個堆疊十分小巧,所以利用函式區域性變數做儲存也未嘗不可,還有不需要對外暴露資料的好處。那麼對於堆疊指標,就需要傳遞二次指標來改變它。比如我們看入棧操作:
1 |
(*(*top)++) = *nbl; |
這是將log物件拷貝給top指向位置,然後將top指標上移,top和bottom的差值就是堆疊元素的數目。由於top是二級指標,所以被賦值的是**top,指標移動就是(*top)++。再來看出棧操作:
1 |
return --*top; |
先將top下移一個單位,然後返回所指向的log物件,也就是*top。
接下來該深入講解套路了,首先,根節點設定成了dummy,這是一個虛擬節點,是為了保證最上層只有一個節點而使用的編碼技巧,好比tree命令輸出目錄樹總是從當前目錄“.”開始。由於第一次進入迴圈,log堆疊為空,不存在所謂回溯點,我們將回溯位置索引設為0,這有兩重含義,一來表示該回溯點無效或不存在,二來既然沒有回溯,那麼接下來就從當前節點的第一個分支開始遍歷。
然後我們將遍歷過的節點壓棧,這裡也是有區分的:如果當前是葉子節點,或者所有分支都遍歷完了,那麼應該繼續上溯去尋找回溯點,我們就將回溯點設為無效後壓棧;否則就將當前節點設為回溯點,並記錄位置索引後壓棧。
畫線輸出部分稍後講。我們根據前面獲取的索引sub_idx進入下一層,直到觸底回溯,這時從log堆疊彈出回溯點,pop有三種情況:由於第一個壓棧為根節點,堆疊為空表示回溯到原點,也就標誌著整個遍歷結束,退出迴圈;否則檢視回溯點是否為NULL,如果空如前所述繼續上溯;如果存在有效回溯點,則將回溯位置索引取出,繼續下一輪遍歷迴圈。
最後講終端輸出。前面說過每一行從左至右的輸出的是樹的層次遍歷,其實就是遍歷log堆疊;換行輸出就是樹的分支遍歷,就是每一輪迴圈。輸出內容主要是三個符號:縮排、分支和節點內容。我們作如下策略:
- 縮排:當堆疊裡回溯點無效,則不存在分支,列印空格,八個字元對齊;
- 分支:當堆疊裡回溯點有效,表示存在分支,列印“|”和空格,八個字元對齊;
- 節點:當堆疊遍歷到最後一個元素,表示後面將要輸出節點內容,列印“+—”,八個字元對齊,後面跟節點內容。
當然你也可以自定義列印策略以便輸出更美觀。好了,說了一大堆,看效果吧,執行程式,一目瞭然。
B+樹
程式碼在此。B+樹是關聯式資料庫常用的底層資料結構,實現起來相當恐怖,所幸本文不講這些,這裡只是將B+樹作為多叉樹示範如何列印,特別是葉子節點和非葉子節點本身定義不同的情況下。從輸出實現上我們發現,log物件記錄的只是節點的指標和回溯位置,同資料節點本身沒有關係。我們幾乎可以原封不動地把上面的程式碼搬過來,執行效果如下:
從形狀上可以看到B+樹的真實資料都儲存在葉子節點,而且整棵樹是平衡的。
紅黑樹(二叉樹)
程式碼在此。理解了多叉樹的實現,二叉樹不過是一種特殊簡化形式罷了。本文挑選了紅黑樹為代表,程式碼自己懶得寫了,直接拿Nginx原始碼。
觀察得出,二叉樹關於回溯點的位置其實只有右邊分支,也就是說回溯位置索引只有一個值,就是1。這樣一來我們可以做個簡化,將左分支索引設為0表示無效回溯位置,右分支索引設為1表示有效回溯位置,程式碼可以這樣寫:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
#define RBTREE_MAX_LEVEL 64 #define RBTREE_LEFT_INDEX 0 #define RBTREE_RIGHT_INDEX 1 void rbtree_dump(struct rbtree *tree) { int level = 0; struct rbnode *node = tree->root, *sentinel = tree->sentinel; struct node_backlog nbl, *p_nbl = NULL; struct node_backlog *top, *bottom, nbl_stack[RBTREE_MAX_LEVEL]; top = bottom = nbl_stack; for (; ;) { if (node != sentinel) { /* Fetch the pop-up backlogged node's sub-id. If not backlogged, set 0. */ int sub_index = p_nbl != NULL ? p_nbl->next_sub_idx : RBTREE_LEFT_INDEX; /* backlog should be reset since node has gone deep down */ p_nbl = NULL; /* Backlog the node */ if (is_leaf(node, sentinel) || sub_index == RBTREE_RIGHT_INDEX) { nbl.node = sentinel; nbl.next_sub_idx = RBTREE_LEFT_INDEX; } else { nbl.node = node; nbl.next_sub_idx = RBTREE_RIGHT_INDEX; } nbl_push(&nbl, &top, &bottom); level++; /* Draw lines as long as sub_idx is the first one */ if (sub_index == RBTREE_LEFT_INDEX) { /* Print intent, branch and node content... */ } /* Move down according to sub_idx */ node = sub_index == RBTREE_LEFT_INDEX ? node->left : node->right; } else { /* Pop up the node backlog... */ } } } |
讓我們看一看輸出效果……等等,我們發現對於二叉樹,右孩子在左孩子的下一行列印,視覺上有點不習慣是嗎?還好我貼心地將LEFT_INDEX和RIGHT_INDEX交換了一下次序,右孩子就先於左孩子輸出了,這樣一來你就可以歪著腦袋直觀地看二叉樹了(笑),同時我們還知道,“翻轉”一棵二叉樹是多麼容易(笑)。
工欲善其事,必先利其器。學會了樹形結構列印工具,針對這樣的資料結構,只有你寫不了的,沒有你寫不對的。最後給出一個思考題:如何用遞迴形式實現列印樹形結構?(提示:利用引數傳遞)