數據結構筆記淺記(十四) 樹

二叉樹

「二叉樹 binary tree」是一種非線性數據結構,代表“祖先”與“後代”之間的派生關係,體現了“一分爲二” 的分治邏輯。與鏈表類似,二叉樹的基本單元是節點,每個節點包含值、左子節點引用和右子節點引用。

 

每個節點都有兩個引用(指針),分別指向「左子節點 left‑child node」和「右子節點 right‑child node」, 該節點被稱爲這兩個子節點的「父節點 parent node」。當給定一個二叉樹的節點時,我們將該節點的左子節點及其以下節點形成的樹稱爲該節點的「左子樹 left subtree」,同理可得「右子樹 right subtree」。

在二叉樹中,除葉節點外,其他所有節點都包含子節點和非空子樹。

1. 初始化二叉樹

與鏈表類似,首先初始化節點,然後構建引用(指針)。

 

2. 插入與刪除節點

與鏈表類似,在二叉樹中插入與刪除節點可以通過修改指針來實現。

 

需要注意的是,插入節點可能會改變二叉樹的原有邏輯結構,而刪除節點通常意味着刪除該 節點及其所有子樹。

 

完美二叉樹

「完美二叉樹 perfect binary tree」所有層的節點都被完全填滿。在完美二叉樹中,葉節點的度爲 0 ,其餘所有節點的度都爲 2 ;若樹的高度爲 ℎ ,則節點總數爲 2 ℎ+1 − 1 ,呈現標準的指數級關係, 反映了自然界中常見的細胞分裂現象。

 

完全二叉樹

完全二叉樹 complete binary tree」只有最底層的節點未被填滿,且最底層節點儘量靠左填充。

完滿二叉樹

「完滿二叉樹 full binary tree」除了葉節點之外,其餘所有節點都有兩個子節點。

 

平衡二叉樹

「平衡二叉樹 balanced binary tree」中任意節點的左子樹和右子樹的高度之差的絕對值不超過 1 。

二叉樹的退化

當二叉樹的每層節點都被填滿時,達到“完美二叉樹”;而當所 有節點都偏向一側時,二叉樹退化爲“鏈表”。

        ‧ 完美二叉樹是理想情況,可以充分發揮二叉樹“分治”的優勢。

        ‧ 鏈表則是另一個極端,各項操作都變爲線性操作,時間複雜度退化至 𝑂(𝑛) 。

 

二叉樹遍歷

從物理結構的角度來看,樹是一種基於鏈表的數據結構,因此其遍歷方式是通過指針逐個訪問節點。然而, 樹是一種非線性數據結構,這使得遍歷樹比遍歷鏈表更加複雜,需要藉助搜索算法來實現。 二叉樹常見的遍歷方式包括層序遍歷、前序遍歷、中序遍歷和後序遍歷等

 

層序遍歷

「層序遍歷 level‑order traversal」從頂部到底部逐層遍歷二叉樹,並在每一層按照從左到右的順序訪問節點。層序遍歷本質上屬於「廣度優先遍歷 breadth‑first traversal」,也稱「廣度優先搜索 breadth‑first search, BFS」,它體現了一種“一圈一圈向外擴展”的逐層遍歷方式。

廣度優先遍歷通常藉助“隊列”來實現。隊列遵循“先進先出”的規則,而廣度優先遍歷則遵循“逐層推進” 的規則,兩者背後的思想是一致的。

 

廣度優先遍歷複雜度分析

        ‧ 時間複雜度爲 𝑂(𝑛) :所有節點被訪問一次,使用 𝑂(𝑛) 時間,其中 𝑛 爲節點數量。

         ‧ 空間複雜度爲 𝑂(𝑛) :在最差情況下,即滿二叉樹時,遍歷到最底層之前,隊列中最多同時存在 (𝑛 + 1)/2 個節點,佔用 𝑂(𝑛) 空間

 

