進階堆積技巧

樹與堆積優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值 更新於 Nov 12, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — 第一輪複習可以先跳過的堆積模式:延遲刪除、掃描線的「存活」堆積、後悔貪婪、資源池配置器、格子圖上的最佳優先搜尋,以及二元堆積之外的結構;六個必背模板留在母文件裡。 另見 — 母文件:heap.md — 標準的 top-k/k 路合併/雙堆積/區間排程模板與模式選擇指南。從同一份檔案拆出來的姊妹篇:heap_examples.md — 完整解過的 LC 題庫;heap_language_apis.md — heapq/PriorityQueue API 參考。鄰近文件:Dijkstra.md — 這些格子圖搜尋所特化的優先佇列最短路徑演算法;monotonic_queue.md — 什麼時候雙端佇列比延遲刪除堆積更好;streaming_algorithms.md — 無界串流上的 top-k。

LeetCode 題目清單

總覽

這裡每一個技巧的存在,都是因為二元堆積少了某個操作:它沒有 decrease-key、沒有移除任意元素,也沒辦法修改先前的決定。下面每個模式,都是用「只能 push、只能 pop 堆頂」來繞過這件事的不同做法。

關鍵性質

  • 複雜度:各模板分別標示;在攤還 pop 的前提下都是 O(n log n) 或 O(n log k)
  • 核心想法:永遠不要去搜尋堆積 — 直接 push 一筆新資料,而且只在讀取的時候清理堆頂
  • 什麼時候用:等到 heap.md 的模板已經變成反射動作之後;這些是 tier-4 的模式,也是「堆積題」和「困難堆積題」的分水嶺

題型分類

模式 題目敘述裡的特徵 代表題
延遲刪除 已經 push 進去的值後來被改掉或被移除 LC 3092、2349、480、1825
雙鏡像延遲堆積 對同一份「會被修正」的對照表同時要最大和最小值 LC 2034
掃描線 + 存活堆積 「在每個 x,求所有覆蓋 x 的區間之最大/最小值」 LC 218、1851
有上限的後悔堆積 k 次免費機會 + 其餘部分的預算 LC 1642、1792
帶後悔的貪婪 你要到後來才發現自己拿太多了 LC 871、630、502
資源池 配置編號最小的空閒資源,並在已知時間釋放 LC 1942、1606、1801、2073、2102
排序 + 固定大小堆積 目標式 = sum(A) × max/min(B) LC 857、1383
格子圖最佳優先 展開代價最小的格子,而不是最近的 LC 407、778、1631、1368、675
範圍跳躍的格子 DP 每個格子可以跳到一個範圍內的格子 LC 2617
頻率唯一化 用最少刪除次數讓所有頻率互不相同 LC 1647、1481
k 路合併的變形 合併虛擬或巢狀的有序來源 LC 632、1439

先備知識

下面每個技巧都是延伸 heap.md 裡的某張卡片;請先把那張卡片練熟。

  • heap § 模板 1:大小為 k 的堆積。有上限的後悔堆積(§ 3)和排序 + 固定大小堆積(§ 6)就是把這個堆積放進貪婪裡跑。
  • heap § 模板 2:先計數,再對計數用堆積。計數在 push 之後還會變時,它就變成延遲刪除(§ 1);頻率唯一化(§ 9)是它的一個實例。
  • heap § 模板 3:k 路合併。§ 11 的變形只改動前緣周圍的簿記。
  • heap § 模板 5:依索引過期、只在到達堆頂時才 pop 的資料。延遲刪除(§ 1、§ 12)、存活堆積(§ 2)和範圍跳躍格子(§ 8)都是把這個過期判定推廣。
  • heap § 模板 6:結束時間的 min-heap。資源池(§ 5)再加一個存空閒 id 的堆積;存活堆積(§ 2)依值排序、依結束點過期。
  • Dijkstra.md:格子最佳優先搜尋(§ 7)在隱式格子上跑的優先佇列最短路徑。

辨識

只有在核心模板的迴圈本身不夠時才用這份文件:push 進去的值會變、某個決定必須修改,或堆積需要第二個 key。

題目敘述中的訊號 限制條件線索 該用 不適用時
已經在堆積裡的值後來會改變或被移除 10⁵ 次混合的更新與查詢 § 1 延遲刪除 值只會變大、而且最後只讀一次最大值 → 單純計數
對一份值會被覆寫的對照表同時求「最大與最小」 10⁵ 次呼叫,讀寫交錯 § 1 兩個鏡像的延遲堆積 可以用且被要求用 sortedcontainers/TreeMap → 有序多重集合
「在每個 x,涵蓋 x 的區間中最高/最低的」 N ≤ 10⁴–10⁵ 個區間 § 2 掃描線 + 存活堆積 只問涵蓋 x 的數量 → 差分陣列/heap § 模板 6
「k 把梯子/k 次免費」,其餘從預算支付 N ≤ 10⁵ § 3 有上限的後悔堆積 沒有預算,只要最大的 k 個 → heap § 模板 1
「最少停靠次數」、「最多修幾門課」,要事後才知道自己拿太多 N ≤ 10⁴–10⁵ § 4 帶後悔的貪婪 每個項目各自放得進自己的區間 → 排序 + 一般貪婪
「最小的空椅子/伺服器」,資源在已知時間釋放 N ≤ 10⁵ 個事件 § 5 資源池 只問需要幾個資源 → heap § 模板 6
目標式 sum(選中 k 個的 A) × max/min(B) n ≤ 10⁵,k ≤ n § 6 排序 + 固定大小堆積 目標式只有一個屬性 → heap § 模板 1
格子,下一個要展開的是最低/最便宜的,而不是最近的 m·n ≤ 10⁴–10⁵ § 7 格子最佳優先 每步代價相同 → BFS
每個格子可跳到同列/同欄的一段範圍 m·n ≤ 10⁵ § 8 範圍跳躍格子 只能跳到單一鄰居 → BFS

模板與演算法

1) 延遲刪除 — 堆積 + 記錄真值的雜湊表 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

核心想法

二元堆積能用 O(log n) 做 push/pop-top,但不能「更新某個元素」或「移除某個任意元素」— 光是找到它就要 O(n)。

所以當某個元素的鍵值變了,我們完全不去動舊的那筆。改成這樣:

text
HEAP    = a bag of CANDIDATES   (some are stale / outdated)
HASHMAP = the SOURCE OF TRUTH   (current real value of each key)

一筆 (value, key) 是過期的,若且唯若 value != hashmap[key]。我們從不主動去獵捕過期資料;只清理堆頂,而且只在讀取時清:

python
# the whole pattern in 3 lines
c_map[key] = new_value                          # 1. update truth
heapq.heappush(pq, (-new_value, key))           # 2. push new candidate (old one stays!)
while pq and -pq[0][0] != c_map[pq[0][1]]:      # 3. pop stale tops until top is valid
    heapq.heappop(pq)

只 pop 到堆頂有效為止,然後停。 其他所有過期資料都原封不動地留在堆積裡 — 它們可能永遠不會被 pop 出來。清理是延遲的:只為真正擋住答案的那些資料付出代價。

text
pq (max-heap by count):  [5:A]  [4:B]  [3:A]  [2:C]  [1:B]  ...
                           ^stale (A is really 3 now)
                           |
           pop it ────────┘, now top = [4:B]
                                         ^ valid? -> STOP. Done.
                                           [3:A], [1:B] stay stale in the heap forever
                                           (or until they bubble to the top someday)

為什麼這樣是對的?

  • 如果堆頂是有效的,它就是真正的最大值 — 堆積裡其他資料都 ≤ 它,而且任何鍵的真值都不可能超過它自己最近一次 push 進去的那筆(那筆就在堆積裡)。
  • 如果堆頂是過期的,那個鍵的真值一定存在堆積的別處(更新時我們有 push 進去),所以丟掉這份過期副本不會損失任何東西。

為什麼這樣夠快?

  • 每次 push 最多產生一筆資料,而每筆資料在整趟執行中最多被 pop 一次 → 總 pop 次數 ≤ 總 push 次數 = O(n)。
  • 每個操作攤還 O(log n);堆積大小上界為 O(n)。

Python 模板

python
# python
# IDEA: heap holds stale candidates; hashmap holds truth; clean top lazily
import heapq

class LazyMaxTracker:
    def __init__(self):
        self.truth = {}   # key -> current real value  (SOURCE OF TRUTH)
        self.pq = []      # max-heap of (-value, key)  (CANDIDATES, may be stale)

    def update(self, key, delta):
        # 1. update the truth
        self.truth[key] = self.truth.get(key, 0) + delta
        # 2. push new candidate -- do NOT remove the old entry
        heapq.heappush(self.pq, (-self.truth[key], key))

    def top(self):
        # 3. lazy delete: pop stale tops ONLY until the top is valid
        while self.pq and -self.pq[0][0] != self.truth[self.pq[0][1]]:
            heapq.heappop(self.pq)
        return -self.pq[0][0] if self.pq else 0

Java 模板

