📖 第 3.5 章 ⏱️ 约 50 分钟 🎯 中级

第 3.5 章:单调栈与单调队列

📝 前置条件: 确保你熟悉双指针/滑动窗口(第 3.4 章)和基本的栈/队列操作(第 3.1 章)。本章直接建立在这些技术之上。

单调栈和单调队列是优雅的工具,能在 O(N) 时间内解决「最近更大/更小元素」和「滑动窗口极值」问题——而朴素做法需要 O(N²)。


3.5.1 单调栈:下一个更大元素

题目: 给定 N 个整数的数组 A,对每个元素 A[i],找下一个更大元素(NGE):i 右侧第一个大于 A[i] 的元素的下标。若不存在,输出 -1。

朴素做法: O(N²) —— 对每个 i,向右扫描直到找到更大的元素。

单调栈做法: O(N) —— 维护一个从底到顶始终递减的栈。压入新元素时,先弹出所有更小的元素(它们刚找到了自己的 NGE!)。

💡 核心思路: 栈中存放还未找到 NGE 的元素的下标。当 A[i] 到来时,栈中所有小于 A[i] 的元素都找到了 NGE(就是 i!)。弹出它们并记录答案。

单调栈状态变化——对 A = [2, 1, 5, 6, 2, 3] 逐步追踪:

Monotonic Stack NGE

📄 ![Monotonic Stack NGE](../images/monotonic_stack_nge.svg)
数组 A:[2, 1, 5, 6, 2, 3]
下标:    0  1  2  3  4  5

处理 i=0 (A[0]=2):栈为空 → 压入 0
栈:[0]          // 栈存放未解决元素的下标

处理 i=1 (A[1]=1):A[1]=1 < A[0]=2 → 直接压入
栈:[0, 1]

处理 i=2 (A[2]=5):
  A[2]=5 > A[1]=1 → 弹出 1,NGE[1] = 2  (A[2]=5 是 A[1] 的下一个更大元素)
  A[2]=5 > A[0]=2 → 弹出 0,NGE[0] = 2  (A[2]=5 是 A[0] 的下一个更大元素)
  栈为空 → 压入 2
栈:[2]

处理 i=3 (A[3]=6):
  A[3]=6 > A[2]=5 → 弹出 2,NGE[2] = 3
  压入 3
栈:[3]

处理 i=4 (A[4]=2):A[4]=2 < A[3]=6 → 直接压入
栈:[3, 4]

处理 i=5 (A[5]=3):
  A[5]=3 > A[4]=2 → 弹出 4,NGE[4] = 5
  A[5]=3 < A[3]=6 → 停止,压入 5
栈:[3, 5]

结束:栈中剩余 [3, 5] → NGE[3] = NGE[5] = -1(右侧无更大元素)

结果:NGE = [2, 2, 3, -1, 5, -1]
验证:
  A[0]=2,下一个更大是 A[2]=5 ✓
  A[1]=1,下一个更大是 A[2]=5 ✓
  A[2]=5,下一个更大是 A[3]=6 ✓
  A[3]=6,无更大 → -1 ✓

完整实现

📄 查看代码:完整实现
// 用单调栈求下一个更大元素 — O(N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<int> A(n);
    for (int& x : A) cin >> x;

    vector<int> nge(n, -1);   // nge[i] = 下一个更大元素的下标,不存在则为 -1
    stack<int> st;             // 单调递减栈(存下标)

    for (int i = 0; i < n; i++) {
        // 当栈顶元素小于 A[i] 时
        // → 当前元素 A[i] 是那些元素的 NGE
        while (!st.empty() && A[st.top()] < A[i]) {
            nge[st.top()] = i;  // ← 关键:记录栈顶的 NGE
            st.pop();
        }
        st.push(i);  // 压入当前下标(尚未解决)
    }
    // 栈中剩余元素没有 NGE → 已初始化为 -1

    for (int i = 0; i < n; i++) {
        cout << nge[i];
        if (i < n - 1) cout << " ";
    }
    cout << "\n";

    return 0;
}

复杂度分析:

  • 每个元素恰好被压入一次最多被弹出一次
  • 总操作:O(2N) = O(N)
  • 空间:O(N)(栈)

