編碼 · 資料結構
二元堆積教學:優先佇列、程式碼與時間複雜度
簡答
二元堆積是儲存在陣列中的完全二元樹,其中每個父節點都小於或等於它的子節點(最小堆積),或大於或等於它們(最大堆積)。最小的項目永遠在索引 0,因此讀取只要 O(1),插入或移除則是 O(log n)。堆積是實作優先佇列的標準做法;在 Python 中請使用 heapq 模組。
堆積解決什麼問題
許多程式需要從不斷變動的集合中反覆取出最緊急的項目:模擬中的下一個事件、Dijkstra 演算法中最近的未拜訪節點、優先權最高的工作,或資料流中最大的 k 個值。每次插入後都重新排序整個集合,每次要花 O(n log n)。排序串列讓移除很便宜,但插入是 O(n)。堆積讓插入與移除最小值都是 O(log n),這正是優先佇列所需要的。
概念:存放在陣列中的樹
堆積是完全二元樹:除了最後一層之外,每一層都是滿的,而最後一層由左邊開始填入。這種形狀讓我們可以不用指標,直接把樹存在一個普通的串列中。
| 關係 | 索引公式(從 0 開始) |
|---|---|
| i 的左子節點 | 2 * i + 1 |
| i 的右子節點 | 2 * i + 2 |
| i 的父節點 | (i - 1) // 2 |
兩種修復操作維持堆積性質。上浮(sift up):在尾端附加新項目後,只要它比父節點小就與父節點交換。下沉(sift down):把最後一個項目移到根節點後,只要它比較小的子節點大,就與該子節點交換。每個操作都沿著從根到葉的一條路徑移動,所以最多約進行 log2(n) 次交換。
實例演練:push 與 pop
從最小堆積 [2, 5, 3, 9, 6] 開始(2 是根節點,它的子節點是 5 和 3,而 5 的子節點是 9 和 6)。
- Push 1:附加在尾端,得到
[2, 5, 3, 9, 6, 1]。索引 5 的父節點是索引 2(值為 3)。 - 1 小於 3,交換:
[2, 5, 1, 9, 6, 3]。現在索引 2 的父節點是索引 0(值為 2)。 - 1 小於 2,交換:
[1, 5, 2, 9, 6, 3]。它已到達根節點,停止。 - Pop:移除根節點 1,把最後一個項目 3 移到根節點:
[3, 5, 2, 9, 6]。 - 根節點的子節點是 5 和 2,較小的是 2,而 3 比它大,交換:
[2, 5, 3, 9, 6]。索引 2 沒有子節點,停止。這次 pop 回傳了 1。
以 Python 實作最小堆積
這個類別用來展示運作機制。實際的程式碼請使用下一節介紹的 heapq。
class MinHeap:
def __init__(self):
self.a = []
def push(self, x):
a = self.a
a.append(x)
i = len(a) - 1
while i > 0 and a[i] < a[(i - 1) // 2]: # sift up
p = (i - 1) // 2
a[i], a[p] = a[p], a[i]
i = p
def pop(self):
a = self.a
top, last = a[0], a.pop() # IndexError if empty
if a:
a[0], i = last, 0
while True: # sift down
c = 2 * i + 1
if c + 1 < len(a) and a[c + 1] < a[c]:
c += 1 # pick the smaller child
if c >= len(a) or a[i] <= a[c]:
break
a[i], a[c] = a[c], a[i]
i = c
return top
h = MinHeap()
for x in [5, 3, 9, 1, 6, 2]:
h.push(x)
print([h.pop() for _ in range(6)]) # [1, 2, 3, 5, 6, 9]使用 Python heapq 的優先佇列
heapq 模組直接操作一般的串列,並讓它始終維持最小堆積。heapify 能在 O(n) 內就地把現有串列轉成堆積。要使用優先權,請 push 元組:Python 會逐個元素比較元組,所以第一個元素就是優先權。再加上一個計數器作為平手時的決勝條件,這樣優先權相同的項目就不需要直接互相比較。
import heapq
from itertools import count
nums = [7, 2, 9, 4, 1]
heapq.heapify(nums)
print(nums[0], heapq.heappop(nums)) # 1 1
print(heapq.nlargest(2, [7, 2, 9, 4])) # [9, 7]
tasks, order = [], count()
heapq.heappush(tasks, (2, next(order), "write report"))
heapq.heappush(tasks, (1, next(order), "fix outage"))
heapq.heappush(tasks, (2, next(order), "reply to email"))
while tasks:
priority, _, name = heapq.heappop(tasks)
print(priority, name)
# 1 fix outage
# 2 write report
# 2 reply to email對於最大堆積,Python 3.14 新增了 heapq.heapify_max、heapq.heappush_max 和 heapq.heappop_max。在較舊的版本中,常用的技巧是 push 取負值的鍵,例如 heapq.heappush(h, -score),pop 時再取一次負值。
時間與空間複雜度
| 操作 | 時間 | 原因 |
|---|---|---|
| 查看最小值 | O(1) | 它永遠在索引 0 |
| Push(插入) | O(log n) | 沿著一條路徑上浮 |
| Pop 最小值 | O(log n) | 沿著一條路徑下沉 |
| 對 n 個項目 heapify | O(n) | 大多數節點靠近底部,只需移動一點點 |
| 搜尋任意值 | O(n) | 堆積並未排序 |
| 堆積排序 | O(n log n) | 先 heapify,再 pop n 次 |
| 空間 | O(n) | 單一陣列,沒有指標 |
用 push 呼叫 n 次來建立堆積,成本是 O(n log n)。heapify 從最後一個父節點到根節點依序下沉,成本是 O(n),所以當你已經擁有全部項目時,應優先使用它。
變體與常見錯誤
- 最大堆積:程式碼相同,只是比較方向反過來;在 Python 中,請使用
_max系列函式(3.14 及之後),或把鍵取負值。 - d 元堆積:每個節點有 d 個子節點。樹較淺,使 push 更便宜,pop 則稍微貴一點。
- 索引式優先佇列 / decrease-key:
heapq無法就地修改項目的優先權。常見的替代做法(Dijkstra 演算法中使用)是 push 一筆新項目,並在舊項目被 pop 時略過它。 - 比較無法比較的項目:push
(priority, dict)時,如果兩個優先權相等,Python 會去比較 dict,進而拋出TypeError。請把計數器放在元組的第二個位置。 - 把堆積串列當成已排序。只有
heap[0]有保證;如果需要全部依序排列,請使用sorted(heap)。 - 差一錯誤(off-by-one):把教科書上從 1 開始的公式(子節點為
2i和2i + 1)和從 0 開始的 Python 串列混用。
何時使用堆積,以及典型問題
當你需要反覆從不斷變動的集合中,只取出最小或最大的項目時,請使用堆積。如果還需要有序迭代或任意刪除,請用排序串列或平衡樹;如果只需要一次最小值,直接用 min() 即可。
經典用途:Dijkstra 最短路徑與 Prim 最小生成樹、合併 k 個排序串列(heapq.merge)、找出最大或最小的 k 個項目(維持大小為 k 的堆積,O(n log k))、動態中位數(兩個堆積)、工作排程器與事件驅動模擬,以及堆積排序。
常見問題
堆積和優先佇列有什麼差別?
優先佇列是抽象資料型別:插入項目,並移除優先權最高的那一個。二元堆積則是實作它最常用的資料結構。
Python 的 heapq 是最小堆積還是最大堆積?
heappush 和 heappop 等主要函式維護的是最小堆積,所以 heappop 回傳最小的項目。Python 3.14 為最大堆積新增了獨立的 _max 函式;在較舊的版本中,請存放取負值的數字。
為什麼 heapify 是 O(n) 而不是 O(n log n)?
Heapify 從底部開始,逐一對每個父節點下沉。大約一半的節點是葉節點,不會移動;四分之一的節點最多移動一層,依此類推;總工作量加起來是 O(n)。
二元堆積和二元搜尋樹一樣嗎?
不一樣。BST 會排序左右子樹,所以能在 O(log n) 內搜尋任何鍵。堆積只保證每個父節點小於它的子節點,所以能很快找到最小值,但搜尋其他鍵是 O(n)。
堆積排序是穩定的嗎?
不是。下沉時的交換可能會改變相等鍵的相對順序。它是就地排序,時間 O(n log n),額外空間 O(1)。