数据结构与算法精讲 · 第11篇 三语言实现:Python / Java / C++
一、引言
🤔 思考:你写过的程序里,有多少地方用到了"后进先出"的规则?
想象一下,你打开一个网页,点击链接进入新页面,再点链接进入另一个页面——当你点击"返回"按钮时,浏览器会回到上一个页面,而不是最开始的那个。这就是栈——后进先出(LIFO, Last In First Out)。
再想象一下,你在打印机前排队打印文档——先来的人先打印,后来的人排队等候。这就是队列——先进先出(FIFO, First In First Out)。
栈和队列是计算机科学中最基础也最实用的两种线性数据结构。它们看似简单,却支撑着无数核心功能:
-
函数调用栈:每次函数调用都会压入栈帧,返回时弹出
-
浏览器历史:前进/后退功能基于栈实现
-
消息队列:分布式系统解耦的核心组件
-
BFS 广度优先搜索:队列是 BFS 的天然搭档
-
括号匹配:编译器中语法检查的基础
栈和队列本质上都是"受限的线性表"——它们限制了元素的访问位置,但正是这种限制带来了确定性和力量。
本文将围绕两个经典题目展开:
-
20. 有效括号(Easy)—— 栈的入门必刷题
-
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:
输入: s = "()"
输出: true
示例 2:
输入: s = "()[]{}"
输出: true
示例 3:
输入: s = "(]"
输出: false
示例 4:
输入: s = "([)]"
输出: false
示例 5:
输入: s = "{[]}"
输出: true
3.2 解法思路
核心思想:利用栈的 LIFO 特性,左括号入栈,遇到右括号时检查栈顶是否匹配。
算法步骤:
-
如果字符串长度为奇数,直接返回
false(剪枝优化) -
遍历字符串:
-
如果是左括号
([{,将其压入栈 -
如果是右括号
)]},检查栈是否为空 + 栈顶是否匹配
-
-
遍历结束后,栈为空则返回
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 易错点
-
空栈时 pop 会报错:在检查栈顶之前,必须先判断栈是否为空。例如
s = ")",栈为空时直接peek()会抛出异常 -
奇数长度剪枝:
s.length() % 2 == 1时直接返回false,这是常见的面试优化点 -
遍历结束后栈必须为空:例如
s = "(()",遍历结束后栈中还有一个'(',返回false -
括号顺序必须正确:
"([)]"虽然每种括号都成对出现,但顺序不正确,返回false
3.6 括号匹配过程图解
以 s = "({[]})" 为例,展示栈的匹配过程:
四、155. 最小栈(Medium)
4.1 题目描述
设计一个支持 push、pop、top 操作,并能在 常数时间 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 易错点
-
辅助栈初始值:第一次 push 时,辅助栈为空,需要直接压入
val而不是min(val, ?) -
最小值相等时也要 push 到辅助栈:例如依次 push
[1, 1, 1],每次辅助栈都要压入当前最小值,否则 pop 后最小值会丢失 -
差值法中的数值溢出:
val - min可能超出 int 范围(如val = INT_MAX, min = INT_MIN),需要使用long long/Long -
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) 的查询性能
三个关键记忆点:
-
栈的匹配:左括号入栈,右括号查栈顶——20题
-
栈的设计:辅助栈同步存最小值——155题
-
空间换时间:用一个额外的数据结构记录历史状态
刷题建议:
-
先手写 3 遍 20 题和 155 题的辅助栈解法,直到能闭着眼写出来
-
然后刷家族题:232(栈实现队列)→ 225(队列实现栈)→ 150(逆波兰表达式)→ 239(单调队列)
-
面试时,先画图展示栈的变化过程,再写代码
下一篇预告:单调栈专题——从每日温度到接雨水,全面掌握单调栈技巧。