⚠️ 常见错误: 在栈中存值而非下标。始终存下标——你需要知道在数组的哪个位置记录答案。


3.5.2 变体:上一个更小、上一个更大

通过改变比较方向和遍历方向,可以得到四个相关问题:

问题栈类型方向使用场景
下一个更大元素(NGE)递减栈从左到右股票价格问题
下一个更小元素(NSE)递增栈从左到右直方图问题
上一个更大元素(PGE)递减栈从右到左区间问题
上一个更小元素(PSE)递增栈从右到左左侧最近更小元素

上一个更小元素的模板:

📄 C++ 完整代码
// 上一个更小元素:对每个 i,找最近的 j < i 使得 A[j] < A[i]
vector<int> pse(n, -1);  // pse[i] = 上一个更小元素的下标,不存在则为 -1
stack<int> st;

for (int i = 0; i < n; i++) {
    while (!st.empty() && A[st.top()] >= A[i]) {
        st.pop();  // 弹出 >= A[i] 的元素(它们不是「上一个更小」)
    }
    pse[i] = st.empty() ? -1 : st.top();  // 栈顶就是上一个更小
    st.push(i);
}

3.5.3 USACO 应用:直方图中的最大矩形

题目: 给定高度数组 H[0..N-1],找能放入直方图内的最大矩形面积。

核心思路: 对每根柱子 i,以 H[i] 为高度的最大矩形向左右延伸,直到遇到更矮的柱子。用单调栈求每个 i 的:

  • left[i] = 上一个更小元素的下标
  • right[i] = 下一个更小元素的下标

每根柱子的左右边界——H = [2, 1, 5, 6, 2, 3]:

Histogram Boundary Computation

💡 公式: 宽度 = right[i] - left[i] - 1面积 = H[i] × 宽度。左边界 = 上一个更小元素的下标;右边界 = 下一个更小元素的下标。

📄 C++ 完整代码
// 直方图中最大矩形 — O(N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<int> H(n);
    for (int& h : H) cin >> h;

    vector<int> left(n), right(n);
    stack<int> st;

    // 上一个更小(左边界)
    for (int i = 0; i < n; i++) {
        while (!st.empty() && H[st.top()] >= H[i]) st.pop();
        left[i] = st.empty() ? -1 : st.top();  // 矩形起点前的下标
        st.push(i);
    }

    while (!st.empty()) st.pop();

    // 下一个更小(右边界)
    for (int i = n - 1; i >= 0; i--) {
        while (!st.empty() && H[st.top()] >= H[i]) st.pop();
        right[i] = st.empty() ? n : st.top();  // 矩形终点后的下标
        st.push(i);
    }

    // 计算最大面积
    long long maxArea = 0;
    for (int i = 0; i < n; i++) {
        long long width = right[i] - left[i] - 1;  // 矩形宽度
        long long area = (long long)H[i] * width;
        maxArea = max(maxArea, area);
    }

    cout << maxArea << "\n";
    return 0;
}

对 H = [2, 1, 5, 6, 2, 3] 的追踪:

left  = [-1, -1, 1, 2, 1, 4]   (上一个更小的下标,-1 = 无)
right = [1, 6, 4, 4, 6, 6]     (下一个更小的下标,n=6 = 无)

宽度:  1-(-1)-1=1, 6-(-1)-1=6, 4-1-1=2, 4-2-1=1, 6-1-1=4, 6-4-1=1
面积:  2×1=2, 1×6=6, 5×2=10, 6×1=6, 2×4=8, 3×1=3

最大面积 = 10
  i=2:H[2]=5,left[2]=1,right[2]=4,宽度=4-1-1=2,面积=5×2=10 ✓
  (下标 2 和 3 的柱子高度都 ≥ 5,所以高度为 5 的矩形跨度为 2)

📌 学习建议: 提交前始终用样例输入手动追踪算法。下标边界的细微差一错误是单调栈问题最常见的 bug。


3.5.4 单调双端队列:滑动窗口最大值

题目: 给定 N 个整数的数组 A 和窗口大小 K,找每个大小为 K 的窗口从左向右滑动时的最大值,输出 N-K+1 个值。

