☰
栈与队列设计题全解:最小栈、队列实现栈与栈实现队列
2026/10/15 4:55:20 网站建设 项目流程

刷题刷到"设计题"这一层,很多人会突然卡壳。前面还在玩指针、递归、动态规划,到了栈和队列这里,题目突然变成"用A实现B"——最小栈、队列实现栈、栈实现队列,三兄弟长得差不多,背答案又总觉得心虚。偏偏面试官还很爱问,因为这三道题能看出来一个人是死记硬背,还是真的理解数据结构到底在干什么。

这篇文章就把这三道题放在一起讲透。它们被归为"设计题",核心考察的其实是一件事:你能不能在一个受限的数据结构之上,通过合理的组织方式,让另一个数据结构的语义成立。我会把每道题的暴力思路、正解推导、复杂度分析、易错细节全部拆开讲,最后再做一次横向对比和面试追问预测。适合正在准备算法面试、或者学完栈和队列基础想进阶的读者,看完之后这三道题基本不用再翻题解。

1. 为什么这三道"设计题"总被放在一起问

面试官问数据结构的题目,最喜欢挖的一个点就是"你懂不懂这个结构的本质限制"。数组和链表是物理存储结构,栈和队列是逻辑结构,它们本身不关心底层怎么存——你完全可以用数组实现栈,也可以用链表实现队列。而这三道题更特殊:用栈实现队列、用队列实现栈,相当于在两种逻辑结构之间做翻译。

1.1 数据结构的本质:存取的约束规则

栈的规则是后进先出,队列的规则是先进先出。这两种规则本身是一组互逆的序列操作:栈把序列倒过来,队列保持序列不变。所以"用队列实现栈"和"用栈实现队列",本质上都是在研究"如何通过另一种顺序操作来模拟一种顺序操作"。

最小栈稍微不同,它不涉及两种结构互相模拟,而是在栈原有的接口上增加一个getMin操作,并且要求O(1)。这考察的是"在引入额外约束时,如何保持核心操作不被破坏"。

三题放在一起,恰好覆盖了设计题的三种常见形态:给原结构加新功能、用另一种结构模拟原结构、用原结构模拟另一种结构。下面这张表能快速看清它们的关系:

题目输入限制要实现的功能核心矛盾
最小栈只能用栈push/pop/top/getMingetMin要求O(1),不能靠遍历
队列实现栈只能用两个队列push/pop/top/emptyFIFO如何模拟LIFO
栈实现队列只能用两个栈push/pop/peek/emptyLIFO如何模拟FIFO

1.2 这三道题到底在考什么能力

先说结论:它们考的不是"背解法",而是"分析约束、设计状态、论证复杂度"的能力。

以最小栈为例,很多人第一次想到的是"用一个变量记录最小值"。这个方案立刻就被推翻了,因为pop把最小值弹走之后,你根本不知道次小值是多少。关键不是记住当前的最小值,而是要记住整个栈生命周期里每一个时刻的最小值。于是辅助栈出来了——栈的每一次状态变化,辅助栈都记录下当时的最小值。

队列实现栈和栈实现队列也是这样。你写代码之前,必须先回答一个问题:元素在什么时候被搬动?是入的时候搬,还是出的时候搬?这个选择直接决定push和pop谁更慢。面试官看到你能主动说出"我选择让入队慢一点因为pop更频繁",比默写三种代码都加分。

这三道题能放在一起,还因为它们共享一种设计模式:用额外的空间/额外的操作次数,去换取某个接口的时间复杂度达标。想通了这一点,后面做LRU缓存、实现跳表、设计消息队列的时候,思路是同一个套路。

2. 最小栈:辅助栈是基础版,差值法是进阶版

先明确题目要求:设计一个栈,支持push、pop、top、getMin四个操作,其中getMin要O(1),并且栈本身还是普通栈的行为。说白了,你只是要在一个正常栈外面包一层"能随时告诉我最小值"的壳。

2.1 为什么不能只存一个变量

很多人第一次的直觉是维护一个min变量,push时更新。这个思路在栈只增不减时没问题,但一旦执行pop,麻烦马上出现。比如依次入栈5、3、4,此时min = 3。如果执行两次pop,4和3都被弹出,栈里只剩5,但你的min变量还停留在3——它已经不在栈里了。你完全不知道被弹走的3之后,栈里的最小值变成了谁。

