Monotonic-Stack
這篇在解決什麼
一整族題目長得完全不一樣——最大矩形、接雨水、等幾天會變暖、所有子陣列最小值的總和——但骨架是同一個:對每個元素,找出兩側第一個比它大/小的位置。暴力對每個元素往兩邊掃是 O(n²),單調棧一趟 O(n) 解決。
難的不是資料結構(就是個棧),而是三件事:
- 什麼時候該算——多數人的第一版會在每一步重掃整個棧,退化回 O(n²)
- 比較用
>還是>=——重複值一處理錯就重複計算,而且小測資看不出來 - 這題到底屬不屬於這個模式——判斷訊號長什麼樣
一、核心:彈出的那一刻,資訊才完備
棧裡放的是「還沒結案的元素」。一個元素進棧時,它的左界已知(見下)、右界未知,只能先掛帳。當某個新元素把它擠出去,那個新元素就是它的右界——這一刻左右都齊了,算一次,結案。
heights: [2, 1, 5, 6, 2, 3]
index: 0 1 2 3 4 5
i=4(值 2)進場,棧內 index [1, 2, 3](高度 1 < 5 < 6)
6 被擠出 → 右界就是 i=4,彈完露出的棧頂 2 是左界 → 寬 4-2-1 = 1
5 被擠出 → 右界就是 i=4,彈完露出的棧頂 1 是左界 → 寬 4-1-1 = 2
1 擋住 → 停止,i=4 入棧,它的左界就是現在的棧頂 1
棧內元素單調遞增,所以某個元素被彈掉後露出來的新棧頂,必然是它左邊第一個更小的——左界不必另外求一趟,pop 這個動作自己把它交出來。而右界只能等,等到有人把它擠出去為止。整個模式就建立在這個不對稱上。
複雜度要數「元素進出棧的總次數」,不是數迴圈層數。每個 index 恰好入棧一次、出棧至多一次,所有 while 迭代加總 ≤ 2n,攤到 n 次外圈就是均攤 O(1)。這叫攤還分析的聚合法(aggregate method);等價的說法是記帳法(入棧時預付一枚代幣給未來那次彈出)或位能法(取 Φ = 棧的大小)。同一套論證也支撐雙指針、單調 deque、並查集路徑壓縮、KMP 的 failure link 回退。
二、模板:一趟掃描,左右界同時到手
// Time: O(n) 每個 index 恰好入棧一次、出棧至多一次
// Space: O(n) 最壞情況整個陣列都堆在棧裡
// left[i] = 左邊第一個 a[j] < a[i] 的 j,不存在為 -1
// right[i] = 右邊第一個 a[j] <= a[i] 的 j,不存在為 n
pair<vector<int>, vector<int>> smallerBounds(const vector<int>& a) {
int n = a.size();
vector<int> left(n, -1), right(n, n), st; // st 存 index
for (int i = 0; i < n; ++i) {
while (!st.empty() && a[st.back()] >= a[i]) {
right[st.back()] = i; // i 把它擠出去,就是它的右界
st.pop_back();
}
left[i] = st.empty() ? -1 : st.back(); // 彈完露出的棧頂就是左界
st.push_back(i);
}
return {left, right};
}
注意 left 和 right 的嚴格性是不對稱的:左邊是 <,右邊是 <=。這不是筆誤,是必要的。
一趟掃描下,彈出條件只有一個,它同時決定了左右兩側的嚴格性——選 >= 就是「左嚴格、右寬鬆」,選 > 就是「左寬鬆、右嚴格」,不存在兩邊都嚴格的版本。真要兩邊都嚴格,就得跑兩趟分別求,但那樣算出來的區間會重疊:[2,2] 兩個 2 都會宣稱自己管轄整個陣列。實測所有子陣列最小值總和(0907-Sum-of-Subarray-Minimums)的差距:
| 輸入 | 兩邊都嚴格 | 正解 |
|---|---|---|
[2,2] |
8 | 6 |
[3,3,3] |
30 | 18 |
[1,2,1] |
8 | 7 |
[3,1,2,4] |
17 | 17 |
最後一列是重點:沒有重複值時兩者相同,所以手邊的小測資很可能全過,上線才 WA。寫這類題一定要用含大量重複值的隨機測資對拍。
四個變體,旋鈕只有一個
要找「更大」還是「更小」,只差彈出條件的比較符號:
| 想找的右界 | 彈出條件 | 順帶得到的左界 | 棧內(底→頂) |
|---|---|---|---|
右邊第一個 < |
a[st.back()] > a[i] |
左邊第一個 <= |
非嚴格遞增 |
右邊第一個 <= |
a[st.back()] >= a[i] |
左邊第一個 < |
嚴格遞增 |
右邊第一個 > |
a[st.back()] < a[i] |
左邊第一個 >= |
非嚴格遞減 |
右邊第一個 >= |
a[st.back()] <= a[i] |
左邊第一個 > |
嚴格遞減 |
記法:彈出條件的比較符號反過來,就是棧內的單調方向(用 > 彈 → 棧遞增),而加不加等號決定嚴格性歸左邊還是右邊(等號在彈出條件裡 → 右界寬鬆、左界嚴格)。
不需要「由右往左掃」的變體:一趟由左往右就同時拿到左右兩界了。真的只要單邊時,省掉另一個陣列即可。
三、貢獻法:翻轉提問的方向
這是這族題目真正的思想。要求「所有子陣列的某個量的總和/最大值」,子陣列有 O(n²) 個,逐一枚舉必爆。貢獻法改問:
不問「每個子陣列的答案是多少」,改問「每個元素貢獻了多少子陣列」。
以「子陣列最小值總和」為例:元素 a[mid] 當最小值的子陣列,左端點可選 (left, mid] 共 mid - left 種、右端點可選 [mid, right) 共 right - mid 種,貢獻就是 (mid - left) * (right - mid) * a[mid]。n 個元素各算一次,O(n) 完成。
// Time: O(n)
// Space: O(n)
// LeetCode 0907:所有子陣列最小值的總和
class Solution {
public:
int sumSubarrayMins(vector<int>& arr) {
const int MOD = 1e9 + 7;
int n = arr.size();
vector<int> st;
long long ans = 0;
for (int i = 0; i <= n; ++i) { // i == n 當哨兵,逼出棧內殘留
while (!st.empty() && (i == n || arr[st.back()] >= arr[i])) {
int mid = st.back();
st.pop_back();
int l = st.empty() ? -1 : st.back();
long long cnt = (long long)(mid - l) * (i - mid); // 左端點數 × 右端點數
ans = (ans + cnt % MOD * arr[mid]) % MOD;
}
st.push_back(i);
}
return ans;
}
};
同一個骨架,換掉「彈出時算什麼」就是不同的題:
| 題目 | 彈出時結算什麼 |
|---|---|
| 0084-Largest-Rectangle-in-Histogram | 高 × 寬,取最大 |
| 0042-Trapping-Rain-Water | 一層凹槽的水量,寬 × 兩側較矮的牆減去底 |
| 0739-Daily-Temperatures | 天數差 i - j,填進答案陣列 |
| 0901-Online-Stock-Span | 往左的連續跨度,串流版 |
| 0907-Sum-of-Subarray-Minimums | 管轄的子陣列數 × 自己的值 |
| 2104-Sum-of-Subarray-Ranges | 同上,跑一次 min 一次 max 相減 |
| 1856-Maximum-Subarray-Min-Product | min × 區間和(配前綴和) |
| 0085-Maximal-Rectangle | 逐列壓成直方圖高度,直接呼叫 0084 |
題目要對所有子陣列/子區間求一個「以極值為主角」的量(最小值、最大值、以某高度為底的面積),就往「每個元素當極值時管轄多寬」想。反過來,若求的量跟極值無關(例如所有子陣列的和),那是前綴和的地盤,不是單調棧。
四、另一族:彈出不是結算,是丟棄
同樣是單調棧,0402-Remove-K-Digits、0316-Remove-Duplicate-Letters 這類構造字典序最優解的題,彈出時什麼都不算——彈出就是「這個字元不要了」。
// Time: O(n)
// Space: O(n)
// LeetCode 0402:從 num 刪掉 k 位,使剩下的數字最小
class Solution {
public:
string removeKdigits(string num, int k) {
string st;
for (char c : num) {
while (!st.empty() && k > 0 && st.back() > c) { // 前面較大的位數優先刪
st.pop_back();
--k;
}
st.push_back(c);
}
st.resize(st.size() - k); // k 還有殘額(原字串非遞減),從尾巴砍
int p = st.find_first_not_of('0');
return p == string::npos ? "0" : st.substr(p);
}
};
兩族的差別值得記牢:
| 結算型(0084、0907…) | 構造型(0402、0316…) | |
|---|---|---|
| 棧裡存什麼 | index(要算距離、要回查值) | 值本身(就是答案的一部分) |
| 彈出代表 | 右界確定,結算並累加 | 這個元素被丟棄 |
| 最後的棧 | 用哨兵逼空 | 就是答案 |
| 額外狀態 | 通常沒有 | 常有配額 k、visited 集合 |
k 用不完(原字串本身非遞減,例如 "12345" 刪 2 位)必須從尾巴砍,不砍就少刪了;前導零要去掉;全刪光要回傳 "0"。這三個邊界比主迴圈更容易寫錯——"10200" 刪 1 位應得 "200","10" 刪 2 位應得 "0"。
五、共通陷阱
[1,2,3] 全程不觸發 pop,迴圈跑完後三個元素還躺在棧裡、一次都沒結算。做法是讓 i 多跑一輪到 n,把值視為 INT_MIN(找更小時用 0 或負無窮)逼所有殘留彈出,比在迴圈外複製一份 flush 程式碼乾淨。求「更大」時哨兵要用 INT_MAX 方向的極值。
index 能還原值(a[st.back()])、又能算距離 i - j 和寬度;值卻還原不了 index。index 是資訊量嚴格更大的那個。只有在原陣列之後會被修改、或資料是串流無法回查時,才需要額外存值。
stack<int> 的底層是 deque,不是 vector
單調棧只用到尾端 push/pop/peek,直接用 vector<int> 配 back()/pop_back()/push_back() 更快。同一台機器 n = 1e5 各跑 25 次取最佳:棧很深的非遞減輸入 0.260 ms(deque)對 0.116 ms(vector),進出頻繁的鋸齒輸入 0.186 ms 對 0.122 ms;隨機輸入落在噪音內(0.766 對 0.741),因為時間都花在 cache miss。要保留 std::stack 的語意又要速度,寫 stack<int, vector<int>>。詳見 Micro-Optimization-Myths。
(mid - l) * (i - mid) 兩個因子都可到 n,乘積是 O(n²)——n = 1e5 就已經 1e10,int 直接爆。乘之前先轉 long long,取模題要邊乘邊取模。
六、和鄰近工具的分界
| 手法 | 適用 | 和單調棧的差別 |
|---|---|---|
| 單調棧 | 找兩側第一個更大/更小;子陣列以極值為主角 | 只從尾端進出 |
| 單調 deque | 固定寬度視窗的極值(0239-Sliding-Window-Maximum) | 兩端都要彈——因為左端會滑出視窗 |
| 前綴和 | 子陣列的和、平均 | 和跟順序無關,不需要「第一個更大」的結構 |
| Sparse-Table-RMQ | 任意區間極值、查詢多次 | 支援隨機查詢;單調棧只走一遍、無法回頭問 |
| Segment-Tree | 帶修改的區間極值 | 單調棧要求陣列靜態 |
單調棧掃一遍算出的每個元素的 (left, right),正是它在笛卡爾樹(以極值為根遞迴分割的樹)上的子樹範圍;棧本身就是笛卡爾樹最右鏈。用單調棧線性建笛卡爾樹是標準做法。看懂這層之後,「為什麼區間恰好不重不漏地被切分」就變成一句話:因為它們是一棵樹的子樹。
Related Problems
0084-Largest-Rectangle-in-Histogram — 結算型的集大成,左右界都要
0739-Daily-Temperatures — 結算型最乾淨的原型,只要右界
0907-Sum-of-Subarray-Minimums — 貢獻法的標準題,也是嚴格性陷阱的教科書案例
0042-Trapping-Rain-Water — 遞減棧,逐層結算凹槽
0402-Remove-K-Digits — 構造型的原型
0853-Car-Fleet — 同款「新元素進場結算舊帳」,改成由右往左掃
0239-Sliding-Window-Maximum — 換成 deque 的近親,攤還論證完全相同