前序、中序、後序遍歷

前序、中序和後序遍歷都屬於「深度優先遍歷 depth‑first traversal」,也稱「深度優先搜索 depth‑first search, DFS」,它體現了一種“先走到盡頭,再回溯繼續”的遍歷方式。 展示了對二叉樹進行深度優先遍歷的工作原理。深度優先遍歷就像是繞着整棵二叉樹的外圍“走”一 圈,在每個節點都會遇到三個位置,分別對應前序遍歷、中序遍歷和後序遍歷。

 

以下展示了前序遍歷二叉樹的遞歸過程,其可分爲“遞”和“歸”兩個逆向的部分

 

前序遍歷的遞歸過程雜度分析

         ‧ 時間複雜度爲 𝑂(𝑛) :所有節點被訪問一次,使用 𝑂(𝑛) 時間。

        ‧ 空間複雜度爲 𝑂(𝑛) :在最差情況下,即樹退化爲鏈表時,遞歸深度達到 𝑛 ,系統佔用 𝑂(𝑛) 棧幀空間。

 

二叉樹數組表示

在鏈表表示下,二叉樹的存儲單元爲節點 TreeNode ,節點之間通過指針相連接。

 

表示完美二叉樹

給定一棵完美二叉樹,我們將所有節點按照層序遍歷的順序存儲在一個數組中,則每個節點都對應唯一的數組索引。 根據層序遍歷的特性,我們可以推導出父節點索引與子節點索引之間的“映射公式”:若某節點的索引爲 𝑖 , 則該節點的左子節點索引爲 2𝑖 + 1 ,右子節點索引爲 2𝑖 + 2 。圖展示了各個節點索引之間的映射關係。

映射公式的角色相當於鏈表中的指針。給定數組中的任意一個節點,我們都可以通過映射公式來訪問它的左 (右)子節點。

 

表示任意二叉樹

完美二叉樹是一個特例,在二叉樹的中間層通常存在許多 None 。由於層序遍歷序列並不包含這些 None ,因此我們無法僅憑該序列來推測 None 的數量和分佈位置。這意味着存在多種二叉樹結構都符合該層序遍歷序列。

爲了解決此問題,我們可以考慮在層序遍歷序列中顯式地寫出所有 None 。這樣處理後,層序遍歷序列就可以唯一表示二叉樹了。

完全二叉樹非常適合使用數組來表示。回顧完全二叉樹的定義,None 只出現在最底層且靠右的位置,因此所有 None 一定出現在層序遍歷序列的末尾。 這意味着使用數組表示完全二叉樹時,可以省略存儲所有 None ,非常方便。

 

二叉樹的數組表示主要有以下優點

         ‧ 數組存儲在連續的內存空間中,對緩存友好,訪問與遍歷速度較快。

         ‧ 不需要存儲指針,比較節省空間。 ‧ 允許隨機訪問節點。

數組表示也存在一些侷限性

         ‧ 數組存儲需要連續內存空間,因此不適合存儲數據量過大的樹。

         ‧ 增刪節點需要通過數組插入與刪除操作實現,效率較低。

         ‧ 當二叉樹中存在大量 None 時,數組中包含的節點數據比重較低,空間利用率較低。

 

二叉搜索樹

「二叉搜索樹 binary search tree」滿足以下條件。

         1. 對於根節點,左子樹中所有節點的值 < 根節點的值 < 右子樹中所有節點的值。

         2. 任意節點的左、右子樹也是二叉搜索樹,即同樣滿足條件 1. 。

 

 

二叉搜索樹的操作

