概述:为什么栈是算法题里的基础结构
学完二分查找和二分答案之后,我们开始进入一类更偏"数据结构思维"的题型:栈。
栈看起来很简单,只支持从一端加入和删除元素。
但在算法题里,它经常出现在下面这些场景中:
- 括号匹配是否合法
- 字符串消除和撤销操作
- 表达式求值
- 函数调用和递归过程
- 寻找左边或右边第一个更大、更小的元素
- 单调栈解决下一个更大元素、每日温度、柱状图面积等问题
很多初学者刚学栈时,只记住了两个操作:
text
push 入栈
pop 出栈
但真正做题时,关键并不是会不会调用 API,而是要理解:
栈适合处理"最近的、还没有被匹配或解决的元素"。
比如括号匹配中,最后出现的左括号,应该最先被右括号匹配;
比如单调栈中,栈里暂时放着那些"还没找到答案"的元素。
这篇文章会围绕三个高频方向展开:
- 栈的基本概念和使用方式
- 括号匹配类问题
- 表达式和字符串处理
- 单调栈的基本思想与模板
学完这篇,你应该能识别"后进先出""最近匹配""等待被解决"这几类问题,并用栈或单调栈写出清晰的解法。
核心概念:栈到底是什么
栈是一种遵循 后进先出 规则的数据结构。
text
Last In First Out
也就是:
最后放进去的元素,最先被取出来。
可以把栈想象成一摞盘子:
text
顶部
[ 4 ] <- 最后放入,最先取出
[ 3 ]
[ 2 ]
[ 1 ]
底部
常见操作有三个:
| 操作 | 含义 | 时间复杂度 |
|---|---|---|
push |
把元素放入栈顶 | O(1) |
pop |
删除并返回栈顶元素 | O(1) |
peek |
查看栈顶元素但不删除 | O(1) |
在 Java 里,刷算法题更推荐用 Deque 来模拟栈,而不是老旧的 Stack 类:
java
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); // 入栈
stack.push(2);
stack.push(3);
int top = stack.peek(); // 查看栈顶:3
int x = stack.pop(); // 出栈:3
为什么不推荐 Stack
Java 里的 Stack 是比较早期的类,继承自 Vector,很多方法带有同步开销。
在算法题中,我们通常不需要这些额外特性。
所以推荐写法是:
java
Deque<Integer> stack = new ArrayDeque<>();
如果栈里可能存 null,则不能使用 ArrayDeque,因为它不允许 null 元素。
不过算法题里几乎不会把 null 当作正常栈元素,所以这个限制通常不是问题。
栈不是重点在"能存数据",而是重点在"最后加入的未处理元素最先被处理"。
适用场景:什么题应该想到栈
如果题目中出现下面这些信号,可以优先考虑栈:
- 匹配关系:括号、标签、成对符号
- 最近关系:最近一个未被匹配的元素
- 撤销操作:输入退格、路径回退、编辑器撤销
- 嵌套结构:表达式、函数调用、递归展开
- 单调关系:找左边或右边第一个更大、更小的元素
可以用一句话判断:
如果当前元素需要和"前面最近的某个元素"发生关系,栈就很可能有用。
下面我们从最经典的括号匹配开始。
经典题型一:括号匹配
括号匹配是栈最经典的入门题。
题目通常是:
给定一个只包含
(、)、[、]、{、}的字符串,判断括号是否有效。
有效字符串需要满足:
- 左括号必须用相同类型的右括号闭合
- 左括号必须以正确顺序闭合
- 每个右括号都有一个对应的左括号
例如:
text
"()[]{}" -> true
"([{}])" -> true
"(]" -> false
"([)]" -> false
为什么括号匹配适合用栈
看这个字符串:
text
([{}])
扫描过程如下:
text
读到 ( :等待匹配,入栈
读到 [ :等待匹配,入栈
读到 { :等待匹配,入栈
读到 } :应该匹配最近的 {
读到 ] :应该匹配最近的 [
读到 ) :应该匹配最近的 (
最后出现的左括号,必须最先被匹配。
这正好符合栈的后进先出特性。
解题思路
遍历字符串中的每个字符:
- 如果是左括号,就入栈
- 如果是右括号:
- 栈为空,说明没有左括号可以匹配,返回
false - 栈顶不是对应的左括号,返回
false - 否则弹出栈顶,表示匹配成功
- 栈为空,说明没有左括号可以匹配,返回
- 遍历结束后,栈必须为空
流程可以表示为:
text
遇到左括号:入栈,等待匹配
遇到右括号:检查栈顶是否匹配
最后检查:栈是否为空
Java 代码实现
java
import java.util.ArrayDeque;
import java.util.Deque;
class Solution {
public boolean isValid(String s) {
Deque<Character> stack = new ArrayDeque<>();
for (char c : s.toCharArray()) {
if (c == '(' || c == '[' || c == '{') {
stack.push(c);
} else {
if (stack.isEmpty()) {
return false;
}
char left = stack.pop();
if (c == ')' && left != '(') {
return false;
}
if (c == ']' && left != '[') {
return false;
}
if (c == '}' && left != '{') {
return false;
}
}
}
return stack.isEmpty();
}
}
复杂度分析
- 时间复杂度:
O(n),每个字符最多入栈一次、出栈一次 - 空间复杂度:
O(n),最坏情况下所有字符都是左括号
常见错误
- 只统计左右括号数量 :数量相等不代表顺序正确,例如
([)] - 遇到右括号时不判断栈空:会导致空栈弹出异常
- 遍历结束后不检查栈是否为空 :例如
"((("会被误判
右括号永远只能匹配"最近的那个还没被匹配的左括号",所以用栈。
经典题型二:字符串消除与退格模拟
除了括号匹配,栈还很适合处理"撤销前一个字符"这类问题。
例如题目:
给定字符串
s和t,其中#表示退格,判断两个字符串最终是否相等。
示例:
text
s = "ab#c"
t = "ad#c"
s 处理后是 "ac"
t 处理后是 "ac"
结果为 true
为什么可以用栈
退格符 # 的含义是:
删除前面最近的一个普通字符。
这个"最近的一个字符",正好就是栈顶元素。
Java 代码实现
java
import java.util.ArrayDeque;
import java.util.Deque;
class Solution {
public boolean backspaceCompare(String s, String t) {
return build(s).equals(build(t));
}
private String build(String str) {
Deque<Character> stack = new ArrayDeque<>();
for (char c : str.toCharArray()) {
if (c == '#') {
if (!stack.isEmpty()) {
stack.pop();
}
} else {
stack.push(c);
}
}
StringBuilder sb = new StringBuilder();
while (!stack.isEmpty()) {
sb.append(stack.pop());
}
return sb.reverse().toString();
}
}
复杂度分析
- 时间复杂度:
O(n + m) - 空间复杂度:
O(n + m)
其中 n 和 m 分别是两个字符串的长度。
进一步优化
这道题也可以用双指针从后往前扫描,把空间复杂度优化到 O(1)。
但作为栈的入门题,用栈模拟过程最直观,也最容易写对。
当题目要求删除"前面最近的一个有效元素"时,栈通常是最自然的模拟方式。
经典题型三:表达式处理
栈在表达式问题中也非常常见。
原因是表达式往往存在:
- 运算优先级
- 括号嵌套
- 临时结果
- 从左到右扫描时暂时无法立刻计算的内容
最典型的例子是:
计算只包含非负整数、
+、-、*、/的表达式。
例如:
text
"3+2*2" -> 7
为什么表达式可以用栈
如果表达式里只有 + 和 -,从左到右计算比较容易。
但一旦有 * 和 /,就不能简单地按顺序计算。
例如:
text
3 + 2 * 2
乘法优先级更高,所以应该先算 2 * 2。
一种常见做法是:
- 遇到
+num,把num入栈 - 遇到
-num,把-num入栈 - 遇到
*num,弹出栈顶并乘以num,再入栈 - 遇到
/num,弹出栈顶并除以num,再入栈 - 最后把栈里所有数相加
这样乘除法会被立刻处理,加减法被转化成正负数累加。
Java 代码实现
java
import java.util.ArrayDeque;
import java.util.Deque;
class Solution {
public int calculate(String s) {
Deque<Integer> stack = new ArrayDeque<>();
char sign = '+';
int num = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (Character.isDigit(c)) {
num = num * 10 + (c - '0');
}
if ((!Character.isDigit(c) && c != ' ') || i == s.length() - 1) {
if (sign == '+') {
stack.push(num);
} else if (sign == '-') {
stack.push(-num);
} else if (sign == '*') {
stack.push(stack.pop() * num);
} else if (sign == '/') {
stack.push(stack.pop() / num);
}
sign = c;
num = 0;
}
}
int ans = 0;
while (!stack.isEmpty()) {
ans += stack.pop();
}
return ans;
}
}
代码关键点
num用来拼接多位数字,例如123sign表示当前数字前面的运算符- 遇到新运算符时,处理上一个数字
- 最后一个数字后面没有运算符,所以需要用
i == s.length() - 1触发结算
复杂度分析
- 时间复杂度:
O(n) - 空间复杂度:
O(n)
常见错误
- 没有处理多位数 :把
123当成1、2、3 - 忘记处理空格:表达式中可能包含空格
- 最后一个数字没有入栈:因为末尾没有运算符触发计算
- 乘除法直接累加:会破坏运算优先级
栈可以把暂时不能最终结算的数字保存起来,并在遇到高优先级运算时及时修正栈顶。
进阶概念:什么是单调栈
普通栈只强调后进先出。
而单调栈在此基础上,还要求栈内元素保持某种单调性。
常见有两类:
- 单调递增栈:从栈底到栈顶递增
- 单调递减栈:从栈底到栈顶递减
它经常用来解决:
对每个元素,找它左边或右边第一个比它大或比它小的元素。
例如:
text
nums = [2, 1, 2, 4, 3]
如果要找每个元素右边第一个更大的数:
text
2 -> 4
1 -> 2
2 -> 4
4 -> -1
3 -> -1
暴力做法是:对每个位置向右扫描,最坏时间复杂度是 O(n^2)。
单调栈可以把它优化到 O(n)。
单调栈的核心思想
单调栈里存的不是"已经得到答案"的元素,而是:
还没有找到答案、正在等待被后续元素解决的元素。
以"下一个更大元素"为例:
- 当前数字
num比栈顶元素大 - 说明
num就是栈顶元素的下一个更大元素 - 弹出栈顶并记录答案
- 继续比较新的栈顶
- 最后把当前元素入栈,等待它自己的答案
流程如下:
text
遍历当前元素 num
当栈不空,并且 num > 栈顶对应的值:
num 就是栈顶元素的答案
弹出栈顶
当前元素入栈
注意:单调栈里通常存的是下标,而不是值。
因为记录答案时,需要知道答案应该填到哪个位置。
单调栈模板:下一个更大元素
题目:
给定数组
nums,返回每个元素右边第一个比它大的元素。如果不存在,返回-1。
示例:
text
输入:nums = [2, 1, 2, 4, 3]
输出:[4, 2, 4, -1, -1]
Java 代码实现
java
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Deque;
class Solution {
public int[] nextGreaterElement(int[] nums) {
int n = nums.length;
int[] ans = new int[n];
Arrays.fill(ans, -1);
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && nums[i] > nums[stack.peek()]) {
int index = stack.pop();
ans[index] = nums[i];
}
stack.push(i);
}
return ans;
}
}
执行过程示例
以 nums = [2, 1, 2, 4, 3] 为例:
| 当前下标 | 当前值 | 栈中等待元素 | 发生的事 |
|---|---|---|---|
0 |
2 |
空 | 2 入栈 |
1 |
1 |
2 |
1 不比 2 大,入栈 |
2 |
2 |
2, 1 |
2 是 1 的下一个更大元素 |
3 |
4 |
2, 2 |
4 依次解决两个 2 |
4 |
3 |
4 |
3 不比 4 大,入栈 |
最后仍留在栈里的元素,说明右边没有更大元素,答案保持 -1。
复杂度分析
- 时间复杂度:
O(n) - 空间复杂度:
O(n)
虽然代码里有 while 循环,但每个下标最多入栈一次、出栈一次。
所以总复杂度仍然是线性的。
单调栈通过维护一组"还没找到答案的元素",让每个元素只进出栈一次,从而把向右查找优化到 O(n)。
单调栈常见变形
单调栈题目看起来变化很多,但本质经常是下面四类。
| 目标 | 扫描方向 | 栈的维护方式 |
|---|---|---|
| 右边第一个更大元素 | 从左到右 | 当前值大于栈顶时弹出 |
| 右边第一个更小元素 | 从左到右 | 当前值小于栈顶时弹出 |
| 左边第一个更大元素 | 从左到右 | 入栈前弹出小于等于当前值的元素 |
| 左边第一个更小元素 | 从左到右 | 入栈前弹出大于等于当前值的元素 |
这里最容易混淆的是:
有些题是在"当前元素解决栈顶元素",有些题是在"栈顶元素作为当前元素的答案"。
初学阶段不建议死背模板,而应该先问清楚两个问题:
- 我要找的是左边还是右边?
- 我要找的是第一个更大还是第一个更小?
然后再决定扫描方向和弹栈条件。
高频例题:每日温度
题目:
给定一个整数数组
temperatures,表示每天的温度,返回一个数组answer,其中answer[i]表示第i天之后需要等待几天才会出现更高的温度。如果之后都不会升高,返回0。
示例:
text
输入:temperatures = [73, 74, 75, 71, 69, 72, 76, 73]
输出:[1, 1, 4, 2, 1, 1, 0, 0]
这道题本质是:
对每一天,找右边第一个温度更高的日子。
所以可以使用单调栈。
Java 代码实现
java
import java.util.ArrayDeque;
import java.util.Deque;
class Solution {
public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] ans = new int[n];
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
while (!stack.isEmpty()
&& temperatures[i] > temperatures[stack.peek()]) {
int index = stack.pop();
ans[index] = i - index;
}
stack.push(i);
}
return ans;
}
}
为什么答案是 i - index
栈里保存的是还没找到更高温度的日期下标。
当第 i 天温度比栈顶日期更高时,说明:
text
第 i 天就是 index 这一天右边第一个更高温度的日子
等待天数就是:
text
i - index
复杂度分析
- 时间复杂度:
O(n) - 空间复杂度:
O(n)
栈与递归的关系
理解栈时,也可以顺便理解递归。
递归函数调用时,系统会维护一个调用栈。
每次函数调用都会压入一层栈帧,函数执行结束后再弹出。
例如:
java
void dfs(int x) {
if (x == 0) {
return;
}
dfs(x - 1);
}
调用 dfs(3) 的过程大致是:
text
dfs(3) 入栈
dfs(2) 入栈
dfs(1) 入栈
dfs(0) 入栈
dfs(0) 返回并出栈
dfs(1) 返回并出栈
dfs(2) 返回并出栈
dfs(3) 返回并出栈
所以递归天然带有栈的特征。
后面学习 DFS、树遍历、回溯时,这一点会非常重要。
常见坑点:写栈题时最容易错在哪里
1. 出栈前没有判断是否为空
错误写法:
java
char c = stack.pop();
如果栈为空,会直接抛异常。
更稳妥的写法:
java
if (stack.isEmpty()) {
return false;
}
char c = stack.pop();
2. 把栈顶方向想反
push、pop、peek 操作的都是栈顶。
如果你用 ArrayDeque,不要一会儿用 push,一会儿又用 addLast,否则很容易把方向搞乱。
建议统一使用:
java
stack.push(x);
stack.pop();
stack.peek();
3. 单调栈里不知道该存值还是下标
如果只需要比较大小,可以存值。
但大多数题还需要填写答案位置或计算距离,所以更推荐存下标。
例如每日温度:
java
ans[index] = i - index;
这里必须知道原来的下标,所以栈里应该存 index。
模板总结:普通栈与单调栈怎么选
普通栈模板
适用于括号匹配、退格、字符串消除、表达式模拟等问题。
java
Deque<Integer> stack = new ArrayDeque<>();
for (int x : nums) {
if (需要入栈) {
stack.push(x);
} else {
if (!stack.isEmpty()) {
int top = stack.pop();
// 根据 top 和当前元素处理逻辑
}
}
}
单调栈模板
适用于寻找下一个更大、更小元素。
java
int n = nums.length;
int[] ans = new int[n];
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && nums[i] > nums[stack.peek()]) {
int index = stack.pop();
ans[index] = nums[i];
}
stack.push(i);
}
这个模板解决的是:
text
每个元素右边第一个更大的元素
如果题目要找的是更小元素,就把比较条件改成:
java
nums[i] < nums[stack.peek()]
总结
栈的代码并不复杂,难点在于识别题型。
你可以记住下面几句话:
- 栈的核心特征是后进先出
- 括号匹配看的是最近的左括号
- 退格和消除看的是最近的有效字符
- 表达式处理用栈保存暂时不能最终结算的数
- 单调栈保存的是还没有找到答案的元素
- 单调栈常用于找左边或右边第一个更大、更小的元素
如果一道题让你反复寻找"最近的未处理元素",优先想普通栈。
如果一道题让你寻找"第一个更大或更小的元素",优先想单调栈。