java
// java
// IDEA: PriorityQueue of stale candidates + HashMap of truth
class LazyMaxTracker {
    Map<Integer, Long> truth = new HashMap<>();               // key -> real value
    // max-heap by value: long[]{value, key}
    PriorityQueue<long[]> pq =
        new PriorityQueue<>((a, b) -> Long.compare(b[0], a[0]));

    public void update(int key, long delta) {
        long v = truth.getOrDefault(key, 0L) + delta;
        truth.put(key, v);
        pq.offer(new long[]{v, key});                          // old entry left behind
    }

    public long top() {
        // pop stale tops only until the top is valid, then stop
        while (!pq.isEmpty() && pq.peek()[0] != truth.get((int) pq.peek()[1])) {
            pq.poll();
        }
        return pq.isEmpty() ? 0 : pq.peek()[0];
    }
}

「過期」的三種口味 — 挑一個符合題目的失效判定:

口味 對 pq[0] 的過期判定 典型題目
值不符(雜湊表存真值) -pq[0][0] != c_map[pq[0][1]] LC 3092 Most Frequent IDs
刪除集合/計數器 pq[0] in removed(然後遞減) LC 480 Sliding Window Median、LC 1825 MK Average
因時間/索引而過期 pq[0].end < day 或 pq[0].idx <= i - k LC 1353 Max Events、LC 239 Sliding Window Max

容易踩到的坑

  • ⚠️ 預設在讀取時清。 只要「過期與否」完全由真值對照表決定,每次 push 完就清其實是安全的 — 但在讀取很少時它就是白做工,而且對因時間而過期的口味根本沒有定義,因為那時是查詢在決定什麼東西過期了。當讀取遠多於寫入時,它才變成一個換取 O(1) 讀取的刻意取捨;見LC 2034 的變形。
  • ⚠️ 用 while,不是 if。 好幾筆過期資料可能疊在一起。
  • ⚠️ 在 while 條件裡以及讀 pq[0] 前,都要檢查 pq 非空 — 這個集合是有可能合法地變空的(LC 3092 範例 2 → 答案 0)。
  • ⚠️ 不要試圖去刪掉舊的那筆。 那是 O(n) 搜尋,整個做法的意義就沒了。
  • ⚠️ 就算相異的鍵只有幾個,堆積仍可能長到 O(n) 筆 — 這就是你用空間換速度付出的代價。

實例 — LC 3092 Most Frequent IDs

nums[i] 是一個 ID,freq[i] 加入(若為負則移除)那麼多個它的複本;每一步之後回報最頻繁 ID 的計數,集合為空時回報 0。普通堆積行不通,因為 ID 的計數在 push 之後還會變:當 2 的計數從 3 掉到 0,(-3, 2) 這筆資料埋在堆積裡,無法用 O(log n) 找到。上面的模板就是完整答案:

text
nums = [2,3,2,1],  freq = [3,2,-3,1]

step 0: c_map={2:3}          push(-3,2)   top=(-3,2) valid           -> ans 3
step 1: c_map={2:3,3:2}      push(-2,3)   top=(-3,2) valid           -> ans 3
step 2: c_map={2:0,3:2}      push( 0,2)   top=(-3,2) STALE (2 is 0)  -> pop
                                          top=(-2,3) valid -> STOP   -> ans 2
                                          ( (0,2) still sits in pq, untouched )
step 3: c_map={2:0,3:2,1:1}  push(-1,1)   top=(-2,3) valid           -> ans 2

output = [3,3,2,2]

第 2 步恰好只 pop 一筆,也就是擋住答案的那一筆,其他所有過期資料都留在原地。

python
# python
# LC 3092 - Most Frequent IDs
# IDEA: hashmap of true counts + max-heap of (-count, id) candidates; pop stale tops at read time
# time = O(n log n), space = O(n)
import heapq

def mostFrequentIDs(nums, freq):
    c_map = {}   # id -> TRUE frequency
    pq = []      # max-heap of (-frequency, id), possibly stale
    ans = []
    for val, cnt in zip(nums, freq):
        c_map[val] = c_map.get(val, 0) + cnt         # 1. update the truth
        heapq.heappush(pq, (-c_map[val], val))       # 2. push; the old entry stays behind
        while pq and -pq[0][0] != c_map[pq[0][1]]:   # 3. pop stale tops only
            heapq.heappop(pq)
        ans.append(-pq[0][0] if pq else 0)           # 4. the top is now accurate
    return ans
java
// java
// LC 3092 - Most Frequent IDs
// IDEA: HashMap of true counts + max-heap of {count, id} candidates; poll stale tops at read time
// time = O(n log n), space = O(n)
public long[] mostFrequentIDs(int[] nums, int[] freq) {
    Map<Integer, Long> cMap = new HashMap<>();          // id -> TRUE frequency
    PriorityQueue<long[]> pq = new PriorityQueue<>((a, b) -> Long.compare(b[0], a[0]));
    int n = nums.length;
    long[] ans = new long[n];
    for (int i = 0; i < n; i++) {
        int val = nums[i];
        long cur = cMap.getOrDefault(val, 0L) + freq[i];
        cMap.put(val, cur);                              // 1. update the truth
        pq.offer(new long[]{cur, val});                  // 2. the old entry stays behind
        while (!pq.isEmpty() && pq.peek()[0] != cMap.get((int) pq.peek()[1])) {
            pq.poll();                                   // 3. drop stale tops only
        }
        ans[i] = pq.isEmpty() ? 0 : pq.peek()[0];        // 4. read the answer
    }
    return ans;
}

陷阱

  • 集合可能變空(nums=[5,5,3], freq=[2,-2,1] → [2,0,1]):5 歸零時 push 進去的 (0, 5) 是有效的(0 == c_map[5]),答案正確地是 0。push 計數為零的資料無害,還能讓判定保持一致。
  • 計數可達 n · max(freq) = 10¹⁰:Java 要用 long,否則 int 會溢位。

相似題 — 同樣的三個步驟

題目 LC # 什麼會過期 過期判定
Design a Number Container System 2349 某個索引被指定了新數字 該索引目前的數字 != 這個數字
Stock Price Fluctuation 2034 某個時間戳的價格被修正,而且是在兩個堆積裡 price != price_map[ts]
Sliding Window Median 480 某個元素滑出了視窗 val in removed 計數器(§ 12)
Finding MK Average 1825 某個元素離開了最後 m 個的串流 刪除集合/多重集合
Maximum Number of Events 1353 某個事件的截止日已過 pq[0] < day
Minimum Number of Visited Cells in a Grid 2617 某個格子已經到不了掃描位置 每列/每欄一個 PQ(§ 8)
Dijkstra(743、1631、778) — 之後找到了更短的路徑 d > dist[node]: continue

Dijkstra 的 if d > dist[u]: continue 這一行就是延遲刪除:它丟掉一筆過期的距離資料, 而不是對堆積做 decrease-key。

變形 — 兩個鏡像的延遲堆積共用一份對照表(LC 2034) 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

差別在哪。 上面那個模板追蹤的是「值會累加」的對照表的單一極值。LC 2034 Stock Price Fluctuation 一次打破它的兩個前提:

模板 1 假設 LC 2034 實際給你的
只問一個極值 同時要 maximum() 和 minimum(),而且是同一份對照表
值是累加的 — truth[k] += delta 值是被覆寫的 — 後面的紀錄會修正某個已經出現過的時間戳的價格
讀取極值的次數不多 10^5 次混合呼叫,讀寫交錯

會殺死樸素堆積的正是這個「修正」:update(1, 3) 之後,還躺在最大堆積裡的 (-10, 1) 是一個再也不存在的價格,而你沒有辦法伸手進去把它拿掉。

核心想法 — 一份真值對照表、兩個鏡像的堆積,而且兩邊用同一個過期判定:

text
price_map[ts] = price      <- SOURCE OF TRUTH (one per timestamp, overwritten on correction)
max_heap: (-price, ts)     <- CANDIDATES for maximum()
min_heap: ( price, ts)     <- CANDIDATES for minimum()

an entry is STALE  iff  its price != price_map[its ts]

current() 根本用不到堆積 — 把 max_time 當成一路取最大值來維護,然後直接查對照表,O(1)。

要在哪裡清 — 這才是這題真正的決策點,而且兩個答案在這裡都是正確的:

在讀取時清(maximum()/minimum()) 在寫入時清(update 的最後)
update O(log n) 最壞情況 O(log n) 攤還
maximum/minimum O(log n) 攤還 O(1) 最壞情況 — 堆頂永遠是有效的
何時安全 永遠 「過期與否」是對照表的純函數
何時該選 讀取很少,或由查詢決定什麼叫「過期」 讀取為主,或你想要平穩的讀取延遲

寫入時清在這裡可行,只因為 price_map 是唯一能讓某筆資料過期的東西,而且只有 update 會碰它。換成因時間而過期的口味,是查詢在決定什麼東西過期了,寫入時根本沒有東西可清。

Python

