【LeetCode算法题精讲】栈与队列精讲—有效括号 + 最小栈

0 阅读13分钟

数据结构与算法精讲 · 第11篇 三语言实现:Python / Java / C++


一、引言

🤔 思考:你写过的程序里,有多少地方用到了"后进先出"的规则?

想象一下,你打开一个网页,点击链接进入新页面,再点链接进入另一个页面——当你点击"返回"按钮时,浏览器会回到上一个页面,而不是最开始的那个。这就是——后进先出(LIFO, Last In First Out)。

再想象一下,你在打印机前排队打印文档——先来的人先打印,后来的人排队等候。这就是队列——先进先出(FIFO, First In First Out)。

栈和队列是计算机科学中最基础也最实用的两种线性数据结构。它们看似简单,却支撑着无数核心功能:

  • 函数调用栈:每次函数调用都会压入栈帧,返回时弹出

  • 浏览器历史:前进/后退功能基于栈实现

  • 消息队列:分布式系统解耦的核心组件

  • BFS 广度优先搜索:队列是 BFS 的天然搭档

  • 括号匹配:编译器中语法检查的基础

栈和队列本质上都是"受限的线性表"——它们限制了元素的访问位置,但正是这种限制带来了确定性和力量。

本文将围绕两个经典题目展开:

  1. 20. 有效括号(Easy)—— 栈的入门必刷题

  2. 155. 最小栈(Medium)—— 栈设计题的经典代表


二、栈与队列基础

2.1 栈(Stack)

栈是一种 后进先出(LIFO) 的数据结构。想象一叠盘子——你总是先拿最上面的盘子,最后放上去的盘子最先被拿走。

核心操作

| 操作 | 描述 | 时间复杂度 | | --- | --- | --- | | push(x) | 将元素 x 压入栈顶 | O(1) | | pop() | 弹出栈顶元素 | O(1) | | peek() / top() | 查看栈顶元素(不弹出) | O(1) | | isEmpty() | 判断栈是否为空 | O(1) |

2.2 队列(Queue)

队列是一种 先进先出(FIFO) 的数据结构。想象排队买票——先排的人先买到票,后来的人排在队尾。

核心操作

| 操作 | 描述 | 时间复杂度 | | --- | --- | --- | | enqueue(x) / offer(x) | 将元素 x 加入队尾 | O(1) | | dequeue() / poll() | 移除队首元素 | O(1) | | peek() | 查看队首元素(不移除) | O(1) | | isEmpty() | 判断队列是否为空 | O(1) |

2.3 LIFO vs FIFO 图解

2.4 不同语言实现

Python

# 栈:使用 list
stack = []
stack.append(1)      # push
stack.append(2)
top = stack[-1]      # peek -> 2
popped = stack.pop() # pop -> 2

# 队列:使用 collections.deque
from collections import deque
queue = deque()
queue.append(1)      # enqueue(右端入队)
queue.append(2)
front = queue[0]     # peek -> 1
popped = queue.popleft()  # dequeue -> 1

Java

// 栈:推荐使用 Deque(ArrayDeque)
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1);         // push
stack.push(2);
int top = stack.peek();  // peek -> 2
int popped = stack.pop(); // pop -> 2

// 队列:使用 Queue(LinkedList)
Queue<Integer> queue = new LinkedList<>();
queue.offer(1);        // enqueue
queue.offer(2);
int front = queue.peek();  // peek -> 1
int popped = queue.poll(); // dequeue -> 1

⚠️ 注意:Java 中 Stack 类继承自 Vector,性能较差且线程安全开销大,官方推荐使用 Deque 替代。

C++

// 栈:使用 std::stack
#include <stack>
std::stack<int> st;
st.push(1);          // push
st.push(2);
int top = st.top();  // peek -> 2
st.pop();            // pop(无返回值)

// 队列:使用 std::queue
#include <queue>
std::queue<int> q;
q.push(1);           // enqueue
q.push(2);
int front = q.front();  // peek -> 1
q.pop();             // dequeue(无返回值)

三、20. 有效括号(Easy)

3.1 题目描述

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

有效字符串需满足:

  1. 左括号必须用相同类型的右括号闭合

  2. 左括号必须以正确的顺序闭合