要解决这个问题,必须让"最小值信息"跟着栈的历史状态走。栈每时每刻有一个当前最小值,把这个序列记录下来,就形成了一个新的栈。这就是辅助栈的核心思想:数据栈负责正常存取,辅助栈负责记录"每一个历史时刻的最小值"。

2.2 辅助栈同步法与不同步法的取舍

最直观的写法是同步法:每次push时,数据栈压入新元素,辅助栈压入"当前元素和已有最小值中较小的那个";pop时两个栈同时弹出。因为辅助栈的高度始终和数据栈相同,所以辅助栈栈顶永远是数据栈所有元素的最小值。

class MinStack: def __init__(self): self.stack = [] self.min_stack = [] def push(self, val: int) -> None: self.stack.append(val) # 辅助栈压入当前最小值 if not self.min_stack or val <= self.min_stack[-1]: self.min_stack.append(val) else: self.min_stack.append(self.min_stack[-1]) def pop(self) -> None: self.stack.pop() self.min_stack.pop() def top(self) -> int: return self.stack[-1] def getMin(self) -> int: return self.min_stack[-1]

同步法的优点在于实现简单、逻辑不容易出错,两个栈一一对应,pop永远不需要额外的判断。缺点是辅助栈里可能存了大量重复值。举个例子,连续入栈10个相同的元素,辅助栈也会存10个相同的数字,空间上存在浪费。嵌入更多实际场景中,比如入栈序列是1, 2, 3, 4, ..., n,辅助栈里就一直存的是1,只有1在变化,其余全是冗余。

于是有了不同步法:只有遇到小于等于当前最小值的元素时才往辅助栈压入,pop时判断弹出的元素是否等于辅助栈栈顶,如果是就一起弹出。这在空间上更省,但要特别注意等于的情况——如果最小值连续出现多次,而你不压入相等的值,pop时就会把唯一的最小值记录弹掉。稳妥做法是遇到等于也要压入,否则需要额外计数器。

两种方法对比下来,我的建议是:面试时先用同步法把逻辑讲清楚,然后主动提一句"如果考虑极端重复数据的空间优化,可以用不同步法",展示你对空间复杂度的敏感度。

2.3 O(1)空间的差值法,理解它比背它重要

辅助栈把空间复杂度做到了O(n),面试官很可能追问能不能不用辅助栈。这时候差值法就登场了。核心思路是:栈里不存元素本身,而是存"当前元素与当前最小值的差值"。

class MinStack: def __init__(self): self.diff = [] self.min_val = 0 def push(self, val: int) -> None: if not self.diff: self.min_val = val self.diff.append(0) else: d = val - self.min_val self.diff.append(d) if d < 0: self.min_val = val # 新元素更小 def pop(self) -> None: d = self.diff.pop() if d < 0: self.min_val = self.min_val - d # 恢复进入前的min_val # 弹出的元素就是旧的min_val def top(self) -> int: d = self.diff[-1] if d < 0: return self.min_val else: return self.min_val + d def getMin(self) -> int: return self.min_val

这个解法很巧妙,但要提醒一下:diff = val - min_val,当diff为负时,说明入栈元素比当前最小值还小。为什么?如果val >= min_val,差值为非负;如果val < min_val,差值一定为负。因此每次push时diff的符号直接告诉你了新元素是否是新的最小元素。

pop时需要恢复之前的min_val。假设入栈前最小值是m,入栈元素v = m + d,入栈后最小值变成v,此时min_val = v。弹出时我们要找回m,因为d < 0,所以m = v - d = min_val - d。代码里那行self.min_val = self.min_val - d就是在做这个复原。

这个解法在Python里很安全,因为整数不担心溢出。但在C++或Java里,val - min_val可能超出int范围,面试时提一句"这个方案需要考虑整数溢出,所以工程上更常用辅助栈",会让面试官觉得你不是在背题,而是真的评估过方案的适用性。

2.4 最小栈的易错点清单

第一个坑是pop时忘记恢复状态。如果用了同步法,只要两个栈都弹出就行,问题不大;如果用不同步法,忘了判断辅助栈栈顶是否等于被弹出元素,会导致辅助栈里残留脏数据。第二个坑是getMin和top在空栈时怎么处理,题目没规定就默认抛异常或返回None,但专门处理一下会让代码更健壮。第三个坑是在push时用<=而不是<,这关系到重复元素会不会被漏记。第四个坑经常出现在和"滑动窗口最小值"混淆的地方——最小栈的getMin只看栈顶,不要求返回任意区间的局部最小值,不要把问题想复杂。