python
# python
# LC 2034 - Stock Price Fluctuation
# IDEA: one hashmap of truth + two mirrored lazy heaps; clean both tops inside update()
# time = update O(log n) amortised, current/maximum/minimum O(1), space = O(n)
import heapq

class StockPrice(object):
    def __init__(self):
        self.price_map = {}    # timestamp -> CURRENT price   (SOURCE OF TRUTH)
        self.max_time = 0      # biggest timestamp seen  -> current() is O(1)
        self.max_heap = []     # (-price, timestamp)     (CANDIDATES, may be stale)
        self.min_heap = []     # ( price, timestamp)

    def update(self, timestamp, price):
        # 1. overwrite the truth -- this is what makes old heap entries stale
        self.price_map[timestamp] = price
        self.max_time = max(self.max_time, timestamp)

        # 2. push into BOTH heaps; never remove the old entries
        heapq.heappush(self.max_heap, (-price, timestamp))
        heapq.heappush(self.min_heap, (price, timestamp))

        # 3. clean both tops: pop only while the top disagrees with price_map.
        #    No emptiness guard: the entry just pushed is valid, so neither heap can empty.
        while -self.max_heap[0][0] != self.price_map[self.max_heap[0][1]]:
            heapq.heappop(self.max_heap)
        while self.min_heap[0][0] != self.price_map[self.min_heap[0][1]]:
            heapq.heappop(self.min_heap)

    def current(self):
        return self.price_map[self.max_time]

    def maximum(self):
        return -self.max_heap[0][0]   # top is clean -> O(1)

    def minimum(self):
        return self.min_heap[0][0]

把那兩個 while 迴圈搬進 maximum()/minimum(),就是讀取時清的寫法 — 同樣的堆積、同樣的判定,只是把攤還成本挪到另一邊。這時 while ... and ... 的非空守衛就變成必要的,因為讀取有可能在任何一次清理之前就被呼叫。

Java

java
// java
// LC 2034 - Stock Price Fluctuation
// IDEA: HashMap of truth + two mirrored lazy heaps; both tops cleaned inside update()
// time = update O(log n) amortised, current/maximum/minimum O(1), space = O(n)
class StockPrice {
    private final Map<Integer, Integer> priceMap = new HashMap<>();   // ts -> CURRENT price
    private int maxTime = 0;
    // both heaps hold {price, timestamp}; only the ordering differs
    private final PriorityQueue<int[]> maxHeap =
        new PriorityQueue<>((a, b) -> Integer.compare(b[0], a[0]));
    private final PriorityQueue<int[]> minHeap =
        new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));

    public void update(int timestamp, int price) {
        priceMap.put(timestamp, price);
        maxTime = Math.max(maxTime, timestamp);
        maxHeap.offer(new int[]{price, timestamp});
        minHeap.offer(new int[]{price, timestamp});

        // clean BOTH tops; the entry just pushed is valid, so neither heap can empty
        while (maxHeap.peek()[0] != priceMap.get(maxHeap.peek()[1])) maxHeap.poll();
        while (minHeap.peek()[0] != priceMap.get(minHeap.peek()[1])) minHeap.poll();
    }

    public int current() { return priceMap.get(maxTime); }
    public int maximum() { return maxHeap.peek()[0]; }
    public int minimum() { return minHeap.peek()[0]; }
}

逐步追蹤 — 官方範例,看清楚那次修正實際只花了一次 pop:

text
update(1,10)  price_map={1:10}        max_heap=[(-10,1)]                 max=10  min=10
update(2, 5)  price_map={1:10, 2:5}   max_heap=[(-10,1),(-5,2)]          max=10  min=5
update(1, 3)  price_map={1:3,  2:5}   top (-10,1): price_map[1]=3 != 10  -> STALE, pop
                                      top  (-5,2): price_map[2]=5 == 5   -> valid, STOP
                                      max_heap=[(-5,2),(-3,1)]           max=5   min=3
update(4, 2)  price_map={1:3,2:5,4:2} max_heap=[(-5,2),(-3,1),(-2,4)]    max=5   min=2

min_heap still holds the stale (10,1) from step 1 — it is below (2,4),(3,1),(5,2),
so nothing ever asks about it. That is the point: it is never paid for.

容易踩到的坑

  • ⚠️ 不要用時間戳判斷過期。 (-10, 1) 和 (-3, 1) 的時間戳是一樣的;只有拿價格去跟對照表比,才分得出誰是誰。
  • ⚠️ current() 不是 maximum() 的鄰居。 它是最大時間戳上的價格,不是最後 push 進去的價格 — 對一個舊時間戳做修正不能讓它動。要用 max_time = max(max_time, timestamp) 維護,而且永遠不能讓它變小。
  • ⚠️ 兩個堆積,就要清兩次。 一次修正會同時讓兩邊鏡像裡的資料失效;只清你正要讀的那一邊,另一邊下次被呼叫時就是錯的。
  • ⚠️ 價格沒變也照樣 push,會多出一筆重複資料。這無害(兩份都是有效的),但這也是為什麼堆積大小是 O(更新次數) 而不是 O(時間戳個數)。

另一條路 — 用有序多重集合(TreeMap<price, count>/SortedList)可以當場把舊價格刪掉,於是沒有任何一筆資料會過期,maximum() 就只是 lastKey()。代價是每次更新都要付一次真正的 O(log n) 刪除,而且需要一個 heapq 沒有的結構;見 design_examples.md § Ordered Map。面試時先講兩個延遲堆積 — 那才是不靠 sortedcontainers 就能用 Python 寫出來的版本。

相似題 — 鏡像/覆寫這一味

題目 LC # 真值對照表 一次寫入會讓什麼失效
Stock Price Fluctuation 2034 timestamp -> price 那個時間戳的舊價格,而且是兩個堆積裡都有
Design a Number Container System 2349 index -> number 那個索引上的舊數字
Most Frequent IDs 3092 id -> frequency 那個 id 先前的頻率
Maximum Frequency Stack 895 value -> count 沒有 — 計數只會增加,所以根本不需要延遲刪除
Sliding Window Median 480 刪除計數器 滑出視窗的那個元素
LRU / LFU Cache 146 / 460 key -> node 這也是為什麼它們用雙向串列而不是堆積:它們要的是 O(1),不是 O(log n)

堆積之外的延遲刪除 — 同一個想法在其他結構裡 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

延遲刪除不是堆積的小技巧。它是針對某一種處境的通用解法,而且一旦你能叫出它的名字,就會在你每天用的系統裡看到一半以上都是它:

這個結構的不變量「修復起來很貴,但檢查起來很便宜」。 那就別在寫入時修復 — 打個記號,讀取時再檢查。

每一個實例都在回答同樣的三個問題:

  1. 用什麼標記一筆資料已死? 墓碑(tombstone)、計數器、更新的版本,或是「跟另一份真值來源對不起來」。
  2. 誰負責檢查? 讀取路徑 — 一定是它,而且必須在信任讀到的東西之前先檢查。
  3. 誰負責回收空間? 不是踩到它的那次讀取,就是背景的壓實(compaction)。
結構 它做不起的刪除 留下了什麼 讀取時的有效性判定 誰來回收
二元堆積 移除任意元素/decrease-key(光是找到它就 O(n)) 被取代掉的那筆 (value, key) value != truth[key] 剛好讀到堆頂的那次讀取
開放定址雜湊表 刪除 — 把一個槽清空會切斷所有經過它的探測鏈 一個墓碑槽,和 EMPTY 不同 探測時跳過墓碑,只在 EMPTY 停下 把墓碑也算進負載因子之後的重新雜湊
BST/字典樹 有兩個子節點的刪除(要接上後繼節點、再平衡) 節點還在,只是掛上 deleted 旗標 走訪時忽略被標記的節點 被標記的超過一半時整棵重建
Dijkstra 的優先佇列 對「找到更短路徑」做 decrease-key 舊的那組 (dist, node) if d > dist[u]: continue pop 本身
LSM-tree(RocksDB、Cassandra、HBase) 原地刪除 — SSTable 是不可變的 一筆時間戳較新的墓碑紀錄 合併讀取時,同一個鍵最新的版本勝出 compaction
MVCC 資料庫(PostgreSQL) 原地更新 — 併行的讀取者還需要舊的那列 舊的列版本,被標記為已死 快照可見性檢查 VACUUM
Redis 鍵過期 TTL 一到就掃過每一個鍵 過期的鍵,還留在字典裡 這個鍵被碰到時才檢查 TTL 那次存取,加上一個抽樣輪詢
追蹤式 GC 在 x = null 當下就知道沒有別人指向它 那個無法到達的物件 從 root 出發的可達性 GC 週期

所以 Dijkstra 裡的 if d > dist[u]: continue 和雜湊表裡的 DELETED 標記是同一個想法,而 Cassandra 的墓碑就是同一個想法放大到磁碟的規模。用的詞不一樣(「過期資料」、「墓碑」、「死列」、「垃圾」),形狀完全一樣。

