1. 算法复健训练的必要性
最近在整理算法笔记时,发现很多之前刷过的题目又变得陌生了。这种情况在算法学习过程中很常见——如果不持续练习,好不容易掌握的解题思路很快就会生疏。于是决定启动这个"算法复健计划",每天针对特定数据结构进行专项训练。
今天选择的主题是栈(Stack)和队列(Queue),这两种线性数据结构在算法题中出现的频率极高。它们看似简单,但实际应用中却有很多精妙的用法。我挑选了LeetCode上4道经典题目(232, 225, 20, 1047)作为今天的训练内容,这些题目覆盖了栈和队列的基础应用和典型场景。
2. 栈与队列基础回顾
2.1 数据结构特性对比
栈和队列虽然都是线性数据结构,但它们的操作特性完全不同:
| 特性 | 栈(Stack) | 队列(Queue) |
|---|---|---|
| 操作原则 | 后进先出(LIFO) | 先进先出(FIFO) |
| 核心操作 | push/pop/peek | enqueue/dequeue/peek |
| 时间复杂度 | 所有操作O(1) | 所有操作O(1) |
| 典型应用 | 函数调用、括号匹配 | BFS、缓存系统 |
2.2 实现方式选择
在解决算法问题时,我们通常有两种实现选择:
-
使用语言原生数据结构:
- Python: list(栈)/deque(队列)
- Java: Stack类/Queue接口
- C++: stack/queue容器
-
手动实现:
- 数组+指针
- 链表实现
- 双栈实现队列等特殊结构
对于面试场景,理解各种实现方式的优缺点比单纯使用库函数更重要。这也是为什么232和225题要求我们手动实现这些数据结构。
3. 题目解析与实现
3.1 LC 232 - 用栈实现队列
这道题要求使用栈来实现队列的所有操作(push, pop, peek, empty)。关键在于如何用LIFO的栈模拟FIFO的队列行为。
解决方案:双栈法
python复制class MyQueue:
def __init__(self):
self.in_stack = []
self.out_stack = []
def push(self, x: int) -> None:
self.in_stack.append(x)
def pop(self) -> int:
self._transfer()
return self.out_stack.pop()
def peek(self) -> int:
self._transfer()
return self.out_stack[-1]
def empty(self) -> bool:
return not self.in_stack and not self.out_stack
def _transfer(self):
if not self.out_stack:
while self.in_stack:
self.out_stack.append(self.in_stack.pop())
关键点分析:
- 维护两个栈:一个负责输入(in_stack),一个负责输出(out_stack)
- 只有当out_stack为空时,才将in_stack的内容全部转移到out_stack
- 这种摊还分析下,每个元素最多经历两次入栈和出栈操作,均摊时间复杂度为O(1)
注意:peek()操作应该与pop()保持相同的栈转移逻辑,避免状态不一致。
3.2 LC 225 - 用队列实现栈
这道题与232相反,要求用队列实现栈的操作(push, pop, top, empty)。
解决方案:单队列旋转法
python复制from collections import deque
class MyStack:
def __init__(self):
self.q = deque()
def push(self, x: int) -> None:
self.q.append(x)
# 将新元素之前的元素全部移到它后面
for _ in range(len(self.q) - 1):
self.q.append(self.q.popleft())
def pop(self) -> int:
return self.q.popleft()
def top(self) -> int:
return self.q[0]
def empty(self) -> bool:
return not self.q
优化思路:
- 每次push时,都将队列旋转,使得新元素位于队列前端
- 这样pop和top操作就自然对应栈的顶部元素
- 虽然push操作是O(n),但其他操作都是O(1)
3.3 LC 20 - 有效的括号
这是栈结构的经典应用题,检验括号字符串是否有效匹配。
解决方案:栈匹配法
python复制def isValid(s: str) -> bool:
stack = []
mapping = {')': '(', '}': '{', ']': '['}
for char in s:
if char in mapping:
top_element = stack.pop() if stack else '#'
if mapping[char] != top_element:
return False
else:
stack.append(char)
return not stack
处理细节:
- 使用哈希表存储括号对,方便快速查找
- 遇到右括号时,检查栈顶是否匹配
- 注意处理空栈和多余字符的情况
- 最终栈必须为空才算完全匹配
3.4 LC 1047 - 删除字符串中的所有相邻重复项
这道题展示了栈在字符串处理中的妙用,可以高效删除相邻重复字符。
解决方案:栈消消乐
python复制def removeDuplicates(s: str) -> str:
stack = []
for char in s:
if stack and stack[-1] == char:
stack.pop()
else:
stack.append(char)
return ''.join(stack)
性能分析:
- 时间复杂度:O(n),每个字符最多入栈出栈一次
- 空间复杂度:O(n),最坏情况下需要存储整个字符串
- 相比递归或暴力解法,栈方法避免了重复扫描
4. 解题技巧与常见错误
4.1 栈与队列的经典应用场景
栈的典型应用:
- 括号匹配和表达式求值
- 函数调用堆栈
- 深度优先搜索(DFS)
- 撤销操作(Undo)和浏览历史
队列的典型应用:
- 广度优先搜索(BFS)
- 缓存实现(FIFO策略)
- 任务调度和消息队列
- 打印机任务队列
4.2 调试技巧与边界条件
在实现这些数据结构时,有几个常见的陷阱需要注意:
-
空容器处理:
- pop/peek操作前必须检查是否为空
- 特别是在双栈实现队列时,两个栈可能同时为空
-
状态一致性:
- 在LC232中,确保只有在out_stack为空时才进行转移
- 错误的转移时机会导致元素顺序混乱
-
时间复杂度误区:
- 虽然摊还分析下某些操作是O(1),但单次操作可能是O(n)
- 面试时需要明确说明这一点
4.3 算法优化思路
对于这类题目,可以考虑以下优化方向:
-
空间优化:
- 在某些情况下可以用O(1)额外空间解决问题
- 例如LC1047可以用双指针代替栈
-
并行处理:
- 对于大规模数据,可以考虑多线程处理
- 但需要注意线程安全和同步问题
-
预处理优化:
- 对输入数据进行预处理可以减少运行时开销
- 例如LC20中可以预先检查字符串长度是否为偶数
5. 扩展练习建议
为了巩固栈和队列的应用能力,建议尝试以下进阶题目:
-
栈的应用:
- LC 155 - 最小栈
- LC 739 - 每日温度
- LC 84 - 柱状图中最大的矩形
-
队列的应用:
- LC 239 - 滑动窗口最大值
- LC 621 - 任务调度器
- LC 346 - 数据流中的移动平均值
-
综合应用:
- LC 341 - 扁平化嵌套列表迭代器
- LC 853 - 车队
- LC 71 - 简化路径
在实际练习时,建议先自己思考实现,再对比最优解。记录下自己的思考过程和遇到的困难,这种反思对提升算法能力非常有帮助。