3. 用队列实现栈:一切操作的前提是想清楚"什么时候搬元素"

用队列实现栈,开局有个强烈的直觉矛盾:队列是先进先出,栈是后进先出,怎么可能用队列模拟栈的语义?答案是可以,因为队列的操作规则一共就那么几个,你完全可以通过"反复出入队"来重新排列元素顺序。

3.1 两种调整时机的本质差异

栈的核心操作是pop要弹出最后入栈的元素,而队列只允许从队尾入、队头出。既然队列天然让最早来的元素先走,那唯一办法就是让最后来的元素移动到队头。什么时候做这件事?两个选择:入栈时做,或者出栈时做。

入栈时调整,叫"入队反转":新元素先放到队尾,然后把它前面的所有元素依次出队再入队,一整个转到队尾之后,队头就变成了最新元素。这样pop只需直接从队头弹出,O(1)。代价是push需要搬动n-1个元素,O(n)。

出栈时调整,叫"出队反转":push直接入队,O(1)。pop时把前n-1个元素搬走(搬到辅助队列),队头剩下的就是要弹出的最后一个元素。注意,如果你有辅助队列,搬走的元素要留在辅助队列里,最后再把辅助队列当作新的主队列。

两种策略没有绝对优劣,要看使用场景。你要是实现一个很少push但频繁pop的结构,选入队反转;反过来选出队反转。面试里这道题最常见的考察方式是"你实现其中一种,然后说清另一种的时间复杂度"。

3.2 双队列实现:一个存数据,一个当缓冲区

我用Python写一个双队列版本,选择push时调整的方案,因为它的pop和top都简单:

from collections import deque class MyStack: def __init__(self): self.q = deque() self.helper = 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

注意这里的技巧:循环的次数是len(self.q) - 1。因为每次popleft之后队列长度实时变化,所以不要提前用变量存死长度。代码里for循环每次执行时重新获取len,这是正确的。helper队列在这个实现里其实用不到,因为所有搬移都在同一个队列内部完成。

这么说你可能有点疑惑:"不是说要两个队列吗?"对,传统题解要求是两个队列,但如果你允许在同一个队列里操作,一个队列就够了。把队头元素循环搬到队尾,重复n-1次,唯一的区别是这里用的是原地循环而不是借助第二个队列。

那第二个队列什么时候真正需要?在"出栈时调整"的方案里才需要。pop时要把前n-1个元素从队首搬到另一个队列暂存,然后原来的队首就是栈顶,弹掉之后,第二个队列保存了剩余元素,重新把它设为主队列。整个过程两个队列必须分工明确:非空的那个是主队列,负责实际存储,另一个永远为空作为中转站。很多人在代码里搞混,就是因为缺少"同一时刻只有一个队列有数据"的意识。

3.3 单队列实现:把辅助队列省掉

前面提了,可以在同一个队列里循环搬移。这个思路对"入队反转"来说更加简洁:

class MyStack: def __init__(self): self.q = deque() def push(self, x: int) -> None: self.q.append(x) n = len(self.q) while n > 1: self.q.append(self.q.popleft()) n -= 1 def pop(self) -> int: return self.q.popleft() def top(self) -> int: return self.q[0]

这里while循环的含义是:把队尾新元素的"身前"所有元素依次挪到身后,循环结束后队头就是最新元素。每次popleft+append,等价于一次"队头搬家",转一圈之后,新元素自然滚到了队头。你可以用一支笔在纸上模拟入栈1、2、3的过程,会发现队内的元素排列始终是逆序的:3在队头,2在中间,1在队尾——这正是栈的弹出顺序。

3.4 细节陷阱:top不弹出时的代价

有些版本要求top返回栈顶但不弹出。入队反转方案里top非常方便:直接返回self.q[0]就行,复杂度O(1)。但如果你选择了出栈时调整的方案,top就比较麻烦——你不能只取队尾,因为队尾是最后入队的元素但不是栈顶,栈顶在队头。实际上在出栈时调整方案中,栈顶永远是"队列的全部元素搬走n-1个之后剩下的那一个",所以top要么实现成"搬移所有元素找到最后一个再搬回来",要么直接复用一次完整的pop逻辑再把它塞回去。