兩個常被混為一談的東西

  • 線段樹的懶惰標記(lazy propagation)延後的是工作,不是移除 — 掛在節點上的一個待處理 +v,等查詢往下走時再推下去。那個延後的更新最終會被套用到每一個子孫;墓碑則是永遠不會被套用到任何東西上。同一個形容詞,相反的生命週期。見 segment_tree.md。
  • 單調佇列(LC 239)是它積極的對照組:它在寫入時就把被支配的元素彈掉,而且它做得到,因為「被支配」是永久的 — 一個比更新的元素還小的值永遠沒用了。只有在你無法證明這件事時才用延遲刪除;能證明的時候,積極清理嚴格地更好(O(1),而且不浪費空間)。見 monotonic_queue.md。

老實說它的代價

  • 是攤還,不是最壞情況。 總 pop 次數 ≤ 總 push 次數,所以平均是 O(log n) — 但某一次倒楣的讀取可能要連續彈掉一長串過期堆頂。如果題目要的是延遲上界而不是吞吐量上界,就要講出來,並提出有序多重集合(或上面的寫入時清理)。
  • 空間就是你付的錢。 在有東西來回收之前,容器裡會留著寫進去的每一個版本;這正是為什麼一個刪除量大的 Cassandra 資料表會一路劣化到 compaction 跑起來為止,也是為什麼一個塞滿墓碑的雜湊表在還沒真的裝滿之前就先變慢了。
  • 讀取變得不再單純。 現在只剩讀取路徑在保證正確性,所以一個忘了做檢查的讀取者,得到的是一個安靜的錯誤答案,而不是一個崩潰。

2) 掃描線 + 「存活」區間的最大堆積 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

核心想法

這裡把堆積當成目前存活值的多重集合。我們沿著座標由左往右掃;每遇到一個事件,就插入剛剛開始存活的值、延遲淘汰區間已經結束的值,然後讀 heap[0] = 目前所有存活值裡的極值。

text
HEAP  = (value, endCoordinate)   sorted by value
ALIVE = heap entries with endCoordinate > sweepPosition

區間結束時你不會去刪它 — 而是等它浮到堆頂、且 end <= pos 時才刪。這就是模板 8 的延遲刪除想法,只是把過期判定換成**「依座標過期」**。

辨認特徵:「在每個 x,所有覆蓋 x 的區間的最大/最小值是多少?」

實例 — LC 218 The Skyline Problem

每棟建築 [L, R, H] 在 [L, R) 上存活。天際線只有在最大存活高度變化時才改變,所以:掃過排序後的事件 x 座標,維護一個 (H, R) 的最大堆積,只要堆頂高度和上一次輸出的高度不同就輸出一個關鍵點。

python
# python
# LC 218 - The Skyline Problem
# time = O(N log N), space = O(N)
# IDEA: sweep x; max-heap of (height, end); lazy-pop buildings whose end <= x; emit on height change
import heapq

class Solution(object):
    def getSkyline(self, buildings):
        # start event: (L, -H, R)   |   end event: (R, 0, 0)
        # sorting on (-H) puts starts before ends at the same x,
        # and taller starts before shorter starts
        events = [(L, -H, R) for L, R, H in buildings]
        events += list({(R, 0, 0) for _, R, _ in buildings})
        events.sort()

        res = [[0, 0]]                      # sentinel: ground level
        live = [(0, float('inf'))]          # max-heap of (-H, end); ground never expires

        for x, negH, R in events:
            # 1) LAZY EVICT: drop every alive entry that already ended
            while live[0][1] <= x:
                heapq.heappop(live)
            # 2) INSERT: only start events carry a height
            if negH:
                heapq.heappush(live, (negH, R))
            # 3) READ: top of heap = current skyline height
            if res[-1][1] != -live[0][0]:
                res.append([x, -live[0][0]])

        return res[1:]
java
// java
// LC 218 - The Skyline Problem
// time = O(N log N), space = O(N)
// IDEA: sweep x; max-heap of {height, end}; lazy-pop ended buildings; emit on height change
public List<List<Integer>> getSkyline(int[][] buildings) {
    List<int[]> events = new ArrayList<>();
    for (int[] b : buildings) {
        events.add(new int[]{b[0], -b[2], b[1]});   // start: NEGATIVE height
        events.add(new int[]{b[1], 0, 0});          // end marker
    }
    // same x -> starts (neg) before ends (0); taller start first
    events.sort((a, b) -> a[0] != b[0] ? Integer.compare(a[0], b[0])
                                       : Integer.compare(a[1], b[1]));

    // max-heap of {height, end}; ground sentinel never expires
    PriorityQueue<int[]> live = new PriorityQueue<>((a, b) -> Integer.compare(b[0], a[0]));
    live.offer(new int[]{0, Integer.MAX_VALUE});

    List<List<Integer>> res = new ArrayList<>();
    int prevH = 0;

    for (int[] e : events) {
        int x = e[0];
        // 1) LAZY EVICT  (size > 1 protects the sentinel: R can be 2^31 - 1 == Integer.MAX_VALUE)
        while (live.size() > 1 && live.peek()[1] <= x) live.poll();
        // 2) INSERT
        if (e[1] < 0) live.offer(new int[]{-e[1], e[2]});
        // 3) READ
        int curH = live.peek()[0];
        if (curH != prevH) {
            res.add(Arrays.asList(x, curH));
            prevH = curH;
        }
    }
    return res;
}

為什麼要放地面哨兵 (0, ∞)? 它保證堆積永遠不空,所以 live[0] 隨時可讀 — 當最後一棟建築結束時,堆頂變成高度 0,我們就能正確輸出「天際線落回地面」的關鍵點。⚠️ 在 Java 中哨兵的 end 是 Integer.MAX_VALUE,而 LC 218 允許真實的 R 剛好等於它(0 <= left < right <= 2^31 - 1),所以淘汰迴圈必須加上 live.size() > 1 的保護 — 否則哨兵會被 pop 掉,下一次 peek() 就會在空堆積上 NPE。Python 的 float('inf') 則不需要保護。

容易踩到的坑

  • ⚠️ 事件的同分處理就是整題的重點。 在同一個 x 上,先處理開始、再處理結束(否則一棟建築剛好在另一棟結束處開始,就會產生假的凹陷),而且較高的開始要排在較矮的開始前面(否則會冒出假的階梯)。
  • ⚠️ 結束事件要去重(set(...)),或乾脆接受重複 — 重複是無害的,因為輸出高度不會變兩次。
  • ⚠️ 只有在高度真的改變時才輸出,否則會吐出多餘的關鍵點。

變形 — LC 1851 Minimum Interval to Include Each Query:用離線方式回答查詢。把區間依 left 排序、查詢依大小遞增排序;對每個查詢 q,把所有 left <= q 的區間以區間長度 (right-left+1, right) 為鍵推進最小堆積,接著在 heap[0].right < q 時延遲 pop。堆頂就是覆蓋 q 的最小區間。一樣是插入、延遲淘汰、讀取這三步,只是掃描由查詢驅動,而不是由 x 座標驅動。

3) 有上限的「後悔」堆積 — 留下最好的 k 個,其餘照付 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

核心想法

你有 k 次免費機會(梯子、VIP 名額、一次性折扣)加上其餘部分的預算,而且必須在還沒看到未來成本前就線上做決定。訣竅是:

text
Optimistically give EVERY cost a free pass.
Keep a MIN-HEAP of the costs currently holding a pass, capped at size k.
When the heap overflows -> the SMALLEST pass-holder is evicted and paid from the budget.

任何時刻堆積裡剛好是目前為止最大的 k 個成本 — 這正好就是那段已處理前綴上,k 次免費機會的最佳分配。完全不需要回溯。

對照 LC 630 Course Schedule III(最大堆積替換:超支時淘汰最大的項目)— 一樣是「先承諾再後悔」的想法,比較器方向相反。這裡我們淘汰最小的,因為把免費機會用在便宜的項目上是浪費。

實例 — LC 1642 Furthest Building You Can Reach

每個往上的落差 d = heights[i+1] - heights[i] > 0,要嘛用一把梯子,要嘛用 d 塊磚。

python
# python
# LC 1642 - Furthest Building You Can Reach
# time = O(N log L), space = O(L)   (L = ladders)
# IDEA: give every climb a ladder; when > L ladders are in use, downgrade the SMALLEST to bricks
import heapq

class Solution(object):
    def furthestBuilding(self, heights, bricks, ladders):
        ladder_jumps = []   # min-heap of the climbs currently using a ladder

        for i in range(len(heights) - 1):
            d = heights[i + 1] - heights[i]
            if d <= 0:
                continue                       # going down / flat is free

            heapq.heappush(ladder_jumps, d)    # optimistically use a ladder

            if len(ladder_jumps) > ladders:
                # the smallest climb loses its ladder and is paid with bricks
                bricks -= heapq.heappop(ladder_jumps)
                if bricks < 0:
                    return i                   # stuck standing on building i

        return len(heights) - 1