示例 1:

输入: s = "()"
输出: true

示例 2:

输入: s = "()[]{}"
输出: true

示例 3:

输入: s = "(]"
输出: false

示例 4:

输入: s = "([)]"
输出: false

示例 5:

输入: s = "{[]}"
输出: true

3.2 解法思路

核心思想:利用栈的 LIFO 特性,左括号入栈,遇到右括号时检查栈顶是否匹配。

算法步骤

  1. 如果字符串长度为奇数,直接返回 false(剪枝优化)

  2. 遍历字符串:

    • 如果是左括号 ( [ {,将其压入栈

    • 如果是右括号 ) ] },检查栈是否为空 + 栈顶是否匹配

  3. 遍历结束后,栈为空则返回 true,否则返回 false

优化技巧:使用哈希映射存储括号对,避免冗长的 if-else 判断。

🤔 思考:为什么字符串长度为奇数时可以直接返回 false?因为有效括号必须成对出现,奇数长度必然无法完全匹配。

3.3 代码实现

Python

class Solution:
    def isValid(self, s: str) -> bool:
        # 剪枝:奇数长度直接返回 false
        if len(s) % 2 == 1:
            return False
        
        # 哈希映射:右括号 -> 左括号
        pairs = {
            ')': '(',
            ']': '[',
            '}': '{'
        }
        
        stack = []
        for ch in s:
            if ch in pairs:  # 右括号
                # 栈为空 或 栈顶不匹配 -> 无效
                if not stack or stack[-1] != pairs[ch]:
                    return False
                stack.pop()  # 匹配成功,弹出栈顶
            else:  # 左括号
                stack.append(ch)
        
        # 遍历结束后栈必须为空
        return not stack

Java

class Solution {
    public boolean isValid(String s) {
        // 剪枝:奇数长度直接返回 false
        if (s.length() % 2 == 1) {
            return false;
        }
        
        // 哈希映射:右括号 -> 左括号
        Map<Character, Character> pairs = new HashMap<>() {{
            put(')', '(');
            put(']', '[');
            put('}', '{');
        }};
        
        Deque<Character> stack = new ArrayDeque<>();
        for (int i = 0; i < s.length(); i++) {
            char ch = s.charAt(i);
            if (pairs.containsKey(ch)) {  // 右括号
                // 栈为空 或 栈顶不匹配 -> 无效
                if (stack.isEmpty() || stack.peek() != pairs.get(ch)) {
                    return false;
                }
                stack.pop();  // 匹配成功,弹出栈顶
            } else {  // 左括号
                stack.push(ch);
            }
        }
        
        // 遍历结束后栈必须为空
        return stack.isEmpty();
    }
}

C++

class Solution {
public:
    bool isValid(string s) {
        // 剪枝:奇数长度直接返回 false
        if (s.size() % 2 == 1) {
            return false;
        }
        
        // 哈希映射:右括号 -> 左括号
        unordered_map<char, char> pairs = {
            {')', '('},
            {']', '['},
            {'}', '{'}
        };
        
        stack<char> st;
        for (char ch : s) {
            if (pairs.count(ch)) {  // 右括号
                // 栈为空 或 栈顶不匹配 -> 无效
                if (st.empty() || st.top() != pairs[ch]) {
                    return false;
                }
                st.pop();  // 匹配成功,弹出栈顶
            } else {  // 左括号
                st.push(ch);
            }
        }
        
        // 遍历结束后栈必须为空
        return st.empty();
    }
};

3.4 复杂度分析

| 维度 | 分析 | | --- | --- | | 时间复杂度 | O(n),n 为字符串长度。每个字符最多被访问一次 | | 空间复杂度 | O(n),最坏情况下全是左括号,栈中存储 n/2 个字符 |

3.5 易错点

  1. 空栈时 pop 会报错:在检查栈顶之前,必须先判断栈是否为空。例如 s = ")",栈为空时直接 peek() 会抛出异常

  2. 奇数长度剪枝s.length() % 2 == 1 时直接返回 false,这是常见的面试优化点

  3. 遍历结束后栈必须为空:例如 s = "(()",遍历结束后栈中还有一个 '(',返回 false

  4. 括号顺序必须正确"([)]" 虽然每种括号都成对出现,但顺序不正确,返回 false