很多人在面试时卡在这一步,就是因为写之前没想清楚"top是否允许改变数据状态"。"栈顶查看但不弹出"这个语义,放在队列实现里其实挺贵的,除非你选对了调整时机。

还有一个容易忽略的细节是Python内置list和deque的性能差异。如果你用list,popleft对应pop(0),这是O(n)的操作,会让整个push的复杂度变成O(n²)。面试时如果聊到实现,建议直接说明"我用collections.deque保证队头弹出是O(1)"。

4. 用栈实现队列:摊还O(1)的第一次实战

用栈实现队列,表面上是上一题的对偶问题,但深入之后会发现它其实更优雅,因为它实现了真正的"摊还O(1)"。这道题是所有"摊还分析"概念的入门题,值得单独拿出来品。

4.1 两个栈分工:一个管进,一个管出

两个栈分别叫inStack和outStack。push一律压入inStack,pop时看outStack有没有元素,没有就把inStack的所有元素全部倒入outStack,然后从outStack弹出。来看一个实例:依次push 1、2、3,inStack从底到顶是[1, 2, 3]。执行pop,先把inStack倒到outStack,从顶到底依次是3、2、1,再pop就拿到了1——这是最早入队的元素。

这个过程最核心的思想是:栈的LIFO特性,反转了一次序列;反转两次,就恢复了原来的FIFO顺序。第一次反转发生在"倒入outStack"时,把inStack的逆序变成了正序;第二次反转发生在"从outStack弹出"时,弹出顺序再次反转,和入队顺序一致。用生活化的比喻:一堆人排队进入一条死胡同再倒着走出去,第二次掉头让首尾顺序和最初完全一样。

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: if not self.out_stack: while self.in_stack: self.out_stack.append(self.in_stack.pop()) return self.out_stack.pop() def peek(self) -> int: if not self.out_stack: while self.in_stack: self.out_stack.append(self.in_stack.pop()) return self.out_stack[-1] def empty(self) -> bool: return not self.in_stack and not self.out_stack

代码里反复出现的"if not out_stack: 倒入"就是整个方案的灵魂。为什么只在outStack为空时才倒入?因为如果outStack还有数据,说明上一次倒入的队列头部还没被消费完,此时再倒入会把后到的元素压在旧元素上面,破坏先进先出顺序。这个条件必须写对,写错这道题就白做了。

4.2 摊还复杂度的精妙论证

很多人会说这个方案最坏情况下pop是O(n),因为要把inStack全部搬过去。这个说法没错,但只对了一半。面试官真正想听到的是摊还复杂度分析。

摊还分析的核心是"把目光放到连续多次操作的总代价上"。考虑在inStack中放入n个元素,期间如果没有pop,那么后续连续n次pop中,只有第一次需要做一次完整的倒入(n次pop),后面的n-1次pop都直接从outStack弹出,O(1)。如果把n次push和n次pop放在一起看,总操作数是push的n次O(1)加第一次pop的O(n)加后面n-1次pop的O(1),合计约O(n),平均到每个操作就是O(1)。

更严格地说,每个元素只会被从inStack搬到outStack一次,也只会被从outStack弹出一次,所以n个元素全部进出一个队列,总搬迁次数是O(n),均摊到这n个元素相关的所有操作上,就是O(1)。

这就是为什么这道题比"用队列实现栈"更"高级"。用队列实现栈时,每次push都要搬运该元素之前的所有元素,却没有一个可以"批量抵销"的机会——每个元素都要被反复搬动多次,摊还下来不可能做到O(1)。

4.3 面试追问:当两个栈的容量不对称

有些面试官会加一个条件:"inStack容量无限,但outStack最多只能放k个元素,怎么办?"这是一个开放题,考察你有没有真正理解两个栈只是逻辑分工,而不是物理隔离。一种可行思路是分块处理:当outStack快满时,把已经倒过去的部分当作队列的前半段先消费,剩余元素留在inStack里,等outStack空了再补货。这种"分桶批量倒"的思路在实际的流式队列、消息缓冲设计中经常用到。

另一个追问方向是"为什么不用一个队列加一个普通变量实现队列"——这个问题本身就是陷阱,如果你试图用单队列实现队列,等于什么都没做。还是要回到两个栈分工的原始模型上去。

4.4 写这道题最容易翻车的地方

