单调栈:用一个栈,干掉所有「下一个更大」

栈里的元素始终保持有序,这个约束本身就是信息。


写在前面

「下一个更大的元素」「接雨水」「柱状图中的最大矩形」——这几道题第一次见很容易想到暴力,两层循环,O(n²)。

但如果你知道单调栈,这类题可以直接 O(n) 解决,而且代码并不复杂,难点只在于想清楚「栈里存什么」「什么时候弹出」。


单调栈是什么

单调栈就是普通的栈,但加了一个约束:栈内元素始终保持单调递增或单调递减的顺序

每次要压入新元素时,先把栈顶里不满足单调性的元素弹出,再压入。被弹出的那一刻,就是信息被「提取」的时刻。

两种形态

单调递增栈(从栈底到栈顶递增):

1
2
3
4
栈底 → 栈顶:小 → 大

压入元素时,弹出所有 >= 新元素 的栈顶
→ 被弹出时,新元素就是它「右边第一个更小的元素」

单调递减栈(从栈底到栈顶递减):

1
2
3
4
栈底 → 栈顶:大 → 小

压入元素时,弹出所有 <= 新元素 的栈顶
→ 被弹出时,新元素就是它「右边第一个更大的元素」

一句话记忆

元素被弹出的那一刻,触发弹出的新元素就是它苦苦等待的「答案」。


例题讲解

例题一:每日温度(LeetCode 739)

给定一个整数数组 temperatures,对于每天,找出下一个温度更高的那天还要等几天。如果之后没有更高温度的天,填 0。

输入:temperatures = [73, 74, 75, 71, 69, 72, 76, 73]
输出:[1, 1, 4, 2, 1, 1, 0, 0]

问题分析

对每个位置,找右边第一个比它大的元素的位置差。暴力 O(n²),单调栈 O(n)。

思路

单调递减栈(栈内温度从大到小):

  • 栈里存下标,不存温度值(这样能算距离)
  • 遇到比栈顶温度更高的新元素,把栈顶弹出,答案就是当前下标减栈顶下标
  • 遍历完后,栈里剩下的元素说明后面没有更高温度,答案为 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
vector<int> dailyTemperatures(vector<int>& temperatures) {
int n = temperatures.size();
vector<int> ans(n, 0);
stack<int> st; // 存下标,单调递减(栈顶温度最小)

for (int i = 0; i < n; i++) {
// 当前温度比栈顶更高,栈顶等到答案了
while (!st.empty() && temperatures[i] > temperatures[st.top()]) {
int j = st.top(); st.pop();
ans[j] = i - j; // 等了 i-j 天
}
st.push(i); // 当前下标入栈,等待右边更高温度
}

return ans; // 没弹出的下标,ans 默认 0
}

执行过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
temperatures = [73, 74, 75, 71, 69, 72, 76, 73]
indices = [0, 1, 2, 3, 4, 5, 6, 7]

i=0: 栈空,压入 0 栈: [0] ans: [0,0,0,0,0,0,0,0]
i=1: 74>73,弹出 0,ans[0]=1-0=1 栈: []
压入 1 栈: [1] ans: [1,0,0,0,0,0,0,0]
i=2: 75>74,弹出 1,ans[1]=2-1=1 栈: []
压入 2 栈: [2] ans: [1,1,0,0,0,0,0,0]
i=3: 71<75,直接压入 栈: [2,3]
i=4: 69<71,直接压入 栈: [2,3,4]
i=5: 72>69,弹出 4,ans[4]=5-4=1
72>71,弹出 3,ans[3]=5-3=2
72<75,停止,压入 5 栈: [2,5] ans: [1,1,0,2,1,0,0,0]
i=6: 76>72,弹出 5,ans[5]=6-5=1
76>75,弹出 2,ans[2]=6-2=4
栈空,压入 6 栈: [6] ans: [1,1,4,2,1,1,0,0]
i=7: 73<76,直接压入 栈: [6,7]

遍历结束,栈中 [6,7] 未弹出,ans 保持 0

最终:[1, 1, 4, 2, 1, 1, 0, 0]

复杂度

  • 时间:O(n),每个元素最多入栈一次、出栈一次
  • 空间:O(n),栈的空间

例题二:下一个更大元素 II(LeetCode 503)

给定一个循环数组,找每个元素的下一个更大元素。

输入:nums = [1, 2, 1]
输出:[2, -1, 2](第 3 个 1 的下一个更大是开头的 2)

问题分析

和 739 类似,但数组是循环的,最后几个元素要能「绕回来」看前面的元素。

思路

把数组遍历两遍(下标对 n 取模模拟循环),但不需要真正复制数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
vector<int> nextGreaterElements(vector<int>& nums) {
int n = nums.size();
vector<int> ans(n, -1);
stack<int> st; // 存下标

for (int i = 0; i < 2 * n; i++) { // 遍历两遍
int idx = i % n; // 循环下标
while (!st.empty() && nums[idx] > nums[st.top()]) {
ans[st.top()] = nums[idx];
st.pop();
}
if (i < n) st.push(idx); // 第二遍不再压入(避免重复)
}

return ans;
}

