堆積與優先佇列 — Min/Max Heap 原理與 Heap Sort 完整實作 | 資料結構與演算法

2026/06/19
堆積與優先佇列 — Min/Max Heap 原理與 Heap Sort 完整實作 | 資料結構與演算法

堆積(Heap) 是一種以完全二元樹為基礎的資料結構,根節點永遠保存整個集合的最大值或最小值,讓你在 O(log n) 時間內完成插入與取出極值。以堆積為底層的 優先佇列(Priority Queue) 更是任務排程、Top-K 問題、Dijkstra 最短路徑等場景的核心利器。本文從陣列表示法、Heapify 原理出發,帶你用 TypeScriptC++ 完整實作 Min Heap、Max Heap 與 Heap Sort。

前言

想像急診室的分診系統:病患到達的順序是隨機的,但醫生永遠優先處理病情最嚴重的患者。這個系統不是用「先到先得」的普通佇列,而是一個 優先佇列(Priority Queue)——每次從隊列中取出「優先級最高」的元素,而非最早進入的那個。

而支撐優先佇列高效運作的底層資料結構,正是 堆積(Heap)。堆積的精妙之處在於:它以陣列儲存一棵完全二元樹,不需要任何指標,既節省記憶體又對 CPU 快取友好。

讀完這篇文章,你將學會:

  • 堆積的結構性質與陣列索引公式
  • Sift UpSift Down 的核心操作邏輯
  • 用 TypeScript 從零實作泛型 MinHeapMaxHeap
  • O(n) Heapify 建堆的原理(為什麼比逐一 push 快?)
  • Heap Sort 的完整實作(O(n log n) 原地排序)
  • C++ std::priority_queue 的正確用法與陷阱
  • Top-K 問題、合併 K 個有序串列、中位數維護等面試必考題

核心概念

完全二元樹與堆積性質

堆積(Heap) 建立在一種特殊的樹形結構——完全二元樹(Complete Binary Tree) 之上。完全二元樹有兩個關鍵特性:

  1. 除最後一層外,其他每一層都是完全填滿的
  2. 最後一層的節點從左到右依序填入,不留空隙

在此基礎上,堆積還必須滿足 堆積性質(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)/2left = 2i+1right = 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 頁面 與我聯繫!

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。