Monotonic-Stack

這篇在解決什麼

一整族題目長得完全不一樣——最大矩形、接雨水、等幾天會變暖、所有子陣列最小值的總和——但骨架是同一個:對每個元素,找出兩側第一個比它大/小的位置。暴力對每個元素往兩邊掃是 O(n²),單調棧一趟 O(n) 解決。

難的不是資料結構(就是個棧),而是三件事:

  1. 什麼時候該算——多數人的第一版會在每一步重掃整個棧,退化回 O(n²)
  2. 比較用 > 還是 >=——重複值一處理錯就重複計算,而且小測資看不出來
  3. 這題到底屬不屬於這個模式——判斷訊號長什麼樣

一、核心:彈出的那一刻,資訊才完備

棧裡放的是「還沒結案的元素」。一個元素進棧時,它的左界已知(見下)、右界未知,只能先掛帳。當某個新元素把它擠出去,那個新元素就是它的右界——這一刻左右都齊了,算一次,結案。

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 這個動作自己把它交出來。而右界只能等,等到有人把它擠出去為止。整個模式就建立在這個不對稱上。

為什麼 while 套 for 還是 O(n)

複雜度要數「元素進出棧的總次數」,不是數迴圈層數。每個 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,不存值

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),正是它在笛卡爾樹(以極值為根遞迴分割的樹)上的子樹範圍;棧本身就是笛卡爾樹最右鏈。用單調棧線性建笛卡爾樹是標準做法。看懂這層之後,「為什麼區間恰好不重不漏地被切分」就變成一句話:因為它們是一棵樹的子樹。

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 的近親,攤還論證完全相同