堆疊(Stack)
範圍 — LIFO 的基本功,加上堆疊的經典模板:括號配對、min-stack、單調堆疊的精簡版、用顯式堆疊做走訪,以及作用域/上下文帳本。 另見:stack_expression_parsing.md — 計算機、decode string 與後綴式求值,整個運算式剖析家族;stack_examples.md — 這些模板背後的解題實作庫;monotonic_stack.md — next greater/previous smaller/span 類問題的深入版;queue.md — FIFO 的對照組;iterator.md — 以堆疊為底的迭代器。
LeetCode 題目清單
時間複雜度
| 資料結構 | 搜尋 | 插入 | 刪除 | 最小/最大 |
|---|---|---|---|---|
| 堆疊 | O(n) | O(1) | O(1) | O(n) |
插入 = push,刪除 = pop,peek —— 全都發生在頂端,全都是 O(1)。最小/最大值可以靠一個輔助的 min/max 堆疊做到 O(1)(見 monotonic_stack.md)。空間是 O(n)。
總覽

堆疊是具有後進先出(LIFO)性質的資料結構。每個操作都在堆疊頂端加入或移除元素。
關鍵性質
- 複雜度:見上方的時間複雜度表
- 核心原理:最後放進去的元素最先被拿出來
- 適用場景:牽涉到順序反轉、樣式配對,或是要維護上下文的問題