朴素做法: O(NK) —— 对每个窗口扫描求最大值。

单调双端队列做法: O(N) —— 维护一个递减双端队列(队首 = 当前窗口最大值)。

💡 核心思路: 我们想要滑动窗口的最大值。维护一个下标的双端队列,使得:

  1. 双端队列中的值递减(队首始终是最大值)
  2. 双端队列只包含当前窗口内的下标

当新元素到来时:

  • 队尾移除所有更小的元素(只要这个新元素在窗口中,它们就不可能是最大值)
  • 队首已超出当前窗口则移除

逐步追踪

📄 查看代码:逐步追踪
数组 A:[1, 3, -1, -3, 5, 3, 6, 7],K = 3

窗口 [1,3,-1]:最大 = 3
窗口 [3,-1,-3]:最大 = 3
窗口 [-1,-3,5]:最大 = 5
窗口 [-3,5,3]:最大 = 5
窗口 [5,3,6]:最大 = 6
窗口 [3,6,7]:最大 = 7

i=0, A[0]=1:双端队列=[0]
i=1, A[1]=3:3>1 → 弹出 0;双端队列=[1]
i=2, A[2]=-1:-1<3 → 压入;双端队列=[1,2];窗口 [0..2]:最大=A[1]=3 ✓
i=3, A[3]=-3:-3<-1 → 压入;双端队列=[1,2,3];窗口 [1..3]:队首=1 仍在窗口,最大=A[1]=3 ✓
i=4, A[4]=5:5>-3→弹出 3;5>-1→弹出 2;5>3→弹出 1;双端队列=[4];窗口 [2..4]:最大=A[4]=5 ✓
i=5, A[5]=3:3<5→压入;双端队列=[4,5];窗口 [3..5]:队首=4 在窗口,最大=A[4]=5 ✓
i=6, A[6]=6:6>3→弹出 5;6>5→弹出 4;双端队列=[6];窗口 [4..6]:最大=A[6]=6 ✓
i=7, A[7]=7:7>6→弹出 6;双端队列=[7];窗口 [5..7]:最大=A[7]=7 ✓

完整实现

📄 查看代码:完整实现
// 用单调双端队列求滑动窗口最大值 — O(N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    cin >> n >> k;
    vector<int> A(n);
    for (int& x : A) cin >> x;

    deque<int> dq;   // 单调递减双端队列,存下标
    vector<int> result;

    for (int i = 0; i < n; i++) {
        // 1. 移除超出当前窗口的元素
        while (!dq.empty() && dq.front() <= i - k) {
            dq.pop_front();   // ← 关键:弹出已过期的队首
        }

        // 2. 维护递减性质
        //    从队尾移除所有小于 A[i] 的元素
        //    (只要 A[i] 还在窗口中,它们就不可能是最大值)
        while (!dq.empty() && A[dq.back()] <= A[i]) {
            dq.pop_back();    // ← 关键:从队尾弹出更小的元素
        }

        dq.push_back(i);   // 加入当前元素

        // 3. 第一个完整窗口形成后开始记录最大值
        if (i >= k - 1) {
            result.push_back(A[dq.front()]);  // 队首 = 当前窗口最大值
        }
    }

    for (int i = 0; i < (int)result.size(); i++) {
        cout << result[i];
        if (i + 1 < (int)result.size()) cout << "\n";
    }
    cout << "\n";

    return 0;
}

复杂度:

  • 每个元素最多被压入/弹出双端队列一次 → 总计 O(N)
  • 空间:O(K)(双端队列)

⚠️ 常见错误 1: 忘记检查 dq.front() <= i - k 来判断窗口过期。双端队列只能包含 [i-k+1, i] 范围内的下标。

⚠️ 常见错误 2: 从队尾弹出时用 < 而非 <=。用 < 会保留相等元素,但重复值可能导致问题。用 <= 维护严格递减的双端队列。


3.5.5 USACO 题型:股票跨度(单调栈)

🔗 灵感: 这类题型在 USACO Bronze/Silver 中出现(「Haybale Stacking」风格)。