3.6 括号匹配过程图解

s = "({[]})" 为例,展示栈的匹配过程:


四、155. 最小栈(Medium)

4.1 题目描述

设计一个支持 pushpoptop 操作,并能在 常数时间 O(1) 内检索到最小元素的栈。

实现 MinStack 类:

  • MinStack() — 初始化栈对象

  • void push(int val) — 将元素 val 压入栈

  • void pop() — 移除栈顶元素

  • int top() — 获取栈顶元素

  • int getMin() — 获取栈中的最小元素

示例:

输入:
["MinStack","push","push","push","getMin","pop","top","getMin"]
[[],[-2],[0],[-3],[],[],[],[]]

输出:
[null,null,null,null,-3,null,0,-2]

解释:
MinStack minStack = new MinStack();
minStack.push(-2);
minStack.push(0);
minStack.push(-3);
minStack.getMin();   --> 返回 -3
minStack.pop();
minStack.top();      --> 返回 0
minStack.getMin();   --> 返回 -2

4.2 解法一:辅助栈(同步存储当前最小值)

核心思想:使用两个栈——数据栈 st 正常存储元素,辅助栈 minSt 同步存储当前最小值。

关键规则

  • push(x):数据栈压入 x,辅助栈压入 min(x, minSt.top())

  • pop():两个栈同时弹出

  • top():返回数据栈栈顶

  • getMin():返回辅助栈栈顶

🤔 思考:为什么辅助栈要同步存储最小值,而不是只存一个变量?因为如果只存一个变量,当最小值被 pop 出去后,我们无法知道次小值是什么。辅助栈存储了每个历史时刻的最小值,保证了任意时刻的 getMin 都是 O(1)。

4.3 代码实现(辅助栈法)

Python

class MinStack:
    def __init__(self):
        self.st = []      # 数据栈
        self.min_st = []  # 辅助栈,存储当前最小值

    def push(self, val: int) -> None:
        self.st.append(val)
        # 辅助栈:如果辅助栈为空,则压入 val;否则压入 min(val, 当前最小值)
        if not self.min_st:
            self.min_st.append(val)
        else:
            self.min_st.append(min(val, self.min_st[-1]))

    def pop(self) -> None:
        self.st.pop()
        self.min_st.pop()

    def top(self) -> int:
        return self.st[-1]

    def getMin(self) -> int:
        return self.min_st[-1]

Java

class MinStack {
    private Deque<Integer> st;      // 数据栈
    private Deque<Integer> minSt;   // 辅助栈

    public MinStack() {
        st = new ArrayDeque<>();
        minSt = new ArrayDeque<>();
    }
    
    public void push(int val) {
        st.push(val);
        // 辅助栈:如果辅助栈为空,则压入 val;否则压入 min(val, 当前最小值)
        if (minSt.isEmpty()) {
            minSt.push(val);
        } else {
            minSt.push(Math.min(val, minSt.peek()));
        }
    }
    
    public void pop() {
        st.pop();
        minSt.pop();
    }
    
    public int top() {
        return st.peek();
    }
    
    public int getMin() {
        return minSt.peek();
    }
}

C++

class MinStack {
private:
    stack<int> st;      // 数据栈
    stack<int> minSt;   // 辅助栈

public:
    MinStack() {
        // 不需要额外初始化
    }
    
    void push(int val) {
        st.push(val);
        // 辅助栈:如果辅助栈为空,则压入 val;否则压入 min(val, 当前最小值)
        if (minSt.empty()) {
            minSt.push(val);
        } else {
            minSt.push(min(val, minSt.top()));
        }
    }
    
    void pop() {
        st.pop();
        minSt.pop();
    }
    
    int top() {
        return st.top();
    }
    
    int getMin() {
        return minSt.top();
    }
};

4.4 解法二:差值法(进阶,省空间)

核心思想:栈中不存原始值,而是存 val - min 的差值,通过数学关系还原。

