# 【考試向】資料結構筆記(樹及二元樹) [TOC] 歡迎你點入本篇文章!我是 LukeTseng,本系列文章主要整理自學資料結構的一些知識,如果你喜歡我的文章,麻煩您不吝嗇的在文章底下按下一顆愛心,或是追蹤我唷~ ## 簡介(Introduction) ![image](https://hackmd.io/_uploads/SyPuQz1tWe.png) Image Source:https://zh.wikipedia.org/zh-tw/%E6%A0%91_(%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84) 樹(Tree)資料結構是一種**非線性**資料結構,用來模擬具有階層性(Hierarchical)關係的資料。 一棵樹的基本構成由以下兩個要素組成: 1. 節點(Node):樹的基本組成單位,每個節點會包含資料(或鍵值),以及指向其子節點的指標。 2. 邊(Edge / Link):連接兩節點的線條,代表節點之間的關係,一棵有 $N$ 個節點的樹,必定會有 $N-1$ 條邊。 ### 節點關係的術語 1. 根節點(Root):樹的最頂層節點,整棵樹只有一個根節點,且是唯一沒有父節點的節點。 - 如上圖中的節點 A 即根節點。 2. 父節點(Parent):若一個節點向下連接到其他節點,該節點就是那些節點的父節點。 - 如上圖中的節點 A 為 B、C 這兩個節點的父節點。 3. 子節點(Child):由父節點向下連接延伸出的節點。 - 如上圖中的節點 B、C 是節點 A 的子節點。 4. 兄弟節點(Sibling):擁有同一個父節點的節點們互稱兄弟節點。 - 如上圖中的節點 B、C 擁有共同父節點 A,因此 B、C 兩節點是兄弟節點。 5. 葉節點(Leaf / External Node):沒有任何子節點的終端節點(terminal node)(即位於樹的最底層分支末端)。 - 如上圖中的節點 K、O、P 都沒有子節點,因而稱為葉節點。 6. 內部節點(Internal Node):除了葉節點以外的節點,也就是至少擁有一個子節點的節點。 7. 祖先(Ancestor)節點與子孫(Descendant)節點:從根節點往下走到某特定節點的路徑上,經過的所有節點都是該節點的「祖先」;反之,某節點向下延伸出的所有節點都是它的「子孫」。 8. 子樹(Subtree):樹中的任何一個節點,連同它所有的後代節點,可以被看作是一棵獨立的小樹,稱為子樹。 - 如上圖中的節點 C、G、L、M、O、H、N、P 可稱為一個子樹。 ### 測量與屬性的術語 1. 分支度(Degree):一個節點所擁有的子節點(或子樹)數量。而「整棵樹的分支度」是指該樹中所有節點分支度的最大值(例如二元樹的最大分支度為 2)。 - 如上圖中的節點 A 的分支度為 2,因為它有兩個子節點。 2. 深度(Depth):從根節點走到某特定節點所需要經過的邊數。根節點的深度通常定義為 0。 - 如上圖中的節點 C 的深度為 1,因為從根節點到 C 只要經過一個邊;而節點 E 的深度為 2。 3. 高度(Height):從某特定節點走到最遠的葉節點所經過的最長路徑邊數。葉節點的高度通常定義為 0,整棵樹的高度就是根節點的高度。 - 如上圖中的節點 A 的高度為 4(葉節點的高度是 0,往上爬依序是 0, 1, 2, 3, 4),即為整棵樹的高度。 4. 階度(Level):代表節點所在的階層位置。通常將根節點定義為 Level 1(有些定義為 Level 0),它的子節點為 Level 2,以此類推。 ### 樹林(Forest) 樹林(Forest)是由零棵或多棵互不相交(Disjoint)的樹所構成的集合。 若將一棵樹的根節點(Root)移除,且不改變其餘節點的連結關係,那其底下原本互相獨立的各個子樹(Subtrees),就會共同形成一個樹林。 反之,若建立一個新的單一節點,並將一個樹林中所有樹的根節點都連向這個新節點作為它的子節點,這個樹林就會變成一棵單一的樹。 ### 樹的種類 - 二元樹(Binary Tree):每個節點最多只有兩個子節點(左子節點與右子節點),此為大多數複雜樹狀結構的基礎。 - 二元搜尋樹(Binary Search Tree, BST):一種具有順序性質的二元樹。規定左子樹所有節點的值必須「小於」父節點,右子樹所有節點的值必須「大於」父節點,適合用於實作動態集合或字典。 - 平衡樹(Balanced Trees):為了解決一般 BST 在極端情況下(如依序插入遞增數列)會退化成鏈結串列(導致搜尋變為 $O(n)$)的問題,常見的實作有 AVL Tree 和紅黑樹(Red-Black Tree),它們會透過旋轉機制自動保持樹的高度平衡。 - 堆積(Heap):一種特殊的完全二元樹,分為最大堆積(父節點恆大於子節點)和最小堆積。常用於實作優先佇列(Priority Queue)與堆積排序法(Heap Sort)。 - B 樹與 B+ 樹(B-Tree / B+ Tree):一種多路搜尋樹,節點可以擁有超過兩個以上的子節點。被廣泛應用在關聯式資料庫(Database Indexing)與檔案系統中,專為減少磁碟 I/O 讀取次數而設計。 - 字典樹 / 前綴樹(Trie):專門用來儲存與搜尋字串的樹狀結構。利用字串的共同前綴來節省空間,在搜尋引擎的自動完成(Auto-complete)或網路 IP 路由匹配中極為常見。 ### 樹的資料結構表示法 以下是 $N$ 元樹的資料結構表示法: ![image](https://hackmd.io/_uploads/rJMfDFlF-g.png) Image Source:筆者繪製。 可用 C 語言撰寫成這樣: ```c= #include <stdio.h> #include <stdlib.h> // 定義 N 元樹的節點結構 typedef struct TreeNode { int data; // 節點儲存的資料 int childCount; // 記錄該節點有幾個子節點 (link 的數量) struct TreeNode** links; // 指標陣列 :用來儲存 link1, link2... linkn } TreeNode; ``` ### 樹在記憶體實作上的指標計算 以 C 語言實作樹狀結構時,每個節點通常會預先宣告固定數量的指標(有些書會稱之為 LINK),指向子節點。 此時會產生「實際使用的指標」與「浪費掉的空指標」的計算問題。 假設有棵分支度(Degree)為 $k$ 的 $k$ 元樹(k-ary tree),且樹中總共有 $N$ 個節點: - 總指標數:每個節點都有 $k$ 個指標欄位,整棵樹總共有 $N \cdot k$ 個指標。 - 已使用的指標數:這些指標的作用是連接子節點,也就是樹的邊數,而每個節點均被一個指標所指向,所以已使用的指標數恆為 $N-1$。 最後求得空指標數(總指標數減去已使用的指標數):$$(N \cdot k) - (N - 1) = N(k - 1) + 1 = Nk - N + 1$$ 因此會有 $Nk - N + 1$ 個指標(LINK)被浪費掉。 #### 練習 設有一棵樹分支度為 6,且有 50 個節點,則該棵樹需要多少個指標(或稱 LINK)欄位,而實際上用了多少個,浪費多少個指標? 1. 計算總共需要多少個指標欄位: $N \cdot k = 6 \cdot 50 = 300$ 2. 計算浪費多少個指標: $N(k - 1) + 1 = 50(6 - 1) + 1 = 251$ 3. 計算實際上用了多少個: - $300 - 251 = 49$ - 或是 $N - 1 = 50 - 1 = 49$ (因為無論樹的分支度是多少,只要是一棵連通的單一樹,有 $N$ 個節點就必定只有 $N-1$ 條邊) ## 二元樹(Binary Tree) 二元樹是一種非線性且階層式(hierarchical)的資料結構。 二元樹是由節點(Node)組成的有限集合,該集合可為空(Empty),或者是由一個根節點(Root)以及兩棵互不相交的、分別稱為左子樹(Left Subtree)與右子樹(Right Subtree)的二元樹所組成。 限制:二元樹中的每一個節點,最多只能擁有兩個子節點(即分支度 Degree $\le 2$)。 下圖是一棵二元樹,可見每個節點僅有兩個子節點。 ![image](https://hackmd.io/_uploads/Hk0KE_lYZe.png) Image Source:https://www.geeksforgeeks.org/dsa/introduction-to-binary-tree/ ### 二元樹與一般樹的不同 | 比較項目 | 二元樹(Binary Tree) | 一般樹 | |-------|-----------------------------------------------|----------------------------------------| | 最大分支度 | 嚴格限制最多為 2。 | 沒有限制,可為 0 到無限大。 | | 子樹的次序 | 具有排序順序的關係,嚴格區分左子樹與右子樹。即使某節點只有一個子節點,也必須明確指出它是左子節點還是右子節點。 | 不區分左右次序。如果只有一個子節點,它就是該節點唯一的子節點,沒有左右之分。 | | 空樹的允許 | 可為完全沒有任何節點的空集合。 | 傳統定義上不能為空,至少必須包含一個節點(根節點)。 | ### 不同型態的二元樹 #### 左斜樹(Left Skewed Tree)與右斜樹(Right Skewed Tree) - 左斜樹:樹中的每一個非葉節點,都只有左子節點。 - 右斜樹:樹中的每一個非葉節點,都只有右子節點。 - 特性: - 這兩種樹的形狀會完全退化成一條直線,本質上等同於單向鏈結串列(Singly Linked List)。 - 出現這兩種樹會導致**樹的高度等於節點總數** $N$,使得搜尋資料的時間複雜度從對數時間 $O(\log n)$ 劣化為線性時間 $O(n)$。 ![image](https://hackmd.io/_uploads/BkzwruxYbg.png) Image Source:https://www.geeksforgeeks.org/dsa/types-of-binary-tree/ #### 滿枝二元樹(Fully Binary Tree) 定義:一棵深度(或階度 level)為 $k$ 的二元樹,如果擁有剛好 $2^k - 1$ 個節點,這棵樹就是滿枝二元樹(**假設根節點深度為 $1$ 的時候成立**)。 特性:除了葉節點(leaf node)外,所有的內部節點(internal node)皆擁有 2 個子節點,不允許只有一個子節點。 ![image](https://hackmd.io/_uploads/SJreFOgYZe.png) Image Source:https://www.geeksforgeeks.org/dsa/what-is-full-binary-tree/ #### 完整二元樹(Complete Binary Tree) 定義:一棵有 $n$ 個節點、深度為 $k$ 的二元樹,如果將它從上到下、從左至右依次編號(從 $1$ 到 $n$),其每個節點的位置,都能夠與同深度的「滿枝二元樹」中編號 $1$ 到 $n$ 的節點位置完全對應。 意思就是**除了最底層之外,上面所有層級都被完全填滿**;而在最底層的節點,必須嚴格遵守從左側開始連續填滿,中間不能有空隙的規則。 要辨別完整二元樹就是看他倒數第二層以上是否都有全部被填滿,如下圖中倒數第二層 Level 2 以上到 Level 0,全部都有兩個子節點,而 Level 3 中的 J 沒有兄弟節點。 ![image](https://hackmd.io/_uploads/B18Jx0sh-l.png) Image Source:https://www.geeksforgeeks.org/dsa/difference-between-full-and-complete-binary-tree/ 應用:完整二元樹適合用一維陣列來儲存(完全不浪費陣列空間),為實作堆積(Heap)與優先佇列(Priority Queue)的基礎。 ### 二元樹的數學性質 1. 第 $i$ 階度(Level)的最大節點數:在二元樹的第 $i$ 層中,最多有 $2^{i-1}$ 個節點(假設根節點在第 1 層, $i \ge 1$)。 2. 階度 $k$ 的最大節點數:一棵階度為 $k$ 的二元樹,最多有 $2^k - 1$ 個節點(假設根節點在第 1 層, $i \ge 1$)。 3. 葉節點與分支度(Degree)為 $2$ 的節點關係:對於任何一棵非空的二元樹,如果其葉節點(分支度為 $0$)的數量為 $N_0$,且分支度為 $2$ 的節點數量為 $N_2$,則必然存在以下公式:$$N_0 = N_2 + 1$$ - 葉節點的數量永遠比擁有兩個子節點的內部節點數量多出 1 個。 如下圖中階度 $3$ 的最大節點數為 $2^3 = 8$ 個節點(因為根節點階度是 $0$,不用 $- 1$),而全部最大節點數為 $2^4 - 1 = 15$ ($0 \sim 3$ 階度總共有四個階度)。 ![image](https://hackmd.io/_uploads/Hk_1sdeYbg.png) Image Source:https://www.geeksforgeeks.org/dsa/difference-between-full-and-complete-binary-tree/ #### 完整二元樹的數學性質 完整二元樹最著名的特性,即父、子節點之間的關係可透過簡單的代數運算來定位,使遍歷與節點交換的時間複雜度極低。 **若根節點儲存在陣列索引 $1$ 的位置(1-based indexing)**: 對於陣列中的任意節點 $i$($1 \leq i \leq n$): - 父節點: - 若 $i = 1$ 則為根節點,無父節點。 - 若 $i \ne 1$,則第 $i$ 個節點的父節點位於索引 $\lfloor \frac{i}{2} \rfloor$ ($\lfloor \frac{i}{2} \rfloor$ 表示 $< \frac{i}{2}$ 的最大正整數,即為 floor 函數)。 - 左子節點: - 若 $2i \le n$ ,則節點 $i$ 的左子節點(left child)索引在 $2i$。 - 若 $2i > n$,代表該節點為葉節點,無左子節點。 - 右子節點: - 若 $2i + 1 \le n$ ,則節點 $i$ 的右子節點(right child)索引在 $2i + 1$ 。 - 若 $2i + 1 > n$,代表無右子節點。 **若根節點儲存在陣列索引 $0$ 的位置(0-based indexing)**: - 父節點:$\lfloor \frac{i - 1}{2} \rfloor$。 - 左子節點:$2i + 1$。 - 右子節點:$2i + 2$。 以上的這些公式用在「由上到下」、「由左至右」的方式編號的完整二元樹,如下圖: ![image](https://hackmd.io/_uploads/rJDcBKet-g.png) Image Source:https://interviewing.io/questions/count-complete-tree-nodes ### 一維陣列表示二元樹 假設有棵樹長這樣(A、B、C、D、E、F 為資料,而括號內為編號、索引): ![image](https://hackmd.io/_uploads/rkeb0Pbtbx.png) Image Source:筆者繪製。 該棵樹的最大節點數為 $2^3 - 1 = 7$ ,因此可將其表示成下列的一維陣列: ![image](https://hackmd.io/_uploads/BkFwJObYWg.png) 由於該棵樹是一棵完整二元樹(Complete Binary Tree),因此可用前面的公式來對該一維陣列做驗證: - 驗證 B 節點($i=2$): - 左子節點 $= 2 \times 2 = 4$(資料為 D) - 右子節點 $= 2 \times 2 + 1 = 5$(資料為 E) - 驗證 F 節點($i=6$): - 父節點 $= 6 / 2 = 3$(資料為 C) #### 優缺分析 使用一維陣列表示法的優缺點: - 優點: 1. 節省空間(針對完整二元樹或滿枝二元樹):不需要儲存左右子樹的指標(Pointer),在 64 位元系統中可節省大量記憶體。 2. 存取快速:利用索引計算可以直接跳轉到目標節點,時間複雜度為 $O(1)$。 3. 適合堆積(Heap):該表示法是實作優先隊列(Priority Queue)或堆積排序(Heap Sort)的標準做法。 - 缺點: 1. 空間浪費(針對稀疏樹):如果二元樹非常不平衡(例如右斜樹),中間會有大量空缺,陣列必須保留這些空間。 - 例如:深度為 $k$ 的右斜樹只有 $k$ 個節點,但陣列需要 $2^k - 1$ 的空間。 - 補充:完整二元樹也可能造成空間浪費,如上陣列圖就多出一個空間。 2. 大小固定:陣列一旦宣告後較難動態擴張,若樹的深度增加,可能需要重新分配整個陣列。 3. 插入與刪除節點效率低:新增或刪除節點時,可能需要大量搬移陣列中的資料來維持索引結構。 ### 鏈結方式表示二元樹 鏈結(Linked)表示法是實作二元樹最常見且最具彈性的方式。 不同於一維陣列表示法在處理「非完整二元樹、滿枝二元樹」時容易造成大量的記憶體空間浪費,鏈結表示法採用動態記憶體配置,反映樹狀結構的階層與分支關係。 另外鏈結方式也解決一維陣列插入與刪除效率低的問題。 在鏈結表示法中,二元樹是由一個個獨立的節點(Node)所串聯而成,每個節點通常包含三個基本區塊: 1. 資料欄位(data):儲存該節點所要記錄的數值或資訊。 2. 左子指標(Left Pointer, 下圖的 L_link):指向該節點的左子節點位置,如果沒有左子節點,此指標會設為空值(C 的 `NULL` 或 C++ `nullptr`)。 3. 右子指標(Right Pointer, 下圖的 R_link):指向該節點的右子節點位置,同樣地,若無右子節點則設為空值。 整棵樹會由一個稱為根節點(Root)的指標作為起點,若這棵樹是空的,則 Root 指標本身就是空值。 ![image](https://hackmd.io/_uploads/BJNKxKZY-g.png) Image Source:筆者繪製。 若寫成 C 語言,則會像下面這樣(不過通常都會寫成 `llink` 跟 `rlink`): ```c= struct TreeNode { int data; // 儲存的資料 TreeNode* L_Link; // 指向左子樹的指標 TreeNode* R_Link; // 指向右子樹的指標 }; ``` 鏈結樹的圖形示意: ![image](https://hackmd.io/_uploads/HJhEbYWYbe.png) Image Source:https://afteracademy.com/article/what-is-a-tree-data-structure #### 優缺分析 使用鏈結表示法的優缺: - 優點: 1. 動態記憶體:空間隨用隨取,只有在新增節點時才消耗記憶體,不像陣列需預先宣告一大塊連續空間而造成浪費。 2. 插入刪除操作靈活:只需改變父節點與子節點之間的指標方向即可,不需像陣列大幅度搬移後續的資料。 - 缺點: 1. 額外的空間開銷:每個節點除了儲存資料本身,都必須額外花費記憶體空間來儲存兩個指標(左、右)。 2. 無法隨機存取:不能像陣列一樣透過索引值(Index)在 $O(1)$ 的時間內直接找到特定節點,必須從根節點(Root)開始,循著指標一步一步往下遍歷尋找。 ## 二元樹的追蹤(traversal) 二元樹的追蹤,或稱遍歷,有以下三種: 1. 前序遍歷(Preorder Traversal):先處理當前節點,再處理子節點。這種方式常用於複製整棵二元樹,或是輸出結構化的文件(如前綴表示法)。 - 拜訪順序:根節點(Root) → 左子樹(Left Subtree) → 右子樹(Right Subtree) 2. 中序遍歷(Inorder Traversal):先處理左邊,再處理中間,最後處理右邊。對於二元搜尋樹(Binary Search Tree, BST)來說,中序遍歷的結果會是一個由小到大排序好的數列。 - 拜訪順序:左子樹(Left Subtree) → 根節點(Root) → 右子樹(Right Subtree) 3. 後序遍歷(Postorder Traversal):先處理完所有子節點,最後才處理當前節點。這種方式常用於刪除整棵二元樹,因為必須要先清空左、右子節點的記憶體,最後才能安全釋放父節點。 - 拜訪順序:左子樹(Left Subtree) → 右子樹(Right Subtree) → 根節點(Root) 繼續以下 C 語言實作前,要先定義二元樹結構: ```c= #include <stdio.h> #include <stdlib.h> // 定義二元樹的節點結構 struct Node { int data; struct Node* llink; struct Node* rlink; }; // 建立新節點的輔助函式 struct Node* createNode(int data) { struct Node* newNode = (struct Node*)malloc(sizeof(struct Node)); newNode->data = data; newNode->llink = NULL; newNode->rlink = NULL; return newNode; } ``` ### C 語言實作:前序遍歷 ```c= void preorderTraversal(struct Node* node) { if (node == NULL) { return; } // 1. 先印出當前節點(根) printf("%d ", node->data); // 2. 遞迴拜訪左子樹 preorderTraversal(node->llink); // 3. 遞迴拜訪右子樹 preorderTraversal(node->rlink); } ``` 在主程式執行: ```c= int main(){ struct Node* n = createNode(10); struct Node* n1 = createNode(20); struct Node* n2 = createNode(30); n -> llink = n1; n -> rlink = n2; preorderTraversal(n); } ``` Output: ``` 10 20 30 ``` ### C 語言實作:中序遍歷 ```c= void inorderTraversal(struct Node* node) { if (node == NULL) { return; } // 1. 遞迴拜訪左子樹 inorderTraversal(node->llink); // 2. 印出當前節點(根) printf("%d ", node->data); // 3. 遞迴拜訪右子樹 inorderTraversal(node->rlink); } ``` 執行結果: ``` 20 10 30 ``` ### C 語言實作:後序遍歷 ```c= void postorderTraversal(struct Node* node) { if (node == NULL) { return; } // 1. 遞迴拜訪左子樹 postorderTraversal(node->llink); // 2. 遞迴拜訪右子樹 postorderTraversal(node->rlink); // 3. 最後印出當前節點(根) printf("%d ", node->data); } ``` 執行結果: ``` 20 30 10 ``` ### 三種遍歷方式比較表 | 遍歷名稱 | 英文名稱 | 執行順序 | 常見應用 | |------|-----------|-------------|------------------| | 前序遍歷 | Preorder | 根 → 左 → 右 | 複製二元樹、建立目錄結構 | | 中序遍歷 | Inorder | 左 → 根 → 右 | 二元搜尋樹(BST)排序輸出 | | 後序遍歷 | Postorder | 左 → 右 → 根 | 安全地釋放(刪除)二元樹的記憶體 | ### 以非遞迴形式實作的前、中、後序遍歷 所謂非遞迴形式就是用堆疊(Stack)的方式實現。 首先準備一個簡單的堆疊結構: ```c= #include <stdio.h> #include <stdlib.h> #define MAX_SIZE 100 struct Node { int data; struct Node* llink; struct Node* rlink; }; struct Stack { int top; struct Node* array[MAX_SIZE]; }; struct Stack* createStack() { struct Stack* stack = (struct Stack*)malloc(sizeof(struct Stack)); stack->top = -1; return stack; } void push(struct Stack* stack, struct Node* node) { stack->array[++stack->top] = node; } struct Node* pop(struct Stack* stack) { if (stack->top == -1) return NULL; return stack->array[stack->top--]; } struct Node* peek(struct Stack* stack) { if (stack->top == -1) return NULL; return stack->array[stack->top]; } int isEmpty(struct Stack* stack) { return stack->top == -1; } ``` #### 前序遍歷 前序遍歷的順序是中 -> 左 -> 右。 由於堆疊為後進先出(LIFO)的資料結構,所以當處理完中間節點後,必須先將右子節點推入堆疊,再推入左子節點,這樣下次取出的時候,才會先取出左子節點。 ```c= void preorderTraversal(struct Node* root) { if (root == NULL) return; struct Stack* stack = createStack(); push(stack, root); while (!isEmpty(stack)) { // 取出堆疊頂部的節點並印出 struct Node* node = pop(stack); printf("%d ", node->data); // 先推入右子樹(因為後進先出) if (node->right) { push(stack, node->right); } // 再推入左子樹 if (node->left) { push(stack, node->left); } } free(stack); } ``` 執行以下主程式: ```c= int main() { struct Node* root = createNode(10); root->left = createNode(20); root->right = createNode(30); preorderTraversal(root); } ``` Output: ``` 10 20 30 ``` #### 中序遍歷 中序遍歷的順序是左 -> 中 -> 右。 需要一個指標 `curr` 來持續往左走,沿途將經過的節點都推入堆疊,當走到盡頭(左子樹為空)時,從堆疊取出一個節點印出,接著轉向該節點的右子樹,重複同樣的過程。 ```c= void inorderTraversal(struct Node* root) { struct Stack* stack = createStack(); struct Node* curr = root; while (curr != NULL || !isEmpty(stack)) { // 不斷往左走,將沿途節點放入堆疊 while (curr != NULL) { push(stack, curr); curr = curr->left; } // 走到最左邊後,取出節點並印出 curr = pop(stack); printf("%d ", curr->data); // 轉向右子樹 curr = curr->right; } free(stack); } ``` 執行結果: ``` 20 10 30 ``` #### 後序遍歷 後序遍歷的順序是左 -> 右 -> 中。 後序遍歷在非遞迴處理上是最複雜的:當從左子樹返回中間節點時,還不能馬上印出中間節點,必須先去處理右子樹。 在此使用單一堆疊搭配一個 `lastVisited` 指標的方法:用以記錄上一個印出的節點。 若當前節點的右子樹為空,或者右子樹剛已被印出過(`lastVisited == curr->right`),就代表左右子樹都處理完了,可直接印出當前節點。 ```c= void postorderTraversal(struct Node* root) { struct Stack* stack = createStack(); struct Node* curr = root; struct Node* lastVisited = NULL; while (curr != NULL || !isEmpty(stack)) { // 盡可能往左走 if (curr != NULL) { push(stack, curr); curr = curr->left; } else { // 查看堆疊頂部節點,但不先取出 struct Node* peekNode = peek(stack); // 如果有右子樹,且右子樹還沒被拜訪過,轉向右子樹 if (peekNode->right != NULL && lastVisited != peekNode->right) { curr = peekNode->right; } else { // 如果沒有右子樹,或右子樹已經拜訪過了,就處理當前節點 printf("%d ", peekNode->data); lastVisited = pop(stack); // 從堆疊取出並記錄為已拜訪 } } } free(stack); } ``` ## 二元樹的性質 ### 一般樹轉二元樹 將一般樹轉換為二元樹最標準且常用的方法稱為左子右兄弟(Left-Child Right-Sibling)表示法。 該方法可讓任何一棵一般樹(甚至是一座森林)轉換成一棵二元樹。 轉換上共有三個步驟: 1. 兄弟相連:將所有同層級、同一個父節點的兄弟節點,從左至右用橫線連接起來。 2. 保留長子、砍掉剩下的:對每個父節點,除了保留與第一個子節點(最左邊的子節點)的連線外,刪除與其他所有子節點的垂直連線。 3. 順時針旋轉 45 度:將整棵樹向右(順時針)傾斜 45 度,重新整理層次,原本往下的垂直線變成二元樹的左子樹,原本橫向的兄弟連線變成二元樹的右子樹。 所謂順時針轉 45 度,就像下圖中那樣: ![image](https://hackmd.io/_uploads/SJzfnA7tbx.png) Image Source:筆者繪製。 那如果是橫的呢?請看下圖: ![image](https://hackmd.io/_uploads/HyTb6RQY-l.png) 而所謂兄弟相連、保留長子就是像下面那樣: ![image](https://hackmd.io/_uploads/Sk75aAXY-l.png) B、C、D 互相都是兄弟節點,而長子就是最左邊的節點 B。 ### 透過前、中、後序遍歷決定唯一的二元樹 如何透過走訪序列還原一棵唯一的二元樹?必須要有中序遍歷(In-order),再搭配前序遍歷(Pre-order)或後序遍歷(Post-order)其中之一。 如果只有前序和後序,是無法決定一棵唯一的二元樹的。 #### 為何單一遍歷無法決定結構? 無論是前、中、後序,本質上都是將 2D 的樹狀結構降維壓扁成 1D 的線性陣列,在此過程中,父子節點的邊界資訊遺失了。 只知道節點遍歷的先後順序,卻不知道誰是誰的左子樹或右子樹。 #### 定位與切割 要精準還原二元樹的結構,需要兩種不同的資訊交互配合: - 前序或後序,負責找根節點(Root): - 前序(中 -> 左 -> 右):序列的第一個元素,絕對是當前樹的根節點。 - 後序(左 -> 右 -> 中):序列的最後一個元素,絕對是當前樹的根節點。 - 中序負責切割左右子樹: - 中序(左 -> 中 -> 右):一旦透過前序或後序知道了「根節點」是誰,回到中序序列中找到該節點的位置,就可將序列切成兩半,即左邊的全是左子樹節點,右邊的全是右子樹節點。 #### 前序 + 中序範例 假設題目提供兩組序列,求出完整唯一的二元樹: - 前序(Pre-order):`[A, B, D, E, C, F]` - 中序(In-order):`[D, B, E, A, F, C]` 第一輪(做確認與切割): 1. 看前序的第一個元素,確認根節點是 `A`。 2. 拿著 `A` 去中序找,發現中序被切成兩半:`[D, B, E]`(左子樹)和 `[F, C]`(右子樹)。 第二輪(處理左子樹): 1. 回到前序,跳過 `A` 之後往後數 `3` 個節點 `[B, D, E]`,即左子樹的前序序列。 2. 左子樹的前序是 `[B, D, E]`,第一個元素是 `B`,所以左子樹的根節點是 `B`。 3. 拿 `B` 去中序的 `[D, B, E]` 找,切出:`[D]`(左子樹)和 `[E]`(右子樹)。 第三輪(處理右子樹): 1. 回到前序,跳過 `A` 之後找出 `[C, F]`,即右子樹的前序序列。 2. 右子樹的前序是 `[C, F]`,第一個元素是 `C`,所以右子樹的根節點是 `C`。 3. 拿 `C` 去中序的 `[F, C]` 找,切出:`[F]`(左子樹)。 最後求出二元樹: ![image](https://hackmd.io/_uploads/S1S0oZNFZx.png) #### 後序 + 中序範例 假設題目給了兩組序列: - 後序(Post-order):`[D, E, B, F, C, A]` - 中序(In-order):`[D, B, E, A, F, C]` 第一輪(做確認與切割): 1. 看後序的最後一個元素,`A` 即為根節點。 2. 拿 `A` 去中序找,將中序序列分兩半:`[D, B, E]`(左子樹)、`[F, C]`(右子樹)。 第二輪(處理左子樹): 1. 看左子樹後序 `[D, E, B]` 的最後一個元素是 `B`,所以 `B` 是這棵左子樹的根節點(`A` 的左子節點)。 2. 拿著 `B` 去左子樹中序 `[D, B, E]` 中尋找,可切成 `[D]`(左子樹)、`[E]`(右子樹)。 第三輪(處理右子樹): 1. 看右子樹後序 `[F, C]` 的最後一個元素是 `C`,所以 `C` 是這棵右子樹的根節點(`A` 的右子節點)。 2. 拿著 `C` 去右子樹中序 `[F, C]` 中尋找,可切成 `[F]`(左子樹)。 最後得出的結果與前序 + 中序的結果相同(此為範例刻意設計,後續遇到不同題目並不代表前序算出的結果跟後序一樣),一樣是這張圖: ![image](https://hackmd.io/_uploads/S1S0oZNFZx.png) ## 引線二元樹(Thread Binary Tree) 引線二元樹(Thread Binary Tree)又稱線索二元樹,為傳統二元樹的一種改良結構。 概念是利用二元樹中原本空著的(Null)指標,來記錄節點在某種遍歷順序下的前行者(Predecessor)和後繼者(Successor)節點。 :::info 1. 前行者 (Predecessor) - 定義: 在某種遍歷順序下,緊接在當前節點「之前」被拜訪的那個節點。 若把遍歷的結果攤平寫成一排直線的陣列,前行者即當前節點左邊的那一個元素。 - 如何在樹的結構中找到中序前行者? 假設找節點 $X$ 的中序前行者: - 情況 A(有左子樹):如果 $X$ 有左子樹,則其前行者就是其左子樹中最右下角的節點,相當於在左子樹中尋找數值最大的節點。 - 情況 B(沒有左子樹):如果 $X$ 沒有左子樹,必須沿著父節點往上找,找到第一個「把它當作右子孫」的祖先節點,則祖先節點就是 $X$ 的前行者。 2. 後繼者(Successor) - 定義: 在某種遍歷順序下,緊接在當前節點「之後」被拜訪的那個節點。 若把遍歷的結果攤平,後繼者就是當前節點右邊的那一個元素。 - 如何在樹的結構中找到中序後繼者? 假設我們要找節點 $X$ 的中序後繼者: - 情況 A(有右子樹):如果 $X$ 有右子樹,則其後繼者就是其右子樹中最左下角的節點,相當於在右子樹中尋找數值最小的節點。 - 情況 B(沒有右子樹):如果 $X$ 沒有右子樹,必須沿著父節點往上找,找到第一個「把它當作左子孫」的祖先節點,則祖先節點就是 $X$ 的後繼者。 ::: ### 為什麼需要引線二元樹? 在一個有 $n$ 個節點的標準二元樹中,總共會有 $2n$ 個指標(每個節點有左、右兩個指標)。 但其中只有 $n-1$ 個指標真正指向了子節點(也就是只有 $n - 1$ 的指標被利用),剩下的 $n+1$ 個指標都是空的(Null)。 傳統上,如果要遍歷(例如中序遍歷)一棵二元樹,通常需要使用遞迴或堆疊來記錄回溯的路徑,會消耗額外的記憶體空間與時間。 引線二元樹就是為了解決該問題,將 $n+1$ 個浪費掉的空指標利用起來,將其變成引線(Threads),直接指向遍歷時的上一個或下一個節點,這樣一來,遍歷樹就不再需要遞迴或堆疊了。 ### 引線(Thread)與標籤(Tag) 為區分一個指標到底是指向真正的子節點,還是指向前行者/後繼者節點的引線,需要在節點結構中加入兩個布林值標籤(Tags): 1. 左指標(Left Pointer): - 若有左子節點,指向左子節點。 - 若無左子節點,就將其變成左引線(Left Thread),指向遍歷順序中的前行者節點。 2. 右指標(Right Pointer): - 若有右子節點,指向右子節點。 - 若無右子節點,就將其變成右引線(Right Thread),指向遍歷順序中的後繼者節點。 3. 標籤(Left Tag & Right Tag): - `lTag = 1`:左指標指向左子節點。 - `lTag = 0`:左指標是引線,指向前行者節點。 - `rTag = 1`:右指標指向右子節點。 - `rTag = 0`:右指標是引線,指向後繼者節點。 在 C 中,這樣的資料結構可定義成: ```c= // 使用 bool 請引入 <stdbool.h> struct ThreadNode { int data; // 節點資料 ThreadNode *llink; // 左指標 ThreadNode *rlink; // 右指標 bool lTag; // 左標籤 (1: 子節點, 0: 引線) bool rTag; // 右標籤 (1: 子節點, 0: 引線) }; ``` 圖解: ![image](https://hackmd.io/_uploads/SyiO4-7tWl.png) Image Source:筆者繪製。 ### 引線二元樹的長相 下圖實線代表一般正常的子節點指標(Link),虛線代表引線(Thread),也就是那些被重新利用的空指標。 ![image](https://hackmd.io/_uploads/SyF-Hb7YWe.png) Image Source:https://www.geeksforgeeks.org/dsa/threaded-binary-tree/ ### 如何將二元樹轉引線二元樹 以最常見的中序引線二元樹為例。 不需改變原本樹的形狀或實體節點的鏈結,只需把那些原本指向空(Null)的指標,依照中序遍歷(左 -> 中 -> 右)的順序,重新指向它們的前行者或後繼者節點。 要做到二元樹轉引線二元樹,則要在遍歷整棵樹的過程中,必須隨時記住剛剛拜訪過的那一個節點。 有兩個變數: - `Curr`(現在的節點) - `Prev`(上一個節點) 現在開始做中序遍歷(初始化 `Curr = 最左邊的節點`, `Prev = NULL`): 1. 處理當前節點(`Curr`)的左引線 - 若左指標為空,代表無實體的左子節點。 - 此時把 `Curr` 的左指標指向 `Prev`。 - 並把 `Curr` 的左標籤(`lTag`)標記為引線,看你定義 1 or 0 是引線就是哪一個。 2. 處理上一個節點的右引線 - 如果 `Prev` 的右指標為空,代表無實體的右子節點。 - 因為現在位於 `Curr`,而 `Curr` 剛好就是 `Prev` 的下一步,所以,將 `Prev` 的右指標指向目前的 `Curr`。 - 並把 `Prev` 的右標籤(`rTag`)標記為引線。 3. 前進 - 當 `Curr` 的左指標和 `Prev` 的右指標都檢查且牽好線之後,就讓 `Prev` 移動到 `Curr` 的位置(現在的節點即將變成下一次檢查時的上一個節點),然後繼續中序遍歷的下一步。 4. 處理最後一個節點 - 當整棵樹都遍歷完畢後,`Prev` 會停留在整棵樹中序遍歷的最後一個節點(整棵樹最右下角的節點)。 - 該節點的右指標必定為空。 - 因為是最後一個,沒有下一個節點可指向,所以其右指標會保持為空(Null),並將右標籤標記為引線。 #### 範例 假設有棵樹長這樣: ![image](https://hackmd.io/_uploads/HJMQszmKbx.png) Image Source:筆者繪製。 則其中序遍歷會是 1 -> 2 -> 3 -> 4 -> 6 設 `Curr = 1`,`Prev = NULL`,因為最左邊那個節點沒有前一個節點。 `Curr`(`1`)的左指標為空,因此把節點 1 的左引線指向 `Prev`(`Null`)。 接著再看 `Prev` 的右指標,而由於 `Prev` 現在是 `NULL`,所以啥事都不用幹,然後現在圖會變這樣: ![image](https://hackmd.io/_uploads/SJ-Wnz7F-l.png) 前進走到節點 2(`Curr = 2`, `Prev = 1`): - 檢查 `Curr`(`2`)的左指標:有指向實體的節點 1,非空,所以不管他。 - 檢查 `Prev`(`1`)的右指標:為空,代表節點 1 需要一條後繼者引線,把節點 1 的右引線指向 `Curr`(`2`)。 ![image](https://hackmd.io/_uploads/ByI02zQKZe.png) 接著前進走到節點 3(`Curr = 3`, `Prev = 2`): - 檢查 `Curr`(`3`)的左指標:為空,將節點 3 的左引線指向 `Prev`(`2`)。 - 檢查 `Prev`(`2`)的右指標:有指向實體的節點 `3`,非空,所以不管他。 ![image](https://hackmd.io/_uploads/HJQOpzXFZx.png) 然後走到節點 4(`Curr = 4`, `Prev = 3`): - 檢查 `Curr`(`4`)的左指標:有指向實體節點 2,不管他。 - 檢查 `Prev`(`3`)的右指標:為空,將節點 3 的右引線指向 `Curr`(`4`)。 ![image](https://hackmd.io/_uploads/r131RMQYWx.png) 最後走到節點 6(`Curr = 6`, `Prev = 4`): - 檢查 `Curr`(`6`)的左指標:為空,將節點 6 的左引線牽向 `Prev`(`4`)。 - 檢查 `Prev`(`4`)的右指標:有指向實體節點 6,不管他。 ![image](https://hackmd.io/_uploads/ryCH0GQFZg.png) 現在所有節點都走完了,做最後的收尾,Prev 停在最後一個節點 6,此時看他的右指標,會是空的,但他後面沒人了,所以將其右引線保持為 `Null`,並標記為引線。 ![image](https://hackmd.io/_uploads/S1wq0z7KZe.png) ### C 語言實作:尋找中序後繼者(Successor) 資料結構定義: ```c= #include <stdio.h> #include <stdbool.h> typedef struct ThreadNode { int data; // 節點資料 struct ThreadNode *llink; // 左指標 struct ThreadNode *rlink; // 右指標 bool lTag; // 左標籤 (1: 子節點, 0: 引線) bool rTag; // 右標籤 (1: 子節點, 0: 引線) } ThreadNode; ``` 接著實作尋找中序後繼者,如同前面所說的,會有兩種情形: - 情況 A:若其右指標是引線(`rTag == 0`),則該條引線即指向後繼者的,直接回傳 `rlink` 即可。 - 情況 B:若其右指標是右子樹(`rTag == 1`),則其後繼者,即右子樹裡面最左邊的節點。 ```c= ThreadNode* inorderSuccessor(ThreadNode *curr) { if (curr == NULL) return NULL; // 情況 A if (curr->rTag == 0) { return curr->rlink; } // 情況 B ThreadNode *temp = curr->rlink; while (temp != NULL && temp->lTag == 1) { temp = temp->llink; } return temp; } ``` ### C 語言實作:尋找中序前行者(Predecessor) - 情況 A:若左指標是引線(`lTag == 0`),直接回傳 `llink`。 - 情況 B:若左指標是左子樹(`lTag == 1`),則前行者即左子樹裡面最右邊的節點。 ```c= ThreadNode* inorderPredecessor(ThreadNode *curr) { if (curr == NULL) return NULL; // 情況 A if (curr->lTag == 0) { return curr->llink; } // 情況 B ThreadNode *temp = curr->llink; while (temp != NULL && temp->rTag == 1) { temp = temp->rlink; } return temp; } ``` ### C 語言實作:引線二元樹的中序遍歷 有了尋找後繼者的函式後,遍歷整棵樹就會變得簡單,從此不再需要遞迴或堆疊,只要: 1. 找出整棵樹的起點(由於中序遍歷,因此在整棵樹最左邊的節點)。 2. 用 `while` 迴圈,不斷呼叫 `inorderSuccessor` 往後跳,直到指標變成 `NULL` 為止。 ```c= void inorderTraversal(ThreadNode *root) { if (root == NULL) return; // 1 ThreadNode *curr = root; while (curr->lTag == 1) { // 只要還有左子節點就一直往左走 curr = curr->llink; } // 2 while (curr != NULL) { printf("%d ", curr->data); // 印出當前節點資料 curr = inorderSuccessor(curr); // 跳到下一個後繼節點 } printf("\n"); } ``` ### C 語言程式實作:引線二元樹的插入操作(插入右方) 假設要將 `newNode` 插入到 `curr` 節點的右方,則插入的兩種情況: 1. 情況一:`curr` 原本沒有右子樹(`curr->rTag == 0`) - 最簡單的狀況,`newNode` 直接變成 `curr` 的右子節點。 - `newNode` 的左引線指回 `curr`(`curr` 為他的前行者) - `newNode` 的右引線會接上 `curr` 原本的右引線(指向 `curr` 原本的後繼者)。 2. 情況二:`curr` 原本已經有右子樹(`curr->rTag == 1`) - 此時 `newNode` 會擠進 `curr` 和它原本的右子樹之間。 - `newNode` 成為 `curr` 的新右子節點。 - `curr` 原本的右子樹,變成 `newNode` 的右子樹。 - 原本右子樹裡最左下角的節點,其左引線原本是指向 `curr`,現在中間多了 `newNode`,所以必須把那條左引線更新,改為指向 `newNode`。 程式實作: ```c= void insert(ThreadNode *curr, ThreadNode *newNode) { if (curr == NULL || newNode == NULL) return; // 先處理 newNode 的右半邊 newNode->rlink = curr->rlink; newNode->rTag = curr->rTag; // 處理 newNode 的左半邊 newNode->llink = curr; newNode->lTag = 0; // 將 newNode 正式掛載為 curr 的右子節點 curr->rlink = newNode; curr->rTag = 1; // 處理情況二 if (newNode->rTag == 1) { // 尋找那棵右子樹中最左下角的節點 ThreadNode *temp = newNode->rlink; while (temp->lTag == 1) { temp = temp->llink; } // 將該節點的左引線更新為指向新插入的 newNode temp->llink = newNode; } } ``` ### 優缺分析 以下是引線二元樹的優缺分析: - 優點: - 節省空間:不需額外的堆疊就能進行遍歷。 - 提升遍歷效率:遍歷的過程變成了單純的指標跳躍,速度比傳統遞迴的二元樹更快。 - 快速尋找鄰居:可非常快速找到任意節點在特定遍歷下的前行者或後繼者節點。 - 缺點: - 插入與刪除複雜:每當樹的結構發生改變(新增或刪除節點)時,除了要修改子節點指標,還必須維護引線的正確性,邏輯較為繁瑣。 - 節點變大:每個節點要多儲存兩個標籤(`lTag`, `rTag`),雖然布林值佔用空間不大,但依然增加了單一節點的大小。 ## 總整理 ### 樹(Tree)基本概念 樹是一種非線性、階層式的資料結構。 若有一棵 $N$ 個節點的連通樹,必定恰好有 $N-1$ 條邊。 重要術語: - 根節點(Root):最頂層、唯一沒有父節點的節點。 - 葉節點(Leaf/Terminal Node):沒有任何子節點的末端節點。 - 內部節點(Internal Node):至少有一個子節點的節點。 - 分支度(Degree):節點擁有的子節點或子樹數量。整棵樹的分支度為最大節點分支度。 - 深度(Depth):從根節點往下走到目標節點的邊數(根為 0 或 1,依定義而定)。 - 高度(Height):從目標節點往上算到最遠葉節點的邊數(葉為 0)。 - 樹林(Forest):移除樹的根節點,底下的子樹集合即為樹林;反之,將多棵樹連上一個共同的根,就變成一棵樹。 考點:$k$ 元樹的空指標計算 - 實作 $k$ 元樹時,每個節點配置 $k$ 個指標,若樹有 $N$ 個節點: - 總指標數:$N \cdot k$ - 已使用指標(邊數):$N - 1$ - 浪費掉的空指標:$N(k - 1) + 1$ ### 二元樹(Binary Tree) 二元樹的最大分支度嚴格限制為 2,並且嚴格區分左、右子樹(即使只有一個子節點也要分左右)。 二元樹允許為空集合。 1. 特殊型態的二元樹 1. 斜樹(Skewed Tree):全偏向左或右,退化成鏈結串列,搜尋時間從對數退化為 $O(n)$。 2. 滿枝二元樹(Full Binary Tree):每一層都長滿。深度 $k$ 的樹必有 $2^k - 1$ 個節點。 3. 完整二元樹(Complete Binary Tree):除了最底層外全滿,且最底層嚴格由左至右連續填滿。適合用陣列實作(如 Heap)。 2. 二元樹的數學性質 - 第 $i$ 階度的最大節點數:$2^{i-1}$ - 階度 $k$ 的整棵樹最大節點數:$2^k - 1$ - 葉節點與度數 $2$ 節點的關係:$$N_0 = N_2 + 1$$ (葉節點永遠比有兩個子節點的內部節點多 $1$ 個) 3. 儲存表示法 | 表示法 | 特性與優缺點 | 節點索引關係(以 1-based index 為例) | |-------|----------------------------------------|-----------------------------| | 一維陣列 | 優點:適合完整/滿枝二元樹,存取快 $O(1)$。<br>缺點:若樹不平衡會嚴重浪費空間。 | 父節點:$\lfloor i/2 \rfloor$<br>左子節點:$2i$<br>右子節點:$2i+1$ | | 鏈結表示法 | 優點:最常用、動態配置記憶體,插入/刪除快。<br>缺點:需額外空間存指標,無法隨機存取。 | 節點包含:`data, llink, rlink` | ### 二元樹的追蹤(Traversal) 將 2D 樹狀結構攤平成 1D 序列的過程,分為遞迴與非遞迴(需依賴堆疊 Stack)實作。 | 遍歷方式 | 拜訪順序 | 常見應用 | |----------------|-----------|------------------| | 前序(Preorder) | 中 → 左 → 右 | 複製整棵樹、建立目錄結構 | | 中序(Inorder) | 左 → 中 → 右 | BST 的排序輸出(由小到大) | | 後序(Postorder) | 左 → 右 → 中 | 刪除整棵樹 | ### 樹的轉換與結構還原 1. 一般樹 $\rightarrow$ 二元樹:左子右兄弟法 - 利用 Left-Child Right-Sibling 技巧: 1. 兄弟相連:同層兄弟水平連線。 2. 保留長子:父節點只保留與最左側子節點的垂直連線。 3. 順時針轉 45 度:原本垂直變左子樹,原本水平連線變右子樹。 2. 透過遍歷序列還原唯一的二元樹 - 必備條件:必須有中序,搭配前序或後序才能還原。 - 前序 / 後序的作用:負責找出根節點(Root)。(前序的第一個,或後序的最後一個)。 - 中序的作用:拿著找出的根節點,回到中序序列中,將其切割出左子樹與右子樹的範圍,反覆遞迴即可畫出整棵樹。 ### 引線二元樹(Thread Binary Tree) 引線二元樹是為了解決傳統二元樹留下 $N+1$ 個空指標的浪費,並免除追蹤時對 Stack/遞迴的依賴。 - 概念:將空的左/右指標,改為指向中序遍歷下的前行者(Predecessor)或後繼者(Successor)。 - 標籤(Tag):為區分是指向實體子樹還是引線(Thread),節點需加入 `lTag` 與 `rTag`(布林值)。 - `Tag = 1`:指標連向實體子節點。 - `Tag = 0`:指標作為引線,連向鄰居。 如何尋找中序鄰居? - 找後繼者(Successor): - 若 `rTag == 0`:右指標就是後繼者。 - 若 `rTag == 1`:右子樹中最左下角的節點。 - 找前行者(Predecessor): - 若 `lTag == 0`:左指標就是前行者。 - 若 `lTag == 1`:左子樹中最右下角的節點。