📄 C++ 完整代码
// 股票跨度问题:对每天 i,找在 i 之前有多少连续天价格 <= prices[i]
// (第 i 天的「跨度」)
vector<int> stockSpan(vector<int>& prices) {
    int n = prices.size();
    vector<int> span(n, 1);
    stack<int> st;  // 单调递减栈(存下标)

    for (int i = 0; i < n; i++) {
        while (!st.empty() && prices[st.top()] <= prices[i]) {
            st.pop();
        }
        span[i] = st.empty() ? (i + 1) : (i - st.top());
        st.push(i);
    }
    return span;
}
// span[i] = 到 i 为止(含)价格 <= prices[i] 的连续天数

⚠️ 第 3.5 章常见错误

  1. 存值而非下标 —— 始终存下标。你需要用它们检查窗口范围和记录答案。

  2. 双端队列中比较用 < 而非 <= —— 滑动窗口求最大值时,A[dq.back()] <= A[i] 时弹出(严格非增)。求最小值时,A[dq.back()] >= A[i] 时弹出。

  3. 忘记窗口过期检查 —— 滑动窗口双端队列中,记录最大值前始终检查 dq.front() < i - k + 1(或 <= i - k)。

  4. 栈的底顶方向搞混 —— 「单调」性质指:从底到顶,栈是递增的(用于 NGE)或递减的(用于 NSE)。搞混时画图辅助。

  5. NGE 和 PSE 的处理顺序:

    • 下一个更大元素:从左到右遍历
    • 上一个更大元素:从右到左遍历(或:从左到右遍历,在压入前记录 stack.top())

本章总结

📌 核心要点

问题数据结构时间复杂度关键操作
下一个更大元素(NGE)单调递减栈O(N)找到更大元素时弹出
上一个更小元素(PSE)单调递增栈O(N)压入前栈顶就是答案
直方图中最大矩形单调栈(两遍)O(N)左边界 + 右边界 + 宽度
滑动窗口最大值单调递减双端队列O(N)维护窗口范围 + 维护递减性质

🧩 模板速查

📄 查看代码:🧩 模板速查
// 单调递减栈(用于 NGE / 下一个更大元素)
stack<int> st;
for (int i = 0; i < n; i++) {
    while (!st.empty() && A[st.top()] < A[i]) {
        answer[st.top()] = i;  // i 是 st.top() 的 NGE
        st.pop();
    }
    st.push(i);
}

// 单调递减双端队列(滑动窗口最大值)
deque<int> dq;
for (int i = 0; i < n; i++) {
    while (!dq.empty() && dq.front() <= i - k) dq.pop_front();  // 移除过期元素
    while (!dq.empty() && A[dq.back()] <= A[i]) dq.pop_back();  // 维护单调性
    dq.push_back(i);
    if (i >= k - 1) ans.push_back(A[dq.front()]);
}

❓ 常见问题

Q1:单调栈应该存值还是下标?

A:始终存下标。即使只需要值,存下标更灵活——通过 A[idx] 可以取值,反之不行。特别是计算宽度时(如直方图问题),必须用下标。

Q2:如何判断用单调栈还是双指针?

A:观察问题结构——若需要「对每个元素找其左/右侧第一个更大/更小的元素」,用单调栈。若需要「维护滑动窗口的最大值」,用单调双端队列。若「两指针从两端相向移动」,用双指针。

Q3:为什么单调栈的时间复杂度是 O(N) 而不是 O(N²)?

A:均摊分析。每个元素最多被压入一次,最多被弹出一次,总共 2N 次操作,所以是 O(N)。虽然单次 while 循环可能弹出多个元素,但所有 while 循环的弹出总次数绝不超过 N。


练习题

题目 3.5.1 — 下一个更大元素 🟢 简单 对数组中每个元素,找其右侧第一个更大的元素。若不存在打印 -1。

提示 维护单调递减下标栈。处理 A[i] 时,弹出栈中所有更小的元素(它们找到了 NGE)。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> a(n); for (int& x : a) cin >> x;
    vector<int> nge(n, -1);
    stack<int> st;
    for (int i = 0; i < n; i++) {
        while (!st.empty() && a[st.top()] < a[i]) {
            nge[st.top()] = a[i];
            st.pop();
        }
        st.push(i);
    }
    for (int x : nge) cout << x << " "; cout << "\n";
}

