本篇補上既有排序教材裡「堆積排序」只有程式、沒有資料結構本身的缺口。讀完應能獨立實作二元堆積、說明 build-heap 為什麼是線性時間,並知道 Dijkstra、Prim、Top-K 為什麼都站在優先佇列上。
相關教材:Sorting_Algorithms_Teaching_Materials.md(堆積排序的排序觀點)、Graph_Algorithms_Complete_Teaching_Materials.tex(Dijkstra、Prim)。
目錄
- 優先佇列在解決什麼問題
- 三種天真實作為什麼不夠
- 二元堆積的兩個不變量
- 陣列索引
- 上浮與下沉
- 插入與取出極值
- 自底向上建堆:為什麼是 O(n)
- 堆積排序
- 索引堆積與 decrease-key
- 複雜度總表
- 應用
- 常見陷阱
- C++ 實作
- 練習題
1. 優先佇列在解決什麼問題
普通佇列是「先進先出」。優先佇列是「優先級最高的先出」:
- 作業系統排程:較高優先權的行程先執行。
- 醫院分診:危急的先看,不是先到的先看。
- 圖演算法:Dijkstra 每次要「目前已知距離最小」的頂點。
需要的操作:
| 操作 | 意思 |
|---|---|
insert(x) |
放進一個元素 |
del-max() / del-min() |
取出目前最大(或最小)並刪除 |
peek |
只看極值,不刪 |
decrease-key |
把已在佇列裡的某個元素的鍵變小(Dijkstra 會用到) |
下面以最大堆積為例。最小堆積只是比較方向相反。
2. 三種天真實作為什麼不夠
| 實作 | insert | del-max | 問題 |
|---|---|---|---|
| 未排序陣列 | (O(1)) | (O(n)) | 每次取出都要掃整份 |
| 已排序陣列 | (O(n)) | (O(1)) | 插入要挪位 |
| 平衡 BST | (O(\log n)) | (O(\log n)) | 正確,但常數大,而且多存了「全序」這種用不到的能力 |
二元堆積把兩種操作都做到 (O(\log n)),而且只用一個陣列,快取友善。它不維持全序,只維持「父節點不小於子節點」。
3. 二元堆積的兩個不變量
- 形狀:一棵完全二元樹。最後一層從左填到右,中間不留洞。所以可以壓進陣列,不必存指標。
- 堆積序(heap order):每個節點的鍵 (\ge) 它的子節點。根就是最大值。
這兩個不變量合起來,並沒有說左右子誰大。[10, 9, 8] 和 [10, 8, 9] 都是合法最大堆積。
4. 陣列索引
0-based(本篇程式用這個):
- 父節點:((i-1)/2)
- 左子:(2i+1)
- 右子:(2i+2)
1-based(很多教科書):父 (i/2),左 (2i),右 (2i+1)。兩套不要混用。
例:陣列 [10, 8, 9, 4, 7, 2, 3]
10
/ \
8 9
/ \ / \
4 7 2 3
5. 上浮與下沉
只靠兩個局部修復,就能維持堆積序。
上浮 swim(插入後用):新元素在尾巴,可能比父親大。只要比父親大就交換,一路往上,直到不大於父親或成為根。
下沉 sink(刪除根之後用):根的位置空了,把尾巴的元素搬到根,它可能比孩子小。每次和「較大的那個孩子」交換,直到它不小於兩個孩子,或沒有孩子。
必須和較大的孩子換。若和較小的孩子換,另一個較大的孩子會違反堆積序。
手算:最大堆積 [10, 8, 9, 4, 7, 2, 3],把根換成尾巴的 3 並刪掉尾巴,陣列暫時是 [3, 8, 9, 4, 7, 2]。
3的孩子是8和9,較大是9,交換 →[9, 8, 3, 4, 7, 2]3現在沒有孩子(右子樹只到索引 2 的位置,孩子索引會超出長度)。停止。
根是 9,堆積序恢復。
6. 插入與取出極值
插入:放到陣列尾巴,然後 swim。最多沿一條根到葉的路徑,(O(\log n))。
插入 11 到上面修好的堆積:
[9, 8, 3, 4, 7, 2, 11] → 11 比父親 3 大,交換 → [9, 8, 11, 4, 7, 2, 3] → 11 比父親 9 大,交換 → [11, 8, 9, 4, 7, 2, 3]。
取出最大值:回傳根,把尾巴搬到根,長度減一,sink。同樣 (O(\log n))。
空堆積上呼叫 del-max 是錯誤,呼叫端要先檢查。
7. 自底向上建堆:為什麼是 O(n)
把任意陣列變成堆積,不要對每個元素做一次插入(那是 (O(n\log n)))。
從最後一個非葉節點往回,對每個位置做一次 sink。葉節點已經滿足堆積序,不必處理。0-based 時最後一個非葉是索引 (n/2 - 1)。
例:[4, 10, 3, 5, 1],(n=5),從索引 1 開始(10 的孩子是 5 和 1,已經是堆積;索引 0 的 4 才需要下沉)。
- 索引 1 的
10不小於孩子,不動。 - 索引 0 的
4孩子是10和3,與10交換 →[10, 4, 3, 5, 1]。 4的孩子是5和1,與5交換 →[10, 5, 3, 4, 1]。
為什麼總時間是線性? 高度為 (h) 的節點,下沉最多 (h) 步。高度 (h) 的節點個數最多約 (n / 2^{h+1})。總成本
[ \sum_{h=0}^{\lfloor \log n \rfloor} h \cdot \frac{n}{2^{h+1}} = O(n) ]
因為 (\sum h / 2^h) 收斂到常數。直覺:絕大多數節點在樹的底部,下沉距離很短;只有少數節點靠近根,才會走很長。
8. 堆積排序
build-heap,(O(n))。- 重複 (n-1) 次:交換根與尾巴,把尾巴視為已排序(不再屬於堆積),對新的根
sink。
每次 sink (O(\log n)),共 (O(n\log n))。額外空間 (O(1))。不穩定:相等鍵可能因為下沉交換而換位。細節與其他排序的對照見排序教材。
9. 索引堆積與 decrease-key
Dijkstra 的鬆弛是「某個頂點的距離變小了,佇列裡那一筆要更新」。一般二元堆積沒有便宜的「找到那個頂點」。
索引堆積多兩個陣列:
pos[v]:頂點v現在在堆積陣列的哪個索引。inv[i]:堆積索引i放的是哪個頂點。
交換時兩個陣列一起改。decrease-key(v, newDist) 先改距離,再依 pos[v] 上浮。時間仍是 (O(\log n))。
若圖很稀疏、實作想省事,也可以每次鬆弛就 insert 一份新的 (dist, v),取出時若 dist 比目前最佳還大就丟棄。正確,但堆積裡會有過期項,空間可到 (O(m))。
10. 複雜度總表
| 操作 | 二元堆積 | 未排序陣列 | 平衡 BST |
|---|---|---|---|
| insert | (O(\log n)) | (O(1)) | (O(\log n)) |
| del-max | (O(\log n)) | (O(n)) | (O(\log n)) |
| peek | (O(1)) | (O(n)) | (O(\log n)) |
| build | (O(n)) | (O(n)) | (O(n\log n)) |
| decrease-key(有索引) | (O(\log n)) | (O(1)) 改值後仍要 (O(n)) 找極值 | (O(\log n)) |
(d) 元堆積把下沉的分支數變成 (d):插入較快(樹較矮),取出較慢(要在 (d) 個孩子裡找最大)。Dijkstra 在某些圖上會用 (d>2)。
11. 應用
- Top-K:維持大小為 (K) 的最小堆積。掃過每個數,比堆頂大才替換。時間 (O(n\log K)),空間 (O(K))。
- 多路合併:(K) 條已排序串流,堆積放每條的當前頭。每次取出最小,推進那一條。外部排序的多路合併用這個。
- Dijkstra / Prim:見圖論教材。沒有優先佇列時,每次線性找最小距離頂點,總時間退回 (O(n^2))。
- 事件驅動模擬:下一個事件時間最小的先處理。
- 霍夫曼編碼:每次合併兩個頻率最小的節點。見貪心教材。
12. 常見陷阱
- 和較小的孩子交換。 下沉必須選較大的孩子。
- 0-based / 1-based 公式混用。 父節點算成負索引或指到自己,迴圈不會停。
sink的範圍用了舊長度。 取出極值後,尾巴已經「排序好」或不屬於堆積,下沉的右邊界要是新的堆大小。- 把「是最大堆積」理解成「陣列已排序」。
[10, 8, 9]是堆積,但不是排序結果。 - 相等鍵與穩定性。 堆積排序不穩定,不能拿來做需要保留原順序的多鍵排序。
- 空堆、單元素。
swim/sink在沒有父或沒有子時必須直接返回。
13. C++ 實作
#include <stdexcept>
#include <utility>
#include <vector>
class MaxHeap {
public:
void push(int x) {
a_.push_back(x);
swim(static_cast<int>(a_.size()) - 1);
}
int top() const {
if (a_.empty()) throw std::logic_error("empty heap");
return a_[0];
}
int pop() {
int m = top();
a_[0] = a_.back();
a_.pop_back();
if (!a_.empty()) sink(0);
return m;
}
bool empty() const { return a_.empty(); }
// 把任意陣列就地變成最大堆積。
static void build(std::vector<int>& a) {
for (int i = static_cast<int>(a.size()) / 2 - 1; i >= 0; --i)
sink_range(a, i, static_cast<int>(a.size()));
}
private:
std::vector<int> a_;
void swim(int i) {
while (i > 0) {
int p = (i - 1) / 2;
if (a_[p] >= a_[i]) break;
std::swap(a_[p], a_[i]);
i = p;
}
}
void sink(int i) { sink_range(a_, i, static_cast<int>(a_.size())); }
static void sink_range(std::vector<int>& a, int i, int n) {
while (true) {
int l = 2 * i + 1, r = l + 1, big = i;
if (l < n && a[l] > a[big]) big = l;
if (r < n && a[r] > a[big]) big = r;
if (big == i) break;
std::swap(a[i], a[big]);
i = big;
}
}
};
14. 練習題
- 對
[1, 2, 3, 4, 5]做自底向上建最大堆積,寫出每一步陣列。 - 說明為什麼逐一插入建堆是 (O(n\log n)),而自底向上是 (O(n))。兩者最後都合法,但形狀可以不同。
- 用大小為 3 的最小堆積,從
[7, 1, 9, 3, 8, 2]取出最大的 3 個數。堆積裡最後應該剩哪些? - 為什麼 Dijkstra 若只用「無索引的堆積 + 過期項丟棄」仍然正確?什麼時候這個做法會比索引堆積費空間?
- 堆積排序是原地的,為什麼仍不適合作為「需要穩定」的排序?
參考答案
- 從索引 1 往回。索引 1 的
2與孩子4、5中較大的5交換,得[1, 5, 3, 4, 2],2沒有孩子。索引 0 的1與5、3中的5交換,得[5, 1, 3, 4, 2];1再與4交換,得[5, 4, 3, 1, 2]。 - 逐一插入時,第 (i) 個元素可能上浮 (\Theta(\log i)) 層,總和是 (\sum \log i = \Theta(n\log n))。自底向上時大多數節點下沉距離是常數,級數 (\sum h/2^h) 收斂,總和線性。
- 掃過後最小堆積大小維持 3,堆內是目前看過的最大三個:
9, 8, 7(順序依插入過程而定,集合是這三個)。 - 過期項的距離比該頂點已記錄的最佳距離差,彈出時忽略即可,第一次彈出某頂點時的距離就是最短距離(非負權)。同一頂點可在堆內出現多次,鬆弛次數多時空間可到 (O(m))。
- 下沉與建堆的交換不看「誰先出現」,相等鍵的相對順序會被打亂。穩定排序要另選合併排序或計數/基底排序。
小結
二元堆積用「完全樹的形狀 + 父大於子」換到插入與取極值都是 (O(\log n)),建堆還能做到 (O(n))。它不是排序好的陣列。需要改已存在元素的鍵時,加上索引,或接受過期項。堆積排序、Top-K、多路合併、Dijkstra 與 Prim 都是這兩個操作的直接應用。