执行过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
nums = [1, 2, 1],n=3

第一遍(i=0~2):
i=0: idx=0,栈空,压入 0 栈: [0]
i=1: idx=1,2>1,弹出 0,ans[0]=2 栈: []
压入 1 栈: [1]
i=2: idx=2,1<2,压入 2 栈: [1,2]

第二遍(i=3~5,只弹不压):
i=3: idx=0,nums[0]=1,1<2,没有弹出
i=4: idx=1,nums[1]=2,2>1(栈顶2),弹出 2,ans[2]=2
2>1(下一栈顶1的值是2,2==2?不大于),停止
此处 nums[1]=2,栈顶是 [1],nums[1]=2 不大于 nums[1]=2,停止
i=5: idx=2,nums[2]=1,1<2,没有弹出

最终:[2, -1, 2]

复杂度

  • 时间:O(n)
  • 空间:O(n)

例题三:柱状图中最大的矩形(LeetCode 84)

给定一个整数数组 heights 表示柱状图中各柱的高度,找出能勾勒出的最大矩形面积。

输入:heights = [2, 1, 5, 6, 2, 3]
输出:10

问题分析

对每根柱子,想知道以它的高度为矩形高度,最远能向左、向右延伸到哪里——也就是找左边第一个比它矮的柱子、右边第一个比它矮的柱子。

这正是单调递增栈的用武之地:元素被弹出时,触发弹出的右边元素就是它右边第一个更矮的,栈中前一个元素就是它左边第一个更矮的。

思路

在数组两端各加一个高度为 0 的哨兵,避免特判边界:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int largestRectangleArea(vector<int>& heights) {
// 加头尾哨兵(高度为 0),保证所有元素最终都被弹出
heights.insert(heights.begin(), 0);
heights.push_back(0);

int n = heights.size();
int ans = 0;
stack<int> st; // 单调递增栈,存下标

for (int i = 0; i < n; i++) {
// 当前高度比栈顶更矮,栈顶找到了右边界
while (!st.empty() && heights[i] < heights[st.top()]) {
int h = heights[st.top()]; st.pop(); // 被弹出柱子的高度
int left = st.top(); // 左边界(栈中前一个)
int width = i - left - 1; // 宽度
ans = max(ans, h * width);
}
st.push(i);
}

return ans;
}

为什么加哨兵?

  • 头部哨兵(高度 0):确保任何柱子都能在左边找到比它矮的边界,不用特判空栈
  • 尾部哨兵(高度 0):确保遍历结束时栈里剩余的柱子都能被弹出处理

执行过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
加哨兵后 heights = [0, 2, 1, 5, 6, 2, 3, 0]
indices = [0, 1, 2, 3, 4, 5, 6, 7]

i=0: h=0,栈空,压入 0 栈: [0]
i=1: h=2>0,压入 1 栈: [0,1]
i=2: h=1<2,弹出 1
h=2,left=0,width=2-0-1=1,area=2*1=2,ans=2
h=1>=h[0]=0,压入 2 栈: [0,2]
i=3: h=5>1,压入 3 栈: [0,2,3]
i=4: h=6>5,压入 4 栈: [0,2,3,4]
i=5: h=2<6,弹出 4
h=6,left=3,width=5-3-1=1,area=6*1=6,ans=6
h=2<5,弹出 3
h=5,left=2,width=5-2-1=2,area=5*2=10,ans=10
h=2>=h[2]=1,压入 5 栈: [0,2,5]
i=6: h=3>2,压入 6 栈: [0,2,5,6]
i=7: h=0<3,弹出 6
h=3,left=5,width=7-5-1=1,area=3*1=3
h=0<2,弹出 5
h=2,left=2,width=7-2-1=4,area=2*4=8
h=0<1,弹出 2
h=1,left=0,width=7-0-1=6,area=1*6=6
h=0>=h[0]=0,停止,压入 7

ans = 10

复杂度

  • 时间:O(n)
  • 空间:O(n)

例题四:接雨水(LeetCode 42)

给定 n 个非负整数,每个数表示柱子的高度,计算下雨后能接到多少雨水。

输入:height = [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]
输出:6

问题分析

某个位置能接多少水,由它左边最高柱子和右边最高柱子中的较小者减去自身高度决定。暴力做法 O(n²),有 O(n) 的双指针和单调栈两种解法,这里展示单调栈。

思路

单调递减栈(栈顶最小):当发现新柱子比栈顶高,说明出现了一个「凹槽」,可以接水:

  • 弹出栈顶(凹槽底部)
  • 新的栈顶是左边界,当前下标是右边界
  • 宽度 = 右边界 - 左边界 - 1
  • 高度 = min(左边界高度, 右边界高度) - 凹槽底部高度
  • 面积 = 宽度 × 高度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int trap(vector<int>& height) {
int ans = 0;
stack<int> st; // 单调递减栈,存下标

for (int i = 0; i < (int)height.size(); i++) {
while (!st.empty() && height[i] > height[st.top()]) {
int bottom = st.top(); st.pop(); // 凹槽底部
if (st.empty()) break; // 没有左边界,接不了水

int left = st.top(); // 左边界
int width = i - left - 1;
int h = min(height[left], height[i]) - height[bottom]; // 水的高度
ans += width * h;
}
st.push(i);
}

return ans;
}