我們將二叉搜索樹封裝爲一個類 BinarySearchTree ,並聲明一個成員變量 root ,指向樹的根節點。

         1. 查找節點

                 給定目標節點值 num ,可以根據二叉搜索樹的性質來查找。我們聲明一個節點 cur ,從二叉 樹的根節點 root 出發,循環比較節點值 cur.val 和 num         之間的大小關係。

                ‧ 若 cur.val < num ,說明目標節點在 cur 的右子樹中,因此執行 cur = cur.right 。

                 ‧ 若 cur.val > num ,說明目標節點在 cur 的左子樹中,因此執行 cur = cur.left 。

                ‧ 若 cur.val = num ,說明找到目標節點,跳出循環並返回該節點。

 

        二叉搜索樹的查找操作與二分查找算法的工作原理一致,都是每輪排除一半情況。循環次數最多爲二叉樹的 高度,當二叉樹平衡時,使用 𝑂(log 𝑛) 時間。

 

        2. 插入節點

           給定一個待插入元素 num ,爲了保持二叉搜索樹“左子樹 < 根節點 < 右子樹”的性質,插入操作流程如圖所示。

                 1. 查找插入位置:與查找操作相似,從根節點出發,根據當前節點值和 num 的大小關係循環向下搜索,直到越過葉節點(遍歷至 None )時跳出循環。

                 2. 在該位置插入節點:初始化節點 num ,將該節點置於 None 的位置。

        在代碼實現中,需要注意以下兩點

                ‧ 二叉搜索樹不允許存在重複節點,否則將違反其定義。因此,若待插入節點在樹中已存在,則不執行插 入,直接返回。

                 ‧ 爲了實現插入節點,我們需要藉助節點 pre 保存上一輪循環的節點。這樣在遍歷至 None 時,我們可以獲取到其父節點,從而完成節點插入操作。

 

        與查找節點相同,插入節點使用 𝑂(log 𝑛) 時間。

 

3. 刪除節點

先在二叉樹中查找到目標節點,再將其刪除。與插入節點類似,我們需要保證在刪除操作完成後,二叉搜索 樹的“左子樹 < 根節點 < 右子樹”的性質仍然滿足。因此,我們根據目標節點的子節點數量,分 0、1 和 2 三 種情況,執行對應的刪除節點操作。

當待刪除節點的度爲 1 時,將待刪除節點替換爲其子節點即可。

 

當待刪除節點的度爲 2 時,我們無法直接刪除它,而需要使用一個節點替換該節點。由於要保持二叉搜索樹 “左子樹 < 根節點 < 右子樹”的性質,因此這個節點可以是右子樹的最小節點或左子樹的最大節點。 假設我們選擇右子樹的最小節點(中序遍歷的下一個節點),則刪除操作流程如圖示。

        1. 找到待刪除節點在“中序遍歷序列”中的下一個節點,記爲 tmp 。

        2. 用 tmp 的值覆蓋待刪除節點的值,並在樹中遞歸刪除節點 tmp 。

 

刪除節點操作同樣使用 𝑂(log 𝑛) 時間,其中查找待刪除節點需要 𝑂(log 𝑛) 時間,獲取中序遍歷後繼節點 需要 𝑂(log 𝑛) 時間。

 

4. 中序遍歷有序

如圖所示,二叉樹的中序遍歷遵循“左 → 根 → 右”的遍歷順序,而二叉搜索樹滿足“左子節點 < 根 節點 < 右子節點”的大小關係。 這意味着在二叉搜索樹中進行中序遍歷時,總是會優先遍歷下一個最小節點,從而得出一個重要性質:二叉搜索樹的中序遍歷序列是升序的。 利用中序遍歷升序的性質,我們在二叉搜索樹中獲取有序數據僅需 𝑂(𝑛) 時間,無須進行額外的排序操作, 非常高效。

 

二叉搜索樹的效率

給定一組數據,我們考慮使用數組或二叉搜索樹存儲。二叉搜索樹的各項操作的時間複雜度都是對數階,具有穩定且高效的性能。只有在高頻添加、低頻查找刪除數據的場景下,數組比二叉搜索樹的效率更高。

 

