算法训练营打卡到第十天,栈和队列这组题目终于正式登场了。我这次要说的四道题——232. 用栈实现队列、225. 用队列实现栈、20. 有效的括号、1047. 删除字符串中的所有相邻重复项——在 LeetCode 上的难度标得都不高,但它们在训练营里被集中安排在一天,其实是很有讲究的。四道题围绕同一个核心数据结构展开,却在不断切换视角,从“用栈模拟队列”到“用队列模拟栈”,再到用栈解决括号匹配和相邻重复删除问题,层层递进,几乎把栈和队列最典型的行为特征都覆盖了。
这篇文章适合正在刷题准备面试的人,也适合刚把数据结构学完、想找经典题目练手的人。我不会只贴题解代码,还会把每道题的推导过程、调试时要注意的边界情况、以及我在实际写码时踩过的坑一起讲清楚。读完之后你不仅能复现这四道题,还能把“栈顶就是最近状态”这个核心直觉迁移到后续更复杂的算法题里。
1. 先搞清楚栈和队列到底在考什么
1.1 后进先出与先进先出的本质差异
写这四道题之前,先花两分钟把基础概念对齐一下。栈是一个只允许在一端进行插入和删除操作的线性表,这个端口叫栈顶,数据的特点是后进先出(LIFO)。你往一个栈里依次压入1、2、3,弹出的时候一定是3、2、1。一个很生活化的例子是叠盘子:最后放上去的盘子最先被拿走。队列则相反,它只允许在队尾插入、在队首删除,数据的特点是先进先出(FIFO),就像奶茶店排队,先到的人先拿到饮料。
这两种结构虽然在思维上差别很大,但都是最基础的数据结构。许多初学者觉得它们太简单,甚至觉得没东西可考。但实际上,栈和队列的行为特征在算法设计里出现得极其频繁,尤其是栈,它几乎天然对应了“最近的未完成状态”。比如你写代码时函数的嵌套调用,底层就是函数调用栈;你在编辑器里做撤销操作,底层也是栈。队列则是任务调度、广度优先搜索这类场景的标配。
这一天的四道题,实际上就是把这些结构特征拿来反复使用:先用栈的LIFO去模拟队列的FIFO,再用队列的FIFO去模拟栈的LIFO,然后在具体问题里直接借助栈顶元素判断和处理相邻匹配关系。
1.2 为什么训练营要在一个晚上安排这四道题
代码随想录算法训练营是每天一个专题、连续打卡的形式,到了第十天,专题就是栈和队列。我一开始觉得一天四道题任务量有点重,但实际写下来发现,前两题做顺了之后,后两题会写得非常快。因为四道题的底层逻辑是贯通的:232题教你“怎么用两个栈还原出一种FIFO的错觉”,225题教你“怎么用一个队列还原出LIFO的错觉”,20题和1047题则是把栈顶元素当作“最近出现但还没处理完的状态”来做判断。
换句话说,前两道题是后两道题的预演。如果你只看题解不动手,很难体会到这种递进关系,但当你亲手跑完232和225,再去做20和1047时,思路会顺畅很多,因为“栈顶 = 最近状态”这个直觉已经被训练出来了。这也是我建议不要跳着刷题的原因,训练营的题目顺序确实有它的内在逻辑。
另外,四道题的难度曲线也是安排好的:232和225是“模拟题”,需要你理解数据结构行为;20和1047是“应用题”,需要你活用栈顶特性。如果一上来就做20题,很多人也能做出来,但未必能意识到它其实是在练习“最近匹配”这个栈的核心特性。放在模拟题之后,你才会猛然发现,原来刚才学的双栈操作就是在为这种匹配问题做铺垫。
1.3 动手之前先建立三个调试意识
在正式写代码之前,我强烈建议先把三个习惯养成,它们能帮你省掉大量调试时间。
第一个习惯是每看到一个数据结构操作,就问自己一句:这个操作在空结构上执行会怎样?以栈实现队列为例,如果 outStack 为空时去 pop,代码必须先把 inStack 里的元素倒过来;如果你忘了这个判断,就会出现弹出错误值的隐蔽 bug。第二个习惯是区分返回值和副作用。pop 是删掉并返回栈顶元素,peek/top 是只返回栈顶元素不删除。这两个操作在题目里经常被混在一起实现,但语义上必须分开。第三个习惯是关注循环里长度表达式的边界。比如225题使用一个队列实现栈时,轮转次数是 len(queue)-1,如果写成 len(queue) 就会把一个元素多倒一次,结果全乱。
这三个习惯不是这四道题专属的,它们是所有数据结构题通用的。越早养成,后面刷到更复杂的题目时越不吃力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用栈实现队列:两个栈倒来倒去的经典套路
2.1 核心思路:两个栈怎么分工
232题要求用栈实现队列的 push、pop、peek、empty 四个方法。栈是后进先出,队列是先进先出,要让一个栈模拟出先进先出是不可能的——你需要两个栈。
这里的基本策略是:一个栈只管入队,另一个栈只管出队。入队时直接把元素压入 inStack;出队时不是直接弹出,而是先检查 outStack 里有没有元素,如果 outStack 是空的,就把 inStack 里的所有元素依次弹出并压入 outStack,然后再从 outStack 弹出队首元素。
为什么这个操作能正确模拟先进先出?我用一个具体序列说明。依次入队1、2、3,inStack 从栈底到栈顶是 1、2、3。第一次出队时,把 inStack 里的元素全部弹出再压入 outStack,outStack 从栈底到栈顶就变成了 3、2、1。此时从 outStack 弹出的是1,正好是第一个入队的元素。后面如果再出队,依次弹出2、3,顺序完全正确。
有人可能会问,为什么不直接用 inStack 作为队列,出队时把栈底的元素取出来?因为栈只允许在栈顶操作,你无法直接访问栈底。强行取出栈底元素需要把上面的元素全部弹出来,弹出来的过程中还要找地方暂存,这就是第二个栈存在的意义。所以两个栈并不是为了提高性能,而是因为单个栈的结构性限制,必须用一个辅助栈来反转元素顺序。
2.2 完整代码与关键细节
以下是 Python 实现:
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())
这里的 _transfer 是核心辅助方法。你可以看到,它只会在 outStack 为空时把 inStack 倒过去,这意味着并不是每一次 pop 都需要搬运所有元素。比如入队 1、2、3 之后连续出队三次,第一次搬运一次,后两次直接弹出,整体操作次数不会超过元素个数的两倍,所以平均下来每个操作是 O(1) 的时间复杂度,这在面试里叫均摊复杂度。
实现时最容易踩的坑是 peek 方法的复用。很多人会把 peek 写成独立逻辑,导致它和 pop 里搬运元素的代码重复。实际上 peek 可以用 pop 简化:先 pop 取到队首元素,再把它塞回 outStack 尾部,返回值不变。但要注意,如果你直接写这个简化版本,需要理解 pop 之后的 outStack 仍然维持着原有的元素顺序,只是队首被临时取出了一次。我给的代码选择用 _transfer 统一处理,可读性更强,也不容易弄乱状态。
提示:232题有一个非常隐蔽的坑——在 outStack 非空时继续搬运 inStack 的元素。一旦发生,队列的弹出顺序就会被打乱,而且很难从报错里一眼看出来。建议在写代码时把搬运逻辑收敛到 _transfer 一个方法里,而不是散落在 pop 和 peek 各处。
2.3 面试追问与延伸思考
这道题在面试里经常被追问,面试官不会满足于你背出代码。最常见的追问是:为什么 outStack 为空时才搬运?如果把条件改成每次都搬运会怎样?答案很明显:那样会破坏顺序,因为 outStack 中还有元素时,它的栈顶就是当前队首,你如果此时再把 inStack 里的新元素倒进来,新元素会压在旧元素上面,弹出顺序就会出错。
另一个追问是:能不能用一个栈加一个变量实现队列?理论上可以硬试,但你会发现变量只能保存一个元素,无法处理连续多次出队的情况,复杂度会退化得很厉害,所以双栈方案才是正解。这道题背后其实还藏着双栈的其他扩展,比如最小栈的设计,就是一个栈存数据、一个栈存当前最小值,方法是相通的。
3. 用队列实现栈:一个队列也能玩出花样
3.1 两个队列的稳妥做法
225题是反过来的:用队列实现栈的 push、pop、top、empty。如果先想两个队列,思路会比较直白:主队列 q1 保存当前所有元素,辅助队列 q2 负责临时腾挪。
入栈时直接往 q1 添加元素。出栈时,除了最后进入 q1 的那个元素,其他元素全部从 q1 出队并进入 q2,此时 q1 里剩下的最后那个元素就是要弹出的栈顶元素,把它弹出。然后交换 q1 和 q2,让 q2 在新的一轮里变成空的辅助队列。top 操作和 pop 类似,只是把最后的元素取出来看一眼,再塞回队列的尾部,保证状态不变。
这种写法很稳,逻辑清晰,不容易出错。但实现时必须记得一个步骤:交换两个队列的引用。如果你忘了交换,下一轮出栈时辅助队列就不是空的,元素顺序会乱掉。这个 bug 我用下面这段代码复现过,你可以试试:
python复制from collections import deque
class MyStack:
def __init__(self):
self.q1 = deque()
self.q2 = deque()
def push(self, x: int) -> None:
self.q1.append(x)
def pop(self) -> int:
while len(self.q1) > 1:
self.q2.append(self.q1.popleft())
res = self.q1.popleft()
self.q1, self.q2 = self.q2, self.q1
return res
def top(self) -> int:
res = self.pop()
self.q1.append(res)
return res
def empty(self) -> bool:
return not self.q1 and not self.q2
注意 top 我用了一个取巧的实现:先 pop 拿到栈顶元素,再把元素塞回去。因为 pop 之后栈顶元素被拿走了,但把它重新加入队列后,它实际上还是最后一个进入的元素,所以仍然位于栈顶,逻辑成立。
3.2 一个队列的进阶做法
如果你已经理解了上面两个队列的做法,可以试着进一步优化:其实一个队列就够。思路是入栈时,先把新的元素加到队尾,然后把前面的 n-1 个元素依次从队首弹出并重新加到队尾。
