1. 项目概述:栈与队列经典算法实战
今天要分享的是数据结构中栈与队列的四个经典算法问题,这也是很多技术面试中的高频考点。作为有十年全栈开发经验的工程师,我经常在面试中考察候选人对这些基础数据结构的理解程度。看似简单的栈和队列,在实际应用中却有着极其重要的地位——从浏览器前进后退功能到消息队列系统,再到编译器语法检查,它们的影子无处不在。
本次要解决的四个问题分别是:
- 232题:用栈实现队列
- 225题:用队列实现栈
- 20题:有效的括号
- 1047题:删除字符串中的所有相邻重复项
这四个问题看似独立,实则层层递进。前两题考察我们对数据结构本质的理解,后两题则是栈结构的典型应用场景。在解决这些问题的过程中,我们会深入探讨:
- 栈的LIFO(后进先出)特性
- 队列的FIFO(先进先出)特性
- 如何通过组合基本数据结构实现更复杂的行为
- 栈在匹配类问题中的妙用
2. 核心数据结构解析
2.1 栈与队列的本质区别
栈和队列是两种最基础的线性数据结构,它们的核心区别在于操作顺序:
| 特性 | 栈(Stack) | 队列(Queue) |
|---|---|---|
| 操作原则 | LIFO(后进先出) | FIFO(先进先出) |
| 插入操作 | push(压栈) | enqueue(入队) |
| 删除操作 | pop(弹栈) | dequeue(出队) |
| 查看操作 | peek(查看栈顶) | front(查看队首) |
| 典型应用 | 函数调用栈、括号匹配 | 消息队列、打印队列 |
关键理解:栈就像一摞盘子,最后放上去的盘子最先被拿走;队列则像排队买票,先来的人先得到服务。
2.2 实现方式对比
在具体实现上,两者都可以用数组或链表来实现:
# 栈的数组实现示例 class Stack: def __init__(self): self.items = [] def push(self, item): self.items.append(item) def pop(self): return self.items.pop() def peek(self): return self.items[-1] if not self.is_empty() else None def is_empty(self): return len(self.items) == 0 # 队列的链表实现示例 class Node: def __init__(self, value): self.value = value self.next = None class Queue: def __init__(self): self.front = None self.rear = None def enqueue(self, item): new_node = Node(item) if self.rear is None: self.front = self.rear = new_node return self.rear.next = new_node self.rear = new_node def dequeue(self): if self.front is None: return None temp = self.front self.front = temp.next if self.front is None: self.rear = None return temp.value3. 用栈实现队列(232题)
3.1 问题分析与解法思路
题目要求仅使用栈的标准操作(push、pop、peek、empty)来实现队列的所有操作(push、pop、peek、empty)。
关键难点在于如何用LIFO的栈实现FIFO的队列行为。我的解决思路是使用两个栈:
- 输入栈(stack_in):负责接收push操作
- 输出栈(stack_out):负责提供pop和peek操作
当需要pop或peek时:
- 如果输出栈为空,则将输入栈的所有元素依次弹出并压入输出栈
- 这样输出栈的栈顶就是最早进入的元素
class MyQueue: def __init__(self): self.stack_in = [] self.stack_out = [] def push(self, x: int) -> None: self.stack_in.append(x) def pop(self) -> int: if self.empty(): return None if not self.stack_out: while self.stack_in: self.stack_out.append(self.stack_in.pop()) return self.stack_out.pop() def peek(self) -> int: if self.empty(): return None if not self.stack_out: while self.stack_in: self.stack_out.append(self.stack_in.pop()) return self.stack_out[-1] def empty(self) -> bool: return not self.stack_in and not self.stack_out3.2 时间复杂度分析
- push操作:O(1) - 直接压入输入栈
- pop/peek操作:摊还分析下也是O(1)
- 最坏情况下需要将n个元素从一个栈移动到另一个栈,O(n)
- 但每个元素最多被移动两次(进一次,出一次),所以平均下来是O(1)
实战技巧:在面试中解释"摊还分析"的概念能展现你的算法功底。可以类比为"虽然偶尔需要大扫除,但日常维护成本很低"。
4. 用队列实现栈(225题)
4.1 解法一:双队列法
与前一题相反,这里需要用FIFO的队列实现LIFO的栈行为。我的第一种解法使用两个队列:
- 主队列(queue_main):存储栈中元素
- 辅助队列(queue_helper):在pop时临时存储元素
每次pop时:
- 将主队列中除最后一个元素外的所有元素转移到辅助队列
- 弹出并返回最后一个元素
- 交换两个队列的角色
from collections import deque class MyStack: def __init__(self): self.queue_main = deque() self.queue_helper = deque() def push(self, x: int) -> None: self.queue_main.append(x) def pop(self) -> int: if self.empty(): return None while len(self.queue_main) > 1: self.queue_helper.append(self.queue_main.popleft()) item = self.queue_main.popleft() self.queue_main, self.queue_helper = self.queue_helper, self.queue_main return item def top(self) -> int: if self.empty(): return None return self.queue_main[-1] def empty(self) -> bool: return len(self.queue_main) == 04.2 解法二:单队列优化
更巧妙的解法是仅使用一个队列。每次push新元素后,立即将队列中已有的元素重新排到新元素后面:
class MyStack: def __init__(self): self.queue = deque() def push(self, x: int) -> None: n = len(self.queue) self.queue.append(x) for _ in range(n): self.queue.append(self.queue.popleft()) def pop(self) -> int: return self.queue.popleft() def top(self) -> int: return self.queue[0] def empty(self) -> bool: return len(self.queue) == 0这种方法push操作是O(n),pop操作是O(1),适合读多写少的场景。
5. 有效的括号(20题)
5.1 问题分析与栈的应用
这是栈的经典应用场景。给定一个只包含'(){}[]'的字符串,判断括号是否有效匹配。
关键观察:
- 遇到左括号就压栈
- 遇到右括号就检查是否与栈顶的左括号匹配
- 最后栈应该为空
def isValid(s: str) -> bool: stack = [] mapping = {')': '(', '}': '{', ']': '['} for char in s: if char in mapping.values(): # 左括号 stack.append(char) elif char in mapping.keys(): # 右括号 if not stack or stack[-1] != mapping[char]: return False stack.pop() else: # 非法字符 return False return not stack5.2 边界条件与优化
需要注意的特殊情况:
- 空字符串是有效的
- 只有左括号或只有右括号是无效的
- 括号类型交叉的情况如"([)]"是无效的
调试技巧:在面试中,建议先写出几个测试用例:
- "()[]{}" → True
- "(]" → False
- "([)]" → False
- "{[]}" → True
6. 删除字符串中的所有相邻重复项(1047题)
6.1 栈解法
这个问题可以看作是括号匹配的变种。我们可以用栈来高效地删除相邻重复项:
- 遍历字符串中的每个字符
- 如果栈不为空且栈顶元素等于当前字符,弹出栈顶
- 否则压入当前字符
- 最后栈中剩下的就是结果
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)6.2 时间复杂度分析
- 时间复杂度:O(n),每个字符最多被处理两次(压栈和弹栈各一次)
- 空间复杂度:O(n),最坏情况下需要存储整个字符串
这个解法比暴力解法(反复扫描字符串)的O(n²)效率要高得多。
7. 常见问题与调试技巧
7.1 栈溢出问题
在递归实现栈操作时,可能会遇到栈溢出。例如:
# 错误的递归实现 def pop(self): if len(self.items) == 1: return self.items.pop() item = self.items.pop() last = self.pop() self.items.append(item) return last这种实现会在处理大量数据时导致递归深度过大。应该改用迭代方式。
7.2 边界条件处理
在实现这些数据结构时,特别要注意边界条件:
- 空栈/队列时的pop操作
- 初始化状态
- 最后一个元素的处理
建议为每个操作都编写单元测试,覆盖这些边界情况。
7.3 性能优化技巧
- 对于频繁的操作(如peek),可以缓存结果
- 在栈实现队列时,只有当输出栈为空时才进行转移
- 使用collections.deque代替list实现队列,因为deque的popleft()是O(1)操作
8. 实际应用场景
这些基础数据结构在真实项目中有着广泛应用:
用栈实现队列:
- 某些语言(如Haskell)的纯函数式队列实现
- 限制性环境下的队列需求
用队列实现栈:
- 线程池的任务调度
- 广度优先搜索的变种实现
有效的括号:
- 编译器语法检查
- JSON/XML解析器
- 代码编辑器的大括号匹配
删除相邻重复项:
- 文本编辑器中的连续空格合并
- 日志文件的重复条目清理
- 数据清洗预处理
在大型项目中,我经常需要根据具体场景选择合适的数据结构。比如在实现一个消息系统时,就混合使用了栈和队列:
- 普通消息使用队列保证顺序
- 高优先级消息使用栈实现插队
- 使用括号匹配算法验证消息格式
9. 扩展思考
9.1 如何实现支持O(1)获取最小值的栈?
这是栈的一个经典变种问题(Leetcode 155题)。可以在栈中同时存储元素和当前最小值:
class MinStack: def __init__(self): self.stack = [] # 存储(值,当前最小值)元组 def push(self, x: int) -> None: if not self.stack: self.stack.append((x, x)) else: self.stack.append((x, min(x, self.stack[-1][1]))) def pop(self) -> None: self.stack.pop() def top(self) -> int: return self.stack[-1][0] def getMin(self) -> int: return self.stack[-1][1]9.2 如何用栈实现递归函数的调用?
函数调用栈是栈的另一个重要应用。每次函数调用时:
- 将返回地址、参数、局部变量压栈
- 函数返回时弹出栈帧
- 这样就能实现函数的嵌套调用
理解这一点对调试递归函数和异常堆栈非常有帮助。
9.3 单调栈与单调队列
这是栈和队列的高级用法,用于解决一些特殊问题:
- 单调栈:用于解决"下一个更大元素"类问题
- 单调队列:用于滑动窗口最大值问题
这些扩展内容值得单独深入探讨,建议在掌握基础后继续学习。