java
// java
// LC 1642 - Furthest Building You Can Reach
// time = O(N log L), space = O(L)   (L = ladders)
// IDEA: min-heap of climbs holding a ladder, capped at L; evicted (smallest) climb costs bricks
public int furthestBuilding(int[] heights, int bricks, int ladders) {
    PriorityQueue<Integer> ladderJumps = new PriorityQueue<>();   // min-heap

    for (int i = 0; i + 1 < heights.length; i++) {
        int d = heights[i + 1] - heights[i];
        if (d <= 0) continue;

        ladderJumps.offer(d);

        if (ladderJumps.size() > ladders) {
            bricks -= ladderJumps.poll();       // smallest climb downgraded to bricks
            if (bricks < 0) return i;
        }
    }
    return heights.length - 1;
}

容易踩到的坑

  • ⚠️ 磚塊用完時,要回傳 i(你正站著的那棟樓),不是 i+1。
  • ⚠️ ladders == 0 也必須能跑:堆積在每次攀爬時立刻溢位,所以每次攀爬都用磚塊付 — 不需要特例處理。
  • ⚠️ 非正的 d 要在 push 之前就跳過,否則零或負的攀爬會白白佔用梯子。

變形 — LC 1792 Maximum Average Pass Ratio:堆積的鍵是邊際增益,不是原始值。把每個班級的 (pass+1)/(total+1) - pass/total 推進最大堆積;每個額外學生都分配給增益最大的班級,然後該班級用更新後的增益重新 push 回去。關鍵洞見:每個班級的增益單調遞減,所以貪婪地取堆頂就是最佳解。

4) 帶後悔的貪婪 — 撤銷過去最糟的決定 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

什麼時候用:你必須一路往前掃並取走項目,而且要到後來才發現自己拿太多/拿太重。先樂觀地全部拿走,把拿過的東西放進堆積,等到超出預算時就撤銷目前為止最糟的那個決定(poll())。形式上這是一個交換論證 — 換成最好的那個被延後的項目,永遠不會變差。

java
// java
// LC 871 - Minimum Number of Refueling Stops
// IDEA: drive as far as fuel allows, pushing every passed station's fuel into a MAX-heap
//       ("I could have stopped there"); when stuck, retroactively refuel at the biggest one
// time = O(n log n), space = O(n)
public int minRefuelStops(int target, int startFuel, int[][] stations) {
    PriorityQueue<Integer> pq = new PriorityQueue<>(Collections.reverseOrder());
    int fuel = startFuel, i = 0, stops = 0;

    while (fuel < target) {
        // every station within reach becomes a "regret option"
        while (i < stations.length && stations[i][0] <= fuel) {
            pq.offer(stations[i][1]);
            i++;
        }
        if (pq.isEmpty()) return -1;    // no option left -> unreachable
        fuel += pq.poll();              // retroactively take the biggest tank
        stops++;
    }
    return stops;
}
python
# python
# LC 871 - Minimum Number of Refueling Stops
# IDEA: max-heap of fuel at already-passed stations; refuel from it only when stuck
# time = O(n log n), space = O(n)
import heapq

def minRefuelStops(target, startFuel, stations):
    pq = []                              # max-heap via negation
    fuel, i, stops = startFuel, 0, 0

    while fuel < target:
        while i < len(stations) and stations[i][0] <= fuel:
            heapq.heappush(pq, -stations[i][1])
            i += 1
        if not pq:
            return -1
        fuel -= heapq.heappop(pq)        # -(-max) => add the largest tank
        stops += 1

    return stops

LC 871 的第二種解法 — 對停靠次數做 DP,O(n²)。 reach[t] 是恰好停 t 次能到達的最遠位置; 第 i 站可以延伸每一個已經涵蓋到它的 t。它不需要交換論證,這正是它的用處:如果你無法證明 貪婪是對的,就給這個答案。堆積是 V0,因為它是 O(n log n),而且後悔論證很短。

python
# python
# LC 871 - Minimum Number of Refueling Stops (DP)
# IDEA: reach[t] = farthest position with exactly t stops; relax t backwards so each station is used once
# time = O(n^2), space = O(n)
def minRefuelStops_dp(target, startFuel, stations):
    n = len(stations)
    reach = [startFuel] + [0] * n   # reach[t] = farthest position with exactly t stops
    for i, (pos, fuel) in enumerate(stations):
        for t in range(i, -1, -1):  # backwards: station i is used at most once
            if reach[t] >= pos:
                reach[t + 1] = max(reach[t + 1], reach[t] + fuel)
    for t in range(n + 1):
        if reach[t] >= target:
            return t
    return -1

陷阱(LC 871)

  • 在經過的最近一站加油,而不是油最多的那站:交換論證只對最大的油量成立。
  • 加油站已經依位置排序;再排一次無害,依油量排序就錯了。
  • startFuel >= target 時就算沒有任何加油站也是零次:pop 之前先檢查 fuel < target。

變形:Course Schedule III(LC 630) — 轉折:依截止時間排序,每門課都先修,一旦 time > deadline 就丟掉目前為止修過最長的那門課。丟掉最長的那門,永遠不會破壞先前那些截止時間的可行性。

java
// java
// LC 630 - Course Schedule III
// IDEA: sort by deadline; greedily take each course, and if the schedule overflows,
//       regret the longest course already taken (max-heap of durations)
// time = O(n log n), space = O(n)
public int scheduleCourse(int[][] courses) {
    Arrays.sort(courses, (a, b) -> Integer.compare(a[1], b[1]));   // by deadline
    PriorityQueue<Integer> pq = new PriorityQueue<>(Collections.reverseOrder());
    int time = 0;

    for (int[] c : courses) {
        time += c[0];
        pq.offer(c[0]);
        if (time > c[1]) time -= pq.poll();   // undo the longest one taken
    }
    return pq.size();
}
python
# python
# LC 630 - Course Schedule III
# IDEA: sort by deadline; take every course, and regret the longest one when it overruns
# time = O(n log n), space = O(n)
import heapq

def scheduleCourse(courses):
    courses.sort(key=lambda x: x[1])   # sort by deadline
    heap = []   # max-heap (store negated durations)
    time = 0
    for duration, deadline in courses:
        if time + duration <= deadline:
            time += duration
            heapq.heappush(heap, -duration)
        elif heap and -heap[0] > duration:
            # Replace longest course with current shorter one
            time += duration + heap[0]   # heap[0] is negative
            heapq.heapreplace(heap, -duration)
    return len(heap)

關鍵洞見:把較長的課換成較短的課,總時間絕不會增加,卻可能塞下更多課。

變形:Furthest Building You Can Reach(LC 1642) — 轉折:堆積裡放的是目前分配給梯子的那些攀爬(最小堆積)。一旦梯子用完,最小的那個已配梯攀爬就被降級成用磚塊 — 所以梯子最後一定落在最大的幾個攀爬上。程式碼見上面的 3) 有上限的「後悔」堆積。

變形:IPO(LC 502) — 用兩個堆積而不是一個:一份依資本排序的鎖定清單,加上一個依利潤排序的可用最大堆積。把目前資金負擔得起的專案全部解鎖,然後只取當下最好的那個專案;重複 k 次。

python
# python
# LC 502 - IPO
# IDEA: unlock projects by capital, then greedily take the highest profit available
# time = O(n log n), space = O(n)
import heapq

def findMaximizedCapital(k, w, profits, capital):
    # Min-heap by capital (locked projects)
    locked = sorted(zip(capital, profits))
    # Max-heap by profit (available projects, negate for max-heap)
    available = []
    i = 0
    for _ in range(k):
        # Unlock all projects we can afford
        while i < len(locked) and locked[i][0] <= w:
            heapq.heappush(available, -locked[i][1])
            i += 1
        if not available: break
        w += -heapq.heappop(available)   # pick highest profit
    return w

重點觀察:

  • 這個模式的特徵:「最少幾次 X」/「最多能做幾個 Y」,而且某個決定可以事後零成本地修改。
  • 堆積的方向就編碼了後悔的形式:想撤銷最糟的/取走延後選項中最好的時用最大堆積(LC 871、630);想把某個有限的高級資源中最便宜的降級時用最小堆積(LC 1642)。
  • 對照 heap.md § 模板 6 結束時間的最小堆積:區間排程從不修改決定;後悔貪婪則整套建立在修改決定上。

5) 把兩個堆積當資源池用(空閒池 + 使用中池) 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

核心想法

不是每個「兩個堆積」的題目都是中位數題。非常常見的一種變形是資源配置器,兩個堆積用不同的鍵排序:

text
freeHeap = min-heap by RESOURCE ID     -> "which resource do I hand out next?"
busyHeap = min-heap by RELEASE TIME    -> "which resource comes back first?"

迴圈永遠是同樣三個步驟,而且順序就是這個:

text
1. RELEASE : while busyHeap and busyHeap.top.releaseTime <= now:  move it to freeHeap
2. ASSIGN  : take freeHeap.top   (or mint a brand-new resource if the pool is empty)
3. OCCUPY  : push (releaseTime, resourceId) into busyHeap

實例 — LC 1942 The Number of the Smallest Unoccupied Chair

