1. 项目概述
作为一名参加过多次算法训练营的老学员,我清楚地记得第一次面对栈和队列相关题目时的困惑。这次参加代码随想录算法训练营的二周目,让我有机会重新梳理这些基础但至关重要的数据结构知识。本篇将详细记录第十天的学习内容,包括理论基础和四道经典题目(232.用栈实现队列、225.用队列实现栈、20.有效的括号、1047.删除字符串中的所有相邻重复项)的解题思路和实现细节。
栈和队列是算法面试中的常客,看似简单却暗藏玄机。很多同学在面试中遇到"用栈实现队列"这类题目时,虽然知道要用两个栈,但总是写不出bug-free的代码。通过这次系统性的训练,我总结出了一套可复用的解题模板和调试技巧。
2. 理论基础精讲
2.1 栈与队列的核心特性
栈(Stack)是一种后进先出(LIFO)的数据结构,只允许在一端(称为栈顶)进行插入和删除操作。它的核心操作包括:
- push:将元素压入栈顶
- pop:弹出栈顶元素
- peek:查看栈顶元素但不弹出
- isEmpty:判断栈是否为空
队列(Queue)则是先进先出(FIFO)的数据结构,允许在一端(队尾)插入,在另一端(队头)删除。核心操作包括:
- offer/enqueue:元素入队
- poll/dequeue:元素出队
- peek:查看队头元素
- isEmpty:判断队列是否为空
关键理解:栈和队列的根本区别在于元素的出入顺序。栈像是叠盘子,最后放上去的盘子最先被拿走;队列像是排队,先来的人先接受服务。
2.2 实现方式对比
在Java中,Stack是一个类,而Queue是一个接口。常见的实现方式:
| 数据结构 | Java实现类 | 时间复杂度 |
|---|---|---|
| 栈 | Stack | push/pop/peek: O(1) |
| 队列 | LinkedList/ArrayDeque | offer/poll/peek: O(1) |
C++中则使用stack和queue模板类:
#include <stack> #include <queue> std::stack<int> s; std::queue<int> q;Python中使用list实现栈,collections.deque实现队列:
stack = [] queue = collections.deque()3. 题目精解与实现
3.1 232. 用栈实现队列
3.1.1 解题思路
这道题要求使用栈的FILO特性来实现队列的FIFO特性。核心思路是使用两个栈:
- 输入栈(inStack):负责接收push操作的新元素
- 输出栈(outStack):负责pop和peek操作
当需要pop或peek时:
- 如果outStack为空,将inStack的所有元素依次弹出并压入outStack
- 此时outStack的栈顶元素就是队列的队头元素
这样操作后,元素的顺序就被反转了两次(inStack反转一次,outStack再反转一次),相当于保持了原始顺序。
3.1.2 代码实现
class MyQueue { private Stack<Integer> inStack; private Stack<Integer> outStack; public MyQueue() { inStack = new Stack<>(); outStack = new Stack<>(); } public void push(int x) { inStack.push(x); } public int pop() { if (outStack.isEmpty()) { while (!inStack.isEmpty()) { outStack.push(inStack.pop()); } } return outStack.pop(); } public int peek() { if (outStack.isEmpty()) { while (!inStack.isEmpty()) { outStack.push(inStack.pop()); } } return outStack.peek(); } public boolean empty() { return inStack.isEmpty() && outStack.isEmpty(); } }3.1.3 复杂度分析
- 时间复杂度:
- push:O(1)
- pop/peek:均摊O(1)(每个元素最多被压入和弹出各两次)
- 空间复杂度:O(n),需要两个栈存储所有元素
注意事项:在面试中写这道题时,一定要先解释清楚为什么均摊时间复杂度是O(1)。可以类比银行排队场景:虽然偶尔需要把整个队伍转移到另一个窗口(O(n)操作),但每个客户平均只被转移一次。
3.2 225. 用队列实现栈
3.2.1 解题思路
这道题与232题相反,需要用队列的FIFO特性实现栈的FILO特性。有两种主要方法:
双队列法(push为O(1),pop为O(n)):
- 主队列存储元素,辅助队列用于倒腾
- push时直接入队主队列
- pop时将主队列的前n-1个元素转移到辅助队列,剩下最后一个元素就是栈顶
单队列法(push为O(n),pop为O(1)):
- 每次push新元素后,将队列中已有的元素依次出队再入队,使新元素位于队头
- pop时直接出队即可
3.2.2 代码实现(双队列法)
from collections import deque class MyStack: def __init__(self): self.main_queue = deque() self.helper_queue = deque() def push(self, x: int) -> None: self.main_queue.append(x) def pop(self) -> int: while len(self.main_queue) > 1: self.helper_queue.append(self.main_queue.popleft()) res = self.main_queue.popleft() self.main_queue, self.helper_queue = self.helper_queue, self.main_queue return res def top(self) -> int: return self.main_queue[-1] if self.main_queue else None def empty(self) -> bool: return not self.main_queue3.2.3 复杂度分析
- 时间复杂度:
- push:O(1)
- pop/top:O(n)
- 空间复杂度:O(n),需要两个队列存储元素
实战技巧:在Python中,使用deque的[-1]可以直接访问队尾元素,这使得top操作可以在O(1)时间内完成。这是Python特有的优化,在其他语言中可能需要完整遍历队列。
3.3 20. 有效的括号
3.3.1 解题思路
这是栈的经典应用场景。算法步骤:
- 初始化一个空栈
- 遍历字符串中的每个字符:
- 如果是左括号('('、'['、'{'),将其压入栈
- 如果是右括号,检查栈顶元素是否匹配:
- 匹配则弹出栈顶
- 不匹配则直接返回false
- 最后检查栈是否为空:
- 空栈说明所有括号都匹配
- 非空栈说明有未匹配的左括号
3.3.2 代码实现
class Solution { public: bool isValid(string s) { stack<char> st; unordered_map<char, char> pairs = { {')', '('}, {']', '['}, {'}', '{'} }; for (char c : s) { if (pairs.count(c)) { // 是右括号 if (st.empty() || st.top() != pairs[c]) { return false; } st.pop(); } else { // 是左括号 st.push(c); } } return st.empty(); } };3.3.3 复杂度分析
- 时间复杂度:O(n),只需遍历一次字符串
- 空间复杂度:O(n),最坏情况下需要存储所有左括号
常见错误:只考虑括号数量匹配而忽略顺序。例如"([)]"虽然每种括号数量匹配,但顺序不正确,应该返回false。使用栈结构可以完美解决顺序问题。
3.4 1047. 删除字符串中的所有相邻重复项
3.4.1 解题思路
这道题可以看作是"有效的括号"的变种,同样使用栈来解决:
- 初始化空栈
- 遍历字符串:
- 如果栈不为空且当前字符等于栈顶字符,弹出栈顶(表示消除这对重复项)
- 否则将当前字符压入栈
- 最后将栈中字符拼接成字符串返回
3.4.2 代码实现
class Solution { public String removeDuplicates(String s) { StringBuilder stack = new StringBuilder(); for (char c : s.toCharArray()) { int len = stack.length(); if (len > 0 && stack.charAt(len - 1) == c) { stack.deleteCharAt(len - 1); } else { stack.append(c); } } return stack.toString(); } }3.4.3 复杂度分析
- 时间复杂度:O(n),每个字符最多被处理两次(压入和弹出)
- 空间复杂度:O(n),最坏情况下需要存储整个字符串
优化技巧:使用StringBuilder模拟栈操作,避免了频繁的栈对象创建和销毁,在Java中性能更好。类似的,在Python中可以直接使用list,C++可以使用string作为栈。
4. 常见问题与调试技巧
4.1 边界条件处理
在实现这些数据结构题目时,最容易出错的就是边界条件的处理。以下是一些常见边界情况:
- 空输入:所有操作都应该能正确处理空栈/空队列的情况
- 连续操作:如连续pop或peek时,确保内部状态正确
- 大小边界:特别是用数组实现时,要考虑扩容和缩容
4.2 调试技巧
- 可视化跟踪:在纸上画出栈和队列的变化过程
- 状态打印:在关键操作后打印内部数据结构的状态
- 单元测试:针对每个方法编写边界测试用例
4.3 面试常见问题
面试官可能会追问:
- 为什么这种方法的时间复杂度是均摊O(1)?
- 如何进一步优化空间使用?
- 如果要求线程安全,该如何修改实现?
5. 扩展思考
5.1 实际应用场景
栈的应用:
- 函数调用栈
- 浏览器前进后退
- 撤销操作(Undo)
- 表达式求值
队列的应用:
- 消息队列
- 打印任务队列
- 广度优先搜索(BFS)
- 缓存实现
5.2 变种数据结构
- 双端队列(Deque):两端都可以进行插入和删除
- 优先队列(PriorityQueue):元素按优先级出队
- 单调栈/队列:维护栈/队列中元素的单调性
5.3 性能优化方向
- 延迟转移:在用栈实现队列时,可以等到必须转移时才进行
- 批量操作:适当合并操作可以减少数据移动次数
- 空间复用:合理设计可以节省一个栈/队列的空间
通过这次系统的训练,我对栈和队列的理解更加深入了。最大的收获是认识到:看似简单的数据结构,通过巧妙的组合和变形,可以解决各种复杂问题。建议大家在掌握这些基础题目后,尝试解决一些更复杂的变种问题,如"155.最小栈"、"239.滑动窗口最大值"等,进一步巩固这些技术。