翻车点第一名是peek忘了触发倒入。peek和pop的语义不同,但都需要访问outStack的栈顶,如果outStack为空就一定得倒。很多人把pop的倒入逻辑写对,但peek单独写了一个"直接return outStack[-1]",栈空时直接报错。

第二个翻车点是empty判断。队列为空当且仅当两个栈都为空,只判断一个栈会导致误判。第三个翻车点是倒的时候忘记while循环,只弹出一个元素就停了,这个错误特别隐蔽——代码不报错,但数据顺序全乱了。写完后一定要用1、2、3入队再连续出队的用例完整验证一遍。

5. 三道题的横向对比与面试追问

设计题的素材积累到一定程度,一定要跳出来看看它们之间的结构关系。三道题看似都要"搬元素",但搬的时机、复杂度、背后逻辑完全不同。

5.1 核心对比

题目最小栈队列实现栈栈实现队列
实现要点辅助栈记录历史最小值入队时反转队列顺序分两个栈,一进一出
空间代价O(n)辅助空间两个队列,或原地循环两个栈,各存一半
push最坏O(1)O(n)(入队反转方案)O(1)
pop最坏O(1)O(1)(入队反转方案)O(n)(首次倒入)
摊还复杂度O(1)O(n)O(1)
核心思想状态快照每次插入都整理顺序延迟整理、批量反转

最值得品的是最后两行:栈实现队列的push和pop摊还都能做到O(1),因为它把昂贵的整理操作延迟到了"outStack为空"的时刻,并且每个元素只被整理一次。队列实现栈做不到,因为你在push时就要立刻把新元素前面的所有元素轮转一遍,而轮转过的元素在下一次push时还得再转——没有延迟的空间。

5.2 面试官可能会追的三个问题

第一个是"能不能把队列实现栈的空间省到O(1)"。如果你想的是两个队列只用一个,答案是完全可以,就是我在3.3节写的原地循环法,不需要第二个队列。但注意空间O(1)并不等于操作O(1),只能省辅助空间。

第二个是"最小栈能不能用链表实现"。面试官想看你是否理解辅助栈和链表的关系。链表其实可以不用辅助栈:每个节点额外维护一个"以该节点为栈顶时的最小值"字段,入栈时把当前最小值挂在节点上,pop时直接抛弃节点。这和辅助栈本质相同,只是把辅助信息内联到了节点里。

第三个是"这些设计题在真实工程里有什么用"。这个问题没有标准答案,但如果能举出例子会很有优势。比如用两个栈实现双端队列的场景、状态快照在编辑器撤销栈中的变体、用队列做限流器时如何保证先进先出语义,都是可以展开的素材。重点不在面面俱到,而在展现你理解数据结构是为了解决真实约束下的问题。

5.3 从这三道题延伸出去:LRU与循环队列

题目做完不是终点。栈和队列的组合式设计,本身是更复杂数据结构的基础。比如实现LRU缓存,底层就是哈希表加双向链表,这里"双向链表"同时承担了顺序维护和快速删除的功能,本质上是在一个结构上叠加两种语义。再比如实现循环队列,数组加上头尾指针就能做到O(1)的入队出队,这又是一种"用底层结构组织更高层语义"的思想。

遇到这类题目时,我会习惯性先问自己三个问题:底层结构有哪些操作是O(1)的?目标结构的语义要求是什么?能不能通过"延迟操作"或者"额外存储"来弥补两者差异?这三道设计题正好覆盖了这三个问题各自最常见的解法,吃透它们,再遇到"实现一个带超时的缓存""实现一个支持栈顺序的队列"这类变体,思路能顺畅很多。

一个小建议:先画状态流转图,再写代码

这三道题都不难,难在有些人看了题解以为懂了,合上书又写不出来。我个人的经验是:每次写之前先不碰键盘,把数据结构的操作过程在纸上画一遍。最小栈就画两个栈的同步变化;队列实现栈就画1、2、3入栈时每次循环搬移的中间状态;栈实现队列就画inStack和outStack里元素的流向。画完你再写代码,那些所谓"容易忘的边界条件"基本不会错,因为你已经在脑子里执行了好几遍。

这三道题放到今天回看,我最感激它们的不是代码本身,而是它们逼着我养成了"先分析操作代价再设计数据接口"的习惯。现在写工程代码,遇到复杂的数据流转需求,我会下意识考虑:这个操作是频繁的吗?能不能把重活挪到不频繁的路径上?这些影响的远远不止面试,也直接反映在代码质量和系统设计能力上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询