执行过程(简化版,取关键步骤):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
height = [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]
idx = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11]

i=0: h=0,压入 0 栈: [0]
i=1: h=1>0,弹出 0(bottom)
栈空,无左边界,break
压入 1 栈: [1]
i=2: h=0<1,压入 2 栈: [1,2]
i=3: h=2>0,弹出 2(bottom=0)
left=1,width=3-1-1=1
h=min(1,2)-0=1
ans += 1*1=1 ans=1
h=2>1,弹出 1(bottom=1)
栈空,break
压入 3 栈: [3]
...(继续类似步骤,i=4 压入 4,i=5 压入 5,i=6 会先弹出 5,再压入 6)

关键步骤 i=7(h=3):
此时栈为 [3,4,6],对应高度 [2,1,1]
弹出 6(bottom=1):left=4,right=7,width=7-4-1=2
h=min(height[4], height[7]) - height[6] = min(1,3)-1 = 0,ans 不变
弹出 4(bottom=1):left=3,right=7,width=7-3-1=3
h=min(height[3], height[7]) - height[4] = min(2,3)-1 = 1,ans += 3,变为 5
继续弹出 3 后栈空,停止
压入 7,栈: [7]

后续 i=10 时还能再接 1 格水,最终 ans = 6

复杂度

  • 时间:O(n)
  • 空间:O(n)

双指针解法(补充)

接雨水还有一个空间 O(1) 的双指针解法,思路不同但更简洁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int trap(vector<int>& height) {
int left = 0, right = height.size() - 1;
int left_max = 0, right_max = 0;
int ans = 0;

while (left < right) {
left_max = max(left_max, height[left]);
right_max = max(right_max, height[right]);

if (left_max < right_max) {
ans += left_max - height[left]; // 由左边的矮墙决定
left++;
} else {
ans += right_max - height[right]; // 由右边的矮墙决定
right--;
}
}

return ans;
}

两种方法都是 O(n),双指针空间更优,单调栈思路更统一,面试时能说出两种更好。


容易踩的坑

1. 栈里存下标,不存值

很多题需要计算宽度(位置差),所以栈里要存下标,通过下标找到对应的值。初学容易直接存值导致算不出距离。

1
2
3
4
5
// ❌ 只存值,算不出位置差
stack<int> st; // 存的是 heights[i]

// ✅ 存下标,需要值时通过 heights[st.top()] 获取
stack<int> st; // 存的是 i

2. 弹出后别忘了判断栈是否为空

LeetCode 84 和 42 里,弹出元素后要找「前一个」作为左边界,但这时栈可能已经空了,要先判断。

1
2
3
int bottom = st.top(); st.pop();
if (st.empty()) break; // 没有左边界,跳过
int left = st.top();

3. 递增栈 vs 递减栈

目标 栈的类型 被弹出的时机
找右边第一个更大 单调递减栈 压入更大元素时
找右边第一个更小 单调递增栈 压入更小元素时
最大矩形面积 单调递增栈 压入更小高度时
接雨水 单调递减栈 压入更大高度时

判断的时候想一句:「我什么时候能知道某个元素的答案?」—— 就是它被弹出的那一刻。

4. 取等号的问题

弹出条件用 > 还是 >= 会影响「相等元素要不要保留在栈里」:

  • >:遇到相等不弹,保留旧元素,适合把相等值也当作潜在边界的写法
  • >=:遇到相等也弹,适合希望边界保持严格更小 / 更大、避免重复计算的写法

关键不是“结果不对就试一下”,而是先想清楚:相等元素在这道题里算不算有效边界


总结

mindmap
  root((单调栈))
    核心思想
      栈内保持单调性
      被弹出时提取答案
      每个元素最多入栈出栈各一次 O(n)
    两种栈
      单调递减栈
        找右边第一个更大
        接雨水
      单调递增栈
        找右边第一个更小
        最大矩形面积
    经典题目
      739 每日温度
      503 下一个更大元素II 循环数组
      84 柱状图最大矩形
      42 接雨水
    常见陷阱
      栈存下标不存值
      弹出后检查栈是否为空
      递增递减选错
      哨兵简化边界处理

各题对比

题目 栈类型 弹出条件 弹出时计算
739 每日温度 递减 新元素更大 距离差
503 下一个更大 递减 新元素更大 新元素值
84 最大矩形 递增 新元素更小 高 × 宽
42 接雨水 递减 新元素更大 凹槽面积

单调栈的核心就是一句话:让每个元素在被弹出的时候,告诉你它想告诉你的信息。 想清楚「什么时候弹」「弹出时能知道什么」,这类题基本上就拿下了。


推荐阅读