摘要訊息 : 應用最多的樹.
0. 前言
在《【資料結構】樹》中, 我們對樹進行了介紹. 但是一般的樹其實應用得不是太多, 因為可以針對它們使用的演算法並不多. 而如果限制子節點的數量, 使得子節點最多不超過兩個, 根據《【資料結構】樹》定義 5, 這棵樹便是二元樹. 二元樹是應用得最多的樹.
更新紀錄 :
- 2022 年 6 月 13 日進行第一次更新和修正.
1. 定義
根據《【資料結構】樹》定義 5, 如果總有 , 那麼這棵樹就是二元樹. 但是這樣的定義和二元樹的嚴格定義存在出入.
定義 1. 若向量 的擴展 滿足
- 必定存在且僅存在一個 ();
- 從任意 出發都不存在回到 的路徑,
那麼我們稱 為二元樹 (binary tree). 其中, 稱為樹的節點, 是節點 的指向型標記組 (指向型標記見《【資料結構】前向連結串列》第 1 節), , 指向父節點 (parent node), 指向左子節點 (left child node), 指向右子節點 (right child node), 且 .
比較《【資料結構】樹》定義 5和二元樹的定義 1, 我們可以發現除了節點存在數量限制之外, 樹和二元樹還有一個區別, 就是樹不允許為空, 但是二元樹可以為空. 即對於樹來說, 不允許 , 但是二元樹的定義中沒有這個限制.
由定義 1 可知, 二元樹中的任意子樹也是一棵二元樹.
定義 2. 對於一棵高度為 的二元樹, 若其恰好有 個節點, 則稱這棵樹為完美二元樹 (perfect binary tree), 也稱為滿二元樹 (full binary tree).
如果為 Figure 1 中的 補充左子節點和右子節點, 再為 補充左子節點, 那麼 Figure 1 中的樹將成為一棵完美二元樹.
2. 性質
性質 1. 若一棵二元樹中有 個節點, 那麼它必定有 條邊. 其中, .
:一棵二元樹中, 除了根節點之外, 每一個子節點都有且唯有一個父節點. 因此, 除根節點之外的剩餘 個節點共有 條邊和其父節點相連結, 而根節點沒有父節點. 故若一棵二元樹有 個元素, 那麼它必定有 條邊. 其中, .
性質 2. 若一棵二元樹的高度為 , 則它至少有 個節點, 至多有 個節點. 其中, .
:首先, 對於一棵高度為 的二元樹, 每一層都至少有一個節點, 因此至少有 個元素.
考慮到二元樹中一個父節點至多攜帶兩個子節點, 因此第 層至多有 個節點, . 當 時, 節點的總數為 , 即 ; 當 時, 節點的總數為 個.
因此, 若一棵二元樹的高度為 , 則它至少有 個節點, 至多有 個節點. 其中, .
定義 3. 對於二元樹 , 設其高度為 , 若其滿足
- 第 層到第 層共有 個節點;
- 最後一層的節點是連續存在的, 即
- 若第 層的任意一個節點存在左子節點, 那麼這一層該節點之前的所有節點都存在左右子節點;
- 若第 層的任意一個節點存在右子節點, 那麼該節點必定存在左子節點, 並且這一層該節點之前的所有節點都存在左右子節點,
那麼我們稱 為完全二元樹 (complete binary tree).
根據定義 3, 如果移除掉一棵完全二元樹的最後一層節點, 那麼它就是一棵完美二元樹. 另外, 最後一層的節點可以不是滿的, 但是必須是連續存在的, 中間不能空缺.
Figure 1 中的二元樹就不是完全二元樹, 因為根據定義 3, 節點 存在左子節點, 那麼就要求第三層在 之前的節點 和 必須存在左右子節點. 另外, 即使節點 存在左右子節點, Figure 1 中的二元樹仍然不存在完全二元樹, 因為 不存在左子節點. 如果移除節點 , 和 之後, 那麼 Figure 1 中的這棵二元樹就會成為一棵完全二元樹.
顯然, 一棵完美二元樹就能滿足定義 3, 所以任意完美二元樹也同時都是完全二元樹.
性質 3. 若一棵二元樹有 個元素, 那麼其高度最大為 , 最小為 .
:由性質 2 可以知道, 若每一層有且唯有一個元素, 那麼此時樹的高度即為 , 且不會超過 .
再由性質 2 可知, 高度為 的二元樹至多有 個元素. 由於 , 因此有 . 由於 是整數, 因此 .
因此, 若一棵二元樹有 個元素, 那麼其高度最大為 , 最小為 .
3. 線性儲存
一般來說, 樹都是採用類似於連結串列 (《【資料結構】連結串列》) 那樣的方式進行儲存, 但是由於二元樹的特殊性, 我們也可以使用向量 (《【資料結構】向量 (順序表)》) 進行儲存.
在一棵完全二元樹種, 如果我們按照從上至下為基礎, 然後從左至右進行編號, 那麼一個父節點和子節點的序號是有嚴格關係的 : 設二元樹 中某個節點的編號為 , 則有
- 如果 , 則這個節點是二元樹的根節點;
- 如果 , 則
- 這個節點的父節點編號為 ;
- 如果 , 則編號為 的節點是這個節點的左子節點;
- 如果 , 則編號為 的節點是這個節點的右子節點.
但是有些時候, 如果二元樹中的元素不多, 那麼使用陣列來儲存就會浪費很多空間 :
因此, 針對元素比較多的二元樹, 我們可以使用向量來儲存. 在大多數程式設計語言中, 一般直接採用陣列儲存, 而陣列的存取會比連結串列這樣的方式要快得多. 但是, 如果二元樹的高度比較大, 但是元素比較少, 應該謹慎考慮是否應該使用向量來儲存二元樹.
4. 尋訪
在《【資料結構】樹》第 2 節, 我們介紹了三種對樹的尋訪方式, 這些方式對二元樹仍然適用, 所以此處我們不再累贅. 不過由於二元樹的特殊性, 二元樹還有一種獨有的尋訪方式, 就是中序尋訪. 二元樹的中序尋訪是從根節點開始的, 首先遞迴地尋訪左子樹, 當左子樹全部尋訪完畢之後才尋訪本節點中的元素, 然後遞迴地尋訪右子樹.




