栈与单调栈专题
Hot100 栈专题共 4 题:有效的括号、字符串解码、每日温度、柱状图中最大的矩形。前两题是栈的基础应用,后两题是单调栈的经典场景。单调栈是这个专题的难点,也是竞赛中高频出现的技巧。
一、前置知识:单调栈
普通的栈只保证后进先出,单调栈在此基础上额外维护栈内元素的单调性(单调递增或单调递减)。
单调递增栈:栈底到栈顶单调递增,新元素入栈前,把所有比它大的元素弹出。
单调递减栈:栈底到栈顶单调递减,新元素入栈前,把所有比它小的元素弹出。
单调栈解决的核心问题是:对于数组中的每个元素,找到它左边/右边第一个比它大或比它小的元素 。暴力做法是 O(n2)O(n^2)O(n2),单调栈可以做到 O(n)O(n)O(n)。
以找每个元素右边第一个更大元素为例,用单调递减栈:
nums = [2, 1, 5, 3, 4]
遍历到2:栈空,直接入栈,栈=[2]
遍历到1:1<2,不破坏单调性,入栈,栈=[2,1]
遍历到5:5>1,弹出1,记录1的右边第一个更大元素=5
5>2,弹出2,记录2的右边第一个更大元素=5
栈空,5入栈,栈=[5]
遍历到3:3<5,入栈,栈=[5,3]
遍历到4:4>3,弹出3,记录3的右边第一个更大元素=4
4<5,入栈,栈=[5,4]
遍历结束,栈中剩余[5,4],它们右边没有更大元素,答案为-1
结果:2→5, 1→5, 5→-1, 3→4, 4→-1
被弹出的那一刻,就是找到了答案的时机。
二、有效的括号(#20)
题意
给一个只含 (、)、{、}、[、] 的字符串 s,判断括号是否有效(开括号必须按正确顺序闭合)。
输入:"()[]{}" 输出:true
输入:"(]" 输出:false
输入:"([)]" 输出:false
输入:"{[]}" 输出:true
思路
用栈维护还未匹配的开括号。
遍历字符串:
- 遇到开括号
(、[、{,压入栈 - 遇到闭括号,检查栈顶是否是对应的开括号:
- 是:弹出栈顶,继续
- 否(栈为空或不匹配):直接返回
false
遍历结束后,栈为空说明所有括号都匹配,否则有未闭合的开括号。
s = "{[]}"
遍历'{':栈=['{']
遍历'[':栈=['{','[']
遍历']':栈顶'['对应']' ✓,弹出,栈=['{']
遍历'}':栈顶'{'对应'}' ✓,弹出,栈=[]
栈为空,返回true ✓
s = "([)]"
遍历'(':栈=['(']
遍历'[':栈=['(','[']
遍历')':栈顶'['对应']',但当前是')',不匹配 → 返回false ✓
代码
cpp
class Solution {
public:
bool isValid(string s) {
stack<char> stk;
for (char c : s) {
if (c == '(' || c == '[' || c == '{') {
stk.push(c); // 开括号直接入栈
} else {
// 闭括号:栈为空或栈顶不匹配,直接返回false
if (stk.empty()) return false;
if (c == ')' && stk.top() != '(') return false;
if (c == ']' && stk.top() != '[') return false;
if (c == '}' && stk.top() != '{') return false;
stk.pop(); // 匹配成功,弹出栈顶
}
}
return stk.empty(); // 栈为空说明全部匹配
}
};
复杂度
- 时间 :O(n)O(n)O(n),每个字符入栈出栈各一次
- 空间 :O(n)O(n)O(n),栈最多存 n/2n/2n/2 个开括号
三、字符串解码(#394)
题意
给编码规则 k[encoded_string],将其解码。k 为正整数,表示 encoded_string 重复 k 次。括号可以嵌套。
输入:"3[a]2[bc]" 输出:"aaabcbc"
输入:"3[a2[c]]" 输出:"accaccacc"
输入:"2[abc]3[cd]ef" 输出:"abcabccdcdcdef"
思路
括号可以嵌套,用栈处理嵌套关系。
遍历字符串,维护两个栈:
numStack:存数字(重复次数)strStack:存字符串(当前层已构建的内容)
同时维护当前数字 curNum 和当前字符串 curStr。
遍历规则:
-
遇到数字:累积到
curNum(数字可能是多位数) -
遇到
[:把curNum和curStr分别压入对应的栈,重置为 0 和空串,开始处理内层 -
遇到
]:弹出栈顶数字和字符串,把curStr重复对应次数后拼接到弹出的字符串后面 -
遇到字母:直接追加到
curStrs = "3[a2[c]]"
遍历'3':curNum=3
遍历'[':numStack=[3],strStack=[""],curNum=0,curStr=""
遍历'a':curStr="a"
遍历'2':curNum=2
遍历'[':numStack=[3,2],strStack=["","a"],curNum=0,curStr=""
遍历'c':curStr="c"
遍历']':弹出num=2,弹出prevStr="a"
curStr = "a" + "c"*2 = "acc"
遍历']':弹出num=3,弹出prevStr=""
curStr = "" + "acc"*3 = "accaccacc"最终:"accaccacc" ✓
注意多位数的处理:遇到连续数字字符时,要用 curNum = curNum * 10 + (c - '0') 累积,而不是直接赋值。
代码
cpp
class Solution {
public:
string decodeString(string s) {
stack<int> numStack;
stack<string> strStack;
string curStr = "";
int curNum = 0;
for (char c : s) {
if (isdigit(c)) {
curNum = curNum * 10 + (c - '0'); // 处理多位数
} else if (c == '[') {
numStack.push(curNum); // 保存当前重复次数
strStack.push(curStr); // 保存当前已构建的字符串
curNum = 0; // 重置,准备处理内层
curStr = "";
} else if (c == ']') {
int num = numStack.top(); numStack.pop();
string prevStr = strStack.top(); strStack.pop();
string repeated = "";
for (int i = 0; i < num; i++) repeated += curStr;
curStr = prevStr + repeated; // 拼接到外层字符串后面
} else {
curStr += c; // 普通字母直接追加
}
}
return curStr;
}
};
复杂度
- 时间 :O(n⋅k)O(n \cdot k)O(n⋅k),kkk 为最大重复次数,字符串拼接的总长度取决于输出长度
- 空间 :O(n)O(n)O(n),栈的深度等于括号嵌套层数
四、每日温度(#739)
题意
给一个整数数组 temperatures,表示每天的气温,返回数组 answer,answer[i] 表示第 i 天之后需要等多少天才能等到更高的气温,如果之后不存在更高气温则为 0。
输入:[73,74,75,71,69,72,76,73]
输出:[1,1,4,2,1,1,0,0]
第0天73度,第1天74度更高,等1天 → answer[0]=1
第2天75度,第6天76度更高,等4天 → answer[2]=4
思路
单调递减栈,栈里存下标,栈内对应的温度单调递减。
遍历到第 i 天时:
-
如果当前温度
temperatures[i]大于栈顶下标对应的温度,说明找到了栈顶那天的"下一个更高温度" -
弹出栈顶
j,answer[j] = i - j -
重复上述操作直到栈为空或栈顶温度 ≥\geq≥ 当前温度
-
把当前下标
i入栈temperatures = [73,74,75,71,69,72,76,73]
i=0(73):栈空,入栈,栈=[0]
i=1(74):74>73,弹出0,answer[0]=1-0=1,栈空,入栈,栈=[1]
i=2(75):75>74,弹出1,answer[1]=2-1=1,栈空,入栈,栈=[2]
i=3(71):71<75,入栈,栈=[2,3]
i=4(69):69<71,入栈,栈=[2,3,4]
i=5(72):72>69,弹出4,answer[4]=5-4=1
72>71,弹出3,answer[3]=5-3=2
72<75,入栈,栈=[2,5]
i=6(76):76>72,弹出5,answer[5]=6-5=1
76>75,弹出2,answer[2]=6-2=4
栈空,入栈,栈=[6]
i=7(73):73<76,入栈,栈=[6,7]遍历结束,栈中剩余[6,7],answer[6]=answer[7]=0(默认值)
结果:[1,1,4,2,1,1,0,0] ✓
代码
cpp
class Solution {
public:
vector<int> dailyTemperatures(vector<int>& temperatures) {
int n = temperatures.size();
vector<int> answer(n, 0); // 默认全为0
stack<int> stk; // 单调递减栈,存下标
for (int i = 0; i < n; i++) {
// 当前温度比栈顶更高,找到了栈顶的答案
while (!stk.empty() && temperatures[i] > temperatures[stk.top()]) {
int j = stk.top(); stk.pop();
answer[j] = i - j;
}
stk.push(i); // 当前下标入栈
}
return answer;
}
};
复杂度
- 时间 :O(n)O(n)O(n),每个下标最多入栈一次、出栈一次
- 空间 :O(n)O(n)O(n),栈最多存 nnn 个下标
五、柱状图中最大的矩形(#84)
题意
给一个数组 heights,表示柱状图中每根柱子的高度,找出柱状图中能勾勒出来的最大矩形面积。
输入:heights = [2,1,5,6,2,3]
输出:10(高度为5和6的两根柱子,宽度为2,面积=10)
#
# #
# # #
# # # #
# # # #
# # # # #
2 1 5 6 2 3
思路
对每根柱子,能构成的最大矩形高度就是这根柱子的高度,宽度取决于左边第一根比它矮的柱子 和右边第一根比它矮的柱子之间的距离。
暴力做法枚举每根柱子,向左右两侧线性扫描找边界,O(n2)O(n^2)O(n2)。用单调递增栈 可以在 O(n)O(n)O(n) 内同时找到每根柱子左右两侧的边界。
单调递增栈的作用 :当一根柱子 i 比栈顶柱子 top 矮时,说明 top 的右边界就是 i,而 top 的左边界就是弹出 top 后新的栈顶(即 top 左边第一根比它矮的柱子)。
哨兵技巧:在数组首尾各加一根高度为 0 的柱子,确保所有柱子都能被弹出(不用单独处理栈中剩余元素),代码更简洁。
heights = [2,1,5,6,2,3]
加哨兵后:[0,2,1,5,6,2,3,0](下标0和7是哨兵)
遍历:
i=0(0):栈空,入栈,栈=[0]
i=1(2):2>0,入栈,栈=[0,1]
i=2(1):1<2,弹出1(高度2)
right=2,left=栈顶0,宽度=2-0-1=1,面积=2*1=2
1>0,入栈,栈=[0,2]
i=3(5):5>1,入栈,栈=[0,2,3]
i=4(6):6>5,入栈,栈=[0,2,3,4]
i=5(2):2<6,弹出4(高度6)
right=5,left=栈顶3,宽度=5-3-1=1,面积=6*1=6
2<5,弹出3(高度5)
right=5,left=栈顶2,宽度=5-2-1=2,面积=5*2=10 ← 最大
2>1,入栈,栈=[0,2,5]
i=6(3):3>2,入栈,栈=[0,2,5,6]
i=7(0):0<3,弹出6(高度3)
right=7,left=栈顶5,宽度=7-5-1=1,面积=3*1=3
0<2,弹出5(高度2)
right=7,left=栈顶2,宽度=7-2-1=4,面积=2*4=8
0<1,弹出2(高度1)
right=7,left=栈顶0,宽度=7-0-1=6,面积=1*6=6
栈=[0],0的高度是0,不再弹出
最大面积=10 ✓
面积计算公式:height[top] * (right - left - 1),其中 right 是当前触发弹出的下标,left 是弹出后新栈顶的下标,中间的宽度是 right - left - 1。
代码
cpp
class Solution {
public:
int largestRectangleInHistogram(vector<int>& heights) {
// 首尾各加一个高度为0的哨兵
heights.insert(heights.begin(), 0);
heights.push_back(0);
stack<int> stk; // 单调递增栈,存下标
int res = 0;
for (int i = 0; i < heights.size(); i++) {
// 当前高度小于栈顶高度,弹出并计算面积
while (!stk.empty() && heights[i] < heights[stk.top()]) {
int top = stk.top(); stk.pop();
int left = stk.top(); // 左边界(新栈顶)
int width = i - left - 1; // 宽度
int area = heights[top] * width;
res = max(res, area);
}
stk.push(i);
}
return res;
}
};
复杂度
- 时间 :O(n)O(n)O(n),每个下标最多入栈一次、出栈一次
- 空间 :O(n)O(n)O(n),栈 + 哨兵
六、专题小结
| 题目 | 数据结构 | 核心操作 | 时间 | 空间 |
|---|---|---|---|---|
| 有效的括号 | 普通栈 | 遇开括号入栈,遇闭括号检查栈顶 | O(n)O(n)O(n) | O(n)O(n)O(n) |
| 字符串解码 | 双栈 | [ 时保存现场,] 时恢复并拼接 |
O(nk)O(nk)O(nk) | O(n)O(n)O(n) |
| 每日温度 | 单调递减栈 | 温度更高时弹出,差值即为等待天数 | O(n)O(n)O(n) | O(n)O(n)O(n) |
| 柱状图最大矩形 | 单调递增栈 | 高度更低时弹出,左右边界围成矩形 | O(n)O(n)O(n) | O(n)O(n)O(n) |
单调栈的选型规律:
- 找右边第一个更大 的元素 → 单调递减栈(大元素入栈前把小元素弹出,弹出时找到了答案)
- 找右边第一个更小 的元素 → 单调递增栈(小元素入栈前把大元素弹出,弹出时找到了答案)
- 找左边的边界,则在弹出后看新栈顶是什么