样例: [2,1,5,6,2,3][5,5,6,-1,3,-1] 复杂度: O(N) —— 每个元素最多被压入/弹出一次。


题目 3.5.2 — 每日温度 🟢 简单 对每一天,找还需等多少天才能迎来更高温度。(LeetCode 739 风格)

提示 这正是 NGE 问题。Answer[i] = NGE下标[i] - i。用单调递减栈。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> T(n); for (int& x : T) cin >> x;
    vector<int> ans(n, 0);
    stack<int> st;
    for (int i = 0; i < n; i++) {
        while (!st.empty() && T[st.top()] < T[i]) {
            ans[st.top()] = i - st.top();  // 等待天数
            st.pop();
        }
        st.push(i);
    }
    for (int x : ans) cout << x << " "; cout << "\n";
}

样例: [73,74,75,71,69,72,76,73][1,1,4,2,1,1,0,0]


题目 3.5.3 — 滑动窗口最大值 🟡 中等 找每个大小为 K 的滑动窗口的最大值。

提示 用单调递减双端队列,维护下标范围在 [i-k+1, i] 内,队首 = 最大值。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, k; cin >> n >> k;
    vector<int> a(n); for (int& x : a) cin >> x;
    deque<int> dq;
    for (int i = 0; i < n; i++) {
        while (!dq.empty() && dq.front() < i - k + 1) dq.pop_front();
        while (!dq.empty() && a[dq.back()] <= a[i]) dq.pop_back();
        dq.push_back(i);
        if (i >= k - 1) cout << a[dq.front()] << " \n"[i==n-1];
    }
}

样例: n=8,k=3,[1,3,-1,-3,5,3,6,7][3,3,5,5,6,7] 复杂度: O(N) 总计——每个元素进/出双端队列一次。


题目 3.5.4 — 直方图中最大矩形 🟡 中等 找直方图中最大矩形的面积。

提示 对每根柱子找上一个更小(左边界)和下一个更小(右边界)。宽度 = 右 - 左 - 1,面积 = 高度 × 宽度。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> H(n); for (int& x : H) cin >> x;
    vector<int> left(n), right(n);
    stack<int> st;

    // 上一个更小(左边界)
    for (int i = 0; i < n; i++) {
        while (!st.empty() && H[st.top()] >= H[i]) st.pop();
        left[i] = st.empty() ? -1 : st.top();
        st.push(i);
    }
    while (!st.empty()) st.pop();

    // 下一个更小(右边界)
    for (int i = n-1; i >= 0; i--) {
        while (!st.empty() && H[st.top()] >= H[i]) st.pop();
        right[i] = st.empty() ? n : st.top();
        st.push(i);
    }

    long long ans = 0;
    for (int i = 0; i < n; i++)
        ans = max(ans, (long long)H[i] * (right[i] - left[i] - 1));
    cout << ans << "\n";
}

样例: [2,1,5,6,2,3]10(下标 2 的柱子,高度 5,宽度 2) 复杂度: O(N) —— 两次单调栈遍历。


题目 3.5.5 — 接雨水 🔴 困难 给定高度图,计算下雨后能接住多少水。

提示 对每个位置 i,接水量 = min(left_max[i], right_max[i]) - height[i]。
✅ 完整题解(双指针——O(N) 时间,O(1) 空间)
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> h(n); for (int& x : h) cin >> x;
    int left = 0, right = n-1, maxL = 0, maxR = 0;
    long long ans = 0;
    while (left < right) {
        if (h[left] <= h[right]) {
            maxL = max(maxL, h[left]);
            ans += maxL - h[left];
            left++;
        } else {
            maxR = max(maxR, h[right]);
            ans += maxR - h[right];
            right--;
        }
    }
    cout << ans << "\n";
}

样例: [0,1,0,2,1,0,1,3,2,1,2,1]6 复杂度: O(N) 时间,O(1) 空间。


🏆 挑战题:USACO 2016 February Silver——围牛栏 给定一个多边形,判断一个点是否在多边形内部。使用射线投射法——实现时需要仔细处理边界情况。