python
# python
# LC 1942 - The Number of the Smallest Unoccupied Chair
# time = O(N log N), space = O(N)
# IDEA: free chairs = min-heap by chair id; occupied = min-heap by leaving time; release -> assign
import heapq

class Solution(object):
    def smallestChair(self, times, targetFriend):
        # process friends in ARRIVAL order, but remember original index
        order = sorted(range(len(times)), key=lambda i: times[i][0])

        free = []          # min-heap of chair ids
        busy = []          # min-heap of (leave_time, chair_id)
        next_chair = 0

        for i in order:
            arrive, leave = times[i]

            # 1) RELEASE : chair frees exactly AT leave time -> `<=`
            while busy and busy[0][0] <= arrive:
                _, c = heapq.heappop(busy)
                heapq.heappush(free, c)

            # 2) ASSIGN : smallest free id, else mint a new chair
            if free:
                chair = heapq.heappop(free)
            else:
                chair = next_chair
                next_chair += 1

            if i == targetFriend:
                return chair

            # 3) OCCUPY
            heapq.heappush(busy, (leave, chair))

        return -1
java
// java
// LC 1942 - The Number of the Smallest Unoccupied Chair
// time = O(N log N), space = O(N)
// IDEA: free chairs = min-heap by id; busy chairs = min-heap by leaving time
public int smallestChair(int[][] times, int targetFriend) {
    int n = times.length;
    Integer[] order = new Integer[n];
    for (int i = 0; i < n; i++) order[i] = i;
    Arrays.sort(order, (a, b) -> Integer.compare(times[a][0], times[b][0]));   // by arrival

    PriorityQueue<Integer> free = new PriorityQueue<>();                    // chair ids
    PriorityQueue<int[]> busy = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0])); // {leave, chair}
    int nextChair = 0;

    for (int i : order) {
        int arrive = times[i][0], leave = times[i][1];

        // 1) RELEASE
        while (!busy.isEmpty() && busy.peek()[0] <= arrive) {
            free.offer(busy.poll()[1]);
        }
        // 2) ASSIGN
        int chair = free.isEmpty() ? nextChair++ : free.poll();

        if (i == targetFriend) return chair;

        // 3) OCCUPY
        busy.offer(new int[]{leave, chair});
    }
    return -1;
}

容易踩到的坑

  • ⚠️ 依抵達時間排序,但要保留原始索引 — 題目問的是 targetFriend 這位朋友,不是第 k 個抵達的人。
  • ⚠️ 釋放步驟要用 <=:在時間 t 空出來的椅子,對同樣在 t 抵達的人是可用的。
  • ⚠️ 先釋放再分配,否則你會生出根本不需要存在的椅子。

這個模板的各種變形:

LC 題目 轉折
1606 Find Servers That Handled Most Number of Requests 空閒池必須從索引 i % k 開始環狀搜尋 — 用兩個空閒堆積(編號 >= i%k 與編號 < i%k),或用附 ceiling() 的 TreeSet。使用中堆積照舊以結束時間為鍵。
1801 Number of Orders in the Backlog 兩個方向相反、會互相吃掉對方的堆積:buy 是依價格的最大堆積,sell 是依價格的最小堆積。每筆新訂單在價格交叉時就跟另一個堆積撮合,剩下的再 push 進去。
2102 Sequentially Ordinal Rank Tracker 兩個堆積以查詢指標為界把串流切開:最大堆積放「已回傳/更好的」,最小堆積放其餘;每次 get() 把一個元素跨過邊界搬過去。

6) 依一個標準排序 + 對另一個標準用固定大小堆積

什麼時候用:目標式是兩個屬性的乘積/組合,例如 cost = (選中的 k 個的 A 總和) * (選中的 k 個的 B 最大值)。依 B 排序,這樣走訪時就固定住「B 的最大值」這個因子,再用一個大小為 k 的堆積在 A 上求最小/最大總和。這是 模板 1:大小為 k 的最小堆積的雙屬性版本。

java
// java
// LC 857 - Minimum Cost to Hire K Workers
// IDEA: pay ratio = wage/quality; sort workers by ratio ascending -> the current worker's
//       ratio is the ratio the whole group must be paid. Keep the k SMALLEST qualities
//       with a max-heap; answer = min(sumQuality * ratio).
// time = O(n log n), space = O(n)
public double mincostToHireWorkers(int[] quality, int[] wage, int k) {
    int n = quality.length;
    double[][] workers = new double[n][2];             // [ratio, quality]
    for (int i = 0; i < n; i++) {
        workers[i] = new double[]{(double) wage[i] / quality[i], quality[i]};
    }
    Arrays.sort(workers, (a, b) -> Double.compare(a[0], b[0]));

    PriorityQueue<Double> pq = new PriorityQueue<>(Collections.reverseOrder()); // max-heap
    double sumQ = 0, res = Double.MAX_VALUE;

    for (double[] w : workers) {
        pq.offer(w[1]);
        sumQ += w[1];
        if (pq.size() > k) sumQ -= pq.poll();          // drop the largest quality
        if (pq.size() == k) res = Math.min(res, sumQ * w[0]);
    }
    return res;
}
python
# python
# LC 857 - Minimum Cost to Hire K Workers
# IDEA: sort by wage/quality ratio; max-heap keeps the k smallest qualities seen so far
# time = O(n log n), space = O(n)
import heapq

def mincostToHireWorkers(quality, wage, k):
    workers = sorted((w / q, q) for q, w in zip(quality, wage))
    pq, sum_q, res = [], 0, float('inf')

    for ratio, q in workers:
        heapq.heappush(pq, -q)          # max-heap via negation
        sum_q += q
        if len(pq) > k:
            sum_q += heapq.heappop(pq)  # pop returns -max -> adding it subtracts
        if len(pq) == k:
            res = min(res, sum_q * ratio)

    return res

變形:Maximum Performance of a Team(LC 1383) — 形狀相同、方向相反:依效率遞減排序(當前效率就是團隊最小值),維護一個大小 k、放速度的最小堆積,讓總和最大化。團隊最多 k 人,所以每個前綴都是候選,不只是滿員的團隊。

python
# python
# LC 1383 - Maximum Performance of a Team
# IDEA: sort by efficiency descending; min-heap keeps the k fastest so far; current eff is the team min
# time = O(n log n), space = O(n)
import heapq

def maxPerformance(n, speed, efficiency, k):
    engineers = sorted(zip(efficiency, speed), reverse=True)   # efficiency high -> low
    heap = []          # min-heap of the speeds on the team
    speed_sum = 0
    best = 0
    for eff, spd in engineers:
        heapq.heappush(heap, spd)
        speed_sum += spd
        if len(heap) > k:
            speed_sum -= heapq.heappop(heap)   # drop the slowest
        best = max(best, speed_sum * eff)      # eff is the team minimum
    return best % (10**9 + 7)
java
// java
// LC 1383 - Maximum Performance of a Team
// IDEA: sort by efficiency descending; min-heap keeps the k fastest so far; current eff is the team min
// time = O(n log n), space = O(n)
public int maxPerformance(int n, int[] speed, int[] efficiency, int k) {
    int[][] engineers = new int[n][2];                   // {efficiency, speed}
    for (int i = 0; i < n; i++) {
        engineers[i] = new int[]{efficiency[i], speed[i]};
    }
    Arrays.sort(engineers, (a, b) -> Integer.compare(b[0], a[0]));   // efficiency high -> low
    PriorityQueue<Integer> minHeap = new PriorityQueue<>();          // speeds on the team
    long speedSum = 0;
    long best = 0;
    for (int[] eng : engineers) {
        minHeap.offer(eng[1]);
        speedSum += eng[1];
        if (minHeap.size() > k) {
            speedSum -= minHeap.poll();                  // drop the slowest
        }
        best = Math.max(best, speedSum * eng[0]);        // eng[0] is the team minimum
    }
    return (int) (best % 1_000_000_007);
}
text
speed = [2,10,3,1,5,8], efficiency = [5,4,3,9,7,2], k = 2
sorted by efficiency desc: (9,1) (7,5) (5,2) (4,10) (3,3) (2,8)

(9,1)   heap=[1]      sum=1    perf= 9
(7,5)   heap=[1,5]    sum=6    perf=42
(5,2)   heap=[2,5]    sum=7    perf=35   (1 dropped)
(4,10)  heap=[5,10]   sum=15   perf=60   <- answer

重點觀察:

  • 排序固定住乘法因子,堆積最佳化加法因子。 認出該依哪個屬性排序,就是整題的關鍵。
  • 取模數只能放在最後(LC 1383)— 在迴圈裡取模會破壞 max 的比較。
  • 堆積方向必須跟你要保留的東西相反:要留最小的 k 個 → 最大堆積;要留最大的 k 個 → 最小堆積(跟 模板 1:大小為 k 的最小堆積是同一條規則)。

7) 格子圖上的最小堆積最佳優先搜尋

