1. 先搞清楚:栈和队列到底在解决什么问题
1.1 用生活场景理解两种"排队方式"
写算法题的人十有八九都翻车过这么一次:第一次看到用栈实现队列的题,脑子里全是"这不就是用List倒腾两下吗"的错觉。但实际上,栈和队列是两种截然不同的数据组织方式,它们的核心区别不是"用什么容器装数据",而是数据的进出顺序。
栈是后进先出(LIFO),你可以把它想象成一摞盘子——你总是先拿最上面那个新放上去的,想拿底下那个得先把上面所有的都端走。队列是先进先出(FIFO),就像食堂打饭的排队队伍,先来的先打到饭,后来的排后面,谁也不许插队。
这两种顺序听起来很简单,但所有进阶的数据结构、算法题、甚至系统设计里的消息队列,底层都逃不开这两种顺序。我见过不少初学者一上来就刷动态规划,结果连"回文串判断用双端队列"这种基础应用都卡壳,回头发现是栈和队列的底子没打牢。训练营放在第10天讲栈和队列,正好卡在这个时间点:你已经有了数组、链表的基础,可以开始接触带约束的数据结构了。
1.2 栈和队列的异同对比
先别急着写代码,把两者的特性用表格列清楚,后面所有题目都从这张表出发:
| 特性 | 栈(Stack) | 队列(Queue) |
|---|---|---|
| 数据进出顺序 | 后进先出 LIFO | 先进先出 FIFO |
| 主要操作 | push(入栈)、pop(出栈)、peek(看栈顶) | enqueue(入队)、dequeue(出队)、front(看队头) |
| 操作位置 | 只在栈顶操作 | 队尾入、队头出 |
| 现实类比 | 叠盘子、浏览器后退、函数调用栈 | 排队打饭、打印机任务队列、消息队列 |
| 常考题型 | 括号匹配、表达式求值、单调栈 | 循环队列、双端队列、用栈实现队列 |
从这张表能看出,栈和队列唯一的差别就是"操作位置"和"顺序",但就是这一点差别,决定了它们被用在完全不同的场景里。栈擅长处理"需要回退、嵌套"的问题,队列擅长处理"需要按顺序公平消费"的问题。
你可能会问:为什么不能直接用数组?当然能用,但数组是无约束的,你想从哪里读就从哪里读,写算法题时约束反而是一种简化——你不用再考虑"从中间插入"之类的操作,所有逻辑都被压缩到一个端点上。这就是为什么栈和队列被称为"受限的线性表",限制越多,思维越清晰。
2. 手写实现:把底层逻辑吃透
2.1 用Python实现一个栈(含动态扩容思考)
刷题时直接用语言内置的list当然方便,但训练营里我一直建议至少手写一遍底层。原因很简单:面试时会问"Python的list底层是怎么扩容的",你不会写数组栈,连这个问题都接不住。再者,手写一遍能让你真正理解"栈顶指针"是怎么移动的。
用Python实现一个纯数组栈:
class ArrayStack: def __init__(self, capacity=8): self.capacity = capacity self.data = [None] * capacity self.top = -1 # 栈顶指针,-1表示空栈 def push(self, value): if self.top + 1 >= self.capacity: # 动态扩容:倍增策略 self._resize(self.capacity * 2) self.top += 1 self.data[self.top] = value def pop(self): if self.is_empty(): raise IndexError("pop from empty stack") value = self.data[self.top] self.data[self.top] = None # 释放引用 self.top -= 1 return value def peek(self): if self.is_empty(): raise IndexError("peek from empty stack") return self.data[self.top] def is_empty(self): return self.top == -1 def _resize(self, new_capacity): new_data = [None] * new_capacity for i in range(self.top + 1): new_data[i] = self.data[i] self.data = new_data self.capacity = new_capacity这里有几个细节新手很容易漏:
- top指针初始化为什么是-1?因为空栈时栈顶位置是"数组下标前面的一个虚拟位置",这样push第一个元素时top从-1变成0,刚好落在下标0上。如果你把top初始化为0,那得额外用一个size变量区分空栈和栈顶位置,反而绕弯子。
- 扩容为什么用倍增而不是每次加1?倍增的时间复杂度均摊下来是O(1),而每次加1是O(n)。Python的list底层用的就是类似策略(实际上它会先申请8个槽位,然后按比例扩容),这是工程界的常青树方案。
- pop时为什么要把原位置置为None?Python有垃圾回收机制,但如果你存的是一个大型对象,不置None的话引用还挂着,这个对象不会被回收。大流量服务里这就是内存泄漏的隐患。刷题时可以不在意,但写工程代码必须养成习惯。
2.2 用Python实现一个队列(解决假溢出问题)
队列的实现比栈多一个坑点——数组队列存在"假溢出"问题。先看一个错误的示范:如果你用两个指针front和rear,每次入队rear加1,出队front加1,那么当rear到达数组末尾时,即便数组前面空着一大片空间,你也没法再入队了。这就是假溢出。
一个标准的数组队列实现:
class ArrayQueue: def __init__(self, capacity=8): self.capacity = capacity self.data = [None] * capacity self.front = 0 # 队头下标 self.rear = 0 # 队尾下标,指向下一个入队的位置 self.size = 0 # 当前元素个数 def enqueue(self, value): if self.size >= self.capacity: self._resize(self.capacity * 2) self.data[self.rear] = value self.rear = (self.rear + 1) % self.capacity # 循环移动 self.size += 1 def dequeue(self): if self.is_empty(): raise IndexError("dequeue from empty queue") value = self.data[self.front] self.data[self.front] = None self.front = (self.front + 1) % self.capacity self.size -= 1 return value def peek_front(self): if self.is_empty(): raise IndexError("peek from empty queue") return self.data[self.front] def is_empty(self): return self.size == 0 def _resize(self, new_capacity): new_data = [None] * new_capacity for i in range(self.size): new_data[i] = self.data[(self.front + i) % self.capacity] self.data = new_data self.front = 0 self.rear = self.size self.capacity = new_capacity这段代码里最关键的是两处取模运算:rear = (rear + 1) % capacity和front = (front + 1) % capacity。取模让指针能够"绕回"数组开头,形成一个逻辑上的环,这就是循环队列的核心思想。如果你不用循环而选择在出队时把所有元素往前挪一位,那时间复杂度会从O(1)变成O(n),刷题时可以AC但面试会挂。
再注意size这个变量。有人会问:为什么不用front == rear来判断空和满?因为空和满时front都等于rear,区分不开。解决方案有两个:一是用size计数,代码直观一些;二是牺牲一个存储单元,让(rear + 1) % capacity == front表示满。我推荐用size,逻辑清晰不容易出错,刷题时效率差别也不大。
2.3 两种实现必须注意的边界条件
把栈和队列实现完,你可能会觉得"不过如此",但边界条件才是真正杀死人的地方。我整理了训练营里学员踩过的坑:
- 空栈pop/peek:必须先判断isEmpty,否则下标越界或返回None。有些语言里这会直接抛异常或崩掉。
- 扩容后数据搬迁:栈扩容时按top顺序搬,队列扩容时要注意从front开始搬,不能从0开始。很多人在写队列resize时直接把原数组整体拷贝过来,结果元素的相对顺序全乱了。
- 队列无效化旧元素:出队后把原位置置None。这不仅仅是防止内存泄漏,更重要的是避免调试时看到一堆脏数据影响判断。
Python刷题时可以偷懒用list模拟栈——因为list的append和pop天然就在末尾操作,复杂度是O(1)。但队列用list的pop(0)就不行,因为pop(0)会触发O(n)的元素迁移,刷题时数据量小可能没感觉,但你会养成非常坏的习惯:不看复杂度,只求过case。训练营的要求是每道题至少分析清楚时间复杂度和空间复杂度再动手,哪怕代码看起来多一行,也要明白这一行是干什么的。
3. 经典算法题拆解:栈和队列的三大必刷题型
3.1 有效的括号:栈最经典的匹配问题
"有效括号"这道题(LeetCode 20)基本是算法面试的入门标配,但每年还是有一大批人在上面翻车。题目要求判断一个只包含()[]{}的字符串是否有效,规则有三种:左括号必须用同类型右括号闭合、闭合顺序要正确、嵌套要正确。
核心思路很简单:遇到左括号就入栈,遇到右括号就弹出栈顶看是否匹配。但如果弹出前没检查栈是否为空,遇到"}"这种单独出现的右括号就会报错。
def is_valid(s: str) -> bool: stack = [] mapping = {')': '(', ']': '[', '}': '{'} for char in s: if char in mapping.values(): stack.append(char) elif char in mapping: if not stack or stack[-1] != mapping[char]: return False stack.pop() else: return False return not stack这个版本用了一个反向映射表,用右括号反查左括号,比用if char == ')' and stack[-1] == '('这种多分支写法清爽得多。要注意最后返回的是not stack,因为如果栈里还有没匹配完的左括号,说明括号没闭合完。
实际刷这道题时,常见的错误排序是:
- 忘了处理
"}("这种情况——遇到右括号时栈是空的,没有判断就pop。 - 用正向映射表
{'(': ')'}然后去比较当前char是否等于栈顶的映射值,逻辑绕来绕去,容易漏case。 - 只验证了数量没验证顺序。
这道题还有一种扩展考法:不只是三种括号,而是出现自定义的符号映射关系。比如把{}替换成< >和《 》,思路完全一样,但需要你真正理解映射表的用法而不是背答案。
3.2 用两个栈实现队列:经典双栈切换
这道题(LeetCode 232)看起来是基础题,实际上把栈和队列的性质玩得很透彻。思路是维护两个栈:in_stack专门用来入队,out_stack专门用来出队。
- 入队时:直接压入
in_stack。 - 出队时:如果
out_stack是空的,就把in_stack的所有元素逐个弹出并压入out_stack,然后弹出out_stack栈顶。
为什么能这样?因为栈的反序恰好是队列的顺序——in_stack里栈顶是最后进来的元素,但队列需要最先出去的恰恰是最先进来的,所以倒一次手后,栈顶就变成了最先进来的元素。
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): # 注意:只有当 out_stack 为空时才搬运 if not self.out_stack: while self.in_stack: self.out_stack.append(self.in_stack.pop())很多初学者在这里犯的一个关键错误是每次pop前都想搬运一次。如果这么干,时间复杂度会变成O(n),而且会破坏队列顺序。比如先push 1、2、3,然后pop两次,每次都搬运,第一次out_stack是[3,2,1],pop出1;第二次再搬运时in_stack已经空了,一来一回没问题。但万一中途又push 4,再pop时如果out_stack还有元素却优先把4搬到out_stack里,顺序就乱了。
正确逻辑是:out_stack有货就先出货,没货才从in_stack搬运全部。这样均摊下来每个元素最多被搬两次,时间复杂度均摊O(1)。面试时如果被问"为什么摊还复杂度是O(1)",就回答:每个元素入栈一次、出栈一次、进out_stack一次、出out_stack一次,总共4次常数操作。
这道题还有个变形:用栈实现双端队列(Deque),思路类似但需要额外处理两端操作。如果你能稳定切出这道题,说明栈和队列的相互转化已经通了。
3.3 用两个队列实现栈:单队列循环法
既然有"用两个栈实现队列",就一定有"用两个队列实现栈"(LeetCode 225)。这道题解法比上一题烧脑一点,因为队列天然是FIFO,要实现LIFO必须玩一个"最后一招"。
我用的是一个更省空间的变体:只用一个队列就能实现栈。核心操作是:每次push新元素后,把队列里前面的所有元素依次取出放到队尾,这样新元素就挪到了队头,pop时直接弹出队头即可。
from collections import deque class MyStack: def __init__(self): self.q = deque() def push(self, x: int) -> None: self.q.append(x) # 把前面的n-1个元素依次挪到队尾 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.qpush操作里的循环是关键:每次入队新元素后,把所有其他元素依次旋转到新元素后面,相当于每次push都是O(n)的代价。这题的均摊复杂度没必要像上一题一样好,因为每次push就是O(n),pop是O(1),属于"写操作贵、读操作便宜"的模型。
官方题解里还有一种双队列做法:push时先把新元素放到q2,然后清空q1全部搬到q2,之后交换两个队列的指针,逻辑上等同于上述旋转。但我个人觉得单队列的旋转思想更直观,代码也更短。面试时如果你能把这个for循环的原理讲清楚,效果比背双队列代码好得多。
4. 实际应用场景:栈和队列是怎么进工程里的
4.1 栈:从函数调用栈到撤销功能再到单调栈
很多刷题的人有一个误区:觉得栈和队列只是考试用的抽象概念,和真实项目没关系。实际上你写的每一行代码都在用栈——函数调用栈(Call Stack)就是栈的典型应用。函数A调用函数B,系统会把A的现场信息压栈,B返回后再弹栈恢复A的执行。你在调试器里看到的"调用堆栈",本质就是一层层栈帧。热词里提到的"函数栈帧的创建与销毁"就是这个过程:caller的局部变量、返回地址、参数按顺序压栈,被调函数结束后弹栈恢复现场。理解了这一点,你再看递归崩溃时的"Stack Overflow"才会真正明白——栈的空间是有限的,无限递归必然爆栈。
另一个非常贴近生活的应用是编辑器的撤销功能(Undo)。每次操作压入栈,撤销时弹出,撤销再撤销就是多重撤销。如果你想实现"支持任意撤销深度",那就是一个永远不弹的栈,但如果为了限制内存就得用固定容量并覆盖旧元素——这又引出了循环队列的思想。
热词里的"单调栈揭秘"很有意思,它是栈在算法竞赛中特别高频的一个进阶话题。单调栈的核心是:维护一个栈,让栈内元素保持单调递增或递减,常用于解决"下一个更大元素"问题——比如热词里的"算法 找下一个身高更高的小朋友"。遇到这种题,暴力解法是O(n^2),单调栈能把复杂度降到O(n)。我建议你把每日温度这道题刷一遍,它会让你意识到"栈不只能做括号匹配,还能做更高效的查找"。
4.2 队列:从消息队列到线程池的阻塞队列
队列在工程里的应用比栈更广泛,因为现实世界中大量的任务是"先到先服务"的。最常见的例子是消息队列(Message Queue):生产者把消息放到队列尾部,消费者从队列头部取走消息。热词里专门有"消息队列重复消费问题"——这正是队列的语义和应用重点:消息一旦被消费并确认(ack),就应该从队列中移除,但如果没有正确处理ack,比如消费者处理完但ack丢失,消息就会重新入队被再次消费,导致重复。这个问题在分布式系统里尤其突出,实际上是通过"分布式幂等"来解决的——即使消费者收到重复消息,也要保证处理结果一致。
线程池里的等待队列也是队列:任务提交时如果线程池满了,新的任务会放入等待队列,空闲线程依次取走执行。热词里提到的"线程池的阻塞队列选择"——Java里LinkedBlockingQueue和ArrayBlockingQueue的区别,本质上就是链表队列和数组队列在并发场景下的取舍。数组队列预分配内存,读写效率更高,但容量固定;链表队列按需分配节点,容量可以设很大,但每个节点有额外内存开销。我自己的经验是,默认选LinkedBlockingQueue通常更稳妥,因为它的锁粒度更细(put和take各一把锁),尤其在读多写多的场景下并发度更高。
还有热词里的"python队列queue不堵塞"——Python的queue.Queue可以直接设置timeout参数,超过指定时间就抛出异常或返回标志,这在写爬虫限速、任务分发时特别好用。另外,Python里还有一个很容易混淆的东西:qsize()法和task_done()。qsize()并不是一个线程安全的准确值,只适合做粗略判断,真正判断队列状态应该用empty()和full(),或者直接依赖阻塞机制。
4.3 常见错误与排查技巧实录
把训练营里学员在栈和队列题目上遇到的典型问题汇总一下,有则改之,无则加勉:
| 问题 | 场景 | 直接原因 | 解决方案 |
|---|---|---|---|
pop from empty stack | 栈题中连续出栈 | 没先检查空栈 | 每次pop前判断is_empty() |
| 队列顺序错乱 | 用两个栈实现队列 | out_stack非空时仍然搬运新元素 | 只在out_stack为空时搬运 |
| 死循环 | 队列入队判断 | 用front == rear判断满且没含size | 用size计数或留一个空位 |
| 只过一半case | 括号匹配 | 没考虑空栈pop | 匹配右括号前检查not stack |
| 栈溢出 | 递归思想误用到栈题里 | 用递归代替显式栈且递归过深 | 先用显式栈模拟,避免递归爆栈 |
| 假溢出 | 数组队列连续入队出队 | rear到达末尾后无法复用前段空间 | 改成循环队列,取模 |
其中"死循环"这个问题调试起来最头痛。有位学员实现队列时用了while not queue.is_empty(),但is_empty判断的是self.front == self.rear,结果入队一个元素后front等于rear(空状态),循环条件立刻不满足,入队操作等于白做。这种问题不看打印日志根本查不出来,因为逻辑看起来完全正确。
我Debug了几年,普遍的经验是:栈和队列的边界错误,靠眼睛看是不行的,必须人为构造边界case来验证。比如栈的题目一定测:空栈、一个元素pop后回到空、连续push到扩容点(如第8个元素)、扩容后pop到空再push。队列的题目一定测:入队一个出队一个再入队(触发循环绕回)、队列刚刚满时再入队(触发扩容)、扩容后数据顺序是否正确。把这些case记下来,刷任何一道栈或队列题都能快速自检。
5. 训练营Day10的实操复盘与个人心得
5.1 本节的完整学习路径总结
讲完理论、实现、刷题和工程应用,还是要把训练营Day10的整体学习路径理一遍。我在这天的安排是:
- 画图理解概念(20分钟):在白板上画出栈的初始化、push、pop三个状态图和队列的初始化、入队、出队三个状态图,一定要画到"rear指针绕回数组开头"这一步,否则循环队列的取模永远理解不透。
- 手写两种结构的代码(30分钟):必须基于数组实现而不是直接用list偷懒。重点体会栈的top指针和队列的front/rear指针的区别,以及扩容时数据搬迁的差异。
- 刷题验证(40分钟):LeetCode 20有效括号、LeetCode 232用栈实现队列、LeetCode 225用队列实现栈。这三道题做完,再把它们的变形题看一眼(比如LeetCode 1047删除字符串中的所有相邻重复项,用栈思路可以直接秒杀)。
- 总结错误场景(10分钟):把自己在做题中写错的case记录到错题本里。我强烈建议用表格的形式记录:错误代码、正确代码、失败case、原因分析。这样一周后回看时,一眼就能定位自己的短板。
这套流程不需要额外资料,就靠白板、编辑器、LeetCode三个工具。第一次基础的同学对栈和队列有惧怕感,但按照这个路径走下来,基本两小时内就能达到中等刷题水平。
5.2 想进一步深入,该往哪些方向扩展
Day10只是part01,栈和队列这个主题还有很大空间,后续训练营会继续展开的内容包括:
- 单调栈:解决"下一个更大元素""每日温度""接雨水"(LeetCode 739/42)。这类题训练的是"什么时候该弹栈"的判断力,是栈的进阶应用。
- 双端队列(Deque):一句话理解一个deque使用场景——滑动窗口最大值(LeetCode 239)。它的巧妙之处在于,不仅两端可以进出,还能在维护窗口时淘汰"过期元素"。
- 优先队列(堆):热词里"堆和栈"经常被混在一起提。堆是一种特殊的树形结构,TopK问题、合并K个有序链表全靠它。堆和栈的区别要单独花时间理解——前者是树,后者是线性表。
- 栈与递归的转换:任意递归都可以改成非递归的显式栈。理解这个转换对理解深度学习中的反向传播也有帮助——神经网络里的梯度传播本质上是个栈式的回溯过程。
如果你能把Day10的栈和队列打牢,到后面学树、图、动态规划时,你会发现好多结构都依赖这些基础的进出顺序思想。说到底,算法不是背题,而是理解数据在特定约束下的流动方式。栈和队列就是那两扇最基础的门,推开它们,后面广阔得很。
我个人在带训练营时的切身体会是:栈和队列的题,看起来简单但极其容易犯错,尤其是边界条件。所以Day10这天宁可多花时间把实现代码写明白、边界case测彻底,也不要急着一口气刷十道题。基础结构一旦理解错了,后面所有题都会跟着错。拿张纸,把每次错误的case画出来,比刷十道题都管用。