編碼 · 資料結構
二元搜尋樹(BST)教學:原理、程式碼與時間複雜度
簡答
二元搜尋樹(BST)是一種二元樹:每個節點的左子樹只包含較小的鍵,右子樹只包含較大的鍵。因為有這條規則,搜尋、插入與刪除都只沿著從根節點出發的一條路徑進行,時間為 O(h),其中 h 是樹的高度:樹平衡時是 O(log n),退化成一條鏈時則是 O(n)。中序走訪會依排序順序拜訪所有鍵。
二元搜尋樹解決什麼問題
排序過的陣列可以快速做二分搜尋,但插入新元素時,後面所有元素都得搬移。雜湊表的查找與插入都很快,卻不保留順序,所以無法回答「比 42 大的最小鍵是什麼?」或「列出 10 到 20 之間的所有鍵」這類問題。二元搜尋樹讓鍵保持排序,同時仍能快速插入與刪除。它是有序映射與集合的基礎,例如 Java 的 TreeMap 和 C++ 的 std::map,這些實作使用的是自平衡的變體。
用白話說明規則
每個節點有一個鍵、一個可有可無的左子節點,以及一個可有可無的右子節點。對每個節點而言:左子樹的所有鍵都比它小,右子樹的所有鍵都比它大。要找某個鍵,從根節點開始比較:鍵比較小就往左,比較大就往右,相等就停下,或是走出樹外也停下。
實例演練:建立並搜尋一棵樹
依序插入 8、3、10、1、6、14、4。每個新鍵都從根節點往下走,在走出樹外的位置成為葉節點。
| 插入 | 經過的路徑 | 放置位置 |
|---|---|---|
| 8 | 空樹 | 根節點 |
| 3 | 8:較小,往左 | 8 的左子節點 |
| 10 | 8:較大,往右 | 8 的右子節點 |
| 1 | 8 往左、3 往左 | 3 的左子節點 |
| 6 | 8 往左、3 往右 | 3 的右子節點 |
| 14 | 8 往右、10 往右 | 10 的右子節點 |
| 4 | 8 往左、3 往右、6 往左 | 6 的左子節點 |
搜尋 4 時依序和 8、3、6、4 比較:在有七個鍵的樹中比較四次。搜尋 7 時依序和 8、3、6 比較,然後發現 6 沒有右子節點,所以 7 不在樹中。
Python 實作
插入與搜尋都是簡短的迴圈。刪除有三種情況:節點沒有子節點(直接移除)、有一個子節點(用該子節點取代它),或有兩個子節點(複製它的中序後繼節點,也就是右子樹中最小的鍵,然後刪除那個後繼節點)。
class Node:
def __init__(self, key):
self.key, self.left, self.right = key, None, None
def insert(root, key):
if root is None:
return Node(key)
if key < root.key:
root.left = insert(root.left, key)
elif key > root.key:
root.right = insert(root.right, key)
return root # duplicates are ignored
def contains(root, key):
while root and root.key != key:
root = root.left if key < root.key else root.right
return root is not None
def delete(root, key):
if root is None:
return None
if key < root.key:
root.left = delete(root.left, key)
elif key > root.key:
root.right = delete(root.right, key)
else:
if root.left is None:
return root.right
if root.right is None:
return root.left
succ = root.right
while succ.left:
succ = succ.left
root.key = succ.key
root.right = delete(root.right, succ.key)
return root
def inorder(root):
return inorder(root.left) + [root.key] + inorder(root.right) if root else []root = None
for k in [8, 3, 10, 1, 6, 14, 4]:
root = insert(root, k)
print(inorder(root)) # [1, 3, 4, 6, 8, 10, 14]
print(contains(root, 4), contains(root, 7)) # True False
root = delete(root, 3) # node with two children
print(inorder(root), root.left.key) # [1, 4, 6, 8, 10, 14] 4時間與空間複雜度
| 操作 | 平衡的樹 | 最差情況(鏈) | 原因 |
|---|---|---|---|
| 搜尋 | O(log n) | O(n) | 每一層比較一次 |
| 插入 | O(log n) | O(n) | 走到葉節點的位置 |
| 刪除 | O(log n) | O(n) | 找到節點,可能還要找它的後繼節點 |
| 最小值 / 最大值 | O(log n) | O(n) | 沿著左(或右)連結走到盡頭 |
| 中序走訪 | O(n) | O(n) | 每個節點拜訪一次 |
| 空間 | O(n) | O(n) | 每個鍵一個節點;遞迴會使用 O(h) 的堆疊 |
最差情況是真實存在的,不只是理論:把已排序的鍵(1, 2, 3, ...)依序插入一棵普通的 BST,每個節點都只會有右子節點,樹就變成了鏈結串列。隨機的插入順序則可得到期望高度 O(log n)。
平衡變體與常見錯誤
自平衡樹在插入與刪除後透過旋轉重整結構,讓高度維持在 O(log n)。
| 變體 | 平衡的想法 | 使用場合 |
|---|---|---|
| AVL 樹 | 兩棵子樹的高度最多相差 1 | 以查找為主的工作負載 |
| 紅黑樹 | 以顏色規則限制最長路徑不超過最短路徑的兩倍 | Java TreeMap、常見的 C++ std::map 實作、Linux 核心 |
| B 樹 / B+ 樹 | 每個節點有許多鍵,所有葉節點在同一深度 | 資料庫與檔案系統 |
| Treap、伸展樹(splay tree) | 隨機優先權,或把被存取的鍵移到根節點 | 較簡單的平衡樹、快取 |
- 只檢查父節點與子節點就驗證 BST。應該改為向下傳遞合法的 (low, high) 範圍。
- 忘記從遞迴的插入與刪除回傳(可能是新的)根節點,導致修改遺失。
- 在退化的樹上遞迴過深:Python 預設的遞迴上限約為 1000,所以 5000 個節點的鏈會讓遞迴程式崩潰。請改用迴圈,或讓樹保持平衡。
- 沒有明確定義重複鍵的處理方式:要決定重複的鍵是被忽略、被計數,還是一律放在同一側。
何時使用 BST,以及常見的面試題
當你需要有序資料,並且頻繁插入與刪除時,請使用平衡的 BST:範圍查詢、floor 與 ceiling 查找、名次統計,或依排序順序迭代。如果只需要精確的鍵查找,請用雜湊表。Python 標準函式庫沒有樹形映射;常見的選擇是對小型或大多不變的資料使用排序串列搭配 bisect 模組,或使用第三方套件 sortedcontainers。
常見的面試題:驗證 BST、找第 k 小的元素(中序走訪)、最低共同祖先、把排序陣列轉成平衡 BST(選中間的元素當根),以及找出某個節點的中序後繼節點。
常見問題
二元樹和二元搜尋樹有什麼差別?
二元樹只限制每個節點最多有兩個子節點。二元搜尋樹則多了順序規則:對每棵子樹而言,較小的鍵在左邊,較大的鍵在右邊。
為什麼 BST 的搜尋是 O(log n)?
每次比較都會往下走一層,並捨棄一棵子樹。有 n 個節點的平衡樹大約有 log2(n) 層,所以搜尋大約只需要這麼多次比較。
如果把已排序的資料插入 BST 會怎樣?
如果沒有平衡機制,每個新鍵都會成為前一個鍵的右子節點,高度變成 n,每個操作都變成 O(n)。AVL 樹與紅黑樹等自平衡樹可以避免這種情況。
如何依排序順序取得 BST 的所有鍵?
進行中序走訪:先拜訪左子樹,再拜訪節點本身,最後拜訪右子樹。時間為 O(n)。
Python 有內建的二元搜尋樹嗎?
沒有。Python 有 dict 和 set(雜湊表),以及處理排序串列的 bisect 模組。有序的樹形容器要靠第三方套件。