data structure heap priority queue heap sort

[Data Structure] 堆積

堆積 (Heap) 是一種以完全二元樹 (Complete Binary Tree) 為形狀約束、並滿足堆積性質 (Heap Property) 的資料結構。堆積讓「取出最大值(或最小值)」的操作穩定為 O(logn)O(\log n),是優先佇列 (Priority Queue) 的標準實作。

完全二元樹

  • 除了最後一層,其餘每層都要填滿
  • 最後一層的節點全部靠左排列

這個形狀限制是為了讓整棵樹可以用一個連續陣列表達,不需要節點物件 + 指標。

Max-heap 與 Min-heap

兩種堆積的差別只在「誰在上面」:

類型堆積性質根節點典型用途
Max-heap每個節點值 \ge 其子節點值最大值取最大、Heap Sort(遞增排序)
Min-heap每個節點值 \le 其子節點值最小值取最小、Dijkstra、Prim、Huffman

堆積不是排序過的結構——只保證父子之間的大小關係,不保證兄弟之間的順序,也不保證層與層之間嚴格遞增 / 遞減。因此 heap 不能取代 BST 做任意查詢。

陣列表示法

把完全二元樹「層序」攤平到陣列。以 1-indexed 為例(實作較直觀):

  • 節點 ii 的父節點:i/2\lfloor i / 2 \rfloor
  • 節點 ii 的左子:2i2i
  • 節點 ii 的右子:2i+12i + 1

若用 0-indexed

  • 父節點:(i1)/2\lfloor (i - 1) / 2 \rfloor
  • 左子:2i+12i + 1
  • 右子:2i+22i + 2

以 max-heap [_, 6, 5, 4, 1, 2, 3](1-indexed,_ 代表 index 0 保留不用,讓 i2i \cdot 2i2+1i \cdot 2 + 1 的公式直接對上位置)為例:

         6  (index 1)
        / \
       5   4
      /|   |
     1 2   3
index123456
654123
66554

以下互動圖解把這棵樹與陣列並排呈現,點任一節點就能即時看到它的 index 與 parent / left / right 公式的計算結果:


核心操作

以下所有操作都用 1-indexed 表示法。隨時記得:index ii 的父 = i/2\lfloor i / 2 \rfloor、左子 = 2i2i、右子 = 2i+12i + 1。「葉節點」定義為 index 落在 n/2+1n\lfloor n/2 \rfloor + 1 \sim n 範圍的節點(下方沒有子)。

Insert — 向上浮 (Sift Up)

步驟:

  1. 把新元素放在陣列尾端(index = 當前 size + 1,維持完全二元樹形狀)
  2. ii = 新元素目前的 index;比較 arr[i]\text{arr}[i] 與父 arr[i/2]\text{arr}[\lfloor i/2 \rfloor]
  3. 若違反堆積性質(max-heap 中子 > 父)就 swap,然後 ii/2i \leftarrow \lfloor i/2 \rfloor(往上爬一層)
  4. 重複步驟 2–3,直到符合性質或 i=1i = 1(已到根)

從空的 max-heap 依序 insert [3, 1, 6, 5, 2, 4](1-indexed):

步驟動作陣列狀態
1insert 3,放 index 1(根)[_, 3]
2insert 1,放 index 2;1 < 3,不動[_, 3, 1]
3insert 6,放 index 3;6 > 父 3,swap → 6 上到 index 1[_, 6, 1, 3]
4insert 5,放 index 4;5 > 父 1,swap → 5 上到 index 2;5 < 父 6,停[_, 6, 5, 3, 1]
5insert 2,放 index 5;2 < 父 5,不動[_, 6, 5, 3, 1, 2]
6insert 4,放 index 6;4 > 父 3,swap → 4 上到 index 3;4 < 父 6,停[_, 6, 5, 4, 1, 2, 3]

最終 max-heap:

         6
        / \
       5   4
      /|   |
     1 2   3

以下動畫逐步重現上面六次 insert 的 sift-up 過程——新插入的節點會先落在陣列尾端,再往上和父節點比較:

  • 時間複雜度:O(logn)O(\log n)(最多爬樹高那麼多層)

Extract Max — 向下沉 (Sift Down)