什麼時候用:在格子圖上,下一個要展開的格子不是步數最近的,而是目前為止代價最低的 — 也就是把格子圖當成隱式圖跑 Dijkstra(戴克斯特拉)。模板 5 假設有鄰接串列;這個變形則是從邊界播種堆積、往內展開。

java
// java
// LC 407 - Trapping Rain Water II
// IDEA: water level is decided by the lowest wall on the border. Seed a min-heap with the
//       whole border, always expand the lowest cell; an inner neighbour lower than the
//       current level traps (level - height) and then becomes a wall of that level.
// time = O(m*n*log(m*n)), space = O(m*n)
public int trapRainWater(int[][] heightMap) {
    int m = heightMap.length, n = heightMap[0].length;
    if (m < 3 || n < 3) return 0;

    boolean[][] seen = new boolean[m][n];
    // min-heap of {height, row, col}
    PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));

    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (i == 0 || j == 0 || i == m - 1 || j == n - 1) {
                pq.offer(new int[]{heightMap[i][j], i, j});
                seen[i][j] = true;
            }
        }
    }

    int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
    int water = 0;

    while (!pq.isEmpty()) {
        int[] cur = pq.poll();
        int level = cur[0], r = cur[1], c = cur[2];

        for (int[] d : dirs) {
            int nr = r + d[0], nc = c + d[1];
            if (nr < 0 || nc < 0 || nr >= m || nc >= n || seen[nr][nc]) continue;
            seen[nr][nc] = true;
            water += Math.max(0, level - heightMap[nr][nc]);          // trapped water
            pq.offer(new int[]{Math.max(level, heightMap[nr][nc]), nr, nc});
        }
    }
    return water;
}
python
# python
# LC 407 - Trapping Rain Water II
# IDEA: min-heap seeded with the border; pop the lowest wall, water on a lower neighbour
#       = level - height, and the neighbour joins the border at max(level, height)
# time = O(m*n*log(m*n)), space = O(m*n)
import heapq

def trapRainWater(heightMap):
    if not heightMap or len(heightMap) < 3 or len(heightMap[0]) < 3:
        return 0

    m, n = len(heightMap), len(heightMap[0])
    seen = [[False] * n for _ in range(m)]
    pq = []

    for i in range(m):
        for j in range(n):
            if i in (0, m - 1) or j in (0, n - 1):
                heapq.heappush(pq, (heightMap[i][j], i, j))
                seen[i][j] = True

    water = 0
    while pq:
        level, r, c = heapq.heappop(pq)
        for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
            nr, nc = r + dr, c + dc
            if 0 <= nr < m and 0 <= nc < n and not seen[nr][nc]:
                seen[nr][nc] = True
                water += max(0, level - heightMap[nr][nc])
                heapq.heappush(pq, (max(level, heightMap[nr][nc]), nr, nc))

    return water

重點觀察:

  • 之所以貪婪是正確的,關鍵在於每次 pop 全域最低的邊界格子:水只能從最矮的牆溢出,所以那個格子的水位就已經定案。
  • seen 要在 push 時標記,不是 pop 時,否則同一格會被重複排進佇列。
  • 骨架相同,只差優先度的鍵:
LC 題目 推進堆積的優先度鍵
407 Trapping Rain Water II max(current level, neighbour height) — 有效牆高
778 Swim in Rising Water max(current level, neighbour height) — 最小化路徑上的最大格子
1631 Path With Minimum Effort max(current effort, abs(height diff)) — minimax 邊權
1368 Minimum Cost to Make at Least One Valid Path in a Grid cost + (0 if arrow points at neighbour else 1) — 0/1 權重(用雙端佇列也可以)
675 Cut Off Trees for Golf Event 樹必須由矮到高砍:把目標依高度排序或放堆積,再在相鄰目標之間跑 BFS

8) 帶範圍跳躍的格子圖最短路徑

什麼時候用:從格子 (i, j) 可以跳到同一列往右最多 grid[i][j] 格、或同一欄往下的任何格子, 問最少要經過幾個格子(LC 2617)。逐一列舉每個跳躍目標的 BFS 是 O(m·n·(m + n))。改成依列優先 順序掃描:每個能到達 (i, j) 的格子,一定在同一列或同一欄的前面,所以對已經確定的格子, 每列一個、每欄一個 min-heap,存 (經過格數, 索引)。

關鍵 — 一筆資料若它的可達範圍 index + grid[...] 到不了目前的欄(或列),掃描後面的任何格子 它也到不了,所以直接永久 pop 掉。第一筆到得了的資料,就是所有到得了的資料中最小的,因為 堆積依距離排序。這就是以「依座標過期」為過期判定的延遲刪除。

實例 — LC 2617 Minimum Number of Visited Cells in a Grid

python
# python
# LC 2617 - Minimum Number of Visited Cells in a Grid
# IDEA: row-major sweep; per-row and per-column min-heaps of (dist, index); pop entries whose
#       reach falls short of the current cell, the first one left gives dist + 1
# time = O(m*n*log(m+n)), space = O(m*n)
import heapq

def minimumVisitedCells(grid):
    m, n = len(grid), len(grid[0])
    dist = [[-1] * n for _ in range(m)]
    row_pqs = [[] for _ in range(m)]     # row_pqs[i]: (dist, col) of finalised cells in row i
    col_pqs = [[] for _ in range(n)]     # col_pqs[j]: (dist, row) of finalised cells in column j
    dist[0][0] = 1
    for i in range(m):
        for j in range(n):
            while row_pqs[i]:            # best cell to the left that still reaches column j
                d, prev_col = row_pqs[i][0]
                if prev_col + grid[i][prev_col] >= j:
                    if dist[i][j] == -1 or d + 1 < dist[i][j]:
                        dist[i][j] = d + 1
                    break
                heapq.heappop(row_pqs[i])        # cannot reach j, so no later column either
            while col_pqs[j]:            # best cell above that still reaches row i
                d, prev_row = col_pqs[j][0]
                if prev_row + grid[prev_row][j] >= i:
                    if dist[i][j] == -1 or d + 1 < dist[i][j]:
                        dist[i][j] = d + 1
                    break
                heapq.heappop(col_pqs[j])
            if dist[i][j] != -1 and grid[i][j] > 0:   # reachable and can jump: offer it forward
                heapq.heappush(row_pqs[i], (dist[i][j], j))
                heapq.heappush(col_pqs[j], (dist[i][j], i))
    return dist[m - 1][n - 1]
java
// java
// LC 2617 - Minimum Number of Visited Cells in a Grid
// IDEA: row-major sweep; per-row and per-column min-heaps of {dist, index}; poll entries that
//       can no longer reach the current cell, the first one left gives dist + 1
// time = O(m*n*log(m+n)), space = O(m*n)
public int minimumVisitedCells(int[][] grid) {
    int m = grid.length, n = grid[0].length;
    int[][] dist = new int[m][n];
    for (int[] row : dist) Arrays.fill(row, -1);

    List<PriorityQueue<int[]>> rowPQs = new ArrayList<>();   // {dist, col} per row
    List<PriorityQueue<int[]>> colPQs = new ArrayList<>();   // {dist, row} per column
    for (int i = 0; i < m; i++) rowPQs.add(new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0])));
    for (int j = 0; j < n; j++) colPQs.add(new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0])));

    dist[0][0] = 1;
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            PriorityQueue<int[]> rowPQ = rowPQs.get(i);
            while (!rowPQ.isEmpty()) {
                int[] top = rowPQ.peek();
                if (top[1] + grid[i][top[1]] >= j) {     // still reaches column j
                    if (dist[i][j] == -1 || top[0] + 1 < dist[i][j]) dist[i][j] = top[0] + 1;
                    break;
                }
                rowPQ.poll();                            // cannot reach j, nor any later column
            }
            PriorityQueue<int[]> colPQ = colPQs.get(j);
            while (!colPQ.isEmpty()) {
                int[] top = colPQ.peek();
                if (top[1] + grid[top[1]][j] >= i) {
                    if (dist[i][j] == -1 || top[0] + 1 < dist[i][j]) dist[i][j] = top[0] + 1;
                    break;
                }
                colPQ.poll();
            }
            if (dist[i][j] != -1 && grid[i][j] > 0) {
                rowPQ.offer(new int[]{dist[i][j], j});
                colPQ.offer(new int[]{dist[i][j], i});
            }
        }
    }
    return dist[m - 1][n - 1];
}

陷阱

  • 起點算一個經過的格子:dist[0][0] = 1,1×1 的格子答案是 1。
  • grid[i][j] == 0 的格子到得了但跳不出去;push 它們無害,略過能讓堆積小一點。
  • 遇到第一筆到得了的資料就 break:它下面的資料距離更大,再往下看只是浪費時間。
  • Java:泛型 PriorityQueue 的陣列需要 unchecked cast;用它們的 List 就能避開。

9) 頻率唯一化 — 貪婪 + 堆積/HashSet

