1. 算法训练营第十天:栈与队列实战精讲
今天继续代码随想录算法训练营的二周目挑战,聚焦栈和队列这对经典数据结构。很多初学者容易混淆它们的特性和应用场景,我在第一次刷题时也经常搞混push和pop的顺序。通过今天的四道题目,我们不仅能掌握它们的底层实现原理,更能理解为什么特定场景下必须使用某种结构——比如用栈实现队列这种看似矛盾的操作,其实蕴含着精妙的设计思想。
2. 理论基础:栈与队列的DNA差异
2.1 栈:后进先出的世界
栈(Stack)就像手枪的弹匣,最后压入的子弹总是最先射出。这种LIFO(Last In First Out)特性使得栈在解决对称性问题时具有天然优势。在Java中,Stack类虽然存在但实际推荐使用Deque接口实现:
Deque<Integer> stack = new ArrayDeque<>(); stack.push(1); // 入栈 int top = stack.peek(); // 查看栈顶 int val = stack.pop(); // 出栈关键点:栈的push和pop操作都在同一端进行,时间复杂度都是O(1)
2.2 队列:先进先出的哲学
队列(Queue)则像超市收银台前的队伍,先来的人先结账(FIFO原则)。在算法中常用于BFS等需要保持顺序的场景。Java中的队列通常这样声明:
Queue<Integer> queue = new LinkedList<>(); queue.offer(1); // 入队 int head = queue.peek(); // 查看队首 int val = queue.poll(); // 出队易错点:add/remove方法在操作失败时会抛出异常,而offer/poll返回特殊值,生产环境推荐后者
2.3 核心对比表
| 特性 | 栈 | 队列 |
|---|---|---|
| 操作端 | 单端(顶) | 双端(头出尾入) |
| 典型应用 | 函数调用、括号匹配 | BFS、缓存系统 |
| Java实现类 | ArrayDeque | LinkedList |
| 线程安全版 | ConcurrentLinkedDeque | LinkedBlockingQueue |
3. 232. 用栈实现队列:负负得正的魔法
3.1 问题本质分析
题目要求用两个栈模拟队列的先进先出特性,这就像用两个反向的传送带实现物品顺序不变。核心在于当出队栈为空时,将入队栈的所有元素"倒置"放入出队栈:
class MyQueue: def __init__(self): self.in_stack = [] self.out_stack = [] def push(self, x): self.in_stack.append(x) def pop(self): if not self.out_stack: while self.in_stack: self.out_stack.append(self.in_stack.pop()) return self.out_stack.pop()时间复杂度摊还分析:每个元素最多经历两次入栈和出栈操作,均摊O(1)
3.2 实际工程中的应用场景
这种双栈结构在浏览器前进后退功能中有典型应用:
- 访问新页面时push到栈A
- 点击后退时从栈A pop到栈B
- 点击前进时从栈B pop回栈A
4. 225. 用队列实现栈:旋转的艺术
4.1 单队列解法
通过每次插入新元素后立即将队列旋转n-1次,使得新元素始终位于队首:
class MyStack { Queue<Integer> q = new LinkedList<>(); public void push(int x) { q.offer(x); for(int i=1; i<q.size(); i++) q.offer(q.poll()); } }4.2 双队列解法
维护一个主队列和一个临时队列,插入时:
- 新元素入临时队列
- 主队列全部转移到临时队列
- 交换两个队列引用
class MyStack: def __init__(self): self.main_q = collections.deque() self.temp_q = collections.deque() def push(self, x): self.temp_q.append(x) while self.main_q: self.temp_q.append(self.main_q.popleft()) self.main_q, self.temp_q = self.temp_q, self.main_q5. 20. 有效的括号:栈的教科书案例
5.1 匹配规则实现
遇到左括号入栈,右括号时检查栈顶是否匹配:
var isValid = function(s) { const map = {')':'(', '}':'{', ']':'['}; const stack = []; for(let c of s) { if(!map[c]) stack.push(c); else if(stack.pop() !== map[c]) return false; } return stack.length === 0; };5.2 边界条件处理
需要特别注意的case:
- 空字符串(应返回true)
- 单字符字符串(如"[")
- 只有右括号的情况(如"())")
- 嵌套层级很深的情况(如"((([[[{{{}}}]]])))")
6. 1047. 删除相邻重复项:栈的消消乐
6.1 常规栈解法
维护结果栈,遇到相同字符就消去:
def removeDuplicates(s): stack = [] for c in s: if stack and stack[-1] == c: stack.pop() else: stack.append(c) return ''.join(stack)6.2 双指针优化
可以用快慢指针模拟栈行为,节省空间:
public String removeDuplicates(String s) { char[] arr = s.toCharArray(); int slow = 0; for(int fast=0; fast<arr.length; fast++){ if(slow>0 && arr[slow-1]==arr[fast]){ slow--; }else{ arr[slow++] = arr[fast]; } } return new String(arr, 0, slow); }7. 工程实践中的陷阱与优化
7.1 线程安全问题
在并发环境下使用栈/队列时:
- Java推荐使用
ConcurrentLinkedDeque或LinkedBlockingQueue - Python需配合
threading.Lock使用 - C++可用
std::stack<std::string, std::deque<std::string>>指定底层容器
7.2 内存管理技巧
对于频繁操作的栈/队列:
- 预设容量避免扩容开销(如
ArrayDeque(int initialCapacity)) - 对象池技术重用节点(特别适合队列)
- 批量操作减少锁竞争(如
addAll)
8. 扩展思考:为什么栈实现队列比队列实现栈更复杂?
从信息论角度分析:
- 栈只暴露顶部元素,要获取底部元素必须转移全部数据
- 队列两端都可访问,旋转操作保留完整信息
- 因此用队列模拟栈只需O(1)额外空间,而栈模拟队列需要O(n)辅助空间
9. 同类问题延伸训练
推荐巩固题目:
- 最小栈(双栈配合)
- 滑动窗口最大值(单调队列)
- 去除重复字母(栈+哈希)
- 移掉K位数字(贪心+栈)
我在实际刷题中发现,很多栈/队列问题都可以转化为"最近相关性"问题——当前元素需要与最近处理的元素比较。这种思维模式对解决括号匹配、行星碰撞等问题特别有效。