第13篇-栈算法入门-括号匹配-表达式与单调栈基础

概述:为什么栈是算法题里的基础结构

学完二分查找和二分答案之后,我们开始进入一类更偏"数据结构思维"的题型:

栈看起来很简单,只支持从一端加入和删除元素。

但在算法题里,它经常出现在下面这些场景中:

  • 括号匹配是否合法
  • 字符串消除和撤销操作
  • 表达式求值
  • 函数调用和递归过程
  • 寻找左边或右边第一个更大、更小的元素
  • 单调栈解决下一个更大元素、每日温度、柱状图面积等问题

很多初学者刚学栈时,只记住了两个操作:

text 复制代码
push 入栈
pop 出栈

但真正做题时,关键并不是会不会调用 API,而是要理解:

栈适合处理"最近的、还没有被匹配或解决的元素"。

比如括号匹配中,最后出现的左括号,应该最先被右括号匹配;

比如单调栈中,栈里暂时放着那些"还没找到答案"的元素。

这篇文章会围绕三个高频方向展开:

  1. 栈的基本概念和使用方式
  2. 括号匹配类问题
  3. 表达式和字符串处理
  4. 单调栈的基本思想与模板

学完这篇,你应该能识别"后进先出""最近匹配""等待被解决"这几类问题,并用栈或单调栈写出清晰的解法。

核心概念:栈到底是什么

栈是一种遵循 后进先出 规则的数据结构。

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 当作正常栈元素,所以这个限制通常不是问题。

栈不是重点在"能存数据",而是重点在"最后加入的未处理元素最先被处理"。

适用场景:什么题应该想到栈

如果题目中出现下面这些信号,可以优先考虑栈:

  • 匹配关系:括号、标签、成对符号
  • 最近关系:最近一个未被匹配的元素
  • 撤销操作:输入退格、路径回退、编辑器撤销
  • 嵌套结构:表达式、函数调用、递归展开
  • 单调关系:找左边或右边第一个更大、更小的元素

可以用一句话判断:

如果当前元素需要和"前面最近的某个元素"发生关系,栈就很可能有用。

下面我们从最经典的括号匹配开始。

经典题型一:括号匹配

括号匹配是栈最经典的入门题。

题目通常是:

给定一个只包含 ()[]{} 的字符串,判断括号是否有效。

有效字符串需要满足:

  1. 左括号必须用相同类型的右括号闭合
  2. 左括号必须以正确顺序闭合
  3. 每个右括号都有一个对应的左括号

例如:

text 复制代码
"()[]{}"    -> true
"([{}])"    -> true
"(]"        -> false
"([)]"      -> false

为什么括号匹配适合用栈

看这个字符串:

text 复制代码
([{}])

扫描过程如下:

