編碼 · 資料結構
二元樹走訪教學:前序、中序、後序與層序
簡答
走訪二元樹,指的是依固定順序恰好拜訪每個節點一次。三種深度優先順序的差別只在於何時拜訪節點本身:前序是節點、左、右;中序是左、節點、右;後序是左、右、節點。層序走訪是廣度優先,利用佇列一列一列地拜訪整棵樹。四種走訪的時間都是 O(n)。
為什麼走訪順序很重要
樹沒有唯一的自然順序,所以演算法會選擇適合任務的那一種。二元搜尋樹的中序走訪會依排序順序產生所有鍵。前序會先拜訪父節點再拜訪子節點,複製一棵樹或把它輸出成可重建的形式時就需要這個順序。後序會先拜訪子節點再拜訪父節點,刪除一棵樹、計算資料夾大小或求值運算式樹時就需要它。層序會最先找到最淺的節點,並能逐列印出一棵樹。
同一個範例的四種順序
我們使用這棵樹:根節點 1,子節點為 2 和 3;節點 2 的子節點是 4 和 5;節點 3 有一個右子節點 6。
1
/ \
2 3
/ \ \
4 5 6| 順序 | 規則 | 結果 |
|---|---|---|
| 前序 | 節點、左、右 | 1 2 4 5 3 6 |
| 中序 | 左、節點、右 | 4 2 5 1 3 6 |
| 後序 | 左、右、節點 | 4 5 2 6 3 1 |
| 層序 | 逐列、由左到右 | 1 2 3 4 5 6 |
前三種有個簡單的記法:名稱說明了節點的位置。前(pre)表示在兩棵子樹之前,中(in)表示在兩棵子樹之間,後(post)表示在兩棵子樹之後。左子樹永遠先於右子樹被拜訪。
遞迴實作
遞迴版本直接對應定義。每個函式在不同的那一行拜訪節點。
class Node:
def __init__(self, val, left=None, right=None):
self.val, self.left, self.right = val, left, right
def preorder(node, out):
if node:
out.append(node.val)
preorder(node.left, out)
preorder(node.right, out)
return out
def inorder(node, out):
if node:
inorder(node.left, out)
out.append(node.val)
inorder(node.right, out)
return out
def postorder(node, out):
if node:
postorder(node.left, out)
postorder(node.right, out)
out.append(node.val)
return out
root = Node(1, Node(2, Node(4), Node(5)), Node(3, None, Node(6)))
print(preorder(root, [])) # [1, 2, 4, 5, 3, 6]
print(inorder(root, [])) # [4, 2, 5, 1, 3, 6]
print(postorder(root, [])) # [4, 5, 2, 6, 3, 1]用堆疊實作迭代式前序與中序
遞迴使用呼叫堆疊。為了避免在很深的樹上碰到遞迴上限,可以自己管理堆疊。前序的做法是:彈出一個節點、拜訪它,然後先 push 右子節點再 push 左子節點,這樣左子節點就會先被處理。
中序的做法是:盡可能往左走,沿途把節點 push 進去。當無法再往左時,彈出一個節點、拜訪它,然後移到它的右子節點。
def preorder_iter(root):
out, stack = [], [root] if root else []
while stack:
node = stack.pop()
out.append(node.val)
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
return out
def inorder_iter(root):
out, stack, node = [], [], root
while stack or node:
while node: # go as far left as possible
stack.append(node)
node = node.left
node = stack.pop()
out.append(node.val)
node = node.right
return out
print(preorder_iter(root)) # [1, 2, 4, 5, 3, 6]
print(inorder_iter(root)) # [4, 2, 5, 1, 3, 6]迭代式後序與層序
迭代式後序最難。有一個乾淨的做法是使用旗標:每個節點 push 兩次,第二次標記為「子節點處理完後即可拜訪」。層序則用佇列而不是堆疊;一次處理佇列中的一整層,也能順便得知每個節點的深度。
from collections import deque
def postorder_iter(root):
out, stack = [], [(root, False)] if root else []
while stack:
node, children_done = stack.pop()
if children_done:
out.append(node.val)
else:
stack.append((node, True))
if node.right:
stack.append((node.right, False))
if node.left:
stack.append((node.left, False))
return out
def level_order(root):
levels, queue = [], deque([root] if root else [])
while queue:
level = []
for _ in range(len(queue)): # nodes of the current level
node = queue.popleft()
level.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
levels.append(level)
return levels
print(postorder_iter(root)) # [4, 5, 2, 6, 3, 1]
print(level_order(root)) # [[1], [2, 3], [4, 5, 6]]另一個常見的後序技巧:執行修改過的前序(節點、右、左),再把結果反轉。
時間與空間複雜度
| 走訪方式 | 時間 | 額外空間 | 原因 |
|---|---|---|---|
| 前序 / 中序 / 後序(遞迴) | O(n) | O(h) | 呼叫堆疊每一層保存一個框架 |
| 前序 / 中序 / 後序(迭代) | O(n) | O(h) | 明確的堆疊最多保存一條路徑再加上兄弟節點 |
| 層序 | O(n) | O(w) | 佇列最多保存一整層 |
| Morris 中序走訪 | O(n) | O(1) | 暫時把空的右連結改接成線索 |
這裡 n 是節點數,h 是高度,w 是最大寬度。對平衡樹而言,h 約為 log2(n),w 可達約 n/2;對鏈狀的樹而言,h 是 n,w 是 1。
常見錯誤
- 在迭代式前序中先 push 左子節點再 push 右子節點。堆疊是 LIFO,想要先處理的子節點必須最後 push。
- 忘記處理空樹:每個函式在
root is None時都應該回傳空的結果。 - 在層序中用
list搭配pop(0)當佇列。每次 pop 是 O(n);請改用collections.deque。 - 在很深的樹上依賴遞迴。Python 會在遞迴上限(預設約 1000)時以
RecursionError中止。 - 以為單一種走訪就能確定一棵樹。只有前序是有歧義的;前序加中序(且值互不相同)就足以重建出這棵樹。
何時使用哪一種走訪
| 任務 | 走訪方式 |
|---|---|
| 依排序順序列出 BST 的鍵、驗證 BST、第 k 小的元素 | 中序 |
| 複製或序列化一棵樹、輸出目錄大綱 | 前序 |
| 刪除一棵樹、計算子樹大小或高度、求值運算式樹 | 後序 |
| 最小深度、右側視圖、逐層輸出、最近的節點 | 層序 |
前序、中序與後序都是深度優先搜尋的形式;層序則是套用在樹上的廣度優先搜尋。同樣的想法也可以延伸到一般的圖,只是這時還需要一個記錄已拜訪節點的集合。
常見問題
前序、中序與後序有什麼差別?
差別只在拜訪節點的位置。前序在子樹之前拜訪節點,中序在左右子樹之間拜訪,後序則在兩棵子樹之後拜訪。
哪一種走訪能讓二元搜尋樹得到排序後的輸出?
中序走訪。在 BST 中,左邊的鍵都比較小,右邊的鍵都比較大,所以依左、節點、右的順序拜訪,就會得到遞增的鍵。
層序走訪就是 BFS 嗎?
是的。層序走訪就是在樹上做的廣度優先搜尋。它使用佇列,並依深度順序拜訪節點。
為什麼要用迭代式走訪而不是遞迴?
遞迴深度等於樹的高度,而 Python 超過遞迴上限時會拋出 RecursionError。明確的堆疊沒有這種限制,也方便暫停或提前結束。
可以只用 O(1) 的額外空間走訪一棵樹嗎?
可以。Morris 走訪會暫時把每個節點的中序前驅節點連回該節點,因此不需要堆疊,並在結束前把樹還原。時間為 O(n)。