在理想情況下,二叉搜索樹是“平衡”的,這樣就可以在 log 𝑛 輪循環內查找任意節點。 然而,如果我們在二叉搜索樹中不斷地插入和刪除節點,可能導致二叉樹退化爲鏈表,這時各種操作的時間複雜度也會退化爲 𝑂(𝑛) 。

二叉搜索樹常見應用

         ‧ 用作系統中的多級索引,實現高效的查找、插入、刪除操作。

         ‧ 作爲某些搜索算法的底層數據結構。

         ‧ 用於存儲數據流,以保持其有序狀態。

 

AVL 樹

在多次插入和刪除操作後,二叉搜索樹可能退化爲鏈表。在這種情況下, 所有操作的時間複雜度將從 𝑂(log 𝑛) 劣化爲 𝑂(𝑛)

 

完美二叉樹中插入兩個節點後,樹將嚴重向左傾斜,查找操作的時間複雜度也隨之劣化。

 

AVL 樹常見術語

AVL 樹既是二叉搜索樹,也是平衡二叉樹,同時滿足這兩類二叉樹的所有性質,因此也被稱爲「平衡二叉搜 索樹 balanced binary search tree」。

 

1. 節點高度

由於 AVL 樹的相關操作需要獲取節點高度,因此我們需要爲節點類添加 height 變量。“節點高度”是指從該節點到它的最遠葉節點的距離,即所經過的“邊”的數量。需要特別注意的是,葉節點 的高度爲 0 ,而空節點的高度爲 −1 。

 

2. 節點平衡因子

節點的「平衡因子 balance factor」定義爲節點左子樹的高度減去右子樹的高度,同時規定空節點的平衡因子爲 0

設平衡因子爲 𝑓 ,則一棵 AVL 樹的任意節點的平衡因子皆滿足 −1 ≤ 𝑓 ≤ 1 。

 

AVL 樹旋轉

AVL 樹的特點在於“旋轉”操作,它能夠在不影響二叉樹的中序遍歷序列的前提下,使失衡節點重新恢復平衡。換句話說,旋轉操作既能保持“二叉搜索樹”的性質,也能使樹重新變爲“平衡二叉樹”。 我們將平衡因子絕對值 > 1 的節點稱爲“失衡節點”。根據節點失衡情況的不同,旋轉操作分爲四種:右旋、 左旋、先右旋後左旋、先左旋後右旋。

 

1. 右旋

如圖所示,節點下方爲平衡因子。從底至頂看,二叉樹中首個失衡節點是“節點 3”。我們關注以該失衡 節點爲根節點的子樹,將該節點記爲 node ,其左子節點記爲 child ,執行“右旋”操作。完成右旋後,子樹 恢復平衡,並且仍然保持二叉搜索樹的性質。

 

如圖所示,當節點 child 有右子節點(記爲 grand_child )時,需要在右旋中添加一步:將 grand_child 作爲 node 的左子節點。

“向右旋轉”是一種形象化的說法,實際上需要通過修改節點指針來實現。

 

2. 左旋

相應地,如果考慮上述失衡二叉樹的“鏡像”,則需要執行圖所示的“左旋”操作。

當節點 child 有左子節點(記爲 grand_child )時,需要在左旋中添加一步:將 grand_child 作爲 node 的右子節點。

可以觀察到,右旋和左旋操作在邏輯上是鏡像對稱的,它們分別解決的兩種失衡情況也是對稱的。基於對稱 性,我們只需將右旋的實現代碼中的所有的 left 替換爲 right ,將所有的 right 替換爲 left 。

 

3. 先左旋後右旋

對於圖中的失衡節點 3 ,僅使用左旋或右旋都無法使子樹恢復平衡。此時需要先對 child 執行“左旋”, 再對 node 執行“右旋”。

4. 先右旋後左旋

如圖所示,對於上述失衡二叉樹的鏡像情況,需要先對 child 執行“右旋”,再對 node 執行“左旋”。

 