text 复制代码
读到 ( :等待匹配,入栈
读到 [ :等待匹配,入栈
读到 { :等待匹配,入栈
读到 } :应该匹配最近的 {
读到 ] :应该匹配最近的 [
读到 ) :应该匹配最近的 (

最后出现的左括号,必须最先被匹配。

这正好符合栈的后进先出特性。

解题思路

遍历字符串中的每个字符:

  1. 如果是左括号,就入栈
  2. 如果是右括号:
    • 栈为空,说明没有左括号可以匹配,返回 false
    • 栈顶不是对应的左括号,返回 false
    • 否则弹出栈顶,表示匹配成功
  3. 遍历结束后,栈必须为空

流程可以表示为:

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),最坏情况下所有字符都是左括号

常见错误

  • 只统计左右括号数量 :数量相等不代表顺序正确,例如 ([)]
  • 遇到右括号时不判断栈空:会导致空栈弹出异常
  • 遍历结束后不检查栈是否为空 :例如 "(((" 会被误判

右括号永远只能匹配"最近的那个还没被匹配的左括号",所以用栈。

经典题型二:字符串消除与退格模拟

除了括号匹配,栈还很适合处理"撤销前一个字符"这类问题。

例如题目:

给定字符串 st,其中 # 表示退格,判断两个字符串最终是否相等。

示例:

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)

其中 nm 分别是两个字符串的长度。

进一步优化

这道题也可以用双指针从后往前扫描,把空间复杂度优化到 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 用来拼接多位数字,例如 123
  • sign 表示当前数字前面的运算符
  • 遇到新运算符时,处理上一个数字
  • 最后一个数字后面没有运算符,所以需要用 i == s.length() - 1 触发结算

复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

常见错误

  • 没有处理多位数 :把 123 当成 123
  • 忘记处理空格:表达式中可能包含空格
  • 最后一个数字没有入栈:因为末尾没有运算符触发计算
  • 乘除法直接累加:会破坏运算优先级

栈可以把暂时不能最终结算的数字保存起来,并在遇到高优先级运算时及时修正栈顶。

进阶概念:什么是单调栈

普通栈只强调后进先出。

而单调栈在此基础上,还要求栈内元素保持某种单调性。

常见有两类:

  • 单调递增栈:从栈底到栈顶递增
  • 单调递减栈:从栈底到栈顶递减

它经常用来解决:

对每个元素,找它左边或右边第一个比它大或比它小的元素。

例如:

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 21 的下一个更大元素
3 4 2, 2 4 依次解决两个 2
4 3 4 3 不比 4 大,入栈

最后仍留在栈里的元素,说明右边没有更大元素,答案保持 -1

复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

虽然代码里有 while 循环,但每个下标最多入栈一次、出栈一次。

所以总复杂度仍然是线性的。

单调栈通过维护一组"还没找到答案的元素",让每个元素只进出栈一次,从而把向右查找优化到 O(n)

单调栈常见变形

单调栈题目看起来变化很多,但本质经常是下面四类。

目标 扫描方向 栈的维护方式
右边第一个更大元素 从左到右 当前值大于栈顶时弹出
右边第一个更小元素 从左到右 当前值小于栈顶时弹出
左边第一个更大元素 从左到右 入栈前弹出小于等于当前值的元素
左边第一个更小元素 从左到右 入栈前弹出大于等于当前值的元素

这里最容易混淆的是:

有些题是在"当前元素解决栈顶元素",有些题是在"栈顶元素作为当前元素的答案"。

初学阶段不建议死背模板,而应该先问清楚两个问题:

  1. 我要找的是左边还是右边?
  2. 我要找的是第一个更大还是第一个更小?

然后再决定扫描方向和弹栈条件。

高频例题:每日温度

题目:

给定一个整数数组 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. 把栈顶方向想反

pushpoppeek 操作的都是栈顶。

如果你用 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()]

总结

栈的代码并不复杂,难点在于识别题型。

你可以记住下面几句话:

  • 栈的核心特征是后进先出
  • 括号匹配看的是最近的左括号
  • 退格和消除看的是最近的有效字符
  • 表达式处理用栈保存暂时不能最终结算的数
  • 单调栈保存的是还没有找到答案的元素
  • 单调栈常用于找左边或右边第一个更大、更小的元素

如果一道题让你反复寻找"最近的未处理元素",优先想普通栈。

如果一道题让你寻找"第一个更大或更小的元素",优先想单调栈。

相关推荐
TCW11211 小时前
AI底层系列:用C++实现线性代数的公式推导与算法设计-基础篇-5.矩阵方程
人工智能·线性代数·算法
我是一颗柠檬1 小时前
【Java项目技术亮点】Redis Lua脚本原子化操作:高并发场景下的终极武器
java·redis·lua
叫我:松哥1 小时前
基于机器学习和flask的体育健身风险智能分析系统,系统集成DeepSeek、聚类算法、分类算法等,准确率达90%
人工智能·python·神经网络·算法·机器学习·flask·聚类
swg3213211 小时前
Redis实现主从选举
java·前端·redis
Java 码思客1 小时前
【ElasticSearch 从入门到架构师】第6章_分词器与文本检索
java·elasticsearch
Flittly1 小时前
【AgentScope Java新手村系列】(6)Hook与Middleware
java·spring boot·笔记·spring·ai
向量引擎1 小时前
AI API 正在进入“请求生命周期治理”阶段:从模型迁移、Agent 接入到成本与安全排错的工程化方法
java·人工智能·python·aigc·ai编程·ai写作·gpu算力
wabs6661 小时前
关于动态规划【0-1背包思想在实际问题中是怎么转化的?】
算法·动态规划
阿文的代码库1 小时前
欧拉回路与欧拉路径的算法流程演示
算法