取出根(最大值)的步驟:

  1. 記錄 arr[1]\text{arr}[1] 當作回傳結果
  2. 最後一個元素(arr[n]\text{arr}[n])搬到 index 1,刪除尾端(維持完全二元樹形狀)
  3. i=1i = 1;讀左子 arr[2i]\text{arr}[2i] 與右子 arr[2i+1]\text{arr}[2i+1](若存在),挑兩者中較大者的 index 為 cc
  4. arr[i]<\text{arr}[i] < arr[c]\text{arr}[c] 則 swap,接著 ici \leftarrow c(下沉到較大的子節點)
  5. 重複步驟 3–4,直到符合性質或 ii 沒有子節點(即 2i>n2i > nii 已是葉)

承上例 [_, 6, 5, 4, 1, 2, 3],連續執行兩次 extract max:

第一次 extract:

步驟動作陣列狀態
1記 max = 6[_, 6, 5, 4, 1, 2, 3]
2把尾端 3 搬到根,刪尾[_, 3, 5, 4, 1, 2]
3index 1 值 3,子為 5 (idx 2)、4 (idx 3);最大 5,swap[_, 5, 3, 4, 1, 2]
4index 2 值 3,子為 1、2;最大 3 本身,停[_, 5, 3, 4, 1, 2]

回傳 6,剩下 [_, 5, 3, 4, 1, 2]

第二次 extract:

步驟動作陣列狀態
1記 max = 5[_, 5, 3, 4, 1, 2]
2尾端 2 搬到根[_, 2, 3, 4, 1]
3index 1 值 2,子 3、4;最大 4 (idx 3),swap[_, 4, 3, 2, 1]
4index 3 值 2 已無子節點,停[_, 4, 3, 2, 1]

回傳 5。

以下動畫從 [_, 6, 5, 4, 1, 2, 3] 開始連續執行 extract max,觀察取出順序與每次 sift-down 的過程——你會發現取出順序恰好是遞減,這就是 Heap Sort 的核心:

  • 時間複雜度:O(logn)O(\log n)

Build Heap — 一次蓋好整個堆積

把無序陣列轉成堆積的兩種做法:

  1. 連續 insert:空堆積起,對每個元素呼叫 insert。總成本 O(nlogn)O(n \log n)
  2. Floyd’s Heapify:從最後一個非葉節點(index n/2\lfloor n / 2 \rfloor)開始,逐一向下 sift down。總成本 O(n)O(n)

Floyd’s 版本較快的直覺:越接近葉子節點越多、但下沉高度越小;越接近根節點下沉高度越大、但數量越少。整體可證明為 O(n)O(n)

[3, 1, 6, 5, 2, 4]n=6n = 6)做 Floyd’s heapify,1-indexed 為 [_, 3, 1, 6, 5, 2, 4],從 index 6/2=3\lfloor 6/2 \rfloor = 3 開始往 1 做 sift down:

步驟處理 index動作陣列狀態
136子 4 (idx 6);6 > 4,停[_, 3, 1, 6, 5, 2, 4]
221子 5 (idx 4)、2 (idx 5);最大 5,swap idx 2 與 idx 4[_, 3, 5, 6, 1, 2, 4]
3(承上)idx 4 值 1,無子,停[_, 3, 5, 6, 1, 2, 4]
413子 5 (idx 2)、6 (idx 3);最大 6,swap idx 1 與 idx 3[_, 6, 5, 3, 1, 2, 4]
5(承上)idx 3 值 3,子 4 (idx 6);最大 4,swap[_, 6, 5, 4, 1, 2, 3]
6(承上)idx 6 值 3,無子,停[_, 6, 5, 4, 1, 2, 3]

最終與 insert 版結果相同:[_, 6, 5, 4, 1, 2, 3]


複雜度總覽

操作時間
取極值(看根)O(1)O(1)
InsertO(logn)O(\log n)
ExtractO(logn)O(\log n)
Build Heap(Floyd’s)O(n)O(n)
任意查詢(找某個值)O(n)O(n)
  • 查詢 O(n)O(n) 是因為堆積只保證父子關係、沒有排序結構,只能線性掃描

Heap Sort(堆積排序)

