跳到主要內容

資料結構

資料怎麼排,決定了哪些操作便宜

主題 · 堆積

堆積

永遠能在一步內拿到最小值的樹,而且整棵樹就存在一個陣列裡。優先佇列就是它。

409172153124105206187
同一個堆積在記憶體裡的樣子:就是一個陣列。第 i 格的父節點在 (i−1)/2,子節點在 2i+1 和 2i+2。
  1. 4
    0
  2. 9
    1
  3. 7
    2
  4. 15
    3
  5. 12
    4
  6. 10
    5
  7. 20
    6
  8. 18
    7
正在比較交換/取出放入/到位

每個節點都不大於它的子節點,所以根節點一定是最小值。加入一個數,看它怎麼往上浮;或取出最小值,看補位的元素怎麼往下沉。

元素
8
樹高(層)
4
這次比較幾次
–
亮起來的是這一步執行的程式碼
class MinHeap {
private a: number[] = [];
push(value: number): void {
const a = this.a;
a.push(value);
let i = a.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (a[i] >= a[parent]) break;
[a[i], a[parent]] = [a[parent], a[i]];
i = parent;
}
}
pop(): number | undefined {
const a = this.a;
if (a.length === 0) return undefined;
const top = a[0];
const last = a.pop()!;
if (a.length === 0) return top;
a[0] = last;
let i = 0;
while (true) {
const left = 2 * i + 1, right = left + 1;
if (left >= a.length) break;
let smallest = i;
if (a[left] < a[smallest]) smallest = left;
if (right < a.length && a[right] < a[smallest]) smallest = right;
if (smallest === i) break;
[a[i], a[smallest]] = [a[smallest], a[i]];
i = smallest;
}
return top;
}
peek(): number | undefined {
return this.a[0];
}
}

模型假設與範圍

  • Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。

什麼時候用

  • 資料不斷進出,而且每次都要「目前最小(或最大)的那一個」:排程、事件模擬、最短路徑。
  • 從大量資料找前 k 名:只要維持一個大小為 k 的堆積。

和其他主題的關係

由這些組成
動態陣列
延伸閱讀
二元搜尋樹

語言內建的版本

JavaScript/TypeScript 沒有內建的堆積或優先佇列。需要時用這頁上面那份實作(二十幾行),或套件;只要前幾名、資料又不多時,直接排序也行。

操作寫法成本
前 k 小(排序)[...nums].sort((x, y) => x - y).slice(0, 3)O(n log n)
最小值(掃一遍)Math.min(...nums)O(n)
插入有序陣列sorted.splice(at, 0, 4)O(n)
取出最小(有序陣列)sorted.shift()O(n)
const nums = [5, 1, 8, 3, 9, 2];
[...nums].sort((x, y) => x - y).slice(0, 3); // → [1, 2, 3]
Math.min(...nums); // → 1
// A sorted array as a stand-in priority queue: fine for small n.
const sorted = [1, 3, 5, 8];
let at = sorted.findIndex((v) => v > 4);
if (at < 0) at = sorted.length;
sorted.splice(at, 0, 4);
sorted; // → [1, 3, 4, 5, 8]
sorted.shift(); // → 1
// Tasks by priority, ties in arrival order: sort is stable.
const tasks = [{ p: 2, name: "b" }, { p: 1, name: "a" }, { p: 2, name: "c" }];
tasks.sort((x, y) => x.p - y.p).map((t) => t.name); // → ["a", "b", "c"]

每個 → 後面的結果都是實際執行這段程式碼驗證過的。

  • 排序法每次都是 O(n log n)、有序陣列每次插入或取出都是 O(n);資料一多、或要反覆「放進去、拿最小的」(例如 Dijkstra),就該用真正的堆積,每次 O(log n)。
  • Math.min(...nums) 會把整個陣列攤成參數,十幾萬個元素以上可能超過參數上限;大陣列用迴圈或 reduce。

出現在這些架構裡

時間與空間複雜度(Big O)

操作平均最差
加入
隨機資料平均只往上浮一兩層;新的最小值才會一路浮到根
O(1)O(log n)
取出最小值
補位的是最底層的元素,幾乎一定要沉到底
O(log n)O(log n)
看最小值O(1)O(1)
一次建好(heapify)O(n)O(n)
找任意一個值O(n)O(n)

空間:O(n),就是一個陣列,不需要任何指標

Big O 實測:n 變大時步數怎麼長

數的是:比較次數(每種操作在該大小下做 1,000 次取平均)

Big On = 1,000n = 10,000n = 100,000n = 1,000,000成長倍數:實測(理論)
加入新的最小值(最差)O(log n)9131619×2.1 (×2.0)
加入隨機數(平均)O(1)2.62.32.32.3×0.9 (×1.0)
取出最小值(平均)O(log n)17.724.531.337.8×2.1 (×2.0)

n 變成一千倍,加入最小值和取出最小值的比較次數只變成兩倍左右(log n 從 10 變 20);隨機加入則幾乎不變。

和其他做法比

堆積:平均堆積:最差排序好的陣列沒排序的陣列
加入2.29507.31
取出最小值15.1181511.0

1,023 個隨機數全部加入再全部取出,數比較加搬動的次數。1,023 個節點的堆積正好 10 層,所以往上浮最多比較 9 次、往下沉每層 2 次。排序好的陣列取出很快但加入要搬一半;沒排序的陣列剛好相反。堆積兩邊都不差,這就是它當優先佇列的原因。

真實世界裡的它

  • 作業系統的計時器與排程佇列、Python 的 heapq、Java 的 PriorityQueue。
  • Dijkstra 和 A* 用它挑下一個要探索的格子;heap sort 用它排序。

取捨與陷阱

  • 只保證最上面是最小值,其他位置並沒有排序:要找任意一個值還是得整個掃一遍。
  • 要調整某個元素的優先順序(decrease-key),得另外記住它在陣列中的位置。
  • 一次把整個陣列變成堆積(heapify)只要 O(n),比一個一個加入的 O(n log n) 快。

LeetCode 練習