堆積與優先佇列
範圍 — 堆積(heap,這個資料結構)以及它所實作的優先佇列(priority queue,這個抽象資料型別),Python 與 Java 兩種語言。本檔案原本拆成
heap.md+priority_queue.md,兩邊把同樣的題目各解了一遍。 另見 — 從本檔案拆出去的深入主題:heap_advanced.md — 延遲刪除、掃描線的「存活」堆積、後悔貪婪、資源池配置器、格子圖最佳優先搜尋;heap_examples.md — 題目集,每題都歸到下面它所化約成的模板之下;heap_language_apis.md — 完整的heapq/PriorityQueueAPI 參考,以及「不 pop 就 peek」的規則。 鄰近文件:priority_queue.md — 轉址用的 stub;monotonic_queue.md — 什麼時候滑動視窗極值用雙端佇列比堆積好;Dijkstra.md — 最經典的優先佇列演算法;streaming_algorithms.md — 串流上的 top-k;sort.md — 放在排序脈絡下的堆積排序。
LeetCode 題目清單
時間複雜度
| 資料結構 | 搜尋 | 插入 | 刪除 | 最小/最大 |
|---|---|---|---|---|
| 堆積(Heap) | O(n) | O(log n) | O(log n) | O(1) |
讀取堆頂元素(min-heap 取最小/max-heap 取最大)是 O(1);要找另一端的極值則是 O(n)。從
n個既有元素建堆,用 heapify 是 O(N) — 不是O(N log N)。空間為 O(N)。
總覽
堆積(Heap) 是一棵滿足堆積性質的完全二元樹,因此非常適合用來高效存取資料集中的最大或最小元素。它是優先佇列與堆積排序演算法的基礎。


關鍵性質
- 複雜度:見上方的時間複雜度表格
- 核心想法:完全二元樹,且父子節點之間滿足堆積性質
- 什麼時候用:需要頻繁存取最小/最大元素、優先權排程、排序
堆積的種類
- 最小堆積(Min Heap):父節點 ≤ 子節點(根是最小值)
- 最大堆積(Max Heap):父節點 ≥ 子節點(根是最大值)

