堆積與優先佇列 — Min/Max Heap 原理與 Heap Sort 完整實作 | 資料結構與演算法
堆積(Heap) 是一種以完全二元樹為基礎的資料結構,根節點永遠保存整個集合的最大值或最小值,讓你在 O(log n) 時間內完成插入與取出極值。以堆積為底層的 優先佇列(Priority Queue) 更是任務排程、Top-K 問題、Dijkstra 最短路徑等場景的核心利器。本文從陣列表示法、Heapify 原理出發,帶你用 TypeScript 與 C++ 完整實作 Min Heap、Max Heap 與 Heap Sort。
前言
想像急診室的分診系統:病患到達的順序是隨機的,但醫生永遠優先處理病情最嚴重的患者。這個系統不是用「先到先得」的普通佇列,而是一個 優先佇列(Priority Queue)——每次從隊列中取出「優先級最高」的元素,而非最早進入的那個。
而支撐優先佇列高效運作的底層資料結構,正是 堆積(Heap)。堆積的精妙之處在於:它以陣列儲存一棵完全二元樹,不需要任何指標,既節省記憶體又對 CPU 快取友好。
讀完這篇文章,你將學會:
- 堆積的結構性質與陣列索引公式
- Sift Up 和 Sift Down 的核心操作邏輯
- 用 TypeScript 從零實作泛型 MinHeap 與 MaxHeap
- O(n) Heapify 建堆的原理(為什麼比逐一 push 快?)
- Heap Sort 的完整實作(O(n log n) 原地排序)
- C++
std::priority_queue的正確用法與陷阱 - Top-K 問題、合併 K 個有序串列、中位數維護等面試必考題
核心概念
完全二元樹與堆積性質
堆積(Heap) 建立在一種特殊的樹形結構——完全二元樹(Complete Binary Tree) 之上。完全二元樹有兩個關鍵特性:
- 除最後一層外,其他每一層都是完全填滿的
- 最後一層的節點從左到右依序填入,不留空隙
在此基礎上,堆積還必須滿足 堆積性質(Heap Property):
- Max-Heap:每個節點的值 ≥ 所有子節點的值,根節點是最大值
- Min-Heap:每個節點的值 ≤ 所有子節點的值,根節點是最小值
注意:堆積只要求父節點與子節點之間的大小關係,不保證左右子節點的相對順序。這是堆積與二元搜尋樹(BST)的根本差異。
陣列表示法與索引公式
完全二元樹的「不留空隙」特性,使得我們可以用一個連續陣列儲存整棵樹——按照從上到下、從左到右的層序編號,直接對應陣列索引。
Min-Heap 樹形圖: 對應陣列(0-indexed):
1 index: [0] [1] [2] [3] [4] [5] [6]
/ \ value: 1 3 2 7 8 5 4
3 2
/ \ / \
7 8 5 4
陣列索引公式(0-indexed):
parent(i) = Math.floor((i - 1) / 2)
left_child(i) = 2 * i + 1
right_child(i) = 2 * i + 2
驗證:
index 0(值 1)的左子:index 1(值 3)✓ 右子:index 2(值 2)✓
index 1(值 3)的左子:index 3(值 7)✓ 右子:index 4(值 8)✓
index 2(值 2)的左子:index 5(值 5)✓ 右子:index 6(值 4)✓
這個陣列實作比鏈結串列(Linked List)節點的指標實作更節省記憶體,且因為資料在記憶體中連續排列,CPU 快取命中率高,效能更好。
Sift Up(上浮)——插入操作
插入新元素時,先把它放到陣列末端(樹的最後位置),再讓它「上浮」到正確位置:
插入 0 到現有 Min-Heap [1, 3, 2, 7, 8, 5, 4]:
步驟 1:放到末端
[1, 3, 2, 7, 8, 5, 4, 0] ← 0 在 index 7
步驟 2:Sift Up
parent(7) = index 3(值 7) > 0,交換
→ [1, 3, 2, 0, 8, 5, 4, 7]
parent(3) = index 1(值 3) > 0,交換
→ [1, 0, 2, 3, 8, 5, 4, 7]
parent(1) = index 0(值 1) > 0,交換
→ [0, 1, 2, 3, 8, 5, 4, 7]
到達根節點,停止。0 成為新根。
每次 Sift Up 至多走樹高 O(log n) 步,因此插入操作時間複雜度為 O(log n)。
Sift Down(下沉)——取出操作
取出根節點(最小/最大值)時,用陣列最後一個元素替換根節點,再讓它「下沉」到正確位置:
從 Min-Heap [0, 1, 2, 3, 8, 5, 4, 7] 取出最小值 0:
步驟 1:取出 root(值 0),用最後元素 7 替換
[7, 1, 2, 3, 8, 5, 4]
步驟 2:Sift Down
比較 7 與子節點 min(1, 2) = 1,交換 index 0 與 1
→ [1, 7, 2, 3, 8, 5, 4]
比較 7 與子節點 min(3, 8) = 3,交換 index 1 與 3
→ [1, 3, 2, 7, 8, 5, 4]
index 3 的子節點超出範圍,停止。
Sift Down 同樣至多走 O(log n) 步。
O(n) Heapify——批次建堆
如果有一個現成的陣列,想把它轉換成堆積,逐一 push 需要 O(n log n)。但有個更聰明的方法:從最後一個非葉節點(index = Math.floor(n/2) - 1)開始,往根節點方向逐一執行 Sift Down,整體時間複雜度只需 O(n)。
為什麼是 O(n)?直覺上:葉節點(佔所有節點的約一半)不需要任何操作;越靠近根節點的節點越少,需要 Sift Down 的距離越長,但這樣的節點數量也越少。數學上對各層的操作量求和,結果收斂到 O(n)。
JavaScript / TypeScript 實作
MinHeap(泛型 + 自訂比較器)
以下實作支援任意型別 T,透過傳入自訂比較器(Comparator)來控制排序方向:
type Comparator<T> = (a: T, b: T) => number;
class MinHeap<T> {
private heap: T[];
private compare: Comparator<T>;
constructor(comparator?: Comparator<T>) {
this.heap = [];
// 預設比較器:數字升序(Min-Heap)
this.compare = comparator ?? ((a, b) => (a as unknown as number) - (b as unknown as number));
}
size(): number {
return this.heap.length;
}
isEmpty(): boolean {
return this.heap.length === 0;
}
// O(1):直接讀取根節點
peek(): T | undefined {
return this.heap[0];
}
// O(log n):插入後 Sift Up
push(val: T): void {
this.heap.push(val);
this.siftUp(this.heap.length - 1);
}
// O(log n):取出根節點後 Sift Down
pop(): T | undefined {
if (this.isEmpty()) return undefined;
this.swap(0, this.heap.length - 1);
const min = this.heap.pop()!;
this.siftDown(0);
return min;
}
// O(n):從陣列批次建堆(比逐一 push 更快)
static heapify<T>(arr: T[], comparator?: Comparator<T>): MinHeap<T> {
const h = new MinHeap<T>(comparator);
h.heap = [...arr];
// 從最後一個非葉節點往根節點方向執行 Sift Down
for (let i = Math.floor(h.heap.length / 2) - 1; i >= 0; i--) {
h.siftDown(i);
}
return h;
}
private siftUp(i: number): void {
while (i > 0) {
const parent = Math.floor((i - 1) / 2);
// 若當前節點比父節點「更小」(依比較器),就上浮
if (this.compare(this.heap[i], this.heap[parent]) < 0) {
this.swap(i, parent);
i = parent;
} else {
break; // 已滿足堆積性質,停止
}
}
}
private siftDown(i: number): void {
const n = this.heap.length;
while (true) {
let smallest = i;
const left = 2 * i + 1;
const right = 2 * i + 2;
if (left < n && this.compare(this.heap[left], this.heap[smallest]) < 0) {
smallest = left;
}
if (right < n && this.compare(this.heap[right], this.heap[smallest]) < 0) {
smallest = right;
}
if (smallest === i) break; // 當前節點已是最小,停止
this.swap(i, smallest);
i = smallest;
}
}
private swap(i: number, j: number): void {
[this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]];
}
}
透過傳入自訂比較器,同一個 MinHeap 類別可以應用在各種場景:
// 使用範例:自訂比較器依物件欄位排序
interface Task {
name: string;
priority: number; // 數字越小,優先級越高
}
const taskQueue = new MinHeap<Task>((a, b) => a.priority - b.priority);
taskQueue.push({ name: "backup", priority: 3 });
taskQueue.push({ name: "deploy", priority: 1 });
taskQueue.push({ name: "test", priority: 2 });
console.log(taskQueue.pop()?.name); // "deploy"(priority 最小 = 最高優先)
console.log(taskQueue.pop()?.name); // "test"
console.log(taskQueue.pop()?.name); // "backup"
MaxHeap——用負數技巧模擬
TypeScript 中最常見的 Max-Heap 實作技巧是:存入時取負號,取出時再取負號還原。如此可直接複用 MinHeap:
// 用 MinHeap 模擬 MaxHeap:存入負數
const maxHeap = new MinHeap<number>();
maxHeap.push(-5);
maxHeap.push(-1);
maxHeap.push(-3);
// 取出時取負號還原為正數
const max = -maxHeap.pop()!; // 5(正確!)
console.log(max); // 5
// 注意陷阱:忘記取負號會取到錯誤值
const wrong = maxHeap.pop(); // -3(這是負數,不是最大值 3)
或者直接透過比較器反轉排序方向:
// 更直覺的方式:比較器反轉
const maxHeap2 = new MinHeap<number>((a, b) => b - a); // b - a 即降序
maxHeap2.push(5);
maxHeap2.push(1);
maxHeap2.push(3);
console.log(maxHeap2.pop()); // 5(最大值先出)
console.log(maxHeap2.pop()); // 3
console.log(maxHeap2.pop()); // 1
Heap Sort
Heap Sort 分兩個階段:先原地建 Max-Heap,再逐一將根節點(最大值)交換到陣列末端:
// 原地排序:O(n log n) 時間,O(1) 額外空間
function heapSort(arr: number[]): number[] {
const n = arr.length;
// 階段一:原地建 Max-Heap(從最後一個非葉節點往根節點 Sift Down)
for (let i = Math.floor(n / 2) - 1; i >= 0; i--) {
siftDownMaxHeap(arr, n, i);
}
// 階段二:逐一取出最大值放到陣列末端
for (let end = n - 1; end > 0; end--) {
// 將根節點(目前最大值)與末端元素交換
[arr[0], arr[end]] = [arr[end], arr[0]];
// 對縮小後的堆(不含末端)重新 Sift Down
siftDownMaxHeap(arr, end, 0);
}
return arr; // 結果為升序排列
}
function siftDownMaxHeap(arr: number[], n: number, i: number): void {
while (true) {
let largest = i;
const left = 2 * i + 1;
const right = 2 * i + 2;
if (left < n && arr[left] > arr[largest]) largest = left;
if (right < n && arr[right] > arr[largest]) largest = right;
if (largest === i) break;
[arr[i], arr[largest]] = [arr[largest], arr[i]];
i = largest;
}
}
// 測試
console.log(heapSort([3, 1, 4, 1, 5, 9, 2, 6]));
// 輸出:[1, 1, 2, 3, 4, 5, 6, 9]
Heap Sort 的特點:時間複雜度穩定為 O(n log n),空間複雜度 O(1)(原地排序,不需要額外記憶體)。但因為存取模式跳躍(不像 Quick Sort 那樣在局部區段操作),快取命中率較低,實務上通常比 Quick Sort 稍慢。
Top-K 問題——Min-Heap 的經典應用
找出陣列中最大的 K 個元素,直覺上可能想用 Max-Heap,但這樣需要把所有元素都放入堆,費時 O(n log n)。更優的策略是維護一個大小為 K 的 Min-Heap:
// 找陣列中最大的 K 個元素
// 策略:大小為 K 的 Min-Heap,root 是堆中最小值
// 若新元素 > root,就移除 root、加入新元素(保持堆中始終是目前最大的 K 個)
// 時間 O(n log k),空間 O(k)——優於完整排序 O(n log n)
function topK(nums: number[], k: number): number[] {
const heap = new MinHeap<number>();
for (const num of nums) {
heap.push(num);
if (heap.size() > k) {
heap.pop(); // 移除最小值,保持堆大小不超過 k
}
}
// 收集結果(從小到大彈出,再反轉)
const result: number[] = [];
while (!heap.isEmpty()) {
result.push(heap.pop()!);
}
return result.reverse(); // 由大到小
}
console.log(topK([3, 1, 4, 1, 5, 9, 2, 6, 5, 3], 3));
// 輸出:[9, 6, 5]
反向思考:找最小的 K 個元素,則用大小為 K 的 Max-Heap——當新元素小於堆的最大值(root),就替換 root。這個「反直覺」的搭配是 Top-K 問題最重要的思路。
C++ 對照實作
std::priority_queue 基本用法
C++ 標準庫的 std::priority_queue 預設是 Max-Heap(最大值在頂端),這是一個常見的陷阱:
#include <queue>
#include <vector>
#include <iostream>
#include <functional>
void basicPriorityQueue() {
// 預設:Max-Heap(最大值在頂部)
std::priority_queue<int> maxPQ;
maxPQ.push(3);
maxPQ.push(1);
maxPQ.push(4);
std::cout << maxPQ.top() << "\n"; // 輸出:4
// Min-Heap:必須明確指定 greater<int> 比較器
std::priority_queue<int, std::vector<int>, std::greater<int>> minPQ;
minPQ.push(3);
minPQ.push(1);
minPQ.push(4);
std::cout << minPQ.top() << "\n"; // 輸出:1
}
自訂型別比較器
C++ 的比較器語義和 std::sort 相反,容易出錯:
struct Task {
std::string name;
int priority;
};
// priority_queue 的比較器:返回 true 表示「第一個參數優先度較低」
// 與 sort 的語義完全相反!
struct TaskComparator {
bool operator()(const Task& a, const Task& b) const {
// a.priority > b.priority:priority 小的先出(Min-Heap 效果)
return a.priority > b.priority;
}
};
void customComparatorExample() {
std::priority_queue<Task, std::vector<Task>, TaskComparator> taskQueue;
taskQueue.push({"backup", 3});
taskQueue.push({"deploy", 1});
taskQueue.push({"test", 2});
while (!taskQueue.empty()) {
// 輸出順序:deploy → test → backup
std::cout << taskQueue.top().name << "\n";
taskQueue.pop();
}
}
Lambda 比較器(C++20)
void lambdaComparator() {
// 依 pair 的 second 升序(Min-Heap)
auto cmp = [](const std::pair<int,int>& a, const std::pair<int,int>& b) {
return a.second > b.second;
};
std::priority_queue<
std::pair<int,int>,
std::vector<std::pair<int,int>>,
decltype(cmp)
> pq(cmp);
pq.push({1, 5});
pq.push({2, 3});
pq.push({3, 7});
std::cout << pq.top().first << "\n"; // 輸出:2(second = 3 最小)
}
std::make_heap / push_heap / pop_heap
若要直接操作 std::vector,可以使用底層堆積操作函數:
#include <algorithm>
void stdHeapFunctions() {
std::vector<int> v = {3, 1, 4, 1, 5};
// 原地建 Max-Heap,O(n)
std::make_heap(v.begin(), v.end());
// 將新元素 push_back 後,呼叫 push_heap 維護堆積性質,O(log n)
v.push_back(9);
std::push_heap(v.begin(), v.end());
// pop_heap 將最大值移到最後,O(log n);再 pop_back 取出
std::pop_heap(v.begin(), v.end());
int maxVal = v.back();
v.pop_back();
std::cout << maxVal << "\n"; // 輸出:9
}
複雜度分析
| 操作 | 時間複雜度 | 說明 |
|---|---|---|
push(插入) |
O(log n) | Sift Up 至多走樹高 |
pop(取出極值) |
O(log n) | Sift Down 至多走樹高 |
peek(查看極值) |
O(1) | 直接讀取 index 0 |
heapify(批次建堆) |
O(n) | 從底部往上 Sift Down |
search(任意搜尋) |
O(n) | 無序結構,需線性掃描 |
delete(任意刪除) |
O(log n) | 需配合 Index Priority Queue |
| Heap Sort | O(n log n) | O(n) 建堆 + n 次 O(log n) 取出 |
Heapify O(n) 的直覺:葉節點約佔 n/2 個,不需任何操作;往上每層節點數量減半,但每個節點需要 Sift Down 的步數增加。數學上對所有層的操作總量求和,結果收斂至 O(n) 而非 O(n log n)。
變體與延伸
D-ary Heap
普通堆積每個節點有 2 個子節點(Binary Heap)。D-ary Heap 將每個節點的子節點數擴展為 d 個:
- 樹高降為 O(log_d n),
push操作更快:O(log_d n) pop操作稍慢:需比較 d 個子節點,O(d × log_d n)- 當
push遠多於pop的場景(如 Dijkstra 稠密圖),4-ary 或 8-ary Heap 效能更佳
Fibonacci Heap
Fibonacci Heap 在攤銷複雜度(Amortized Complexity)上達到理論最優:
| 操作 | Fibonacci Heap | Binary Heap |
|---|---|---|
push |
O(1) | O(log n) |
decreaseKey |
O(1) | O(log n) |
extractMin |
O(log n) | O(log n) |
在 Fibonacci Heap 下,Dijkstra 最短路徑演算法的複雜度從 O((V+E) log V) 降至 O(E + V log V)。但實作極其複雜,工程上幾乎不使用;在理論演算法分析中具有重要地位。
Index Priority Queue(索引優先佇列)
普通堆積只能 O(n) 搜尋並更新任意元素。Index Priority Queue 額外維護一個反向映射(element → heap index),使得對任意位置元素的更新(decreaseKey)也能達到 O(log n)。
應用場景:Prim 最小生成樹演算法(需要 decreaseKey)、A* 路徑搜尋。
面試考點
Top-K 問題的思路框架
Top-K 是堆積最高頻的面試題型,核心原則:
- 找最大 K 個 → 維護大小為 K 的 Min-Heap(堆中最小值是「門檻」,新元素 > 門檻才入堆)
- 找最小 K 個 → 維護大小為 K 的 Max-Heap(堆中最大值是「門檻」,新元素 < 門檻才入堆)
時間複雜度 O(n log k),空間複雜度 O(k),均優於完整排序的 O(n log n) 和 O(n)。
合併 K 個有序串列
K 個已排序陣列,要合併成一個完整排序序列。暴力法全部合併再排序需 O(N log N)(N 為總元素數)。用 Min-Heap 可達到 O(N log K):
// 合併 K 個已排序陣列
// Heap 中每個元素存:[值, 陣列索引, 元素索引]
function mergeKSortedArrays(arrays: number[][]): number[] {
const heap = new MinHeap<[number, number, number]>((a, b) => a[0] - b[0]);
// 初始化:每個陣列的第一個元素入堆
for (let i = 0; i < arrays.length; i++) {
if (arrays[i].length > 0) {
heap.push([arrays[i][0], i, 0]);
}
}
const result: number[] = [];
while (!heap.isEmpty()) {
const [val, arrayIdx, elemIdx] = heap.pop()!;
result.push(val);
// 將同一陣列的下一個元素加入 Heap
const nextIdx = elemIdx + 1;
if (nextIdx < arrays[arrayIdx].length) {
heap.push([arrays[arrayIdx][nextIdx], arrayIdx, nextIdx]);
}
}
return result;
}
// 測試
console.log(mergeKSortedArrays([
[1, 4, 7],
[2, 5, 8],
[3, 6, 9],
]));
// 輸出:[1, 2, 3, 4, 5, 6, 7, 8, 9]
// 時間:O(N log K),空間:O(K)(Heap 中最多 K 個元素)
中位數維護(雙堆法)
資料流持續進入,每次需要查詢目前所有數字的中位數。排序後找中位數需要 O(n) 插入,太慢。雙堆法 維護兩個不變量,讓中位數始終在兩個堆的頂端觸手可及:
// 雙堆法維護中位數
// maxHeap(左半部):儲存較小的一半,root 是左半最大值
// minHeap(右半部):儲存較大的一半,root 是右半最小值
// 不變量:
// 1. maxHeap.top ≤ minHeap.top(左半所有值 ≤ 右半所有值)
// 2. |maxHeap.size - minHeap.size| ≤ 1(兩半大小最多差 1)
class MedianFinder {
// maxHeap 存負數以模擬 Max-Heap 行為
private maxHeap: MinHeap<number>; // 儲存較小的一半
private minHeap: MinHeap<number>; // 儲存較大的一半
constructor() {
this.maxHeap = new MinHeap<number>((a, b) => a - b); // 存負數
this.minHeap = new MinHeap<number>();
}
addNum(num: number): void {
// 步驟 1:新數先加入 maxHeap(存負數)
this.maxHeap.push(-num);
// 步驟 2:確保順序不變量:maxHeap.top ≤ minHeap.top
if (!this.minHeap.isEmpty() && -this.maxHeap.peek()! > this.minHeap.peek()!) {
this.minHeap.push(-this.maxHeap.pop()!);
}
// 步驟 3:確保大小不變量:maxHeap 最多比 minHeap 多 1 個
if (this.maxHeap.size() > this.minHeap.size() + 1) {
this.minHeap.push(-this.maxHeap.pop()!);
} else if (this.minHeap.size() > this.maxHeap.size()) {
this.maxHeap.push(-this.minHeap.pop()!);
}
}
findMedian(): number {
// 總數為奇數:中位數在 maxHeap(多一個元素)
if (this.maxHeap.size() > this.minHeap.size()) {
return -this.maxHeap.peek()!;
}
// 總數為偶數:中位數是兩堆頂端的平均
return (-this.maxHeap.peek()! + this.minHeap.peek()!) / 2;
}
}
// 測試
const mf = new MedianFinder();
mf.addNum(1);
mf.addNum(2);
console.log(mf.findMedian()); // 輸出:1.5
mf.addNum(3);
console.log(mf.findMedian()); // 輸出:2
mf.addNum(4);
console.log(mf.findMedian()); // 輸出:2.5
// addNum O(log n),findMedian O(1)
雙堆法的關鍵:步驟 2(確保順序)必須在步驟 3(確保大小)之前,否則會產生無效的中位數狀態。
常見陷阱
陷阱一:C++ priority_queue 預設是 Max-Heap
// 錯誤:以為是 Min-Heap
std::priority_queue<int> pq;
pq.push(5); pq.push(1); pq.push(3);
int top = pq.top(); // 5(Max-Heap!不是 1)
// 正確:用 greater<int> 才是 Min-Heap
std::priority_queue<int, std::vector<int>, std::greater<int>> minPQ;
minPQ.push(5); minPQ.push(1); minPQ.push(3);
int minTop = minPQ.top(); // 1(正確)
陷阱二:TypeScript 負數模擬 MaxHeap 時取出忘記取負
const maxHeap = new MinHeap<number>();
maxHeap.push(-5); maxHeap.push(-1); maxHeap.push(-3);
const wrong = maxHeap.pop(); // -5(負數!不是最大值 5)
const correct = -maxHeap.pop()!; // 3(正確,取出後取負還原)
陷阱三:逐一 push 與 heapify 的複雜度差異
const nums = [3, 1, 4, 1, 5, 9, 2, 6];
// 慢:O(n log n)
const h1 = new MinHeap<number>();
for (const n of nums) h1.push(n); // 每次 push 是 O(log n)
// 快:O(n)
const h2 = MinHeap.heapify(nums); // 批次建堆
// n = 10^6 時,heapify 約 2×10^6 次操作;逐一 push 約 20×10^6 次
陷阱四:MedianFinder 步驟順序不能顛倒
// 錯誤:先平衡大小再確保順序,可能讓 maxHeap.top > minHeap.top
addNumBug(num: number): void {
this.maxHeap.push(-num);
if (this.maxHeap.size() > this.minHeap.size() + 1) { // 先平衡大小
this.minHeap.push(-this.maxHeap.pop()!);
}
// 此時可能 maxHeap.top > minHeap.top,中位數計算錯誤!
}
// 正確:必須先確保順序,再平衡大小(見上方 MedianFinder 實作)
LeetCode 練習題
| # | 題目 | 難度 | 核心技巧 |
|---|---|---|---|
| 703 | Kth Largest Element in a Stream | Easy | 維護大小為 K 的 Min-Heap |
| 215 | Kth Largest Element in an Array | Medium | Min-Heap 或 Quick Select |
| 347 | Top K Frequent Elements | Medium | 計頻 + Min-Heap 按頻率排序 |
| 23 | Merge K Sorted Lists | Hard | Min-Heap + 多路歸併 |
| 295 | Find Median from Data Stream | Hard | 雙堆(Max-Heap + Min-Heap) |
215. Kth Largest Element in an Array
思路:維護大小為 K 的 Min-Heap。遍歷所有元素,堆滿後若新元素大於堆頂(目前第 K 大的「門檻」),則彈出堆頂並加入新元素。最終堆頂即為第 K 大的元素。時間 O(n log k),空間 O(k)。
function findKthLargest(nums: number[], k: number): number {
const heap = new MinHeap<number>();
for (const num of nums) {
heap.push(num);
if (heap.size() > k) {
heap.pop(); // 移除目前最小的,堆中始終保留最大的 k 個
}
}
return heap.peek()!; // 堆頂就是第 k 大的元素
}
console.log(findKthLargest([3, 2, 1, 5, 6, 4], 2)); // 輸出:5
console.log(findKthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4)); // 輸出:4
347. Top K Frequent Elements
思路:用 HashMap 計算每個數的出現頻率 O(n),再用大小為 K 的 Min-Heap(按頻率排序)找出頻率最高的 K 個數,整體 O(n log k)。
function topKFrequent(nums: number[], k: number): number[] {
// 步驟 1:計算每個數字的出現頻率
const freq = new Map<number, number>();
for (const num of nums) {
freq.set(num, (freq.get(num) ?? 0) + 1);
}
// 步驟 2:用 Min-Heap(依頻率)維護 Top-K
// Heap 中存 [頻率, 數值]
const heap = new MinHeap<[number, number]>((a, b) => a[0] - b[0]);
for (const [num, count] of freq) {
heap.push([count, num]);
if (heap.size() > k) {
heap.pop(); // 移除頻率最低的
}
}
// 步驟 3:收集結果
const result: number[] = [];
while (!heap.isEmpty()) {
result.push(heap.pop()![1]);
}
return result;
}
console.log(topKFrequent([1, 1, 1, 2, 2, 3], 2)); // 輸出:[2, 1](順序可能不同)
23. Merge K Sorted Lists
思路:K 個已排序鏈結串列,每次從 K 個串列的頭節點中取最小值。用 Min-Heap 儲存各串列的當前頭節點,每次 pop 後將該節點的 next 加入堆。時間 O(N log K),N 為總節點數。
class ListNode {
val: number;
next: ListNode | null;
constructor(val?: number, next?: ListNode | null) {
this.val = val ?? 0;
this.next = next ?? null;
}
}
function mergeKLists(lists: Array<ListNode | null>): ListNode | null {
// Heap 中存 [節點值, 節點],依值排序
const heap = new MinHeap<[number, ListNode]>((a, b) => a[0] - b[0]);
// 初始化:每個非空串列的頭節點入堆
for (const node of lists) {
if (node) heap.push([node.val, node]);
}
const dummy = new ListNode(0);
let curr = dummy;
while (!heap.isEmpty()) {
const [, node] = heap.pop()!;
curr.next = node;
curr = curr.next;
// 將該節點的下一個節點加入堆
if (node.next) {
heap.push([node.next.val, node.next]);
}
}
return dummy.next;
}
總結
堆積(Heap)是資料結構中極為優雅的一個設計——用一個簡單的陣列模擬完全二元樹,以 O(log n) 的代價維護「永遠能 O(1) 取得極值」的能力。
本文涵蓋了以下核心知識點:
- 陣列索引公式:
parent = (i-1)/2,left = 2i+1,right = 2i+2 - Sift Up / Sift Down:插入與取出的核心操作,各 O(log n)
- O(n) Heapify:批次建堆比逐一 push 快一個數量級
- Heap Sort:O(n log n) 時間、O(1) 空間的原地排序
- Top-K 反直覺規則:找最大 K 個用 Min-Heap,找最小 K 個用 Max-Heap
- 雙堆法:中位數維護的優雅解法,O(log n) 插入、O(1) 查詢
- C++ 陷阱:
priority_queue預設是 Max-Heap,Min-Heap 需加greater<T>
下一篇文章,我們將進入 排序演算法(Sorting Algorithms) 的完整解析——從 Bubble Sort 到 Tim Sort,理解各種排序在不同場景下的取捨與應用。
如果這篇文章對你有幫助,或在閱讀過程中有任何問題,歡迎透過 Contact 頁面 與我聯繫!