參考資料
辨識
| 題目敘述裡的訊號 | 限制條件線索 | 該用什麼 | 不是這個的情況 |
|---|---|---|---|
| 「移除相鄰重複」、「退格」、「復原上一步」 | n ≤ 10⁵ → 掃一趟 O(n) | 模板 1 與頂端相消 | 同一字元要連成 k 個才消失 → 改放 [char, count] 配對(stack_examples.md) |
| 「合法/平衡的括號」,且括號有好幾種 | n ≤ 10⁴ → O(n) | 模板 2 括號配對 | 只有一種括號 → 用平衡計數器,O(1) 空間 |
| 括號的「最少刪除數/最長合法區段」 | n ≤ 10⁵ | 模板 2 § LC 1249 索引堆疊 | |
| 「下一個更大/更暖/更高」、「還要等幾天」、「最近的較小值」 | n ≤ 10⁵ → O(n²) 的掃描會超時 | 模板 3 單調堆疊 | 每個寬度 k 視窗裡的最大值 → monotonic_queue.md |
| 在 push/pop 之外「以常數時間取得最小值」 | 每個操作 O(1) | 模板 4 逐層彙總 | 可以從任何位置刪除,不是 LIFO → 搭配 lazy deletion 的 heap.md |
| 對一棵樹「迭代地」、「不用遞迴」 | 歪斜的樹深度可達 10⁵,遞迴會爆 | 模板 5 顯式堆疊 | 「一層一層」→ 用佇列(bfs.md) |
以 tab 縮排的路徑、start/end 的 log、「現在是誰在執行」 |
n ≤ 10⁴ → O(n) | 模板 6 作用域帳本 | 巢狀結構需要被求值 → stack_expression_parsing.md |
| 「基本計算機」、「decode string」、「逆波蘭表示法」 | stack_expression_parsing.md |
題型分類
十種形狀幾乎涵蓋所有堆疊題。在哪裡這一欄告訴你程式碼放在下面哪個模板,或是解法搬到了哪一份表。
| 題型 | 堆疊裡放的是什麼 | LC | 在哪裡 |
|---|---|---|---|
| 括號/巢狀驗證 | 還欠一個右括號的左括號 | 20, 921, 1541, 1614 | 模板 2 |
| 括號修復/計量 | 未配對括號的索引 | 1249, 32, 856 | 模板 2 § LC 1249、stack_examples.md |
| 單調 —— next greater/smaller | 還在等答案的元素 | 496, 503, 739, 84, 907, 2104 | 模板 3、monotonic_stack.md |
| 單調 —— 貪婪移除 | 目前建出來最好的前綴 | 402, 316, 1081, 1673 | monotonic_stack.md |
| 單調 —— span 累積 | [value, span] 配對,串流式處理 |
901, 735 | monotonic_stack.md、stack_examples.md |
放 [element, count] 配對的堆疊 |
前綴的遊程壓縮表示 | 1047, 1209, 1544 | stack_examples.md |
| 堆疊上的 O(1) 彙總值 | 每一層一個 min/max/delta 的狀態 |
155, 716, 1381, 895 | 模板 4 |
| 運算式剖析 | 運算元/延後的項/未關閉的作用域 | 224, 227, 772, 394, 150, 682 | stack_expression_parsing.md |
| 作用域/上下文帳本 | 外層的上下文,以深度為鍵 | 388, 636, 591, 71 | 模板 6 |
| 順序反轉/暫停的走訪 | 還沒做完的工作 | 144, 145, 173, 341, 445 | 模板 5 |
值得知道的堆疊變形
- 單一堆疊
- 用堆疊做出佇列
- LC 232(用
2 stack)
- LC 232(用
- 用佇列做出堆疊
- 放 (char, count) 配對的堆疊
- 存
[element, count]配對,而不是原始元素 - LC 1047(k=2 的特例,單純 pop)
- LC 1209(移除 k 個連續重複字元)
- LC 1544(Make The String Great)
- LC 394(Decode String,用堆疊記重複次數)
- LC 726(Number of Atoms)
- 存
模板與演算法
模板對照表
| 模板 | 堆疊元素 | 迴圈形狀 | 複雜度 | 什麼時候用 |
|---|---|---|---|---|
| 1 —— 與頂端相消 | 尚未被消掉的前綴 | 掃一趟,相同就 pop | O(n)/O(n) | 相鄰相消、復原、退格 |
| 2 —— 括號配對 | 左括號字元 | 掃一趟,遇右括號就 pop | O(n)/O(n) | 驗證巢狀,且括號種類 >1 |
| 3 —— 單調堆疊 | 索引 | for 裡包一層 while |
O(n)/O(n) | next greater/smaller/span |
| 4 —— Min stack | 值 + 每一層一個彙總狀態 | — | 每次操作 O(1) | O(1) getMin()/getMax();前綴彙總、路徑狀態 |
| 5 —— 顯式堆疊 | 待處理的節點 | while stack |
O(n)/O(h) | 迭代式走訪、順序反轉 |
| 6 —— 作用域帳本 | 每層深度的外層上下文 | 掃一趟,截到當前深度 | O(n)/O(depth) | 有縮排的輸入、start/end 事件 |
模板 1:與頂端相消 —— LC 1047
辨識 —— 「移除相鄰重複」、「退格」、「復原」、「相鄰兩個互相抵消」:每個新元素不是抵消最近的倖存者,就是加入倖存者。n ≤ 10⁵ → 預期掃一趟 O(n);每刪一次就重掃字串是 O(n²)。
關鍵 —— 堆疊就是目前為止的答案:它存的是已經套用完所有抵消的前綴,所以新元素唯一能互動的對象就是 stack[-1]。
# python
# LC 1047 - Remove All Adjacent Duplicates In String
# IDEA: the stack holds the reduced prefix; a char equal to the top cancels it, otherwise it joins
# time = O(n), space = O(n)
def removeDuplicates(s):
stack = [] # the reduced prefix, top = last surviving char
for ch in s:
if stack and stack[-1] == ch:
stack.pop() # ch cancels its neighbour; the char below becomes adjacent
else:
stack.append(ch)
return ''.join(stack) # bottom -> top is left -> right order
// java
// LC 1047 - Remove All Adjacent Duplicates In String
// IDEA: the stack holds the reduced prefix; a char equal to the top cancels it, otherwise it joins
// time = O(n), space = O(n)
public String removeDuplicates(String s) {
Deque<Character> stack = new ArrayDeque<>(); // top = last surviving char
for (char ch : s.toCharArray()) {
if (!stack.isEmpty() && stack.peek() == ch) {
stack.pop(); // cancel, and expose the char below
} else {
stack.push(ch);
}
}
StringBuilder sb = new StringBuilder();
while (!stack.isEmpty()) {
sb.append(stack.pollLast()); // the bottom is the leftmost survivor
}
return sb.toString();
}
下面每個模板都由這三個操作組成:
| 操作 | Python(list) |
Java(以 ArrayDeque 實作的 Deque<T>) |
代價 |
|---|---|---|---|
| push | stack.append(x) |
stack.push(x) |
均攤 O(1) |
| pop | stack.pop() —— 空的會 raise |
stack.pop() —— 空的會 throw |
O(1) |
| peek | stack[-1] —— 先用 if stack 擋 |
stack.peek() —— 空的回傳 null |
O(1) |
複雜度
- 時間 O(n):
for ch in s迴圈每個字元跑一次,每個字元最多 push 一次、pop 一次。 - 空間 O(n):
stack.append(ch)會留下每個倖存者;"abcd"什麼都不會被消掉。
試跑
s = "abbaca"
ch action stack
a push a
b push a b
b cancels top a
a cancels top (empty) <- the cancel exposed 'a', which the next 'a' then cancels
c push c
a push c a -> "ca"
陷阱
- 每刪一次就重掃(
while "aa" in s: s = s.replace(...)):在"abcddcba"這類輸入上是 O(n²),因為每次刪除都會露出新的一對。 - Java 的
Stack<Character>由底往頂迭代,ArrayDeque卻由頂往底迭代;直接迭代ArrayDeque來組答案會把結果反過來。用pollLast()從底部取出。 - Java 的
stack.peek() == ch之所以安全,只因為ch是基本型別char;兩個裝箱的Character用==比較的是參考。
不適用時 —— 同一字元要連成 k 個才會消失(LC 1209)→ 改 push [char, count] 配對,而不是單一字元(stack_examples.md)。
練習 —— LC 1047 · LC 844 (退格:遇到 # 就 pop,不用比對) · LC 1544 (同字母、大小寫相反才相消) · LC 232 (兩個堆疊組成佇列) · LC 1209 ([char, count] 配對)
模板 2:括號配對 —— LC 20 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 —— 「合法括號」、「格式正確」、「平衡」、「正確巢狀」,而且括號或標籤不只一種。n ≤ 10⁴ → 掃一趟 O(n)。
關鍵 —— 堆疊裡剛好就是還欠一個右括號的左括號,最內層在頂端;右括號只有在跟頂端配對時才合法,因為最內層開著的作用域必須最先關閉。
# python
# LC 20 - Valid Parentheses
# IDEA: STACK — push openers, pop-and-verify on closers, stack must end empty
# time = O(n), space = O(n)
class Solution(object):
def isValid(self, s):
pairs = {')': '(', ']': '[', '}': '{'}
stack = []
for c in s:
# closer: needs a non-empty stack AND the matching partner on top
if c in pairs:
if not stack or stack.pop() != pairs[c]:
return False
# opener
else:
stack.append(c)
# leftover openers were never closed
return not stack
// java
// LC 20 - Valid Parentheses
// IDEA: STACK — push openers, pop-and-verify on closers, stack must end empty
// time = O(n), space = O(n)
public boolean isValid(String s) {
// closer -> its matching opener
Map<Character, Character> pairs = new HashMap<>();
pairs.put(')', '(');
pairs.put(']', '[');
pairs.put('}', '{');
Deque<Character> st = new ArrayDeque<>();
for (char c : s.toCharArray()) {
if (pairs.containsKey(c)) {
if (st.isEmpty()) {
return false; // a closer with nothing open
}
char top = st.pop(); // unbox to char before comparing
if (top != pairs.get(c)) {
return false; // wrong partner
}
} else {
st.push(c);
}
}
return st.isEmpty(); // leftover openers -> invalid
}
複雜度
- 時間 O(n):只有一個
for c in s迴圈;每個字元只 push 或 pop 一次。 - 空間 O(n):
stack.append(c)可能存下每個字元,例如"(((((("。
陷阱
- 只有
")":還沒檢查not stack就 pop 會 raise/throw。先檢查是否為空。 "(]":右括號需要兩個檢查 —— 堆疊非空,而且頂端是配對的那一半。"(((":迴圈從來不會失敗,所以迴圈後直接回傳True是錯的。堆疊最後必須是空的。- Java:
st.pop() != pairs.get(c)兩邊都是裝箱的Character時比較的是參考。先把其中一邊拆成char。
不適用時 —— 只有一種括號(LC 921、LC 1541、LC 1614)→ 一個平衡計數器就夠,O(1) 空間。需要出問題括號的位置(LC 1249、LC 32)→ push 索引,見下方。
練習 —— LC 20 ⭐ · LC 1614 (最大深度 = 最大平衡值) · LC 921 (只用計數器) · LC 1249 (索引堆疊,見下方) · LC 856 (存部分分數的堆疊) · LC 32 (以 -1 為基準的索引堆疊)
括號家族 —— 同一個模板的四種變形(各自的解法在哪裡,連結就在該列):
| LC | 變形 | 堆疊裡放什麼 |
|---|---|---|
| 1249 | 不只驗證,還要修復 —— 見下方 | 未配對 ( 的索引 —— 或不用堆疊:兩趟計數掃描,每個方向一趟 |
| 921 | 只有一種括號,用計數器就夠 —— O(1) 空間(greedy_examples § 7) | 什麼都不放(堆疊退化成一個 size) |
| 32 | 最長合法區段的長度(stack_examples § LC 32) | 索引,外加一個 -1 當基準哨兵 |
| 856 | 從巢狀結構摺出一個分數(stack_examples § LC 856) | 每一層的部分結果 |
括號修復變形 —— push 的是索引,不是字元(LC 1249)優先度 4/5 — 高價值 — 這裡有缺口就會掉關
關鍵 —— LC 20 只回答是/否,所以堆疊可以只放左括號字元、不必記得它們在哪。LC 1249 必須刪掉出問題的括號,所以需要它們的位置 —— push 每個
(的索引。這題只有一種括號,pop 時不用比對:任何一個還開著的(都能配任何一個)。
歸約到:模板 2 —— 堆疊放的是索引而不是字元。
| LC 20 —— 驗證 | LC 1249 —— 修復 | |
|---|---|---|
| 堆疊裡放 | 左括號字元 | 未配對 ( 的索引 |
遇到 ) 而堆疊是空的 |
return False |
把索引 i 標記為要刪,繼續掃 |
遇到 ) 而堆疊非空 |
pop,檢查是不是另一半 | pop —— 這一對保留 |
| 掃描結束 | 堆疊必須是空的 | 還留在堆疊上的每個索引都是未配對的 ( → 一樣刪掉 |
| 輸出 | 一個布林值 | s 去掉被標記的位置 |
為什麼這就是最少。 一個 ) 如果前面沒有開著的 (,右邊的任何東西都不可能配到它;掃完後還留在堆疊上的 (,後面也沒有 ) 可配。每一次刪除都是被迫的,其餘全部成對,所以沒有刪掉任何不必刪的括號。
完整解法(Python 與 Java,先標記再重建,附陷阱)在
string.md 模板 7;
不用堆疊的兩趟計數掃描(左→右刪掉多出來的 ),右→左刪掉多出來的 ( —— 面試官接下來會問的 follow-up)在
stack_examples.md § LC 1249。
模板 3:單調堆疊 —— Next Greater/Smaller —— LC 739 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 —— 「右邊下一個更大/更暖/更高」、「還要等幾天」、「兩側最近的較小值」。n ≤ 10⁵ → 每個元素往右掃的 O(n²) 會超時;預期 O(n)。
關鍵 —— 堆疊裡放的是還在等答案的索引,它們的值由底往頂遞減、允許相等;第一個勝過頂端的值,就是它 pop 掉的每個索引的答案。
這份表只保留一個精簡版。完整家族(previous smaller、span、直方圖、子陣列最小/最大值總和,以及貪婪移除的各種變形)是 monotonic_stack.md 的主題,它的解題實作也在那裡。
# python
# IDEA: the stack holds indices still waiting for a warmer day; a warmer `val` answers every one it pops
# time = O(n), space = O(n)
# LC 739 - Daily Temperatures (next greater element, as a distance)
def daily_temperatures(temperatures):
n = len(temperatures)
res = [0] * n # 0 = no warmer day ahead
stack = [] # indices, temperatures non-increasing bottom -> top (equal ones stay)
for i, val in enumerate(temperatures):
while stack and temperatures[stack[-1]] < val:
j = stack.pop()
res[j] = i - j # i is the first warmer day after j
stack.append(i)
return res
// java
// IDEA: the stack holds indices still waiting for a warmer day; a warmer value answers every one it pops
// time = O(n), space = O(n)
// LC 739 - Daily Temperatures (next greater element, as a distance)
public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] res = new int[n]; // 0 = no warmer day ahead
Deque<Integer> stack = new ArrayDeque<>(); // indices, temperatures non-increasing (equal ones stay)
// Traverse the array from left to right
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && temperatures[stack.peek()] < temperatures[i]) {
int j = stack.pop();
res[j] = i - j; // i is the first warmer day after j
}
stack.push(i);
}
return res;
}
把比較方向翻過來,就換一個方向:
| 想找的東西 | pop 的條件 | 答案怎麼讀出來 | LC |
|---|---|---|---|
| next greater 元素/下一個更暖的日子 | top < cur |
cur 把 top pop 掉的當下 |
496, 503, 739 |
| next smaller 元素,或左右邊界 | top > cur |
cur 把 top pop 掉的當下 |
84, 907, 2104 |
| 往回到上一個更大值的 span | top <= cur,並累加被 pop 掉的 span |
累加起來的計數 | 901 |
| 環狀陣列 | 一樣的做法,跑 nums * 2 並用 idx % n |
一樣 | 503 |
複雜度
- 時間 O(n):內層
while看起來是巢狀的,但每個索引只被stack.append(i)push 一次、最多 pop 一次,所以整趟下來 pop 的總數最多 n 次。 - 空間 O(n):嚴格遞減的輸入(
[5, 4, 3, 2, 1])從不 pop,堆疊會存下全部 n 個。
試跑
temperatures = [73, 74, 75, 71, 69, 72, 76, 73]
i val pops (j -> res[j]) stack (indices)
0 73 - [0]
1 74 0 -> 1 [1]
2 75 1 -> 1 [2]
3 71 - [2, 3]
4 69 - [2, 3, 4]
5 72 4 -> 1, 3 -> 2 [2, 5]
6 76 5 -> 1, 2 -> 4 [6]
7 73 - [6, 7] -> [1, 1, 4, 2, 1, 1, 0, 0]
陷阱
- push 值而不是索引:
[73, 74]就只能說出有更暖的日子,卻說不出多遠。push 索引,再透過索引讀值。 <與<=:用<=時,相同溫度[70, 70, 71]中第二個 70 會把第一個 pop 掉並回報 1 天,但 70 並沒有更暖。「嚴格大於」就表示在嚴格小於時 pop。- 最後還留在堆疊上的索引沒有答案;它們保留預設值
0(LC 496/503 是-1),所以res要用它初始化。
不適用時 —— 答案是固定寬度滑動視窗裡的最大或最小值(LC 239)→ 單調雙端佇列,它還會從前端過期淘汰(monotonic_queue.md)。
練習 —— LC 739 ⭐ · LC 496 · LC 503 (環狀:走 2n) · LC 901 (線上 span) · LC 907 (貢獻計數) · LC 84 (兩側的 next smaller)
模板 4:Min Stack —— O(1) getMin —— LC 155 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 —— 「設計一個堆疊,支援 push、pop、top,並能以常數時間取得最小元素」。每個操作 O(1),最多 3·10⁴ 次呼叫。
關鍵 —— min_values[i] == min(stack[0..i]):第二個陣列逐層對映堆疊,存的是「這一層以下所有東西」的答案,所以 pop 一層就連帶 pop 掉它的答案,而下一層早就知道自己的答案。
竅門在於再開一個逐層對映的陣列,裡面放的不是元素本身,而是*「這一層以下所有東西」對這個查詢的答案*。LC 155 的查詢是「最小值」,所以 min_values 存的是每一層的最小值。這是前綴彙總在設計題裡的長相,而同一個想法在堆疊之外也到處出現 —— 見本模板最後的經典題表。
# python
# LC 155 - Min Stack
# IDEA: 2 ARRAYS — stack holds the elements, min_values holds each layer's minimum
# time = O(1) per operation, space = O(n)
class MinStack(object):
def __init__(self):
self.stack = []
# min_values[i] == min(stack[0..i]) — a STATE per layer, not the elements sorted
self.min_values = []
def push(self, val):
self.stack.append(val)
if not self.min_values:
self.min_values.append(val)
else:
self.min_values.append(min(val, self.min_values[-1]))
def pop(self):
# the popped layer's state leaves with it — no comparison needed
self.min_values.pop()
return self.stack.pop()
def top(self):
return self.stack[-1]
def getMin(self):
# the top layer's state IS the current minimum — O(1)
return self.min_values[-1]
// java
// LC 155 - Min Stack
// IDEA: 2 STACKS — stack holds the elements, minValues holds each layer's minimum
// time = O(1) per operation, space = O(n)
class MinStack {
private final Deque<Integer> stack = new ArrayDeque<>();
private final Deque<Integer> minValues = new ArrayDeque<>(); // one entry per layer
public void push(int val) {
stack.push(val);
if (minValues.isEmpty()) {
minValues.push(val);
} else {
minValues.push(Math.min(val, minValues.peek()));
}
}
public void pop() {
stack.pop();
minValues.pop(); // the layer's state leaves with it
}
public int top() {
return stack.peek();
}
public int getMin() {
return minValues.peek(); // the top layer's state is the answer
}
}
複雜度
- 每個操作時間 O(1):
push在min(val, self.min_values[-1])裡只合併兩個數字;getMin只讀一格self.min_values[-1]。沒有任何操作需要掃描。 - 空間 O(n):每 push 一個元素,
self.min_values.append(...)就多一筆。
試跑
push -2, 0, -3 then pop()
stack = [-2, 0, -3] stack = [-2, 0]
min_values = [-2, -2, -3] min_values = [-2, -2] <- getMin() = -2, nothing recomputed
^ ^ ^
| | min of all three
| min of the first two (0 did not beat -2)
min of the first one
陷阱
- 只用一個
self.min變數而不是陣列:push 1, push 0, pop之後最小值必須回到 1,但單一變數早已被覆寫。要復原只能 O(n) 重掃。 - 只 push 新最小值的省空間變形必須用
<=比較:用<時,push 0, push 0, pop會讓 min-stack 變空,而堆疊裡還有一個 0(見下方的省空間變形)。上面的逐層陣列在pop裡完全不需要比較。 - Java,省空間變形:對裝箱的
Integer寫stack.peek() == minStack.peek()比較的是參考,所以 push 進去的128跟自己也配不上。用.equals比較,或先拆箱。
不適用時 —— 最小值必須在從中間或任意順序刪除後依然正確(不是 LIFO)→ 搭配 lazy deletion 的 heap(heap.md);視窗從前端滑走(LC 239)→ 用兩個這種堆疊組成佇列,或單調雙端佇列(見下方經典題表)。
練習 —— LC 155 ⭐ · LC 1381 (每層一個延遲加值) · LC 1026 (遞迴堆疊就是那個堆疊) · LC 716 (max stack,popMax) · LC 895 (每個頻率一個堆疊)
| 做法 | push |
pop |
getMin |
差在哪裡 |
|---|---|---|---|---|
單一 self.min 變數 |
O(1) | O(n) | O(1) | 把最小值 pop 掉之後,得重新掃一遍找前一個最小值 |
heap(heapq) |
O(log n) | O(log n),需搭配 lazy deletion | O(1) | 為一個堆疊根本用不到的排序付 log 的代價 |
{value: count} map |
O(1) | O(1) | O(n) | min(counts) 是一次掃描(min-stack.py 的 V0-1 就是這個) |
逐層的 min_values |
O(1) | O(1) | O(1) | 當前頂端的答案在它被 push 的那一刻就定下來了 |
一般形式 —— 每一層一個彙總值
Invariant: min_values[i] = min(stack[0], stack[1], ..., stack[i])
-> min_values is NOT the elements sorted; it is a STATE per layer
push(v): min_values.append(min(v, min_values[-1])) # new layer, new state
pop(): min_values.pop() # the popped layer's state goes with it
getMin(): min_values[-1] # the top layer's state IS the answer
從這個不變量直接推出三件事:
- 兩個陣列永遠等長 —— 每次
push兩邊都 append,每次pop兩邊都移掉。pop()裡不需要if,也不用把 pop 出來的值跟最小值比:min_values[-1]是屬於那一層的狀態,不是某個元素的 副本,所以把那一層 pop 掉就是全部的工作。這就是為什麼上面的模板不需要省空間變形那個stack.pop() == mins[-1]的檢查。 - 重複值不用特別處理。
push(0); push(0); pop()之後min_values = [0],因為每個0都有自己的一層。只存新最小值的那種變形必須寫<=才能做對 —— 這是 LC 155 的經典 bug(見下方的 省空間變形)。 - 任何是前綴函數的彙總值都一樣可行 ——
max、累加和、累積 GCD、待套用的 delta(LC 1381)、 出現次數(LC 895)。把push裡的min(...)換掉,其他一行都不用動。
同一個不變量也能裝進一個存 (value, min_so_far) tuple 的堆疊 —— 一個結構取代兩個,界限不變:
# python
# LC 155 - Min Stack, one stack of (value, min_so_far) tuples
# IDEA: each layer carries its own minimum next to its value
# time = O(1) per operation, space = O(n)
class MinStack(object):
def __init__(self):
self.stack = []
def push(self, x):
if not self.stack:
self.stack.append((x, x))
else:
self.stack.append((x, min(x, self.stack[-1][1])))
def pop(self):
self.stack.pop()
def top(self):
return self.stack[-1][0]
def getMin(self):
return self.stack[-1][1]
省空間變形 —— 第二個堆疊只放最小值
最小值堆疊不需要每一層都有一筆:只在新值是新的最小值或與它相等時才 push 進去,也只在 pop 出來的值等於它的頂端時才 pop。這樣它的值由底往頂不遞增,頂端仍然是目前的最小值。大部分 push 都不會壓低最小值時,它比較省空間;而且這是面試官最常看到的寫法,所以要知道它唯一的陷阱:push 時的比較必須用 <=。
# python
# LC 155 - Min Stack, auxiliary non-increasing stack
# IDEA: mins holds only the values that were a minimum when pushed; '<=' keeps duplicate minima
# time = O(1) per operation, space = O(n) worst case
class MinStack(object):
def __init__(self):
self.stack = []
self.mins = [] # non-increasing bottom -> top; top = current minimum
def push(self, val):
self.stack.append(val)
if not self.mins or val <= self.mins[-1]:
self.mins.append(val) # '<=': a second copy of the minimum gets its own entry
def pop(self):
if self.stack.pop() == self.mins[-1]:
self.mins.pop() # the minimum is leaving, so its entry leaves too
def top(self):
return self.stack[-1]
def getMin(self):
return self.mins[-1]
// java
// LC 155 - Min Stack, auxiliary non-increasing stack
// IDEA: mins holds only the values that were a minimum when pushed; '<=' keeps duplicate minima
// time = O(1) per operation, space = O(n) worst case
class MinStack {
private final Deque<Integer> stack = new ArrayDeque<>();
private final Deque<Integer> mins = new ArrayDeque<>(); // non-increasing
public void push(int val) {
stack.push(val);
if (mins.isEmpty() || val <= mins.peek()) {
mins.push(val);
}
}
public void pop() {
int v = stack.pop(); // unbox before comparing
if (v == mins.peek()) {
mins.pop();
}
}
public int top() {
return stack.peek();
}
public int getMin() {
return mins.peek();
}
}
經典 bug 是只在嚴格 < 時才 push 新的最小值:push(0); push(0); pop() 會把 mins 裡唯一的 0 移掉,之後 getMin() 會在空堆疊上失敗,而堆疊裡明明還有一個 0。
為什麼是 O(1) —— 狀態在 push 時就算好了
getMin() 只讀一個陣列格子。不需要掃描,因為工作已經搬到 push 那邊去做了,而 push 只要合併
兩個數字:新值,以及下一層的答案。整個論證就這樣 —— 每個操作只碰兩個陣列的頂端,其他什麼
都不碰。
保留每一層的狀態而不是只用一個 self.min 變數,關鍵在 pop()。單一變數在更小的值進來時可以往下
調,但那個值離開時卻調不回去 —— 更早的最小值已經被覆寫掉了。陣列記得,因為它從來沒被覆寫;
它一直就在下一層。
同一個想法走出堆疊 —— 經典題 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
這個不變量需要一個只在一端變動的容器,這樣頂端消失時「下一層的狀態」才仍然成立。LC 題庫裡有 四種東西是這樣運作的。看表時從第一欄下手:找到你的題目裡是什麼在扮演堆疊,每層該帶什麼彙總值就 跟著出來了。
| 什麼在扮演堆疊 | 每層的狀態 | 回答的查詢 | 經典 LC |
|---|---|---|---|
| 一個顯式堆疊 | 至今的 min/max |
O(1) 的 getMin()/getMax() |
155 Min Stack、716 Max Stack(peekMax) |
| 對下方所有元素待套用的 delta | O(1) 的整批 increment(k, val) |
1381 Design a Stack With Increment Operation | |
| 每個頻率等級一個堆疊 | O(1) pop 出現最多次的元素 | 895 Maximum Frequency Stack | |
| 兩個堆疊組成的佇列(LC 232) | 每個堆疊各自至今的 min/max |
攤銷 O(1) 的視窗 min/max,不需要單調 deque | 239 Sliding Window Maximum、1438 Longest Continuous Subarray With Absolute Diff ≤ Limit |
| 一個只會變長的陣列 —— 索引 = 層 | 前綴 min/max/sum/product |
對每個 i 回答「i 之前(含)的最佳值」 |
121 Best Time to Buy and Sell Stock、2016 Maximum Difference Between Increasing Elements、42 Trapping Rain Water、238 Product of Array Except Self、303 Range Sum Query、769 Max Chunks To Make Sorted、915 Partition Array into Disjoint Intervals、1477 Find Two Non-overlapping Sub-arrays Each With Target Sum |
| 遞迴堆疊 —— 根到節點的路徑 | 沿路徑的 (lo, hi)/max/前綴值 |
每個節點關於其祖先的答案 | 1026 Maximum Difference Between Node and Ancestor、98 Validate Binary Search Tree、1448 Count Good Nodes in Binary Tree、129 Sum Root to Leaf Numbers |
該保留多少狀態,由兩條規則決定:
- 之後的查詢會問到「不是頂端」的那一層時,整個陣列都要留著。 LC 42 第二趟需要每個
i的left_max[i];LC 238 需要每個前綴乘積;LC 769/915 在每個切點把前綴 max 跟後綴 min 比。單一變數 回答不了它已經走過的那一層。 - 只會查頂端、而且永遠不 pop 時,可以塌成一個變數。 LC 121 每個索引問一次「至今最小」,從不回頭,
所以
min_values就是單一個lo—— 只留陣列的最後一格,其餘丟掉。LC 155 不能這樣做,因為pop()確實會回頭。
# LC 239 (as a min queue) / LC 1438 - a queue built from two min stacks
# IDEA: a queue is two stacks (LC 232). Give each stack its per-layer min and the
# queue's min is the smaller of the two tops — no monotonic deque needed.
# Swap min -> max for LC 239 itself; run one of each for LC 1438.
# time = O(1) amortised per operation, space = O(n)
class MinQueue(object):
def __init__(self):
self.inbox, self.outbox = [], [] # each entry: (value, min_so_far)
@staticmethod
def _push(st, v):
st.append((v, v if not st else min(v, st[-1][1])))
def push(self, v):
self._push(self.inbox, v)
def pop(self):
if not self.outbox: # refill: each element moves once
while self.inbox:
self._push(self.outbox, self.inbox.pop()[0])
return self.outbox.pop()[0]
def getMin(self):
return min(st[-1][1] for st in (self.inbox, self.outbox) if st)
# LC 1026 - Maximum Difference Between Node and Ancestor
# IDEA: the recursion stack IS the stack; (lo, hi) is that layer's aggregate over the
# root-to-node path. Returning from the call pops it — no bookkeeping at all.
# time = O(n), space = O(h)
def maxAncestorDiff(root):
def dfs(node, lo, hi):
if not node:
return hi - lo
lo, hi = min(lo, node.val), max(hi, node.val)
return max(dfs(node.left, lo, hi), dfs(node.right, lo, hi))
return dfs(root, root.val, root.val)
各列的深入內容在哪裡:LC 1381 的 delta 技巧在
design_examples.md § LC 1381,
LC 239 的 deque 寫法在
monotonic_queue.md,
前綴陣列在 prefix_sum.md,LC 121 在
stock_trading.md,(lo, hi) 邊界傳遞的模式在
bst_advanced.md。
模板 5:顯式堆疊 —— 迭代式走訪 —— LC 144, LC 145 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 —— 「迭代地」、「不用遞迴」、「follow-up:遞迴解很簡單,你能用迭代做嗎?」,或樹/串列深到(歪斜的樹 n 可達 10⁵)會讓呼叫堆疊溢位。
關鍵 —— 堆疊存的是還沒做的工作,而堆疊吐出工作的順序與放入時相反:先 push right 再 push left,left 就會先被處理。後序就是以 root → right → left 跑的前序,再反轉。
同一個堆疊的暫停版本 —— 必須在元素之間停下來的迭代器 —— 是 bst_advanced.md § 模板 5b 的 LC 173,以及 iterator.md § 2-2 的 LC 341,更完整的家族在 iterator.md。
# python
# LC 144 - Binary Tree Preorder Traversal
# IDEA: EXPLICIT STACK — push RIGHT before LEFT so LEFT is popped first
# time = O(n), space = O(h)
class Solution(object):
def preorderTraversal(self, root):
if not root:
return []
res, stack = [], [root]
while stack:
node = stack.pop()
res.append(node.val)
# right first -> left ends up on TOP
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
return res
# LC 145 - Binary Tree Postorder Traversal
# IDEA: preorder variant (root -> RIGHT -> LEFT), then REVERSE => left-right-root
# time = O(n), space = O(h)
class Solution(object):
def postorderTraversal(self, root):
if not root:
return []
res, stack = [], [root]
while stack:
node = stack.pop()
res.append(node.val)
# mirrored push order compared with preorder
if node.left:
stack.append(node.left)
if node.right:
stack.append(node.right)
return res[::-1] # root-right-left -> left-right-root
// java
// LC 144 - Binary Tree Preorder Traversal
// IDEA: EXPLICIT STACK — push RIGHT before LEFT so LEFT is popped first
// time = O(n), space = O(h)
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
if (root == null) {
return res;
}
Deque<TreeNode> st = new ArrayDeque<>();
st.push(root);
while (!st.isEmpty()) {
TreeNode node = st.pop();
res.add(node.val);
// right pushed FIRST, so left is popped FIRST
if (node.right != null) {
st.push(node.right);
}
if (node.left != null) {
st.push(node.left);
}
}
return res;
}
// LC 145 - Binary Tree Postorder Traversal
// IDEA: preorder with LEFT/RIGHT swapped (root-right-left), then REVERSE
// time = O(n), space = O(h)
public List<Integer> postorderTraversal(TreeNode root) {
LinkedList<Integer> res = new LinkedList<>();
if (root == null) {
return res;
}
Deque<TreeNode> st = new ArrayDeque<>();
st.push(root);
while (!st.isEmpty()) {
TreeNode node = st.pop();
res.addFirst(node.val); // addFirst == "append, then reverse", done incrementally
if (node.left != null) {
st.push(node.left);
}
if (node.right != null) {
st.push(node.right);
}
}
return res;
}
複雜度
- 時間 O(n):
while stack每個節點恰好 pop 一次,每次 pop 最多 push 兩個子節點。 - 空間 O(h)(堆疊本身):最多只存一條從根到葉路徑上待處理的兄弟節點 —— 平衡時 O(log n),歪斜時 O(n)。後序的
res[::-1]副本是輸出,O(n),不算工作空間。
陷阱
- 前序時先 push
left再 pushright,得到的是root → right → left;在[1, 2, 3](2 在左、3 在右)上會回傳[1, 3, 2]。 - 把
root = Nonepush 進堆疊,會在node.val上崩潰;先回傳[]。 - 後序的反轉技巧得到的是值的順序;如果每個節點必須在子節點之後才帶著副作用被處理(例如釋放它們),就不能用 —— 改用
(node, visited)旗標。
不適用時 —— 題目要一層一層的節點(「層序」、「右視圖」、「最小深度」)→ 用 FIFO 佇列,不是堆疊(bfs.md)。
練習 —— LC 144 · LC 145 · LC 94 (中序:一路往左、pop、再往右) · LC 173 (暫停式中序迭代器) · LC 341 (巢狀串列的暫停式走訪)
模板 6:作用域/上下文帳本 —— LC 388, LC 636 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 —— 以 tab 縮排或巢狀的輸入(「以字串表示的檔案系統」、「標籤」),或 start/end 事件 log(「獨佔時間」、「呼叫堆疊」);任何在一趟由左到右的掃描中問「我現在的父層是誰?」的題目。輸入長度 ≤ 10⁴ → O(n)。
關鍵 —— 堆疊索引 == 巢狀深度:stack[d] 存的是深度 d 累積的上下文(一段路徑前綴、一個正在執行的函式),所以正在讀的東西的父層永遠是 stack[depth]/stack[-1]。
這是 Google 出現頻率最高的那批堆疊題背後的模式,而且它不是括號配對:這裡的「括號」是隱含的(縮排深度、start/end 的 log 事件)。
# python
# LC 388 - Longest Absolute File Path
# IDEA: SCOPE STACK indexed by DEPTH — stack[d] = length of the path prefix at depth d
# time = O(n), space = O(depth)
class Solution(object):
def lengthLongestPath(self, input):
res = 0
stack = [0] # stack[d] = prefix length at depth d (trailing '/' included)
for line in input.split('\n'):
name = line.lstrip('\t')
depth = len(line) - len(name) # number of leading tabs
# we left deeper scopes -> trim the stack back to this depth
while len(stack) > depth + 1:
stack.pop()
if '.' in name:
# file: measure, never push (a file has no children)
res = max(res, stack[depth] + len(name))
else:
# dir: becomes the prefix for depth+1, +1 for the '/'
stack.append(stack[depth] + len(name) + 1)
return res
// java
// LC 388 - Longest Absolute File Path
// IDEA: SCOPE STACK indexed by DEPTH — stack.get(d) = length of the path prefix at depth d
// time = O(n), space = O(depth)
public int lengthLongestPath(String input) {
int res = 0;
// stack.get(d) = length of "dir1/dir2/.../" for the current branch at depth d,
// trailing '/' included -> the index in the list IS the nesting depth
List<Integer> stack = new ArrayList<>();
stack.add(0); // depth 0 has an empty prefix
for (String line : input.split("\n")) {
// depth = number of leading '\t'
int depth = 0;
while (depth < line.length() && line.charAt(depth) == '\t') {
depth++;
}
String name = line.substring(depth);
// we LEFT the previous deeper scopes -> pop back to `depth`
while (stack.size() > depth + 1) {
stack.remove(stack.size() - 1);
}
if (name.contains(".")) {
// a FILE never becomes a parent -> just measure it
res = Math.max(res, stack.get(depth) + name.length());
} else {
// a DIRECTORY becomes the context of depth+1 (+1 for the '/')
stack.add(stack.get(depth) + name.length() + 1);
}
}
return res;
}
複雜度
- 時間 O(n):
for line in input.split('\n')每個字元只碰常數次;修剪用的while每個被 push 的目錄最多 pop 一次。 - 空間 O(depth):
stack.append(...)只在輸入越來越深時成長,每個開著的目錄一筆。
試跑
"dir\n\tsubdir2\n\t\tfile.ext"
line depth stack after res
"dir" 0 [0, 4] ("dir/")
"subdir2" 1 [0, 4, 12] ("dir/subdir2/")
"file.ext" 2 [0, 4, 12] 12 + 8 = 20
陷阱
- 忘了修剪:
"a\n\tb\nc\n\td.txt"會把d.txt算在a/b/底下,而不是c/。 - 把檔案 push 進去:檔案不會有子節點,後面的兄弟會讀到錯的前綴。
- 忘了
/的+ 1:"dir\n\tfile.ext"會回傳 11 而不是 12。 - 完全沒有檔案(
"a"):答案是0,不是最長的目錄路徑。
不適用時 —— 巢狀是用明確的括號寫的,而且要驗證 → 模板 2;要求值(計算機、decode string)→ stack_expression_parsing.md。
練習 —— LC 388 · LC 71 (.. pop 掉父目錄) · LC 636 · LC 591 (開標籤堆疊加上 CDATA 規則)
變體:覆寫深度位置,而不是 pop —— LC 388
關鍵 —— 輸入是前序(pre-order)列出的,所以深度
d的一行只會讀path_len[d],而那個位置最後一次寫入正是它自己的父目錄。更深層的過期位置在被讀到之前一定會先被覆寫,所以這本帳其實就是每個深度最後一次的寫入。
歸約到:模板 6 —— 拿掉修剪迴圈。README 把 LC 388 歸在 Hash_table 底下,就是這個寫法。複雜度一樣是 O(n)。
# python
# LC 388 - Longest Absolute File Path
# IDEA: HASH MAP by DEPTH — path_len[d] = prefix length for children at depth d; overwrite, never pop
# time = O(n), space = O(depth)
class Solution(object):
def lengthLongestPath(self, input):
res = 0
path_len = {0: 0} # depth-0 entries have an empty prefix
for line in input.split('\n'):
name = line.lstrip('\t')
depth = len(line) - len(name)
if '.' in name:
res = max(res, path_len[depth] + len(name))
else:
# overwrite the slot for depth+1 — a stale deeper slot is rewritten before it is read
path_len[depth + 1] = path_len[depth] + len(name) + 1
return res
// java
// LC 388 - Longest Absolute File Path
// IDEA: HASH MAP by DEPTH — pathLen[d] = prefix length for children at depth d; overwrite, never pop
// time = O(n), space = O(depth)
public int lengthLongestPath(String input) {
int res = 0;
Map<Integer, Integer> pathLen = new HashMap<>();
pathLen.put(0, 0); // depth-0 entries have an empty prefix
for (String line : input.split("\n")) {
int depth = line.lastIndexOf('\t') + 1; // tabs are leading and contiguous
String name = line.substring(depth);
if (name.contains(".")) {
res = Math.max(res, pathLen.get(depth) + name.length());
} else {
// overwrite the slot for depth+1 — a stale deeper slot is rewritten before it is read
pathLen.put(depth + 1, pathLen.get(depth) + name.length() + 1);
}
}
return res;
}
Exclusive Time of Functions — LC 636
關鍵 —— 堆疊就是題目描述的那個呼叫堆疊,所以頂端是此刻在 CPU 上執行的函式;
prev是還沒有被算給任何人的第一個時間單位。每一行 log 都會把[prev, …]這一段收尾、算給原本的頂端,然後移動游標。
歸約到:模板 6 —— 上下文是「哪個函式正在執行」,end 事件會把它 pop 掉。
# python
# LC 636 - Exclusive Time of Functions
# IDEA: SCOPE STACK of function ids — the RUNNING function is always the stack top
# time = O(n), space = O(n)
class Solution(object):
def exclusiveTime(self, n, logs):
res = [0] * n
stack = [] # ids of functions currently RUNNING (top = executing now)
prev = 0 # start of the current time slice
for log in logs:
fid, typ, t = log.split(':')
fid, t = int(fid), int(t)
if typ == 'start':
# the caller ran on [prev, t) before being preempted
if stack:
res[stack[-1]] += t - prev
stack.append(fid)
prev = t
else:
# 'end' timestamp is INCLUSIVE -> [prev, t] => + 1
res[stack.pop()] += t - prev + 1
prev = t + 1
return res
// java
// LC 636 - Exclusive Time of Functions
// IDEA: SCOPE STACK of function ids — the RUNNING function is always the stack top
// time = O(n), space = O(n)
public int[] exclusiveTime(int n, List<String> logs) {
int[] res = new int[n];
Deque<Integer> stack = new ArrayDeque<>(); // ids of functions currently RUNNING
int prev = 0; // timestamp where the current "run slice" started
for (String log : logs) {
String[] p = log.split(":"); // {id, "start"|"end", timestamp}
int id = Integer.parseInt(p[0]);
int t = Integer.parseInt(p[2]);
if (p[1].equals("start")) {
// the caller (stack top) ran during [prev, t) -> credit it, then it is PREEMPTED
if (!stack.isEmpty()) {
res[stack.peek()] += t - prev;
}
stack.push(id);
prev = t;
} else {
// "end at t" is INCLUSIVE -> the slice is [prev, t], hence `+ 1`; the caller resumes at t + 1
res[stack.pop()] += t - prev + 1;
prev = t + 1;
}
}
return res;
}
n = 2, logs = ["0:start:0","0:start:2","0:end:5","1:start:6","1:end:6","0:end:7"]
log credit (to stack top) stack prev res
0:start:0 (stack empty) [0] 0 [0, 0]
0:start:2 res[0] += 2 - 0 = 2 [0, 0] 2 [2, 0] <- recursive call, same id
0:end:5 res[0] += 5 - 2 + 1 = 4 [0] 6 [6, 0]
1:start:6 res[0] += 6 - 6 = 0 [0, 1] 6 [6, 0] <- zero-length slice, harmless
1:end:6 res[1] += 6 - 6 + 1 = 1 [0] 7 [6, 1]
0:end:7 res[0] += 7 - 7 + 1 = 1 [] 8 [7, 1] -> answer [7, 1]
為什麼只有
end要+ 1。start:t指的是時間單位t的開頭,end:t指的是時間單位t的結尾。所以 start 的邊界在t,end 的邊界在t + 1。一解析到end:t就把它換成邊界t + 1,兩個分支就變成同一個半開區間[prev, boundary)的相減 —— 也就是下面的寫法。複雜度一樣是O(n);值得學它的理由是:off-by-one 直接消失,而不是靠記憶。
# python
# LC 636 - Exclusive Time of Functions
# IDEA: normalise every log to a half-open BOUNDARY (end:t -> t + 1), so one formula credits both
# time = O(n), space = O(n)
class Solution(object):
def exclusiveTime(self, n, logs):
res = [0] * n
stack = []
prev = 0 # boundary where the current slice began
for log in logs:
fid, typ, t = log.split(':')
fid, t = int(fid), int(t)
boundary = t if typ == 'start' else t + 1
if stack:
# the stack top ran on [prev, boundary)
res[stack[-1]] += boundary - prev
if typ == 'start':
stack.append(fid)
else:
stack.pop()
prev = boundary
return res
LC 636 特有的陷阱:
- 先記帳,再 push 或 pop。 剛結束的那一段屬於原本的頂端;先 push 的話,呼叫者的時間就被算給了被呼叫者。
- 每次呼叫一個 frame,而不是每個 id 一筆。 函式可以呼叫自己(範例 2),所以用
{id: start_time}的 map 會覆寫外層那次呼叫。同一個 id 有兩個 frame 才是對的。 prev是一個全域游標,不是每個函式各自的開始時間。 另一種寫法是存[id, resume_time]frame,並在每次 pop 之後把呼叫者的resume_time改成t + 1—— 同一個想法,只是游標存在 frame 上,而且多一行容易忘記的程式。- 長度為零的一段沒關係。 在
prev = 6之後緊接著1:start:6,算進去的是0;不需要特別處理。
練習路徑
| 階段 | 題目(由易到難) | 新增的能力 |
|---|---|---|
| 核心 ⭐ | LC 20 ok · LC 155 again · LC 739 ok | 括號配對、逐層彙總、單調堆疊 —— 各一題 |
| 練習 | LC 1047 未評定 · LC 144 未評定 · LC 232 again · LC 1249 未評定 · LC 636 again · LC 388 again | 與頂端相消、顯式走訪、兩個堆疊組成佇列、索引堆疊,以及兩次作用域帳本 |
| 延伸 | LC 895 未評定 · LC 32 again · LC 591 未評定 | 每個頻率一個堆疊;-1 基準哨兵;完整的標籤文法 |
摘要與速查
決策表 —— 該用哪一種堆疊模式?
| 問題類型 | 模式 | 核心想法 | 例題 |
|---|---|---|---|
| 找 next greater/smaller 元素 | 單調堆疊 | 維持遞增/遞減的順序 | LC 496, 503, 739 |
| 移除相鄰重複字元 | 放 [element, count] 配對的堆疊 | 記次數,湊到 k 就 pop | LC 1047, 1209, 1544 |
| 帶括號的字串解碼 | 帶計數的堆疊 | 用配對處理巢狀重複 | LC 394, 726 |
| 算術運算式 | 帶運算子的堆疊 | 處理優先序與求值 | LC 224, 227 |
| 移除 k 位數使數字最小 | 貪婪 + 單調 | 划算就把較大的數字 pop 掉 | LC 402 |
| 有重複字元時求字典序最小 | 單調 + 最後出現位置 | 貪婪移除,搭配「後面還會出現」的檢查 | LC 316, 1081 |
| 串流/線上頻率統計 | 帶 span 配對的堆疊 | 用配對累加計數 | LC 901 |
| 用 LIFO 做出 FIFO | 兩個堆疊 | 用 input/output 兩個堆疊模擬佇列 | LC 232 |
| push/pop 之外還要 O(1) min/max | 逐層彙總值 | min_values[i] = min(stack[0..i]),跟著那一層一起 pop |
LC 155, 716, 1381 |
| 括號平衡驗證 | 括號配對 | push 左括號,遇右括號 pop 並驗證 | LC 20, 1249, 32 |
| 巢狀上下文(縮排、start/end 事件) | 作用域/上下文帳本 | stack[depth] = 外層上下文 |
LC 388, 636, 591 |
| 反轉一個只能往前走的序列 | 全部 push,再全部 pop | pop 出來就是逆序 | LC 445, 234, 143 |
| 迭代式走訪/延遲式迭代器 | 顯式堆疊 | 堆疊裡放的是還沒做的工作 | LC 144, 145, 173, 341 |
| 後綴式/RPN 求值 | 運算元堆疊 | 遇運算子就 pop 兩個、push 結果 | LC 150, 682 |
怎麼用:在最左欄找到你的問題目標,再拿對應的模式和例題當起點。
各模式的複雜度
這裡列的是每個模式的成本。結構本身每個操作的成本在最上面的時間複雜度表。
| 模式 | 時間 | 空間 | 為什麼 |
|---|---|---|---|
| 括號配對 | O(n) | O(n),只有一種括號時 O(1) | 掃一趟,每個字元最多 push 一次 |
| 單調堆疊 | O(n) | O(n) | 每個元素 push 一次、最多 pop 一次 |
貪婪移除(丟掉 k 個) |
O(n) | O(n) | 同樣的攤還分析;k 限制了 pop 的次數 |
[element, count] 配對 |
O(n) | O(n) | 堆疊就是前綴的遊程編碼 |
| Min stack | 每次操作 O(1) | O(n) | 每次 push 多存一筆輔助資料 |
| 顯式堆疊走訪 | O(n) | O(h) | 待處理的只有當前那條 root 到節點的路徑 |
| 作用域帳本 | O(n) | O(depth) | 每個未關閉的作用域一筆 |
| 運算式剖析 | O(n) | O(n) | 堆疊深度 = 巢狀深度 |
常見陷阱
- 題目要嚴格大於,卻在
<=時就 pop(LC 496 / 739):輸入[2, 2, 3]時,第二個 2 會把第一個 pop 掉,交給它的答案是 2 而不是 3。只在堆頂嚴格較小時才 pop。 - 連續段的計數放在堆疊外面(LC 1209):
s = "deeedbbcccbdaa", k = 3刪掉eee之後兩個d變成相鄰;計數必須以[char, count]的形式放在堆疊上,pop 之後才還在。 - 貪婪移除掃完之後還剩
k(LC 402):num = "12345", k = 2一次都不會 pop,所以迴圈結束後要砍掉最後k位;num = "10200", k = 1會留下開頭的 0 要去掉("200")。 - 先檢查堆疊是否為空:
s = "]"(LC 20)會對空堆疊 pop;每個由右括號觸發的pop()/peek()前面都要有!stack.isEmpty()。 - Java 的
Charactervschar:對兩個裝箱的Character用!=比的是參考。ASCII 剛好沒事(0–127 有快取),遇到第一個非 ASCII 字元就壞掉 —— 先拆箱,或用.equals。 - 整數除法是往零截斷(LC 150):
-3 / 2必須是-1,但 Python 的//會往下取整成-2;要寫int(a / b)。 - 當堆疊必須吐出由左到右的順序時,子節點要反著 push:先 push 左再 push 右,
[1, 2, 3]的前序就會變成1, 3, 2。
其他內容在哪裡
| 你在找 | 檔案 |
|---|---|
| 計算機(LC 224/227/772)、decode string(LC 394)、後綴式(LC 150) | stack_expression_parsing.md |
| 上面提到那些題目的解題實作 | stack_examples.md |
| next greater/previous smaller/直方圖的理論 | monotonic_stack.md |
| 迭代器設計(LC 173, 341, 284) | iterator.md |
| FIFO、雙端佇列、單調佇列 | queue.md、monotonic_queue.md |