與優先佇列的關係
- 優先佇列(Priority Queue):以優先權存取的抽象資料型別
- 堆積(Heap):優先佇列最常見的實作方式
- 關鍵差別:優先佇列是概念,堆積是實作
實作方式
- 通常用二元堆積(min-heap 或 max-heap)實作
- 需要進階操作時也可以用平衡二元搜尋樹或費氏堆積(Fibonacci heap)
- Python:
heapq模組(預設為 min-heap) - Java:
PriorityQueue類別(預設為 min-heap)
題型分類
模式 1:第 k 個元素問題
- 描述:在資料集中找第 k 大/第 k 小的元素
- 範例:LC 215、703、1492 - Kth Largest Element、Kth Largest in Stream、Kth Factor
- 模式:使用大小為 k 的 min/max heap,維持堆積性質
模式 2:Top K 問題
- 描述:找出頻率或數值最高/最低的前 k 個元素,或是讓所有頻率互不相同
- 範例:
- Top K:LC 347、692、973 - Top K Frequent Elements、Top K Words、K Closest Points
- 頻率唯一化:LC 1647、1481 - Make Frequencies Unique、Least Unique After K Removals
- 模式:先統計頻率,再用堆積維護 top k 結果或確保頻率唯一
模式 3:合併問題
- 描述:高效合併多個有序陣列/串列
- 範例:LC 23、373、378 - Merge k Lists、K Smallest Pairs、Kth Smallest in Matrix
- 模式:用 min heap 追蹤每個來源目前的最小值
模式 4:滑動視窗極值
- 描述:高效求出滑動視窗內的最小/最大值
- 範例:LC 239、480、1438 - Sliding Window Maximum、Sliding Median、Longest Subarray
- 模式:用堆積搭配延遲刪除,或用雙端佇列追蹤極值
模式 5:排程問題
- 描述:依優先權/時間安排任務或事件
- 範例:LC 1353、502、630、621、1834 - Max Events、IPO、Course Schedule III、Task Scheduler、Single-Threaded CPU
- 模式:用堆積依開始/結束時間或優先權維護事件
- 關鍵洞察(時間掃描 + 截止期限堆積):依視窗起點排序,讓元素按時間順序進堆積;堆積依視窗終點排序,這樣每個時間格都服務**最急迫(最早截止)**的元素;過期的堆頂用延遲刪除清掉
- 特徵:「每單位時間只能處理一件事」 + 「每件事有有效區間/截止期限」 → 見 scanning_line_examples.md § LC 1353
- 陷阱:不要套用 LC 253 的計數掃描。在那題裡
[1,3]是佔用第 1~3 天;在這裡它只花掉{1,2,3}中的一天,而且每天最多一個事件 — 所以用最大重疊數會高估([[1,1],[1,1],[1,1]]→ 掃描得 3,答案是 1)。見 scanning_line_examples.md § LC 1353
模式 6:資料串流問題
- 描述:處理連續資料串流上的最小/最大值查詢
- 範例:LC 295、480、1825 - Find Median、Sliding Median、Finding MK Average
- 模式:用兩個堆積(min + max)維持平衡結構
模式 7:可跳躍範圍的格子圖最短路徑
- 描述:在格子圖上求最短路徑,其中每個格子可以跳到一個範圍內的格子
- 範例:LC 2617 - Minimum Number of Visited Cells in a Grid
- 模式:DP + 每列/每行各一個優先佇列,搭配延遲刪除
- 關鍵洞察:一般 BFS 每個格子要 O(N²);用優先佇列可降到每格 O(log N)
- 相似題:LC 778(Swim in Rising Water)、LC 1631(Path With Minimum Effort)
模式 8:延遲刪除(堆積中的過期資料) 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
- 描述:堆積裡的值後來會被更新/失效,但二元堆積沒有 「decrease-key」/「移除任意元素」的操作 — 所以我們直接 push 新值,把舊的留在裡面
- 範例:LC 3092、2349、1834、480、1825、2336、621、1353、2406
- 模式:堆積 = 候選集合(可能過期) + 雜湊表 = 真值來源 → 只在讀取時清理堆頂,而且只清到堆頂有效為止
- 關鍵洞察:你從來不去堆積裡搜尋過期資料。你只檢查
heap[0], 而每筆過期資料整段執行下來最多被 pop 一次 → 攤還 O(log n) - 另見:heap_advanced.md § 延遲刪除,其中完整走過 LC 3092
- 同時要兩個極值(LC 2034):把堆積做成鏡像 — 一份真值對照表,加上存同樣
(price, ts)的 最大堆積與最小堆積,兩邊用同一個判定清理 → heap_advanced.md § LC 2034 變形 - 同一個想法在別處:雜湊表的墓碑、LSM-tree 的刪除、MVCC 的死列、Dijkstra 的
d > dist[u]跳過 → heap_advanced.md § 堆積之外
模式 9:掃描線 + 「存活」區間堆積 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
- 描述:掃描一個座標軸;堆積裡放的是當下覆蓋該座標的所有區間
- 範例:LC 218 The Skyline Problem、LC 1851 Minimum Interval to Include Each Query
- 模式:堆積存
(value, endCoordinate)→ 在起點插入,當end <= pos時在堆頂延遲驅逐,然後讀heap[0] - 特徵:「在每個 x,求所有覆蓋 x 的區間之最大/最小值」
- 另見:heap_advanced.md § 掃描線
模式 10:有上限的「後悔」堆積(k 次免費機會) 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
- 描述:k 份免費資源 + 其餘部分的預算,而且要**線上(online)**決定
- 範例:LC 1642 Furthest Building You Can Reach、LC 1792 Maximum Average Pass Ratio
- 模式:樂觀地先給每個元素一次免費機會;min-heap 上限為 k;被擠掉的(最小的)那個改用預算支付
- 對照:LC 630 擠掉的是最大的(max-heap replace)— 同樣是「先承諾再後悔」的想法,只是比較器相反
- 另見:heap_advanced.md § 有上限的後悔堆積
模式 11:兩個堆積當資源池 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
- 描述:配置器模擬 — 不是每一題「兩個堆積」都是中位數問題
- 範例:LC 1942 Smallest Unoccupied Chair、LC 1606 Find Servers、LC 1801 Orders in Backlog、LC 2073 Process Tasks Using Servers
- 模式:
free= 依資源編號排序的 min-heap,busy= 依釋放時間排序的 min-heap → 釋放 → 指派 → 佔用 - 另見:heap_advanced.md § 資源池
模式 12:帶限制的貪婪字串/序列建構 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
- 描述:貪婪地用出現次數最多的元素來建構字串/序列,但當加進去會違反限制時(例如連續 3 個相同字元)就先跳過。用 max-heap 讓當前次數最多的元素隨時可取。
- 範例:LC 1405(Longest Happy String)、LC 767(Reorganize String)、LC 621(Task Scheduler)、LC 358(Rearrange String k Distance Apart)
- 模式:依計數排序的 max-heap;每一步先試堆頂元素 — 如果違反限制,就暫時改用第二個元素,再把第一個放回去
- 關鍵技巧:兩種情況的迴圈
- 情況 1 — 違反限制:poll 出
second,接上去、計數減一、若 > 0 再放回;然後把first放回(它沒有被消耗) - 情況 2 — 安全:接上
first、計數減一、若 > 0 再放回
- 情況 1 — 違反限制:poll 出
- 另見:模板 7 — 連續上限(1 還是 2)就是 LC 767 和 LC 1405 的差別所在
模式 13:優先佇列 + 冷卻佇列(k 距離排程) 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
- 描述:從 max-heap 貪婪地取出次數最多的元素,用完後把它鎖進冷卻佇列 k 步才能再被使用。這是「相同元素之間至少要相隔 k」這類題目的標準模式。
- 範例:LC 358(Rearrange String k Distance Apart)、LC 621(Task Scheduler)、LC 767(Reorganize String — k=2 的特例)
- 模式:max-heap 挑下一個元素;用完後該元素進入冷卻佇列並記
releaseTime = time + k;當time == releaseTime時再移回堆積 - 關鍵洞察:光靠優先佇列無法追蹤「上次使用位置」— 冷卻佇列扮演一條長度為 k 的延遲線,k 步之後自動讓元素重新可用
- 什麼時候用:
- 題目說「相同元素至少相隔 k」或「冷卻時間 k」
- 需要貪婪地挑出當前可用且次數最多的元素
- 元素在「可用 → 使用中 → 冷卻中 → 可用」之間循環
- 與模式 7 的差異:模式 7 檢查回看視窗並交換元素;模式 8 用一個明確的冷卻佇列來強制距離限制,對於可變的 k 來說更乾淨
參考資料
辨識
限制條件的線索來自 complexity_cheatsheet.md 裡的尺規。當集合一直在變、而你需要反覆取得當前極值時,答案就是堆積;對靜態資料只問一次的題目,通常有更便宜的做法。
| 題目敘述中的訊號 | 限制條件線索 | 該用 | 不是這個,如果 |
|---|---|---|---|
| 「第 k 大/第 k 小」、「最接近的 k 個」 | n ≤ 10⁵、k ≪ n → O(n log k) | 模板 1 大小為 k 的堆積 | 對靜態陣列只問一次 → quickselect,平均 O(n)(quickselect.md) |
| 「出現最頻繁的前 k 個」、「最常見的 k 個單字」 | 相異值 ≤ n | 模板 2 先計數,再用大小為 k 的堆積 | 計數上限為 n 且 k 接近 n → 桶排序,O(n) |
| 「合併 k 個有序串列/陣列」、「有序矩陣中第 k 小」 | k 個串列、共 N 個 → O(N log k) | 模板 3 K 路合併 | 只有兩個來源 → 一般的雙指標合併 |
| 「資料串流的中位數」、「平衡兩半」 | 大量 add + query 呼叫 |
模板 4 雙堆積 | 視窗也會移除值 → 延遲刪除(heap_advanced.md) |
| 「max − min ≤ limit 的最長子陣列」、可變視窗同時需要最大與最小 | n ≤ 10⁵ | 模板 5 雙堆積 + 依索引過期 | 固定大小視窗、只要一個極值 → 單調雙端佇列,O(n)(monotonic_queue.md) |
| 「最少需要幾間會議室/幾組」、「同時重疊的最大區間數」 | n ≤ 10⁴–10⁵ 個區間 | 模板 6 結束時間的 min-heap | 每個區間只花掉其範圍中的一天(LC 1353)→ 逐日掃描,見模板 6 的變形 |
| 「不能有三個相同字母連在一起」、「相鄰不可相同」 | 字母表 ≤ 26 | 模板 7 貪婪 + 連續長度上限 | 間隔是一般的 k → 模板 8 |
| 「相同任務/字母至少相隔 k」、「冷卻時間 n」 | 字母表 ≤ 26 | 模板 8 堆積 + 冷卻佇列 | 只問長度(LC 621)→ 計數公式,O(n) |
| 加權最短路徑、邊權非負 | V, E ≤ 10⁵ | Dijkstra.md — 優先佇列演算法放在那裡 | 權重皆為 1 → BFS(bfs.md) |
| push 進去的值之後會變動或過期 | heap_advanced.md § 延遲刪除 |
模板與演算法
模板對照表
標準模板放在這裡,每一個都是一張兩種語言都有的卡片,Python 在前。tier-4 的特化版本被拆到 heap_advanced.md — 右邊那一欄說明每個模板去了哪裡。
| 模板 | 使用情境 | 複雜度 | 位置 |
|---|---|---|---|
| 大小為 k 的堆積 | 第 k 大/第 k 小 | O(N log k) | 模板 1 |
| 計數 + 大小為 k 的堆積 | 最常/最少出現、最接近的 k 個 | O(N log k) | 模板 2 |
| K 路合併 | 合併有序串列/陣列 | O(N log k) | 模板 3 |
| 雙堆積(中位數) | 串流的中位數 | 每次 add 為 O(log N) | 模板 4 |
| 雙堆積 + 依索引過期 | 可變視窗且同時需要最大與最小 | O(N log N) | 模板 5 |
| 結束時間的 min-heap | 會議室、重疊數 | O(N log N) | 模板 6 |
| 貪婪 + 連續長度上限 | 建構不含連續 k 個相同字元的字串 | O(N log Σ) | 模板 7 |
| 堆積 + 冷卻佇列 | k 距離/任務排程 | O(N log Σ) | 模板 8 |
| 圖的最短路徑 | 用優先佇列的 Dijkstra | O(E log V) | Dijkstra.md |
| 延遲刪除 | push 進去的值會變動或過期 | 攤還 O(log N) | heap_advanced.md |
| 掃描 + 存活堆積 | 覆蓋 x 的區間之最大/最小值 | O(N log N) | heap_advanced.md |
| 有上限的後悔堆積 | k 次免費機會 + 預算 | O(N log k) | heap_advanced.md |
| 帶後悔的貪婪 | 撤銷過去最差的決定 | O(N log N) | heap_advanced.md |
| 資源池(2 個堆積) | 依編號的空閒池 + 依釋放時間的忙碌池 | O(N log N) | heap_advanced.md |
| 排序 + 固定大小堆積 | 目標式 = sum(A) × max/min(B) |
O(N log N) | heap_advanced.md |
| 格子圖最佳優先 | 展開代價最小的格子 | O(MN log MN) | heap_advanced.md |
| 格子圖範圍跳躍 | 每個格子跳到一個範圍 | O(MN log(M+N)) | heap_advanced.md |
| 頻率唯一化 | 讓所有頻率互不相同 | O(N + K log K) | heap_advanced.md |
| 堆積 + 去重集合 | 唯一性限制 | O(log N) | heap_advanced.md |
每張卡片都會用到的基本 heapq / PriorityQueue 呼叫(min-heap、用取負或
reverseOrder() 做 max-heap、heapify、peek)都在下方的語言 API。
模板 1:用大小為 k 的 Min-Heap 求第 k 大 — LC 215 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「第 k 大」、「第 k 小」、「最接近的 k 個」、「最強的 k 個」。n ≤ 10⁵ 且 k 遠小於 n → 預期的複雜度是 O(n log k)。
關鍵 — 堆積裡放的是目前為止最大的 k 個值,所以它的堆頂(這 k 個裡最小的)就是第 k 大。 第 k 小則反過來:用大小為 k 的 max-heap 存最小的 k 個,堆頂就是答案。
# python
# IDEA: keep a min-heap of the k largest seen so far; its root is the kth largest
# time = O(n log k), space = O(k)
# LC 215 - Kth Largest Element in an Array
import heapq
def findKthLargest(nums, k):
min_heap = []
for num in nums:
heapq.heappush(min_heap, num)
if len(min_heap) > k:
heapq.heappop(min_heap) # drop the smallest; the k largest stay
return min_heap[0] # the smallest of the k largest = the kth largest
// java
// IDEA: keep a min-heap of the k largest seen so far; its root is the kth largest
// time = O(n log k), space = O(k)
// LC 215 - Kth Largest Element in an Array
public int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
for (int num : nums) {
minHeap.add(num);
if (minHeap.size() > k) {
minHeap.poll(); // drop the smallest; the k largest stay
}
}
return minHeap.peek();
}
複雜度
- 時間 O(n log k):迴圈跑 n 次,每次
heappush/heappop操作的堆積 最多只有 k + 1 個元素。 - 空間 O(k):
len(min_heap) > k時的 pop 讓堆積上限維持在 k。
陷阱
- 方向弄反:「第 k 大」用 max-heap 會留下最小的 k 個。
[3,2,1,5,6,4]、 k = 2 會回傳 2 而不是 5。寫程式前先把不變量講出來。 - 重複值要算進去:LC 215
[3,2,3,1,2,4,5,5,6]、k = 4 的答案是 4 — 不要去重。 - 數字字串(LC 1985)會依字典序比較:
"10" < "6"。Python 用(len(s), s)當堆積的 key, Java 則先比長度、再用compareTo。 - 讓堆積長到 n、最後再 pop k 次是 O(n log n) — 設上限才是重點。
不適用時 — 對靜態陣列只查一次:quickselect 平均 O(n) (quickselect.md)。當值以串流方式到來(LC 703), 或要反覆查詢第 k 個元素時,堆積才勝出。
練習 — LC 215 ⭐ · LC 703 (同一個堆積,跨多次呼叫保留) · LC 1985 (key = (len, s)) ·
LC 1337 (key = (soldiers, row)) · LC 378 (有序矩陣中第 k 小 — 或用模板 3)
模板 2:先計數,再對計數用大小為 k 的堆積 — LC 347 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「出現最頻繁的 k 個」、「前 k 個單字」、「最接近的 k 個點」:排名用的 key 是 推導出來的(計數、距離),而不是值本身。相異值 ≤ n。
關鍵 — 雜湊表一次把輸入轉成 (key, item) 配對;之後就是對 key 做模板 1。
大小為 k 的 min-heap 存著最好的 k 組配對,堆頂是還留在裡面最弱的那一組。
# python
# LC 347 - Top K Frequent Elements
# IDEA: count with a hash map, then keep a min-heap of the k highest (freq, num) pairs
# time = O(n log k), space = O(n)
import heapq
from collections import Counter
def topKFrequent(nums, k):
count = Counter(nums) # num -> frequency
heap = [] # (freq, num), at most k of them
for num, freq in count.items():
heapq.heappush(heap, (freq, num))
if len(heap) > k:
heapq.heappop(heap) # evict the least frequent
return [num for freq, num in heap]
// java
// LC 347 - Top K Frequent Elements
// IDEA: count with a hash map, then a min-heap of keys ordered by count, capped at k
// time = O(n log k), space = O(n)
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> count = new HashMap<>();
for (int n : nums) {
count.put(n, count.getOrDefault(n, 0) + 1);
}
PriorityQueue<Integer> minHeap = new PriorityQueue<>(
(a, b) -> Integer.compare(count.get(a), count.get(b))); // least frequent on top
for (int key : count.keySet()) {
minHeap.offer(key);
if (minHeap.size() > k) {
minHeap.poll();
}
}
int[] result = new int[k];
for (int i = k - 1; i >= 0; i--) {
result[i] = minHeap.poll(); // most frequent ends up first
}
return result;
}
複雜度
- 時間 O(n log k):計數是一次 O(n) 的掃描;堆積迴圈對每個相異值(≤ n)跑一次, 而堆積上限為 k。
- 空間 O(n):
count表存了每個相異值;堆積再多 O(k)。
陷阱
- 平手時,堆積要有可比較的順序。 key 平手時 Python 會比較 tuple 的下一個欄位,
所以
(freq, obj)裡若 payload 無法比較就會丟TypeError— 改 push(freq, counter, obj)(見平手時的決勝)。Java 則要把決勝規則寫進比較器。 - LC 692 要求平手時依字母順序,但堆積驅逐的是最少出現的 — 所以驅逐用的比較器是
(freq 遞增, word 遞減),和輸出順序相反。 - 自訂物件的堆積:Python 需要在類別上定義
__lt__(或用 tuple key);Java 需要用Integer.compare建的比較器,絕不能用a - b。
第二種解法 — 依計數做桶排序,O(n)。 計數最多是 n,所以用計數當桶的索引, 由高往低讀:不需要比較排序,也不需要堆積。這是 LC 347 追問(「比 O(n log n) 更好」)的答案; 堆積仍是 V0,因為當 key 不是小整數(LC 973 的距離)或輸入是串流時,它照樣能用。
# python
# LC 347 - Top K Frequent Elements (bucket sort)
# IDEA: buckets[f] holds the values seen exactly f times; scan f from n down until k are taken
# time = O(n), space = O(n)
from collections import Counter
def topKFrequent_bucket(nums, k):
count = Counter(nums)
buckets = [[] for _ in range(len(nums) + 1)] # buckets[f] = values seen exactly f times
for num, freq in count.items():
buckets[freq].append(num)
res = []
for freq in range(len(nums), 0, -1): # highest count first
for num in buckets[freq]:
res.append(num)
if len(res) == k:
return res
return res
不適用時 — k 接近 n 且計數上限為 n:依頻率做桶排序是 O(n)
(即上面的第二種解法)。沒有 k、只是「全部依 key 排序」的靜態題,直接用 sorted 就好。
練習 — LC 347 ⭐ · LC 973 (key = 距離平方) · LC 692 (反向決勝) ·
LC 451 (輸出 char * freq) · LC 1338 (一直 pop 最頻繁的,直到刪掉一半)
模板 3:K 路合併 — LC 23 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「合併 k 個有序串列/陣列」、「涵蓋 k 個串列的最小範圍」、「第 k 小的配對/有序矩陣中第 k 小」。 k 個來源、總共 N 個元素 → O(N log k)。
關鍵 — 堆積裡每個非空來源恰好只有一個候選,也就是它目前的頭。 全域最小值一定是這些頭之一,所以 pop 出它、再 push 該來源的下一個元素, 就能維持這個不變量。
# python
# LC 23 - Merge k Sorted Lists
# IDEA: min-heap of (head value, list index, node); pop the smallest, push its successor
# time = O(N log k), space = O(k)
import heapq
def mergeKLists(lists):
heap = []
for i, node in enumerate(lists):
if node:
heapq.heappush(heap, (node.val, i, node)) # i breaks ties before node is compared
dummy = ListNode()
tail = dummy
while heap:
val, i, node = heapq.heappop(heap)
tail.next = node
tail = node
if node.next:
heapq.heappush(heap, (node.next.val, i, node.next))
return dummy.next
// java
// LC 23 - Merge k Sorted Lists
// IDEA: min-heap of current list heads ordered by value; pop the smallest, offer its next
// time = O(N log k), space = O(k)
public ListNode mergeKLists(ListNode[] lists) {
PriorityQueue<ListNode> minHeap = new PriorityQueue<>((a, b) -> Integer.compare(a.val, b.val));
for (ListNode head : lists) {
if (head != null) {
minHeap.offer(head);
}
}
ListNode dummy = new ListNode(0);
ListNode tail = dummy;
while (!minHeap.isEmpty()) {
ListNode node = minHeap.poll();
tail.next = node;
tail = node;
if (node.next != null) {
minHeap.offer(node.next);
}
}
return dummy.next;
}
複雜度
- 時間 O(N log k):N 個節點每個都被 push 一次、pop 一次,而堆積 最多只有 k 個項目(每個來源一個)。
- 空間 O(k):就是堆積;輸出直接重用輸入的節點。
陷阱
- 沒有索引的
(node.val, node):兩個值相同時 Python 會去比較ListNode→ 在 LC 23[[1,4,5],[1,3,4],[2,6]]上丟TypeError。來源索引在堆積的項目中是唯一的, 所以一定能分出勝負。 []和[None]都是 LC 23 的合法輸入:初始化時跳過空來源,並回傳dummy.next(也就是None)。- 若是陣列,就 push
(value, source, index)並前進到index + 1— 形狀相同, 只是原本的next指標換成了索引。
第二種解法 — 兩兩分治合併,不用堆積。 先把串列兩兩合併,再把結果兩兩合併, 就像合併排序由下往上的各層。總共 log k 輪、每輪碰到全部 N 個節點,所以同樣是 O(N log k), 額外空間是 O(1) 而不是堆積的 O(k)。把串列一個接一個併進同一個結果是陷阱寫法: 第一個串列會被重走 k 次,O(kN)。
# python
# LC 23 - Merge k Sorted Lists (pairwise divide and conquer)
# IDEA: merge lists i and i + step for step = 1, 2, 4, ...; each round halves the number of lists
# time = O(N log k), space = O(1) extra
def mergeTwo(a, b):
dummy = ListNode()
tail = dummy
while a and b:
if a.val <= b.val:
tail.next = a
a = a.next
else:
tail.next = b
b = b.next
tail = tail.next
tail.next = a if a else b # one list is exhausted; append the rest
return dummy.next
def mergeKLists_dc(lists):
if not lists:
return None
step = 1
while step < len(lists): # log k rounds
for i in range(0, len(lists) - step, step * 2):
lists[i] = mergeTwo(lists[i], lists[i + step])
step *= 2
return lists[0]
不適用時 — 只有兩個來源:一般的雙指標合併就是 O(N),不需要堆積。若答案只是 單一的第 k 個元素、而來源是矩陣的各列,對值域做二分搜尋可能勝過堆積(LC 378)。
練習 — LC 23 ⭐ · LC 373 (配對組成的虛擬格子) · LC 378 (把各列當來源) · LC 632 (同時追蹤 k 個頭的最大值) · LC 1439 (一列一列合併) — 變形見 heap_advanced.md § K 路合併變形
模板 4:用雙堆積維護即時中位數 — LC 295 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「資料串流的中位數」、「每次插入後回報中間值」、「平衡兩半」。
add / query 大量交錯呼叫,所以每次重新排序(O(n log n))行不通。
關鍵 — small(max-heap)存下半部、large(min-heap)存上半部;
small 中每個值都 ≤ large 中每個值,而且兩邊大小最多差一。
於是中位數可以直接從兩個堆頂以 O(1) 讀出來。
# python
# LC 295 - Find Median from Data Stream
# IDEA: max-heap `small` (negated) for the lower half, min-heap `large` for the upper half;
# route every number through the other heap so the halves stay ordered and balanced
# time = O(log n) per addNum, O(1) per findMedian, space = O(n)
import heapq
class MedianFinder:
def __init__(self):
self.small = [] # max-heap via negation: the lower half
self.large = [] # min-heap: the upper half; len(large) == len(small) or one more
def addNum(self, num):
if len(self.small) == len(self.large):
# pass num through small so only the lower half's max moves up
heapq.heappush(self.large, -heapq.heappushpop(self.small, -num))
else:
heapq.heappush(self.small, -heapq.heappushpop(self.large, num))
def findMedian(self):
if len(self.small) == len(self.large):
return (self.large[0] - self.small[0]) / 2.0
return float(self.large[0])
// java
// LC 295 - Find Median from Data Stream
// IDEA: max-heap `small` for the lower half, min-heap `large` for the upper half;
// push through small, move its max to large, then rebalance so small is never shorter
// time = O(log n) per addNum, O(1) per findMedian, space = O(n)
class MedianFinder {
private PriorityQueue<Integer> small = new PriorityQueue<>(Collections.reverseOrder());
private PriorityQueue<Integer> large = new PriorityQueue<>();
public void addNum(int num) {
small.offer(num);
large.offer(small.poll()); // the lower half's max crosses over: halves stay ordered
if (large.size() > small.size()) {
small.offer(large.poll()); // sizes: small == large or small == large + 1
}
}
public double findMedian() {
if (small.size() > large.size()) {
return small.peek();
}
return (small.peek() + (double) large.peek()) / 2.0; // widen before adding
}
}
複雜度
- 每次
addNum為 O(log n):固定次數的堆積 push 與 pop(heappushpop只做一次 sift)。findMedian讀兩個堆頂,O(1)。 - 空間 O(n):每個加入過的數字都在兩個堆積之一裡。
試跑
add sizes equal? routed through small (lower) large (upper) median
1 yes small -> out 1 -> large [] [1] 1.0
2 no large -> out 1 -> small [1] [2] (1+2)/2 = 1.5
3 yes small -> out 3 -> large [1] [2,3] large[0] = 2.0
陷阱
- 直接 push 進某一個堆積、只修正大小,會破壞順序:直接把 5 再把 1 加進
large,small的堆頂就可能大於large的。每個數字都要先繞經另一個堆積(或先和堆頂比較)。 - Java 的整數溢位:
(small.peek() + large.peek()) / 2.0會先把兩個int相加才轉型; 兩個接近Integer.MAX_VALUE的值會溢位。先把其中一邊轉成double。 - Python 的
small存的是負數:讀-small[0],push-num。
不適用時 — 視窗也會移除舊值(LC 480 滑動視窗中位數):雙堆積需要延遲刪除 (heap_advanced.md), 或改用有序容器。
練習 — LC 295 ⭐ · LC 703 (只要一個名次,一個堆積就夠) · LC 480 (+ 延遲刪除) · LC 502 (雙堆積當可用/鎖定池) · LC 1825 (三段切分)
模板 5:雙堆積 + 依索引過期 — LC 1438 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 可變大小的視窗,同時需要最大與最小: 「max − min ≤ limit 的最長子陣列」。n ≤ 10⁵ → O(n log n) 就夠了。
關鍵 — 每筆資料都是 (value, index),而且當且僅當 index < left 時資料過期。
left 前進時什麼都不刪;過期資料只有浮到堆頂時才會被 pop,所以比較時兩個堆頂
一定都在視窗內。要修正不合法的視窗,就把 left 跳到兩個違規極值中較早那一個之後 —
這是唯一能拆掉那組配對的移動。
maxHeap = (-value, index) minHeap = (value, index)
stale <=> index < left (the element has fallen out of the window)
# python
# LC 1438 - Longest Continuous Subarray With Absolute Diff Less Than or Equal to Limit
# IDEA: two heaps hold window max/min; shrink past the older extreme; purge stale indices lazily
# time = O(N log N), space = O(N)
import heapq
class Solution(object):
def longestSubarray(self, nums, limit):
max_h = [] # max-heap: (-val, idx)
min_h = [] # min-heap: ( val, idx)
left = 0
res = 0
for i, v in enumerate(nums):
heapq.heappush(max_h, (-v, i))
heapq.heappush(min_h, (v, i))
# window invalid -> must drop at least one of the two extremes
while -max_h[0][0] - min_h[0][0] > limit:
# move left PAST the earlier of the two extreme indices
left = min(max_h[0][1], min_h[0][1]) + 1
# lazy delete: anything left of the window is stale
while max_h[0][1] < left:
heapq.heappop(max_h)
while min_h[0][1] < left:
heapq.heappop(min_h)
res = max(res, i - left + 1)
return res
// java
// LC 1438 - Longest Continuous Subarray With Absolute Diff Less Than or Equal to Limit
// IDEA: max-heap + min-heap of {val, idx}; shrink past older extreme; lazy-purge stale indices
// time = O(N log N), space = O(N)
public int longestSubarray(int[] nums, int limit) {
PriorityQueue<int[]> maxH = new PriorityQueue<>((a, b) -> Integer.compare(b[0], a[0])); // {val, idx}
PriorityQueue<int[]> minH = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));
int left = 0, res = 0;
for (int i = 0; i < nums.length; i++) {
maxH.offer(new int[]{nums[i], i});
minH.offer(new int[]{nums[i], i});
while (maxH.peek()[0] - minH.peek()[0] > limit) {
left = Math.min(maxH.peek()[1], minH.peek()[1]) + 1;
while (maxH.peek()[1] < left) maxH.poll();
while (minH.peek()[1] < left) minH.poll();
}
res = Math.max(res, i - left + 1);
}
return res;
}
一次只縮一步 — 多數人第一次寫出來的形式,同樣正確:每次把 left 加 1,每一步之後都清理,而不是直接跳。
複雜度一樣是 O(N log N) — left 總共最多移動 N 次 — 只是迴圈多跑幾圈。
# python
# LC 1438 - shrink form, the inner loop only
# IDEA: left += 1 per step, purge stale tops after each step
# time = O(N log N), space = O(N)
while -max_h[0][0] - min_h[0][0] > limit:
left += 1
while max_h[0][1] < left:
heapq.heappop(max_h)
while min_h[0][1] < left:
heapq.heappop(min_h)
複雜度
- 時間 O(N log N):每個索引在兩個堆積各被 push 一次、最多被 pop 一次,而清理用的
while只會 pop、從不掃描 — 所以它們所有的迭代加起來不會超過 push 的次數。 - 空間 O(N):過期資料要等浮上來才會離開,所以一個堆積可能存下全部 N 筆。
試跑 — 以縮一步的形式跑 nums = [8, 2, 4, 7]、limit = 4。.1 那幾列就是延遲刪除:
元素只有在位於堆頂而且索引 < left 時才會被 pop。
step r v left window min_h[0] max_h[0] max-min action res
1 0 8 0 [8] (8,0) (-8,0) 0 valid 1
2 1 2 0 [8,2] (2,1) (-8,0) 6 6 > 4 -> left = 1 1
2.1 1 - 1 [2] (2,1) (-2,1) 0 pop (-8,0) from max_h: 0 < 1 1
3 2 4 1 [2,4] (2,1) (-4,2) 2 valid 2
4 3 7 1 [2,4,7] (2,1) (-7,3) 5 5 > 4 -> left = 2 2
4.1 3 - 2 [4,7] (4,2) (-7,3) 3 pop (2,1) from min_h: 1 < 2 2
陷阱
- 堆頂是
h[0],絕不是h[-1]。heapq的串列只有部分有序,所以h[-1]只是 某個葉節點,不是最大值(見下方常見錯誤 #7)。這裡每次讀取都是max_h[0]/min_h[0]。 - 兩個堆積都要清理,不能只清堆頂被移動的那個。 在
[8,2,4,7]上跳躍之後, 另一個堆積的堆頂也可能落在新的left左邊;拿它來比較會得到錯的max − min。 - 清理迴圈不需要
heap and的防護。 剛推入的元素索引是i >= left, 所以清理時任何一個堆積都不可能被清空。兩次 push 之後再清理一次什麼也不會做。
不適用時 — 視窗是固定大小,或驅逐永遠是「最舊的先出」:兩個單調雙端佇列能以 O(N) 解 LC 1438 — monotonic_queue.md → Template 7。 當驅逐規則是依值或某個任意條件時才用堆積,那是雙端佇列表達不了的。
練習 — LC 1438 ⭐ · LC 1696 (堆積存 dp[j];當 j < i − k 時 pop) ·
LC 1499 (key 為 y − x;依座標 x_j − x_top > k 過期) · LC 480 (中位數,雙堆積 + 延遲刪除)
模板 6:結束時間的 Min-Heap — LC 253 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「最少需要幾間會議室/月台/組」、「同一時間最多有幾個區間重疊」。 n ≤ 10⁴–10⁵ 個區間 → 排序 + 堆積,O(n log n)。
關鍵 — 依開始時間排序後,堆積存的是使用中的會議室的結束時間;堆頂就是 最早空出來的那間。開始時間在該結束時間或之後的會議就沿用這間(先 pop 再 push); 否則就開一間新的。堆積的大小就是曾經需要的會議室數量。
# python
# LC 253 - Meeting Rooms II
# IDEA: sort by start; min-heap of end times = rooms in use; reuse the earliest-ending room
# time = O(n log n), space = O(n)
import heapq
def minMeetingRooms(intervals):
if not intervals:
return 0
intervals.sort(key=lambda x: x[0])
heap = [intervals[0][1]] # end time of each room in use
for start, end in intervals[1:]:
if start >= heap[0]: # the earliest-ending room is free by now
heapq.heappop(heap)
heapq.heappush(heap, end)
return len(heap)
// java
// LC 253 - Meeting Rooms II
// IDEA: sort by start; min-heap of end times = rooms in use; reuse the earliest-ending room
// time = O(n log n), space = O(n)
public int minMeetingRooms(int[][] intervals) {
if (intervals.length == 0) return 0;
Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));
PriorityQueue<Integer> pq = new PriorityQueue<>(); // end times of rooms in use
pq.offer(intervals[0][1]);
for (int i = 1; i < intervals.length; i++) {
if (intervals[i][0] >= pq.peek()) { // room frees up in time
pq.poll();
}
pq.offer(intervals[i][1]);
}
return pq.size();
}
複雜度
- 時間 O(n log n):排序,加上每個區間一次 push、最多一次 pop。
- 空間 O(n):最壞情況(所有會議都重疊)每個結束時間都在堆積裡。
陷阱
>=還是>:[[1,5],[5,10]]只需要一間,因為 5 點結束的會議會把會議室 讓給 5 點開始的會議。寫成>會回傳 2。- 依結束時間而不是開始時間排序會讓掃描失效:堆積必須依會議開始的順序看到它們。
- Java 比較器:
Integer.compare(a[0], b[0]),絕不用a[0] - b[0](時間很大時會溢位)。
不適用時 — 每個區間只花掉其範圍中任選的一個時間單位,而不是佔用整段
(LC 1353 Maximum Number of Events That Can Be Attended)。在那裡數重疊會
高估([[1,1],[1,1],[1,1]] → 3,答案是 1)。要一天一天掃描,堆積存開放中事件的
結束日,每天參加最早結束的那一個:
// java
// LC 1353 - Maximum Number of Events That Can Be Attended
// IDEA: sort by start day; each day push all events that opened, drop expired ones,
// then attend the one with the earliest end day (min-heap)
// time = O(n log n), space = O(n)
public int maxEvents(int[][] events) {
Arrays.sort(events, (a, b) -> Integer.compare(a[0], b[0]));
PriorityQueue<Integer> pq = new PriorityQueue<>(); // end days of open events
int i = 0, n = events.length, res = 0, day = 0;
while (i < n || !pq.isEmpty()) {
// idle -> jump to the next start day, otherwise advance one day
day = pq.isEmpty() ? events[i][0] : day + 1;
while (i < n && events[i][0] <= day) pq.offer(events[i++][1]); // now open
while (!pq.isEmpty() && pq.peek() < day) pq.poll(); // expired
if (!pq.isEmpty()) { pq.poll(); res++; } // attend earliest-ending event
}
return res;
}
LC 1353 的 Python 版本,以及「跳日 vs 逐日掃描」的取捨分析: scanning_line_examples.md § LC 1353。
練習 — LC 253 ⭐ · LC 252 (一間:到底有沒有任何重疊?) · LC 1094 (容量,不是
會議室) · LC 2406 (閉區間:只有 start > heap[0] 才 pop) · LC 1353 (每個事件一天) · LC 1834 (單執行緒
CPU:兩種排序)
模板 7:帶連續長度上限的貪婪建構 — LC 1405
辨識 — 「建出最長、且沒有三個相同字母連在一起的字串」、「重新排列使相鄰兩個不相同」。 字母表很小(≤ 26,常常只有 3 個),計數很大。
關鍵 — 永遠放剩餘最多的字母,因為它是最後才會沒地方放的那個; 當放它會打破連續長度上限時,改放第二多的,並把第一個原封不動放回去。 堆積只負責回答「誰剩最多」,所以最多只存整個字母表。
# python
# LC 1405 - Longest Happy String
# IDEA: max-heap by remaining count; if the top would make 3 in a row, use the 2nd and
# push the top back untouched; stop when only the blocked letter is left
# time = O(n log 3) = O(n), space = O(1) n = a + b + c
import heapq
def longestDiverseString(a, b, c):
heap = [(-cnt, ch) for cnt, ch in ((a, 'a'), (b, 'b'), (c, 'c')) if cnt > 0]
heapq.heapify(heap) # negated counts: most remaining on top
res = []
while heap:
cnt1, ch1 = heapq.heappop(heap)
if len(res) >= 2 and res[-1] == ch1 and res[-2] == ch1:
if not heap: # only the blocked letter is left
break
cnt2, ch2 = heapq.heappop(heap)
res.append(ch2)
if cnt2 + 1 < 0: # counts are negative: + 1 uses one up
heapq.heappush(heap, (cnt2 + 1, ch2))
heapq.heappush(heap, (cnt1, ch1)) # first was NOT used
else:
res.append(ch1)
if cnt1 + 1 < 0:
heapq.heappush(heap, (cnt1 + 1, ch1))
return "".join(res)
// java
// LC 1405 - Longest Happy String
// IDEA: max-heap by count; two-case loop:
// Case 1: top char would create 3 consecutive -> use 2nd, put 1st back
// Case 2: safe -> use top char directly
// time = O((a+b+c) * log 3) = O(n), space = O(1) heap size bounded by alphabet
class ValCnt {
char val;
int cnt;
ValCnt(char val, int cnt) { this.val = val; this.cnt = cnt; }
}
public String longestDiverseString(int a, int b, int c) {
PriorityQueue<ValCnt> pq = new PriorityQueue<>((x, y) -> Integer.compare(y.cnt, x.cnt));
if (a > 0) pq.add(new ValCnt('a', a));
if (b > 0) pq.add(new ValCnt('b', b));
if (c > 0) pq.add(new ValCnt('c', c));
StringBuilder sb = new StringBuilder();
while (!pq.isEmpty()) {
ValCnt first = pq.poll();
int len = sb.length();
// Case 1: adding `first` would create 3 consecutive -> use second instead
if (len >= 2
&& sb.charAt(len - 1) == first.val
&& sb.charAt(len - 2) == first.val) {
if (pq.isEmpty()) break; // no alternative -> stop
ValCnt second = pq.poll(); // use 2nd most frequent
sb.append(second.val);
second.cnt--;
if (second.cnt > 0) pq.add(second);
pq.add(first); // first was NOT used, put it back
// Case 2: safe to use the most frequent character
} else {
sb.append(first.val);
first.cnt--;
if (first.cnt > 0) pq.add(first);
}
}
return sb.toString();
}
複雜度
- 時間 O(n):每次迴圈接上一個字母(最多 n = a + b + c 次),而每次堆積操作 最多只有 3 筆,所以 O(log 3) = O(1)。
- 空間 O(1):堆積最多存整個字母表;輸出本身是 O(n)。
陷阱
- 在情況 1 消耗了
first:(a, b, c) = (7, 1, 0)必須得到長度 5 的字串("aabaa")。 在first被跳過時還把它減一,會少一個a,回傳較短的字串。 - 只有在被擋住的字母是最後一個時才跳出迴圈 — 在那裡寫
continue會無窮迴圈。 - 上限就是回看檢查:
res[-1] == res[-2] == ch允許連續兩個(LC 1405);LC 767 只允許一個,所以檢查只要res[-1] == ch。
不適用時 — 相同字母之間的間隔是一般的 k:回看檢查會隨 k 變大, 用模板 8 的冷卻佇列更乾淨。
練習 — LC 1405 · LC 767 (上限為 1) · LC 1054 (數字版的 LC 767) · LC 984 (兩種字母, 不需要堆積)
模板 8:Max-Heap + 冷卻佇列 — LC 358
辨識 — 「相同字母/任務至少相隔 k」、「相同任務之間冷卻 n」。 字母表 ≤ 26,所以堆積大小是 O(1)。
關鍵 — 用過的字母進入 FIFO 的冷卻佇列,而不是放回堆積。當佇列長度達到 k, 它最前面那個已經等了恰好 k − 1 個位置,可以放在下一個位置:把它移回堆積。 因此堆積提供的永遠只有此刻合法的字母。
# python
# LC 358 - Rearrange String k Distance Apart
# IDEA: max-heap picks the most frequent available letter; a k-long FIFO delays its return
# time = O(n log 26) = O(n), space = O(26) = O(1)
import heapq
from collections import Counter, deque
def rearrangeString(s, k):
if k <= 1:
return s
heap = [(-cnt, ch) for ch, cnt in Counter(s).items()] # negated: most remaining on top
heapq.heapify(heap)
cooldown = deque() # (negated remaining, letter)
res = []
while heap:
cnt, ch = heapq.heappop(heap)
res.append(ch)
cooldown.append((cnt + 1, ch)) # one used up
if len(cooldown) == k: # front has cooled for k steps
ready_cnt, ready_ch = cooldown.popleft()
if ready_cnt < 0: # still has copies left
heapq.heappush(heap, (ready_cnt, ready_ch))
return "".join(res) if len(res) == len(s) else ""
// java
// LC 358 - Rearrange String k Distance Apart
// IDEA: max-heap picks most frequent available char; cooldown queue locks used chars for k steps
// time = O(n log 26) = O(n), space = O(26) = O(1)
public String rearrangeString(String s, int k) {
if (k <= 1) return s;
int[] freq = new int[26];
for (char c : s.toCharArray()) {
freq[c - 'a']++;
}
// Max-heap ordered by remaining frequency
PriorityQueue<Integer> pq = new PriorityQueue<>((a, b) -> Integer.compare(freq[b], freq[a]));
for (int i = 0; i < 26; i++) {
if (freq[i] > 0) pq.offer(i);
}
// Cooldown queue: [charIndex, remainingCount]
Queue<int[]> cooldown = new LinkedList<>();
StringBuilder res = new StringBuilder();
while (!pq.isEmpty()) {
int idx = pq.poll();
res.append((char) ('a' + idx));
freq[idx]--;
cooldown.offer(new int[]{idx, freq[idx]}); // enter cooldown with its remaining count
if (cooldown.size() == k) { // front has cooled for k steps
int[] ready = cooldown.poll();
if (ready[1] > 0) {
pq.offer(ready[0]);
}
}
}
return res.length() == s.length() ? res.toString() : "";
}
複雜度
- 時間 O(n):每次迭代放一個字母,每次堆積操作最多只有 26 筆。
- 空間 O(1):堆積和冷卻佇列各最多 26 個字母;輸出是 O(n)。
陷阱
- 把用過的字母直接放回堆積:
"aabbcc"、k = 3 可能產生"abab…"。 強制間隔的正是冷卻佇列。 - 無解的輸入會以「堆積空了、佇列裡還有字母在等」結束:
"aaabc"、k = 3 必須回傳""— 檢查len(res) == len(s),而不只是「迴圈結束了」。 - k = 0 或 1 代表沒有限制:在建任何東西之前就回傳
s(k = 0 的佇列永遠不會放行)。 - Java 版的比較器會讀
freq[],所以要在字母離開堆積之後才更新freq[idx], 絕不能在它還在堆積裡時改。
不適用時 — 只問排程的長度(LC 621 Task Scheduler):答案是
max(n_tasks, (maxCount − 1) × (cooldown + 1) + numberOfTasksWithMaxCount),O(n)、不需要
堆積(heap_examples.md § LC 621)。只有需要實際順序時才用這個模板模擬。
練習 — LC 358 · LC 1953 (只問「做不做得到?」) · LC 767 (k = 2) · LC 621 ⭐ (數閒置格;或用公式)
語言 API
完整參考 — 每一個 heapq 呼叫及其輸出、peek 規則、偏序陷阱:
heap_language_apis.md。
| 你需要什麼 | Python heapq |
Java PriorityQueue |
|---|---|---|
| Min-heap | h = [] |
new PriorityQueue<>() |
| Max-heap | push 取負的鍵值:heappush(h, -v) |
new PriorityQueue<>(Collections.reverseOrder()) |
| 從串列建堆 — O(N),不是 O(N log N) | heapq.heapify(lst) |
new PriorityQueue<>(collection) |
| Push | heapq.heappush(h, v) |
pq.offer(v) |
| Pop 堆頂 | heapq.heappop(h) |
pq.poll() |
| Peek 堆頂 — O(1) | h[0] — 沒有 peek() |
pq.peek() |
| 先 pop 再 push(只做一次 sift) | heapq.heapreplace(h, v) |
pq.poll(); pq.offer(v); |
| 先 push 再 pop(只做一次 sift) | heapq.heappushpop(h, v) |
pq.offer(v); pq.poll(); |
| 最大/最小的前 k 個 | heapq.nlargest(k, it) / nsmallest |
大小為 k 的 min-/max-heap,最後倒出來 |
| 判斷是否為空 | if h: |
pq.isEmpty() |
| 自訂順序 | 元組鍵值,或在類別上定義 __lt__ |
comparator lambda |
三條規則可以避開大部分堆積的 bug
- 只有索引
0有意義。h[1]、h[-1],以及走訪 Java 的PriorityQueue,得到的都是 偏序,不是排序好的順序。 - 比較器要用
Integer.compare(a, b)/Long.compare(a, b),絕不要用a - b— 遇到很大的 數或負數時減法會溢位。 - 守住空的情況。
h[0]會丟IndexError;Java 的peek()回傳null,而element()會拋例外。在while條件裡把是否為空的判斷放最前面,才能短路。
# python
# the size-k idiom, written once
import heapq
def k_largest(nums, k): # time = O(N log k), space = O(k)
h = []
for v in nums:
if len(h) < k:
heapq.heappush(h, v)
elif v > h[0]: # peek, then replace in one sift
heapq.heapreplace(h, v)
return h # h[0] == the kth largest
def k_smallest(nums, k): # max-heap = negate on the way in and out
h = []
for v in nums:
heapq.heappush(h, -v)
if len(h) > k:
heapq.heappop(h)
return [-x for x in h] # -h[0] == the kth smallest
練習路線
| 階段 | 題目(易 → 難) | 增加了什麼 |
|---|---|---|
| 核心 ⭐ | LC 215 ok · LC 347 again · LC 253 ok · LC 621 未評定 · LC 23 again · LC 295 ok | 模板 1、2、6、8、3、4,各一次 |
| 練習 | LC 703 again · LC 1046 ok · LC 973 ok · LC 692 未評定 · LC 767 未評定 · LC 1438 again · LC 378 again | 跨呼叫保留的堆積、模擬、推導出的 key、反向決勝、連續長度上限、依索引過期、把矩陣當 k 個來源 |
| 延伸 | LC 1353 again · LC 502 未評定 · LC 480 未評定 · LC 632 未評定 · LC 358 未評定 | 每個事件一天、兩個池、還會刪除的中位數、k 個頭加上它們的最大值、冷卻佇列 |
總結與快速參考
決策表 — 該用哪一種堆積模式?
由上往下讀;第一個吻合的列就是該用的模式。T1–T8 連到上面的 card;其餘的列導向該列寫明的文件。
| 如果題目說… | 就用 | 堆積的形狀 | 經典 LC |
|---|---|---|---|
| 「第 k 大」/「第 k 小」 | T1 第 k 個元素 | 第 k 大用大小為 k 的 min-heap;第 k 小用大小為 k 的 max-heap | 215、703、378、1492、1985、1337 |
| 「出現最多的前 k 個」/「最近的 k 個」 | T2 Top K 頻率 | Counter → 依計數/距離排序、大小為 k 的堆積 |
347、692、973、658、451、1338、1054 |
| 「合併 k 個有序…」或一個有序的格子 | T3 合併 K 個來源 | 存 (value, sourceIdx, elemIdx) 的 min-heap |
23、373、378、632、786、1439 |
| 串流的「中位數」/「兩半平衡」 | T4 雙堆積系統 | 小的那一半用 max-heap + 大的那一半用 min-heap,兩者大小差不超過 1 | 295、480、1825 |
| 可變大小視窗且同時需要最大與最小 | T5 視窗極值(2 個堆積) | 兩個存 (value, index) 的堆積,index < left 即為過期 |
1438、1696、1499 |
| 「最少幾間會議室/幾組」— 數重疊 | T6 區間排程 | 依起點排序,結束時間放 min-heap,堆積大小就是答案 | 253、2406、1094 |
| 「每單位時間一件事」+ 截止期限 | T6 區間排程(逐日掃描) | 依起點排序,結束日放 min-heap,參加最早截止的 | 1353、1834、1705 |
| 「不能連續 k 個相同」/「相隔 k」 | T7 貪婪 + 限制,或 T8 優先佇列 + 冷卻佇列 | 依剩餘次數排序的 max-heap(+ 一條 k 格的延遲線) | 1405、767、621、358、1054 |
| 圖上的加權最短路徑 | Dijkstra — Dijkstra.md | 存 (distance, node) 的 min-heap |
743、787、1514、1631 |
| push 進去的值後來變動或過期 | 延遲刪除 — heap_advanced.md | 候選堆積 + 真值雜湊表;在讀取時清理堆頂 | 3092、2349、2034、480、1825、239 |
| 「在每個 x,求所有覆蓋 x 的區間之最大值」 | 掃描 + 存活堆積 — heap_advanced.md | 存 (value, end) 的 max-heap,end <= x 就驅逐 |
218、1851 |
| 「k 把梯子/k 次免費升級」+ 預算 | 有上限的後悔堆積 — heap_advanced.md | 上限為 k 的 min-heap;被擠掉的最小者改用預算支付 | 1642、1792 |
| 你要到後來才發現自己拿太多了 | 帶後悔的貪婪 — heap_advanced.md | 先全拿,再 poll() 掉最差的決定 |
871、630、502 |
| 「編號最小的空椅/伺服器/座位」 | 資源池 — heap_advanced.md | 依編號的空閒堆積 + 依釋放時間的忙碌堆積 | 1942、1606、1801、2073、2102 |
目標式是 sum(A) × max/min(B) |
排序 + 固定大小堆積 — heap_advanced.md | 依 B 排序,A 用大小為 k 的堆積 | 857、1383 |
| 格子圖上代價是 minimax 或累積型 | 格子圖最佳優先 — heap_advanced.md | 從邊界初始化的 min-heap,每次展開最便宜的 | 407、778、1631、1368、675 |
| 每個格子跳到一個範圍的格子 | 格子圖範圍跳躍 — heap_advanced.md | 每列一個、每行一個優先佇列,延遲 pop | 2617 |
| 「讓所有頻率互不相同」 | 頻率唯一化 — heap_advanced.md | max-heap 逐次遞減,或用已使用頻率的集合 | 1647、1481 |
| 固定大小視窗,而且只要最大值 | 不要用堆積 — monotonic_queue.md | 單調雙端佇列,攤還 O(1) | 239、1425 |
| 只問一次第 k 個元素,之後沒有更新 | 不要用堆積 — quickselect,平均 O(N) | — | 215 |
複雜度速查
| 操作 | 二元堆積 | 有序陣列 | 平衡 BST |
|---|---|---|---|
| 從 n 個元素建構 | O(n)(heapify) | O(n log n) | O(n log n) |
| 插入 | O(log n) | O(n) | O(log n) |
| 刪除最小/最大 | O(log n) | O(1) | O(log n) |
| 讀取最小/最大 | O(1) | O(1) | O(log n) |
| 搜尋/刪除任意元素 | O(n) | O(log n) / O(n) | O(log n) |
| 合併兩個結構 | O(n + m) | O(n + m) | O(n + m) |
| 空間 | O(n) | O(n) | O(n) |
面試時值得主動講出來的推論:
- 沒有 decrease-key,也不能移除任意元素。 heap_advanced.md 裡的所有技巧,存在的理由就是為了繞開這件事。
- 大小為 k 的堆積把
O(N log N)變成O(N log k),把O(N)空間變成O(k)。 - 另一端的極值是 O(n):min-heap 對它的最大值提供不了任何廉價資訊。
常見模式與技巧
Python 的 Max Heap(用取負數)
import heapq
# Create max heap by negating values
max_heap = [-x for x in nums]
heapq.heapify(max_heap)
# Insert into max heap
heapq.heappush(max_heap, -val)
# Get max value (remember to negate back)
max_val = -max_heap[0] # peek
max_val = -heapq.heappop(max_heap) # pop
搭配自訂物件的堆積
# Method 1: Using tuples (automatic comparison)
heap = []
heapq.heappush(heap, (priority, data))
# Method 2: Using custom class with __lt__
class Task:
def __init__(self, priority, data):
self.priority = priority
self.data = data
def __lt__(self, other):
return self.priority < other.priority
heap = []
heapq.heappush(heap, Task(1, "high priority"))
平手時的決勝
# python
# a heap of (key, payload) compares the payload when keys tie -> unorderable types crash.
# push a monotone counter as the second element so the comparison never reaches the payload.
import heapq, itertools
counter = itertools.count()
heapq.heappush(pq, (priority, next(counter), payload)) # FIFO among equal priorities
常見錯誤與提示
🚫 常見錯誤
- 堆積方向搞反。 「第 k 大」用大小為 k 的 min-heap;「第 k 小」用大小為 k 的 max-heap。動手寫之前先把不變量唸出來:「堆積裡放的是目前看過最大的 k 個,所以堆頂就是答案。」
- 忘記負回去 — 在 Python 用取負來假裝 max-heap 時,push 和 讀取兩邊都要處理。
- 讓堆積無限長大:在第 k 個元素的題目裡,一旦
len(h) > k就要 pop,否則你白白付了O(N log N)。 - 雙堆積系統失衡。 每一次插入之後都要重新確立
|len(small) - len(large)| <= 1, 而不是等到要讀中位數時才做。 - Java 裡用
a - b當比較器 — 會溢位。請用Integer.compare/Long.compare。 - 在寫入時清理過期資料。 延遲刪除是在讀取時清理堆頂,用
while(過期資料可能疊好幾筆), 而且要加上空值守衛。 - 讀
h[1]/h[-1]以為會拿到第二小或最大值。堆積只有偏序。
✅ 最佳實務
- 對既有串列用
heapify— O(N) 勝過 N × O(log N)。 - 反正都要換掉堆頂時,就用
heapreplace/heappushpop(只做一次 sift)。 - push 元組時把第一個元素當排序鍵;加一個計數器來決勝平手。
- 先考慮替代方案:靜態資料用排序、一次性的第 k 個用 quickselect、
固定大小視窗用單調雙端佇列、真的需要任意刪除時用
TreeMap/SortedList。 - 測
k == 1、k == n、空輸入,以及所有元素相同的情況。
面試提示
- 先問清楚:允許重複值嗎?
k > n可能嗎?是串流還是靜態資料?值會被更新嗎? - 說出模式名稱(用上面的決策表),然後講出堆積的不變量 — 面試官在聽的就是這一句話。
- 用 k 來表達複雜度,而不只是用 n:
O(N log k)時間、O(k)空間,正是堆積勝過排序的全部理由。 - 預期會被追問:「如果值會變動呢?」(延遲刪除)、「如果視窗是固定的呢?」(單調雙端佇列)、 「如果要重複詢問第 k 個元素呢?」(把堆積留著)。
相關主題
- Dijkstra.md — 最經典的優先佇列演算法;它的
if d > dist[u]: continue就是一次延遲刪除 - monotonic_queue.md — 固定大小視窗極值的 O(1) 替代方案
- sort.md — 堆積排序,以及什麼時候排序勝過堆積
- streaming_algorithms.md — 無界串流上的 top-k
- greedy.md — 讓後悔類模式成立的交換論證
- intervals.md / scanning_line.md — 區間排程家族中 不用堆積的另一半
各語言注意事項
- Python
heapq— 只有 min-heap;要 max-heap 就取負;heappush、heappop、heapify、heapreplace、heappushpop、nlargest、nsmallest、merge。 - Java
PriorityQueue— 預設是 min-heap;要 max-heap 就用new PriorityQueue<>(Collections.reverseOrder())或用Integer.compare建的比較器;offer、poll、peek、size。 - C++
priority_queue— 預設是 max-heap;要 min-heap 請用priority_queue<int, vector<int>, greater<int>>。