「用最少的刪除讓每個頻率都不同」(LC 1647)就是 heap § 模板 2 再多一步: 用 max-heap 從最大的計數往下處理,堆頂和下一個計數平手時就削掉一個再 push 回去。它用不到這份文件的 任何東西,所以以題目卡片的形式詳解在 heap_examples.md § LC 1647, 用 set 記錄用過計數的替代解法也在那裡提到。

陷阱

  • 用排序的版本若一遇到被削成 0 的計數就 break,會停得太早:4,4,1,1,1 ("aebacabbbad")在跳出後還有兩個 1,回傳 2 而不是 3。
  • LC 1481(移除 k 個後最少的相異值)看起來相關,但是相反的貪婪: 把最小的計數整個移除 — heap_examples.md § LC 1481。

10) 帶去重的堆積

python
# python
# IDEA: a set guards the heap so each value is pushed once (LC 264 generates duplicates otherwise)
# time = O(n log n), space = O(n)
import heapq

def solve_with_unique_heap(nums):
    heap = []
    seen = set()
    for num in nums:
        if num not in seen:
            heapq.heappush(heap, num)
            seen.add(num)
    return heap

實際用例:LC 264 Ugly Number II,2·3 和 3·2 都會 push 6 — Dijkstra_examples § Variation B。

11) k 路合併的各種變形

模板 2 的變形(骨架相同,只是簿記方式不同):

LC 題目 k 路合併上的轉折
632 Smallest Range Covering Elements from K Lists 額外追蹤 k 個堆積元素的最大值;每次 pop 都給出一個涵蓋所有清單的視窗 [heap_min, running_max] — 任一清單耗盡就停
355 Design Twitter 這裡的「k 個有序清單」是被追蹤者的推文清單(新的在前);把每位被追蹤者的第一則推進以時間戳為鍵的最大堆積,pop 10 次
373 / 378 K Pairs with Smallest Sums/Kth Smallest in Sorted Matrix 清單是有序格子中的虛擬列 — 見 heap_examples.md § LC 373/§ LC 378
python
# python
# LC 632 - Smallest Range Covering Elements from K Lists
# IDEA: k-way merge frontier; window = [heap top, max of frontier]
# time = O(N log k), space = O(k)   N = total elements
import heapq

def smallestRange(nums):
    pq = [(row[0], i, 0) for i, row in enumerate(nums)]
    heapq.heapify(pq)
    cur_max = max(row[0] for row in nums)
    best = [pq[0][0], cur_max]

    while pq:
        val, i, j = heapq.heappop(pq)
        if cur_max - val < best[1] - best[0]:
            best = [val, cur_max]
        if j + 1 == len(nums[i]):
            break                      # a list is exhausted -> no more covering window
        nxt = nums[i][j + 1]
        cur_max = max(cur_max, nxt)
        heapq.heappush(pq, (nxt, i, j + 1))

    return best

變形 — LC 1439 Find the Kth Smallest Sum of a Matrix With Sorted Rows:不要去合併各列,而是一列一列地合併。維護一份由前 r 列組成的、最小的 k 個和的清單,再用跟 LC 373 相同的「k 個最小配對」技巧把它和第 r+1 列合併(最小堆積以 (prev[0] + row[0], i=0, j=0) 播種,展開 (i+1, j)/(i, j+1),pop k 次後停)。這把指數級的搜尋降到 O(m * k log k)。

12) 延遲刪除 — 「刪除計數器」口味

當你沒辦法從堆積裡移除任意元素時,就把它標記成無效,pop 的時候跳過。

這是**「刪除計數器」口味。至於「雜湊表存真值」**口味(值被改掉而非元素被移除),見 1) 延遲刪除,以及同一節裡的實例 LC 3092 Most Frequent IDs。

python
# python
# IDEA: remove() only counts the value; pop() discards counted values when they reach the top
# time = O(log n) amortised per operation, space = O(n)
import heapq

class LazyHeap:
    def __init__(self):
        self.heap = []
        self.removed = {}   # val -> count of removed instances

    def push(self, val):
        heapq.heappush(self.heap, val)

    def remove(self, val):
        self.removed[val] = self.removed.get(val, 0) + 1

    def pop(self):
        while self.heap:
            val = self.heap[0]
            if self.removed.get(val, 0) > 0:
                heapq.heappop(self.heap)
                self.removed[val] -= 1
            else:
                return heapq.heappop(self.heap)
        return None

# Used in: LC 480 Sliding Window Median, LC 1825 Finding MK Average

二元堆積之外

索引式優先佇列

  • 支援 decrease-key 操作
  • 對 Dijkstra 的最佳化很有用
  • 需要追蹤元素在堆積中的位置

Fibonacci 堆積

  • 插入與 decrease-key 攤還 O(1)
  • extract-min 為 O(log n)
  • 實作複雜,實務上很少用

二元堆積的變種

  • d 元堆積:快取效能較好
  • 二項式堆積:合併操作較好
  • 配對堆積:實作簡單,實務效能不錯

不適用時

大多數看起來需要這裡技巧的題目,其實用一張核心卡片就能解。先檢查這些。

技巧 看起來需要它的時候 通常就夠用的較簡單答案
§ 1 延遲刪除 堆積裡的值會變 值只會變大、過期但較大的資料仍是合法上界 → 不用清;或視窗依索引過期、只要一個極值 → 單調 deque(monotonic_queue.md)
§ 1 兩個鏡像的延遲堆積 一個會變的集合的最大與最小值 值只會新增、加進來後永不改變 → 兩個一路更新的變數(LC 2034 不行:之後的 update 會覆寫舊價格,一路更新的最大值卻還留著它);語言有有序對照表(TreeMap)且面試官接受
§ 2 掃描線 + 存活堆積 區間加上「在每個 x」 只問涵蓋 x 的數量 → 差分陣列,或用 heap § 模板 6 求峰值
§ 3 有上限的後悔堆積 「k 次免費」 沒有可支付的預算 → 最大的 k 個就是 heap § 模板 1
§ 4 帶後悔的貪婪 「最少停靠次數/最多任務數」 項目彼此獨立、光依截止時間排序就能決定 → 單純排序 + 貪婪(greedy.md)
§ 5 資源池 「分配會議室/伺服器」 只要數量、不管是哪個 id → heap § 模板 6
§ 6 排序 + 固定大小堆積 兩個屬性、挑 k 個 目標式只用一個屬性 → heap § 模板 1
§ 7 格子最佳優先 有權重的格子 每步代價都是 1 → BFS(bfs.md);明確的圖 → Dijkstra.md
§ 8 範圍跳躍格子 每個格子跳得很遠 跳躍距離受小常數限制 → 對鄰居做 BFS
§ 9 頻率唯一化 「讓計數各不相同」 它就是核心卡片:heap § 模板 2,或用 set 記錄用過的計數
§ 10 帶去重的堆積 會 push 重複值 重複值不影響答案 → pop 時略過和上一個 pop 出來相同的值
§ 11 k 路合併的各種變形 好幾個有序來源 來源是真的串列 → 照寫 heap § 模板 3
§ 12 刪除計數器式延遲刪除 值會離開雙堆積視窗 允許用 sortedcontainers.SortedList/TreeMap → 有序多重集合直接刪除

總結與速查

題目裡的訊號 模式 章節
push 進去的值被改掉/被移除 候選堆積 + 記錄真值的雜湊表 1
對同一份會變動的對照表同時要最大和最小值 兩個鏡像的延遲堆積共用真值對照表 1
「這個想法還會出現在哪?」— 墓碑 雜湊表、LSM-tree、MVCC、GC、Dijkstra 1
「所有覆蓋 x 的東西的最大/最小值」 掃描線 + 存活堆積,依座標淘汰 2
「k 把梯子/k 次免費升級」 上限為 k 的最小堆積,被淘汰的照付 3
「最少停幾次/最多修幾門課」 全部先拿,卡住時 poll() 掉最糟的 4
「編號最小的空椅子/伺服器」 依編號的空閒堆積 + 依釋放時間的使用中堆積 5
目標式是 sum(A) × max/min(B) 依 B 排序,對 A 用大小 k 的堆積 6
格子圖,代價是 minimax 或累加權重 在隱式格子圖上跑 Dijkstra 7
每個格子跳到一個範圍 逐列/逐行的優先佇列 + 延遲 pop 8
「讓所有頻率互不相同」 最大堆積遞減,或用一個已用頻率集合 9
合併虛擬/巢狀的有序來源 會重建前緣的 k 路合併 11
訊號 模式
「第 k 大/第 k 小」 大小為 k 的最小堆積
「出現頻率前 k 高」 Counter + nlargest/桶排序
「串流的中位數」 兩個堆積(最大 + 最小)
「在限制下總是取當前最好的」 貪婪 + 最大堆積(IPO 模式)
「塞下最多的任務/課程」 依截止時間排序 + 替換式堆積
「從堆積中移除任意元素」 延遲刪除
「合併 k 個有序串列」 以 (val, list_idx, elem_idx) 為鍵的最小堆積

複雜度比較

操作 二元堆積 有序陣列 AVL 樹
插入 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(log n)
從陣列建堆 O(n) O(n log n) O(n log n)