资讯动态

队列模拟栈:双队列与单队列实现原理及复杂度解析

发布时间:2026/10/20 13:58:52 来源:尧图企业网站定制
1. 题目拆解这道题到底在考什么225这道题题面非常简洁请你仅使用两个队列实现一个后入先出LIFO的栈并支持普通栈的全部四种操作push、top、pop 和 empty。如果你光看题面会以为这就是个简单模拟题随便写写就过去了。但真动手做的时候很多人才发现自己对“队列”和“栈”这两种数据结构的本质差异并没有想透。先花十秒钟回顾一下基础概念。栈是后进先出它的口子只有一个所有元素的进出都发生在同一端。队列是先进先出元素从队尾进入、从队首离开两个口子各管各的。现在题目说我的底层存储只能拿队列来当容器但要对外表现出栈的行为。这就好比你手里只有“先进先出”这一种调度规则却要模拟出“后进先出”的效果来。本质上这个问题考的是如何用队列的元素进出顺序去模拟栈的输出顺序。很多人的第一反应是——“这不简单嘛我用两个队列倒腾一下就好了”。思路确实是对的但倒腾的方式有好几种不同方式对应的复杂度差异非常大。而且这道题特别阴险的地方在于它有一个默认前提你用两个队列但两个队列里必须始终保证至少有一个是空的。这个前提在思考过程中容易被忽略导致写着写着逻辑就乱了。还有更聪明的做法是只用一个队列就能实现栈的全部操作这在很多官方题解里甚至都没提到。后面我会详细说。另外这题不要只看它本身的实现它真正的价值在于训练你的抽象能力——你习惯用栈来解决括号匹配、表达式求值、递归转迭代这些问题如果有一天让你换一个底层容器你能否在完全不同的进出规则下依然保证接口语义不变这就是“用队列实现栈”背后最想考察的东西。我建议刷这题之前先确保自己的队列基础是清楚的队列的入队是加在队尾出队是从队首取在大多数语言的标准库实现里队列是一个双端结构从一端进、从另一端出。搞清楚这一点后面理解反转思路的时候会顺畅很多。2. 两种实现思路的设计原理解析2.1 不用急着写代码先想清楚“谁先出”的问题队列只能从队首出元素而栈要求“最后进来的元素最先出”。设想你现在依次入栈 A、B、C栈顶应该是 C。如果用队列存储这三个元素在队里的顺序自然就是 A 在前、C 在后。这时候如果你执行出栈操作队列给出的必须是队首的 A但栈要求返回的是 C。这就是核心矛盾队列的天然输出顺序和栈的期望输出顺序完全相反。解决的办法有两种路线。第一控制“进”的顺序每次 push 新元素的时候想办法让新元素站到队首的位置那 pop 的时候直接取队首就行。第二控制“出”的顺序每次 pop 的时候先把队列里之前的元素都挪走让最后一个元素变成队首再取出来。两者在思路上是镜像对称的但实现的复杂度和操作次数的分布完全不同。很多初学的人一上来就选择控制“出”的顺序因为 push 操作可以无脑做完只有 pop 的时候才需要折腾。这个选择没什么问题而且是最直观的。但如果你顺着这个思路写下去你会发现代码里到处是临时性转移可读性有点差。另一条路是每次 push 的时候折腾一下把新元素直接送到队首pop 就变简单了。我们逐一来看。2.2 双队列法经典“临时倒腾”方案假设你有两个队列 Q1 和 Q2。Q1 永远是“主队列”存储当前栈里的所有元素并且它的队首就是栈顶。Q2 只作为辅助的临时容器。push 操作怎么做为了让新元素直接成为队首可以先把 Q1 中已有的元素全部转移到 Q2再把新元素放进空的 Q1最后把 Q2 的元素全部倒回 Q1。经过这三步新元素稳稳站在 Q1 的队首位置其余元素的相对顺序不变。这个过程非常像把一摞书从桌上搬到椅子上、在桌上放一本新书、再把椅子上的书搬回来——新书就压在原来的书上面了。pop 和 top 就更简单了因为栈顶始终是 Q1 的队首直接取队首即可。empty 判断 Q1 有没有元素。这种实现思路清晰不容易出错也是面试时最容易表达清楚的一种方案。但这种方案的空间复杂度评价存在争议因为 Q1 和 Q2 本来是两个独立对象算下来是 O(n) 的空间但实际上 Q2 在转移过程中存储的元素和 Q1 中的元素是同一批并没有真正用两倍空间。不过面试的时候空间复杂度按 O(n) 讲总是不会错的。2.3 单队列法一个容器如何完成反转如果能够突破“必须用两个队列”的思维限制最优解其实只需要一个队列。这个思路的核心是每次 push 的时候先把新元素放进队尾然后将队列前面的所有元素依次取出并从原队尾重新入队。这样一来新元素就被顶到了队首位置而原来的所有元素按照相对顺序跟在新元素后面。举个例子。队列现在是 [A, B, C]新元素 D 要入栈。先把 D 入队队列变成 [A, B, C, D]然后把 A、B、C 依次取出、依次重新入队最终队列变成 [D, A, B, C]。你看D 站在了队首这不就是栈顶吗这个操作的关键在于一个小的循环控制细节新元素入队后需要出队再入队的次数是队列原来元素的个数n-1 次。如果循环次数写错要么翻转不彻底要么把新元素又转走了。这个边界是我自己踩过的坑后面步骤部分会详细展开。单队列法的时间复杂度和双队列法完全一致但代码量更少、逻辑更紧凑在面试时展示出来会让人眼前一亮。空间上也可以理直气壮地说只用了 O(n) 的额外空间实际容器只开了一个。2.4 两种方案怎么选从掌握的角度我建议两套都写一遍。第一遍用双队列法来建立直觉因为转移过程非常符合直觉第二遍用单队列法来训练抽象思维因为你需要理解“循环旋转”这个操作的本质。从面试展示的角度单队列法的代码更短说着也更快但双队列法更容易让人听懂你的思路。实际面试中我一般会先讲双队列法然后主动提一句“其实只用单个队列也可以实现”复杂度相同但更简洁这往往能给面试官留下不错的印象。3. 代码初版双队列法完整实现以主流语言为例3.1 Python 实现from collections import deque class MyStack: def __init__(self): self.queue deque() self.helper deque() def push(self, x: int) - None: # 把主队列里所有元素临时转移到辅助队列 while self.queue: self.helper.append(self.queue.popleft()) # 新元素入主队列此时它位于队首 self.queue.append(x) # 辅助队列里的元素按原序回到主队列 while self.helper: self.queue.append(self.helper.popleft()) def pop(self) - int: # 队首即栈顶 return self.queue.popleft() def top(self) - int: # 只看队首但不移除 return self.queue[0] def empty(self) - bool: return not self.queue判断是否写对的简单方法push之后立刻打印一下队列内容你应该能看到新元素确实是队列的第一个位置。这个直观验证非常有效我写代码的时候习惯用这种方式盯着看。3.2 Java 实现class MyStack { private DequeInteger q1; private DequeInteger q2; public MyStack() { q1 new ArrayDeque(); q2 new ArrayDeque(); } public void push(int x) { while (!q1.isEmpty()) { q2.offer(q1.poll()); } q1.offer(x); while (!q2.isEmpty()) { q1.offer(q2.poll()); } } public int pop() { return q1.poll(); } public int top() { return q1.peek(); } public boolean empty() { return q1.isEmpty(); } }Java 里有一个小坑Deque接口同时支持双端操作虽然它的实现类ArrayDeque可以执行addFirst等方法但你要是真把它当双端队列用这个题目的训练价值就没了。所以上面的代码我特意只用offer和poll也就是只在队尾添加、只在队首移除保证底层语义真的是队列。3.3 C 实现class MyStack { private: queueint q; // 利用标准库 queue底层是 deque 的受限包装 queueint helper; public: MyStack() { } void push(int x) { while (!q.empty()) { helper.push(q.front()); q.pop(); } q.push(x); while (!helper.empty()) { q.push(helper.front()); helper.pop(); } } int pop() { int val q.front(); q.pop(); return val; } int top() { return q.front(); } bool empty() { return q.empty(); } };C 标准库的queue底层默认是deque但queue这一个封装层确实只暴露了队列语义push从尾部进pop从头部出。用 C 写这题的时候一般不需要自己造底层容器直接用queue就够了。如果你用了优先队列priority_queue那就不符合题意了它的排序规则直接把题目难度给破坏了这题就失去意义了。4. 进阶实现单队列反转法的完整拆解4.1 为什么单队列反转法更值得写我在第三节说双队列法是经典方案但实际代码写下来你会发现它有一个结构上的冗余push 操作里两次 while 循环本质上都是在“搬运”。而单队列反转法则把两次搬运压缩成一次循环旋转让代码更短、逻辑更集中。如果你追求的是面试时快速、清晰地表达单队列法在信噪比上是更优的。4.2 单队列实现Pythonfrom collections import deque class MyStack: def __init__(self): self.queue deque() def push(self, x: int) - None: n len(self.queue) self.queue.append(x) # 将队列前 n 个元素依次取出再从队尾入队 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 not self.queue配合 Java 和 C 的实现逻辑完全一致这里就不重复贴了。重点聊几个细节。循环次数的推导入队新元素后队列长度变成n 1。我们要把新元素“转”到队首需要把旧元素全部从队首拿出来放入队尾旧元素一共 n 个所以循环 n 次。这个推导相当简单但也非常容易出错——很多人会顺手写成len(self.queue)结果循环次数变成了 n 1把刚入队的新元素也转到了队尾整个顺序就反了。我当初第一次写的时候看到自己把栈写成了“队列反转器”百思不得其解后来打印每一步的队列状态才发现了这个低级错误。再次强调队首即栈顶因为每次 push 完成后新元素必然是队首而旧的元素按原顺序依次排在后面所以 pop 和 top 的代码几乎不需要思考。这样的设计让整个类的大部分代码都变得很“无聊”而这种无聊恰恰是好事——越少的逻辑分支意味着越少的出错机会。4.3 单队列的 push 动态过程模拟用一个具体例子把这整个过程跑一遍。初始队列为空。push(1)n0入队 [1]循环 0 次队列为 [1]。栈顶就是 1正确。push(2)n1入队 [1, 2]循环 1 次把 1 取出再入队队列 [2, 1]。栈顶 2正确。push(3)n2入队 [2, 1, 3]循环 2 次第一次取出 2 入队变 [1, 3, 2]第二次取出 1 入队变 [3, 2, 1]。栈顶 3正确。这个模拟过程建议你写在纸上自己画一遍比背任何结论都有用。很多字符串类题目的核心也就是类似“旋转”的思想比如旋转数组、轮转链表。理解了这个过程之后遇到别的旋转类问题也能快速迁移。5. 复杂度分析别被标准答案骗了5.1 时间复杂度双队列法的 push 操作每次要把主队列全部元素搬走再搬回来。假设当前栈里有 n 个元素push 一次就是 O(n) 的时间。pop 和 top 则是 O(1)。单队列法的 push 也是这样需要循环 n 次完成反转所以 push 是 O(n)pop 和 top 是 O(1)。这两种实现的时间复杂度分布完全一样这也是为什么我说两种方案本质上是同一种思想的不同操作顺序。但要注意这里说的 O(n) 是单次操作的复杂度。如果你连续执行 n 次 push总复杂度是 O(1 2 ... n) O(n²)均摊下来单次 push 是 O(n)所以你不能在面试时说“均摊 O(1)”——那是另一个解法比如用普通数组直接模拟才有的性质。5.2 空间复杂度双队列法的空间复杂度要仔细分析。两个队列在任意时刻存储的元素总数恰好是当前栈的元素个数因为转移过程中只是换了一个容器存储同一批元素。所以额外的空间是 O(n)——这里 n 是某一时刻栈中最多元素的个数。单队列法自然也是 O(n)。有人会问如果从容器的真实占用来看双队列申请了两个底层数组是不是空间翻倍了严格来说队列底层容器通常有容量增长的机制两个队列同时存在时的确会占用更多内存但复杂度分析只看数量级所以依然算 O(n)。面试里如果被追问这点大方承认双队列确实多开了一个容器的开销但不影响渐进复杂度但如果追求极致的空间表现单队列法更合适。5.3 一个很现实的坑官方题解的“错误示范”我在网上看到不少版本的题解包括一些高赞答案会写“用两个队列但 push 总是入非空队列pop 时把非空队列除最后一个元素外全部移到空队列再弹出剩下的那个”。这种思路对不对逻辑上完全正确也是队列模拟栈的标准做法。但有些实现里pop 之后没有把两个队列的“主从关系”回切下次 push 的时候就会往“非空”队列里加入元素而这个队列在 pop 时已经空了于是元素就跑到了错误的队列里状态就乱了。这种问题非常隐蔽看起来只是少了几行切换代码但运行起来就是各种错乱。我建议如果采用了非空队列为主、空队列为辅的方案每次 pop 之后务必检查两个队列的角色是否和你预想的一致。如果不一致交换引用。这个坑让我想起一个朋友在面试时被面试官追问“两个队列到底哪个是主哪个是辅”他当时没答上来因为自己也没想清楚。所以写这种题重要的不是默写模板而是每一步都清楚当前元素放在哪里、为什么放在那里。6. 常见问题与实测排查6.1 元素顺序反转不完全这个问题的典型表象连续 push A、B、C 之后top() 返回的却是 A。排查思路很简单在 push 之后立即打印队列内容看队首是不是最新元素。如果是那就说明循环次数出了问题。检查循环次数是不是“队列原有元素个数”而不是“新队列长度”。6.2 用到了双端队列的“作弊”操作很多语言里队列的实现类本身就支持双端操作。比如 Java 的ArrayDeque有addFirst方法Python 的deque有appendleft方法。如果你在实现中直接用了这些方法那实现难度直接降维——因为你已经不是在“用队列实现栈”了你是在“用双端队列实现栈”这超出了题目的限制。面试官如果注意看代码很可能会扣分或者追问你到底哪里用了队列的本质特性。我的原则是只允许使用append从尾部加和popleft从头部取。这样才能保证你的实现真的遵循了队列约束。6.3 pop 空栈时的行为题目并没有规定空栈时 pop 和 top 应该返回什么不同语言的习惯不一样。我一般会在 LeetCode 的框架下保持标准行为Python 的话popleft会直接抛IndexErrorC 的front在空队列上是未定义行为虽然很多实现不报错。如果你在工业级代码中要用建议加上防御性判断比如返回一个哨兵值或者抛异常。面试时主动提这个细节能显得你考虑问题比较周全。6.4 时间表现不稳定单看 LeetCode 的判题你会发现单队列法和双队列法的耗时差距非常小。这是因为测试数据规模通常不够大且 O(n) 的 push 操作在 n 不够大的时候几乎可以忽略。别被 LeetCode 上的耗时曲线误导真正区分优劣要看你在规模增长时的扩展性。如果面试官说“能不能优化到均摊 O(1)”这个问题的答案涉及另一个层面——比如用链表直接模拟栈但那就不符合“仅使用队列”的约束了你要敏锐意识到考察点的转换。6.5 用调试这题的技巧反推其他模拟题这道题的调试思路有一个迁移价值每次操作后打印整个容器状态。我平时刷模拟题的时候无论题目是队列模拟、栈模拟还是双向链表模拟都会先写好一个printState函数每次操作后看一眼经过比对预期这样能大幅加快排查速度。这题把这一点体现得特别典型——它操作次数不多、元素量不大非常适合打印出来逐行看。7. 这道题背后的思维迁移很多人刷完这道题觉得“哦我会用队列模拟栈了”就算过去了。但实际上这个题的价值远远不止于此。队列模拟栈的核心操作是“反转”。你仔细想想很多现实场景中要做的事情也是反转浏览器里点击“后退”按钮它需要把访问记录导回栈消息队列里要处理乱序到达的请求可能需要用临时缓冲区来反转顺序甚至某些处理器指令流水线里也有类似的顺序重排机制。数据结构不是背下来的知识而是你对“顺序”的控制能力。这道题还有一个隐藏的关联点——用栈实现队列也就是 LeetCode 232。这一题和 225 是互为镜像的存在。如果你把这两个题放在一起看把它们的四种组合解法都实现一遍你对这两种数据结构的理解会有一个明显提升。我在刷题群里看到不少人说这两题“完全一样”其实它们的思考和翻转方向正好相反强烈推荐连刷。最后分享一个我自己的习惯每道数据结构模拟题我都会在本地写一个小的测试脚本里面把所有可能的操作序列随机生成最后用 Java 的Stack或 Python 的list作为标准答案对比输出。保证每一步操作后我的模拟栈和标准栈状态完全一致。这种自动化对拍方式让我在刷任何“模拟xxx”的题目时心里都很踏实。如果你在刷题过程中也遇到类似的“知道思路但写不对”的问题不妨也试试这个对拍法——它比盲猜边界条件靠谱得多。补充说明文中代码均基于常见编程语言实现核心是以 Python 为例讲解思路Java 与 C 代码结构类似实现时注意各语言的标准库约束即可。不同语言的队列库实现略有差异但“尾进头出”的队列语义是一致的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