把堆積拿來排序的自然應用,原地排序、O(nlogn)O(n \log n)、不穩定:

  1. 對陣列做 Build Heap(max-heap)→ O(n)O(n)
  2. 反覆:把根(最大值)與當前堆積尾端交換,堆積大小減 1,對新根做 sift down → 共 n1n - 1 次、每次 O(logn)O(\log n)

[3, 1, 6, 5, 2, 4] 為例(1-indexed,排序區以粗體標記在陣列尾端):

| 步驟 | 動作 | 陣列狀態(排序區用 | 分隔)| | :---: | :--- | :--- | | 1 | Build max-heap | [, 6, 5, 4, 1, 2, 3] | | 2 | swap idx 1 與 6(堆積大小 6 → 5),sift down 3 | [, 5, 3, 4, 1, 2 | 6] | | 3 | swap idx 1 與 5(5 → 4),sift down 2 | [, 4, 3, 2, 1 | 5, 6] | | 4 | swap idx 1 與 4(4 → 3),sift down 1 | [, 3, 1, 2 | 4, 5, 6] | | 5 | swap idx 1 與 3(3 → 2),sift down 2 | [, 2, 1 | 3, 4, 5, 6] | | 6 | swap idx 1 與 2(2 → 1),sift down 1 | [, 1 | 2, 3, 4, 5, 6] | | 7 | 堆積只剩 1 個元素,結束 | [1, 2, 3, 4, 5, 6] |

結果為遞增陣列。若想遞減排序則改用 min-heap。

與 Merge Sort 相比:兩者都是 O(nlogn)O(n \log n),但 Heap Sort 是原地排序 (in-place)O(1)O(1) 額外空間),Merge Sort 需要 O(n)O(n) 輔助空間。代價是 Heap Sort 不穩定、cache 友善性略差,所以實務上純排序常選 Quick Sort / Merge Sort / 混合排序(如 Timsort),Heap 更常用於優先佇列而非直接排序。


優先佇列 (Priority Queue)

優先佇列 抽象資料型別:每個元素帶有「優先級 (priority)」,每次取出的都是當前優先級最高(或最低)者。

底層結構InsertExtract適用
未排序陣列O(1)O(1)O(n)O(n)幾乎不插入、很少取
已排序陣列O(n)O(n)O(1)O(1)大量查詢、少量插入
HeapO(logn)O(\log n)O(logn)O(\log n)兩者兼顧,實務標準

Java 的 PriorityQueue、Python 的 heapq、C++ STL 的 priority_queue 底層皆為堆積。

Java PriorityQueue 用法
import java.util.PriorityQueue;

public class PQExample {
    public static void main(String[] args) {
        PriorityQueue<Integer> pq = new PriorityQueue<>();  // 預設 min-heap
        pq.offer(3); pq.offer(1); pq.offer(6); pq.offer(5); pq.offer(2); pq.offer(4);

        while (!pq.isEmpty()) {
            System.out.print(pq.poll() + " ");  // 1 2 3 4 5 6
        }
    }
}

Heap 的實作

以 1-indexed max-heap 為例。

Java 完整實作(insert / extractMax / siftUp / siftDown)
import java.util.ArrayList;

public class MaxHeap {
    private ArrayList<Integer> heap = new ArrayList<>();

    public MaxHeap() {
        heap.add(0);  // index 0 當作保留
    }

    public int size() { return heap.size() - 1; }
    public boolean isEmpty() { return size() == 0; }
    public int peek() { return heap.get(1); }

    public void insert(int value) {
        heap.add(value);
        siftUp(heap.size() - 1);
    }

    public int extractMax() {
        int max = heap.get(1);
        int last = heap.remove(heap.size() - 1);
        if (!isEmpty()) {
            heap.set(1, last);
            siftDown(1);
        }
        return max;
    }

    private void siftUp(int i) {
        while (i > 1) {
            int parent = i / 2;
            if (heap.get(i) > heap.get(parent)) {
                swap(i, parent);
                i = parent;
            } else break;
        }
    }

    private void siftDown(int i) {
        int n = heap.size() - 1;
        while (2 * i <= n) {
            int left = 2 * i;
            int right = 2 * i + 1;
            int largest = (right <= n && heap.get(right) > heap.get(left)) ? right : left;
            if (heap.get(largest) > heap.get(i)) {
                swap(i, largest);
                i = largest;
            } else break;
        }
    }