关键规则

  • push(x)

    • 如果栈为空:min = x,栈中压入 0

    • 如果栈非空:压入 x - min,如果 x < min 则更新 min = x

  • pop()

    • 弹出差值 diff

    • 如果 diff < 0,说明当前栈顶是当时的最小值,需还原:min = min - diff

  • top()

    • 如果栈顶差值 diff >= 0,返回 min + diff

    • 如果 diff < 0,返回 min(因为当前栈顶就是最小值)

  • getMin():直接返回 min

🤔 思考:差值法的巧妙之处在于,它用数学关系代替了额外的辅助栈。当 diff < 0 时,说明入栈元素比当前最小值还小,这个元素就是新的最小值。当这个元素被 pop 时,通过 min - diff 就能还原出上一个最小值。

Python

class MinStack:
    def __init__(self):
        self.st = []   # 存储差值
        self.min_val = 0

    def push(self, val: int) -> None:
        if not self.st:
            self.min_val = val
            self.st.append(0)
        else:
            diff = val - self.min_val
            self.st.append(diff)
            if diff < 0:
                self.min_val = val  # 更新最小值

    def pop(self) -> None:
        diff = self.st.pop()
        if diff < 0:
            # 还原上一个最小值
            self.min_val = self.min_val - diff

    def top(self) -> int:
        diff = self.st[-1]
        if diff >= 0:
            return self.min_val + diff
        else:
            return self.min_val

    def getMin(self) -> int:
        return self.min_val

Java

class MinStack {
    private Deque<Long> st;  // 存储差值,使用 Long 防止溢出
    private long minVal;

    public MinStack() {
        st = new ArrayDeque<>();
    }
    
    public void push(int val) {
        if (st.isEmpty()) {
            minVal = val;
            st.push(0L);
        } else {
            long diff = (long)val - minVal;
            st.push(diff);
            if (diff < 0) {
                minVal = val;
            }
        }
    }
    
    public void pop() {
        long diff = st.pop();
        if (diff < 0) {
            minVal = minVal - diff;
        }
    }
    
    public int top() {
        long diff = st.peek();
        if (diff >= 0) {
            return (int)(minVal + diff);
        } else {
            return (int)minVal;
        }
    }
    
    public int getMin() {
        return (int)minVal;
    }
}

C++

class MinStack {
private:
    stack<long long> st;  // 存储差值,使用 long long 防止溢出
    long long minVal;

public:
    MinStack() {
    }
    
    void push(int val) {
        if (st.empty()) {
            minVal = val;
            st.push(0);
        } else {
            long long diff = (long long)val - minVal;
            st.push(diff);
            if (diff < 0) {
                minVal = val;
            }
        }
    }
    
    void pop() {
        long long diff = st.top();
        st.pop();
        if (diff < 0) {
            minVal = minVal - diff;
        }
    }
    
    int top() {
        long long diff = st.top();
        if (diff >= 0) {
            return (int)(minVal + diff);
        } else {
            return (int)minVal;
        }
    }
    
    int getMin() {
        return (int)minVal;
    }
};

4.5 复杂度分析

| 解法 | 时间复杂度 | 空间复杂度 | 特点 | | --- | --- | --- | --- | | 辅助栈法 | O(1) per op | O(n) | 思路直观,不易出错 | | 差值法 | O(1) per op | O(n) | 省掉一个栈,但需处理溢出 |

  • 所有操作(push/pop/top/getMin)均为 O(1) 时间复杂度

  • 空间复杂度 O(n):n 为已入栈的元素个数

4.6 易错点

  1. 辅助栈初始值:第一次 push 时,辅助栈为空,需要直接压入 val 而不是 min(val, ?)

  2. 最小值相等时也要 push 到辅助栈:例如依次 push [1, 1, 1],每次辅助栈都要压入当前最小值,否则 pop 后最小值会丢失

  3. 差值法中的数值溢出val - min 可能超出 int 范围(如 val = INT_MAX, min = INT_MIN),需要使用 long long / Long

  4. pop 后辅助栈和数据栈必须同步:两个栈的 pop 操作要成对出现,否则数据会错位

4.7 辅助栈结构图解


五、对比总结