例題 1. 寫出二元樹
從根節點開始的尋訪結果.
:對於層級尋訪, 就是按照從上到下, 從左到右的順序進行尋訪即可, 因此結果為 , , , , , , .
對於次序尋訪, 實際上是需要遞迴進行的, 因此我們採用堆疊的演示方式. 首先來看前序尋訪 :
- 開始尋訪. 目前位於根節點. 根據前序尋訪的順序, 我們首先存取元素, 此時結果為 . 然後前往節點 的左子節點 :
- 現在來到了節點 . 根據尋訪順序, 首先存取元素, 此時的結果為 , . 然後前往節點 的左子節點 :
- 根據尋訪順序, 首先存取元素, 此時的結果為 , , . 然後嘗試前往節點 的左子節點的時候, 發現為空; 然後嘗試前往節點 的右子節點的時候, 發現仍然為空. 因此回溯到節點 .
- 現在回到了節點 . 跟根據尋訪順序, 接下來要前往節點 的右子節點 :
- 根據尋訪順序, 首先存取元素, 此時的結果為 , , , . 然後嘗試前往節點 的左子節點的時候, 發現為空; 然後嘗試前往節點 的右子節點的時候, 發現仍然為空. 因此回溯到節點 .
- 現在又回到了節點 , 由於節點 的左右子樹都已經尋訪, 因此回溯到節點 .
- 現在回到了節點 . 根據前序尋訪的順序, 現在前往節點 的右子節點 :
- 現在來到了節點 根據尋訪換序, 首先存取元素, 此時的結果為 , , , , . 然後嘗試前往節點 的左子樹 :
- 根據尋訪順序, 首先存取元素, 此時的結果為 , , , , , . 然後嘗試前往節點 的左子節點的時候, 發現為空; 然後嘗試前往節點 的右子節點的時候, 發現仍然為空. 因此回溯到節點 .
- 現在回到了節點 . 根據尋訪順序, 接下來要前往節點 的右子節點 :
- 根據尋訪順序, 首先存取元素, 此時的結果為 , , , , , , . 然後嘗試前往節點 的左子節點的時候, 發現為空; 然後嘗試前往節點 的右子節點的時候, 發現仍然為空. 因此回溯到節點 .
- 現在又回到了節點 . 由於節點 的左右子樹都已經尋訪, 因此回溯到節點 .
- 現在又回到了節點 . 由於節點 的左右子樹都已經尋訪, 因此嘗試回到節點 的父節點. 由於節點 沒有父節點, 代表著節點 就是這棵樹的根節點, 故尋訪完畢.
最終前序尋訪的結果為 , , , , , , .
對於中序尋訪 :
- 開始尋訪. 目前位於根節點. 根據中序尋訪的順序, 我們首先前往節點 的左子節點 :
- 現在來到了節點 . 根據尋訪順序, 我們前往節點 的左子節點 :
- 現在來到節點 . 根據尋訪順序, 我們首先前往節點 的左子節點, 但是節點 的左子節點為空; 然後, 我們存取元素, 此時的尋訪結果為 ; 最後嘗試前往節點 的右子節點, 發現右子節點也為空. 此時, 節點 尋訪完畢, 回到節點 .
- 現在回到了節點 . 我們要存取節點中的元素, 此時的尋訪結果為 , . 然後嘗試前往節點 的右子節點 :
- 現在來到了節點 . 根據尋訪順序, 我們首先前往節點 的左子節點, 但是節點 的左子節點為空; 然後, 我們存取元素, 此時的結果為 , , ; 最後嘗試前往節點 的右子節點, 發現右子節點也為空. 此時, 節點 尋訪完畢, 回到節點 .
- 現在又回到了節點 , 由於節點 的左右子樹都已經尋訪, 因此回溯到節點 .
- 現在回到了節點 . 根據中序尋訪的順序, 存取元素, 此時的結果為 , , , . 最後根據尋訪順序, 我們前往節點 的右子節點 :
- 現在來到了節點 . 根據尋訪換序, 我們前往節點 的左子節點 :
- 現在來到節點 . 根據尋訪順序, 我們首先前往節點 的左子節點, 但是節點 的左子節點為空; 然後, 我們存取元素, 此時的結果為 , , , , ; 最後嘗試前往節點 的右子節點, 發現右子節點也為空. 此時, 節點 尋訪完畢, 回到節點 .
- 現在回到了節點 . 存取節點 中的元素, 此時的結果為 , , , , , . 最後根據尋訪順序, 我們前往節點 的右子節點 :
- 現在來到了節點 . 根據尋訪順序, 我們首先前往節點 的左子節點, 但是節點 的左子節點為空; 然後, 我們存取元素, 此時的結果為 中的元素, 此時的結果為 , , , , , , ; 最後嘗試前往節點 的右子節點, 發現右子節點也為空. 此時, 節點 尋訪完畢, 回到節點 .
- 現在又回到了節點 . 由於節點 的左右子樹都已經尋訪, 因此回溯到節點 .
- 現在又回到了節點 . 由於節點 的左右子樹都已經尋訪, 因此嘗試回到節點 的父節點. 由於節點 沒有父節點, 代表著節點 就是這棵樹的根節點, 故尋訪完畢.
最終中序尋訪的結果為 , , , , , , .
最後來看看後序尋訪 :
- 開始尋訪. 目前位於根節點. 根據後序尋訪的順序, 我們首先前往節點 的左子節點 :
- 現在來到了節點 . 根據尋訪順序, 我們前往節點 的左子節點 :
- 現在來到節點 . 根據尋訪順序, 我們首先前往節點 的左子節點, 但是節點 的左子節點為空; 然後, 嘗試前往節點的右子節點 , 發現右子節點也為空; 最後我們存取節點 中的元素, 此時的尋訪結果為 . 此時, 節點 尋訪完畢, 回到節點 .
- 現在回到了節點 . 根據尋訪的順序, 我們前往節點 的右子節點
- 現在來到節點 . 根據尋訪順序, 我們首先前往節點 的左子節點, 但是節點 的左子節點為空; 然後, 嘗試前往節點 的右子節點, 發現右子節點也為空; 最後我們存取元素, 此時的結果為 , . 此時, 節點 尋訪完畢, 回到節點 .
- 現在又回到了節點 , 最後我們存取節點 的元素, 此時的結果為 , , . 節點 為根的子樹已經尋訪完畢了, 現在回到節點 .
- 現在回到了節點 . 根據後序尋訪的順序, 我們前往節點 的右子節點 :
- 現在來到了節點 . 根據尋訪換序, 我們前往節點 的左子節點 :
- 現在來到節點 . 根據尋訪順序, 我們首先前往節點 的左子節點, 但是節點 的左子節點為空; 然後, 嘗試前往節點 的右子節點, 發現右子節點也為空; 最後我們存取元素, 此時的結果為 , , , . 此時, 節點 尋訪完畢, 回到節點 .
- 現在回到了節點 . 根據尋訪的順序, 我們要前往節點 的右子節點 :
- 現在來到節點 . 根據尋訪順序, 我們首先前往節點 的左子節點, 但是節點 的左子節點為空; 然後, 嘗試前往節點 的右子節點, 發現右子節點也為空; 最後我們存取元素, 此時的結果為 , , , , . 此時, 節點 尋訪完畢, 回到節點 .
- 現在又回到了節點 . 最後我們存取節點 的元素, 此時的結果為 , , , , , . 節點 為根的子樹已經尋訪完畢了, 現在回到節點 .
- 現在又回到了節點 . 根據尋訪順序, 我們還要存取節點 中的元素. 此時的結果為 , , , , , , . 由於節點 的左右子樹都已經尋訪, 因此嘗試回到節點 的父節點. 由於節點 沒有父節點, 代表著節點 就是這棵樹的根節點, 故尋訪完畢.
最終後序尋訪的結果為 , , , , , , .
綜上所述, 樹 的層次尋訪結果為 前序尋訪結果為 中序尋訪的結果為 後序尋訪的結果為
5. 旋轉
二元樹可以以某個節點為基準, 進行左旋轉和右旋轉. 這是普通的樹無法做到的.
設 是樹 的某個節點, 是 的左子節點 (可以為空), 是 的父節點. 以節點 為基礎的左旋轉, 就是使用節點 去替換原來 , 然後將被擠出來的 掛到 的右子樹當中. 如果 存在父節點 , 原來 位於 哪一個子節點, 那麼就把旋轉之後以 為根節點的子樹掛到 對應的子節點中; 如果 本來是根節點, 那麼經過旋轉之後, 就變成了根節點.
從 Figure 5 中, 我們可以發現左旋轉看起來像以節點 為中心, 節點 隨著它自己和 的連接線, 向左自然滑落一樣. 這便是左旋的名稱由來. 被擠掉的 節點被重新安排至 的右子節點, 因為 的右子節點本來是 , 滑落之後 的右子節點總為空.
設 是樹 的某個節點, 是 的右子節點 (可以為空), 是 的父節點. 以節點 為基礎的右旋轉, 就是使用節點 去替換原來 , 然後將被擠出來的 掛到 的左子樹當中. 如果 存在父節點 , 原來 位於 哪一個子節點, 那麼就把旋轉之後以 為根節點的子樹掛到 對應的子節點中; 如果 本來是根節點, 那麼經過旋轉之後, 就變成了根節點.
從 Figure 6 中, 我們可以發現右旋轉看起來像以節點 為中心, 節點 隨著它自己和 的連接線, 向右自然滑落一樣. 這便是右旋的名稱由來. 被擠掉的 節點被重新安排至 的左子節點, 因為 的左子節點本來是 , 滑落之後 的左子節點總為空.
值得注意的是, 上面這兩種旋轉的角度都是不大於 的. 而旋轉角度如果大於 , 這樣的旋轉是不存在也是無效的, 也就是說
這兩種旋轉是錯誤的.
定理 1. 在對二元樹進行旋轉之後, 二元樹的中序尋訪結果不改變.
:首先證明二元樹在進行左旋轉之後, 中序尋訪結果不發生改變.
設 是二元樹 的某個非空節點, 為 的非空右子節點, 為 的左子節點. 記 為存取 節點元素之前產生的尋訪結果, 為節點 的左子樹的尋訪結果, 為存取節點 元素之後的尋訪結果, 旋轉過後的二元樹為 . 我們直接使用 和 來表示節點 和 中的元素. 那麼在進行左旋轉之前, 二元樹 的中序尋訪結果為 .
要證明二元樹在左旋轉之後中序尋訪順序不變, 只需要證明二元樹 在左旋轉之後的中序尋訪結果仍然為 .
若 為二元樹 的根節點, 在二元樹 以節點 為中心進行左旋轉之後, 將成為 的根節點. 根據中序尋訪的規則, 我們並不是直接存取 中的元素, 而是首先前往 的左子樹 . 而左旋並沒有改變 的左子樹, 因此在旋轉之後, 不改變. 根據中序尋訪的規則, 在存取 中的元素之後, 目前的尋訪結果為 . 接下來要尋訪 的右子樹. 在旋轉之後, 的右子樹發生了改變, 變成了以 為根節點的子樹. 而 是以 為根節點的子樹的尋訪結果, 因此可以確定尋訪完 之後, 現在的尋訪結果為 . 現在 的左子樹已經尋訪完畢, 要存取 中的元素, 於是尋訪結果為 . 旋轉並不改變原本位於 節點的右子樹, 於是 便是以 的右子節點為根節點的子樹的尋訪結果. 因此, 總體的尋訪結果為 .
若 不是二元樹 的根節點, 我們已經證明了以 為根節點的子樹經過旋轉, 變為 為根節點之後, 子樹的尋訪順序不改變. 因此, 我們只需要考慮 和 是否改變即可. 對於 的左子樹位於 的部分和 的右子樹位於 的部分, 我們可以由上面的分析可知這兩個部分的尋訪結果不改變. 而左旋只改變了原本以 為根節點的子樹, 其餘部分的節點順序並未改變, 因此尋訪結果也不改變. 因此, 尋訪完畢之後的結果仍然為 .
所以在二元樹進行左旋轉之後, 中序尋訪的結果不發生改變.
接下來證明二元樹進行右旋轉之後, 中序尋訪的結果仍然不發生改變.
設 是二元樹 的某個非空節點, 為 的非空左子節點, 為 的右子節點. 記 為存取 節點元素之前產生的尋訪結果, 為存取 節點元素之後產生的尋訪結果, 為節點 的右子樹的尋訪結果, 旋轉過後的二元樹為 . 我們直接使用 和 來表示節點 和 中的元素. 那麼在進行左旋轉之前, 二元樹 的中序尋訪結果為 .
要證明二元樹在左旋轉之後中序尋訪順序不變, 只需要證明二元樹 在右旋轉之後的中序尋訪結果仍然為 .
若 為二元樹 的根節點, 在二元樹 以節點 為中心進行右旋轉之後, 將成為 的根節點. 根據中序尋訪的規則, 我們並不是直接存取 中的元素, 而是首先前往 的左子樹 . 而左子樹 的尋訪結果即是旋轉之前 的左子樹的尋訪結果. 旋轉並沒有改變本來位於 左子樹中的節點順序, 因此 不變, 於是現在的尋訪結果為 . 根據中序尋訪的順序, 接下來要存取 節點中的元素, 於是尋訪結果為 . 然後要尋訪 的右子樹. 由於旋轉之後, 成為了 的右子樹, 因此需要首先尋訪 的左子樹. 而 被放置在了 的左子節點中, 旋轉並沒有改變以 為根節點的子樹的順序, 因此 不發生改變. 於是, 現在的尋訪結果為 . 接下來要存取 節點中的元素, 尋訪結果會變為 . 之後就會尋訪 的右子節點, 而旋轉也沒有改變其右子樹的順序, 因此尋訪完畢之後結果為 .
若 不是二元樹 的根節點. 我們已經證明了以 為根節點的子樹經過旋轉, 變為 為根節點之後, 子樹的尋訪順序不改變. 因此, 我們只需要考慮 和 是否改變即可. 對於 的左子樹位於 的部分和 的右子樹位於 的部分, 我們可以由上面的分析可知這兩個部分的尋訪結果不改變. 而右旋只改變了原本以 為根節點的子樹, 其餘部分的節點順序並未改變, 因此尋訪結果也不改變. 因此, 尋訪完畢之後的結果仍然為 .
所以在二元樹進行右旋轉之後, 中序尋訪的結果不發生改變.
綜上所述, 在對二元樹進行旋轉之後, 二元樹的中序尋訪結果不改變.
6. 實作
我自己用 C++ 實作了二元樹 : https://github.com/Jonny0201/data_structure/blob/master/data_structure/binary_tree.hpp, 大家可以參考後自行實作.
自創文章, 原著 : Jonny. 如若閣下需要轉發, 在已經授權的情況下請註明本文出處 :