5. 旋轉的選擇

圖展示的四種失衡情況與上述案例逐個對應,分別需要採用右旋、先左旋後右旋、先右旋後左旋、左旋 的操作。

AVL 樹常用操作

1. 插入節點

AVL 樹的節點插入操作與二叉搜索樹在主體上類似。唯一的區別在於,在 AVL 樹中插入節點後,從該節點到根節點的路徑上可能會出現一系列失衡節點。因此,我們需要從這個節點開始,自底向上執行旋轉操作,使所有失衡節點恢復平衡。

 

2. 刪除節點

類似地,在二叉搜索樹的刪除節點方法的基礎上,需要從底至頂執行旋轉操作,使所有失衡節點恢復平衡。

 

AVL 樹典型應用

        ‧ 組織和存儲大型數據,適用於高頻查找、低頻增刪的場景。

        ‧ 用於構建數據庫中的索引系統。

        ‧ 紅黑樹在許多應用中比 AVL 樹更受歡迎。這是因爲紅黑樹的平衡條件相對寬鬆,在紅黑樹中插入與刪 除節點所需的旋轉操作相對較少,其節點增刪操作的平均效率更高。

 

樹的更多知識參考學習

 

Q:對於只有一個節點的二叉樹,樹的高度和根節點的深度都是 0 嗎?

是的,因爲高度和深度通常定義爲“經過的邊的數量”

 

Q:二叉樹中的插入與刪除一般由一套操作配合完成,這裏的“一套操作”指什麼呢?可以理解爲資源的子 節點的資源釋放嗎?

拿二叉搜索樹來舉例,刪除節點操作要分三種情況處理,其中每種情況都需要進行多個步驟的節點操作。

 

Q:爲什麼 DFS 遍歷二叉樹有前、中、後三種順序,分別有什麼用呢?

與順序和逆序遍歷數組類似,前序、中序、後序遍歷是三種二叉樹遍歷方法,我們可以使用它們得到一個特 定順序的遍歷結果。例如在二叉搜索樹中,由於節點大小滿足 左子節點值 < 根節點值 < 右子節點值 ,因此 我們只要按照“左 → 根 → 右”的優先級遍歷樹,就可以獲得有序的節點序列。

 

Q:右旋操作是處理失衡節點 node、child、grand_child 之間的關係,那 node 的父節點和 node 原來的連接 不需要維護嗎?右旋操作後豈不是斷掉了?

我們需要從遞歸的視角來看這個問題。右旋操作 right_rotate(root) 傳入的是子樹的根節點,最終 return child 返回旋轉之後的子樹的根節點。子樹的根節點和其父節點的連接是在該函數返回後完成的,不 屬於右旋操作的維護範圍。

 

Q:在 C++ 中,函數被劃分到 private 和 public 中,這方面有什麼考量嗎?爲什麼要將 height() 函數和 updateHeight() 函數分別放在 public 和 private 中呢?

主要看方法的使用範圍,如果方法只在類內部使用,那麼就設計爲 private 。例如,用戶單獨調用 updateHeight() 是沒有意義的,它只是插入、刪除操作中的一步。而 height() 是訪問節點高度,類似於 vector.size() ,因此設置成 public 以便使用。

 

Q:如何從一組輸入數據構建一棵二叉搜索樹?根節點的選擇是不是很重要?

是的,構建樹的方法已在二叉搜索樹代碼中的 build_tree() 方法中給出。至於根節點的選擇,我們通常會 將輸入數據排序,然後將中點元素作爲根節點,再遞歸地構建左右子樹。這樣做可以最大程度保證樹的平衡 性。

 

Q:廣度優先遍歷到最底層之前,隊列中的節點數量是 2 ℎ 嗎?

是的,例如高度 ℎ = 2 的滿二叉樹,其節點總數 𝑛 = 7 ,則底層節點數量 4 = 2ℎ = (𝑛 + 1)/2 。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章