| 对比维度 | 20. 有效括号 | 155. 最小栈 | | --- | --- | --- | | 难度 | ⭐ Easy | ⭐⭐ Medium | | 栈的作用 | 存储未匹配的左括号 | 存储数据和历史最小值 | | 核心技巧 | 哈希映射简化判断 | 辅助栈空间换时间 | | 数据结构 | 单栈 + Map | 双栈(数据栈 + 辅助栈) | | 时间复杂度 | O(n) | O(1) per operation | | 空间复杂度 | O(n) | O(n) | | 关键判断 | 栈顶是否匹配当前右括号 | 辅助栈顶即当前最小值 | | 边界处理 | 空栈判断、奇数长度剪枝 | 辅助栈初始值、相等值处理 | | 面试频率 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | | 典型应用 | 编译器语法检查、HTML标签匹配 | 需要随时获取最小值的场景 |

核心感悟

  • 20 题展示了栈的 匹配能力——利用 LIFO 特性处理嵌套结构

  • 155 题展示了栈的 记录能力——通过辅助栈记录历史状态

  • 两道题共同体现了栈的核心价值:"最近相关性"——最近入栈的元素与当前操作最相关


六、家族题

掌握了栈与队列的基础用法后,以下题目可以一通百通:

232. 用栈实现队列(Easy)

  • 题目:使用两个栈实现队列的 push/pop/peek/empty 操作

  • 解法:两个栈——输入栈和输出栈。push 到输入栈,pop/peek 时如果输出栈为空则将输入栈全部倒入输出栈

  • 核心思想两次后进先出 = 先进先出

  • 关键点:输出栈为空时才倒数据,避免顺序错乱

225. 用队列实现栈(Easy)

  • 题目:使用一个或两个队列实现栈的 push/pop/top/empty 操作

  • 解法一(双队列):push 到非空队列,pop 时把前 n-1 个元素移到另一个队列

  • 解法二(单队列):push 后,将前 n-1 个元素依次弹出再入队,让新元素在队首

  • 核心思想队列轮转 = 后进先出

239. 滑动窗口最大值(Hard)

  • 题目:给你一个整数数组和一个滑动窗口大小,返回每个窗口中的最大值

  • 解法:单调队列(Monotonic Queue)——维护一个递减队列,队首永远是当前窗口最大值

  • 核心思想队列中只保留"可能成为最大值"的候选元素

  • 关键点:入队时移除所有比当前元素小的队尾元素

150. 逆波兰表达式求值(Medium)

  • 题目:计算逆波兰表达式(后缀表达式)的值

  • 解法:栈——遇到数字入栈,遇到运算符弹出两个数计算后入栈

  • 核心思想栈是表达式求值的天然工具

  • 关键点:注意除法和减法的操作数顺序(先弹出的是右操作数)

家族题难度梯度


七、结语

🤔 思考:回顾一下,栈和队列这两个看似简单的数据结构,为什么能解决如此多的问题?

栈和队列的核心价值在于——它们通过限制元素的访问位置,换来了操作的确定性和高效性。

  • 栈的 LIFO 天然匹配"最近相关性"场景——括号匹配、函数调用、表达式求值

  • 队列的 FIFO 天然匹配"公平性"场景——任务调度、BFS、消息队列

  • 辅助栈/辅助队列 展示了"空间换时间"的设计哲学——用一个额外的数据结构记录历史状态,换取 O(1) 的查询性能

三个关键记忆点

  1. 栈的匹配:左括号入栈,右括号查栈顶——20题

  2. 栈的设计:辅助栈同步存最小值——155题

  3. 空间换时间:用一个额外的数据结构记录历史状态

刷题建议

  • 先手写 3 遍 20 题和 155 题的辅助栈解法,直到能闭着眼写出来

  • 然后刷家族题:232(栈实现队列)→ 225(队列实现栈)→ 150(逆波兰表达式)→ 239(单调队列)

  • 面试时,先画图展示栈的变化过程,再写代码

下一篇预告单调栈专题——从每日温度到接雨水,全面掌握单调栈技巧。


八、参考文献

  1. 力扣官方题解 — 20. 有效括号

  2. 力扣官方题解 — 155. 最小栈

  3. 代码随想录 — 栈与队列篇

  4. labuladong — 栈和队列的用法

  5. LeetCode 232. 用栈实现队列

  6. LeetCode 225. 用队列实现栈

  7. LeetCode 239. 滑动窗口最大值

  8. LeetCode 150. 逆波兰表达式求值