    private void swap(int a, int b) {
        int tmp = heap.get(a);
        heap.set(a, heap.get(b));
        heap.set(b, tmp);
    }
}

常見考法

#題型重點
1手跑連續 insert 建 heap每次 insert 新元素放尾端 → sift up
2手跑 Floyd’s heapifyn/2\lfloor n/2 \rfloor 往 1 做 sift down
3手跑 extract max 連續 kk根↔尾 swap → 砍尾 → sift down
4Heap Sort 完整過程Build → 反覆 swap root/tail + sift down
5給陣列判斷是否為合法 heap檢查每個 ii2i2i2i+12i+1 的關係
6Build heap O(n)O(n) 的推導h=0lognn2h+1h=O(n)\sum_{h=0}^{\log n} \frac{n}{2^{h+1}} \cdot h = O(n)(等比加權和收斂)
7Insert / Extract O(logn)O(\log n) 推導樹高 = log2n\lfloor \log_2 n \rfloor;每層 O(1)O(1)
81-indexed 與 0-indexed 公式轉換父:i/2\lfloor i/2 \rfloor vs (i1)/2\lfloor (i-1)/2 \rfloor
9為什麼 heap 不能 O(logn)O(\log n) 任意查詢只保證父子、兄弟與層序無序 → 只能線性掃
10Top-K 問題用哪種 heap找前 KK KK 大小的 min-heap(反直覺)

複雜度推導

Insert / Extract O(logn)O(\log n)

樹高 h=log2n, 每層 swap O(1)O(logn)\text{樹高 } h = \lfloor \log_2 n \rfloor,\ \text{每層 swap } O(1) \Rightarrow O(\log n)

Floyd’s Build Heap O(n)O(n):高度為 hh 的節點數 n/2h+1\le \lceil n / 2^{h+1} \rceil,每個節點下沉最多 hh 層:

T(n)=h=0lognn2h+1hnh=0h2h+1=n1=O(n)T(n) = \sum_{h=0}^{\lfloor \log n \rfloor} \left\lceil \frac{n}{2^{h+1}} \right\rceil \cdot h \le n \sum_{h=0}^{\infty} \frac{h}{2^{h+1}} = n \cdot 1 = O(n)

h/2h+1=1\sum h / 2^{h+1} = 1 是常見等比加權和。)

Heap Sort O(nlogn)O(n \log n):Build O(n)O(n) + n1n-1 次 extract × O(logn)O(\log n) = O(nlogn)O(n \log n)

考試常踩的陷阱

  1. Build heap 是 O(n)O(n) 不是 O(nlogn)O(n \log n):連續 insert 才是 O(nlogn)O(n \log n)
  2. Heap Sort 不穩定:相同 key 的相對順序可能被打亂
  3. extract max 要把尾巴搬到根,不是把第二大搬上去——要走完 sift down
  4. Sift down 取左右子較大者(max-heap);取錯會破壞性質
  5. Top-K 大用 min-heap:因為要淘汰「最小的那個」,heap 大小固定 KK
  6. heap 不等於 BST:heap 只有父子關係、沒有中序排序

堆積在經典演算法中的角色

許多圖論與貪婪演算法的效能關鍵就在於優先佇列——而優先佇列的主流實作就是堆積。

演算法用途堆積的功能
Dijkstra單源最短路徑min-heap 每次取出距離最小的未訪頂點
Prim最小生成樹 (MST)min-heap 每次取出連接已選樹的最輕邊
Huffman最小前綴編碼min-heap 每次取出兩個頻率最小的節點合併
K-way Merge多路歸併(如 external sort)min-heap 每次取 kk 條串列當前最小值
Top-K 問題找前 KK 大(小)元素大小為 KK 的 min-heap(或 max-heap)

這些演算法改用堆積的效果:Dijkstra 從 O(V2)O(|V|^2) 降到 O((V+E)logV)O((|V| + |E|) \log |V|);Prim 同理;Huffman 從 O(n2)O(n^2) 降到 O(nlogn)O(n \log n)。換句話說,堆積是把「每回合找最值」從 O(n)O(n) 壓到 O(logn)O(\log n) 的核心工具,這也是為什麼優先佇列在演算法課程裡反覆出現。