LeetCode 225这道题,几乎所有刷到“栈和队列”专题的人都会路过。表面上看,它只是让你用队列实现一个栈,但真的动手写的时候,很多人会卡在同一个地方:队列是先进先出,栈是后进先出,这两种容器的出队方向天生拧着,怎么才能用队列把一个“后进来的先出去”的行为完整复刻出来?这篇是我自己从第一次看官方题解一头雾水,到能不看答案写出三种解法的完整记录,适合正在刷 LeetCode 栈与队列专题的人,也适合准备面试前想把手底数据结构真正理清的同学。我会把这题的几个核心思路、带注释代码、容易踩的坑,还有面试官常追问的内容一次聊透。
1. 题目到底在考什么:先理清栈和队列的顺序矛盾
1.1 题干逐句翻译
原题要求的接口很简单:实现一个类 MyStack,里面有 push(int x)、pop()、top()、empty() 四个方法,pop 和 top 都要求返回栈顶元素。麻烦的是题目加了一句限制:你只能使用队列的标准操作,也就是从队尾入队、从队首出队、查看队首、获取队列大小、判断队列是否为空。
换句话说,你不能直接拿一个数组倒过来用,也不能用双端队列的 addFirst、removeLast 这种作弊操作。你能动的只有队首和队尾这两个口,而且数据只允许从队尾进来、从队首出去。这个限制一加上,问题从“实现一个类”变成了“用一类线性容器去模拟另一类线性容器的行为”。
这也引出了计算机里一个很重要的抽象思维:只要接口行为对外一致,内部用什么东西实现并不重要。调用方不关心你的栈底层是数组、链表还是队列,他们只关心 push 进去的元素,pop 的时候能不能按照后进先出的顺序出来。
1.2 表格化对比:栈和队列的“下一个出口”
要搞懂解法,先记住一个基本矛盾:栈的出口在“最后进来的元素”那里,队列的出口固定在“最先进来的元素”那里。
| 容器 | 入容器顺序 | 出容器顺序 | 下一个被弹出的元素 |
|---|---|---|---|
| 栈 | 1, 2, 3 | 3, 2, 1 | 最后进入的 3 |
| 队列 | 1, 2, 3 | 1, 2, 3 | 最先进入的 1 |
| 本题目标 | push(1), push(2), push(3) | 3, 2, 1 | 最后进入的 3 |
如果你 push 了 1、2、3,普通队列的队首是 1,队尾是 3。但栈要求 pop 先拿到 3。这中间差了一整个队列的距离,所有解法本质上都是在想方设法让“最新进来的元素”能够出现在队首,或者说让队首的含义等于栈顶。
基于这个矛盾,解题路径可以分成两条:一条是在 push 的时候就把顺序处理好,让新元素永远站在队首;另一条是 push 先不管,等 pop 或 top 的时候再去队尾把最后一个元素翻出来。两条路都能走通,但代码逻辑和复杂度分布差别很大。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法一:push 时就让新元素站到队首,pop 只用 O(1)
2.1 两队列实现:临时队列当“排队助手”
第一种思路很好理解。既然栈顶是最后进来的元素,那我在 push 的时候就直接把队列内部顺序反转过来,让队首永远等于栈顶。具体做法是准备两个队列,一个主队列 q1 存放调整好的元素,另一个辅助队列 q2 作为临时中转区。
每次 push 一个数字 x 时,先把 x 放到空的 q2 里,然后把 q1 里已有的所有元素依次搬到 q2。操作完成后,x 反而变成了 q2 的队首,而原来的老元素都排在它后面。最后交换 q1 和 q2,让主队列继续持有全部数据。
这里有个关键点:为什么 x 先入队,老元素后入队,x 反而成了队首?因为队列的特性是“先入先出”,x 先进去,所以它在最前面。老元素是被追加到 x 后方的。从直观上说,x 后到却插队到了最前面,正好对应了“后进先出”的需求。
2.2 两个队列变成转圈的优化:一个队列就够了
如果认真观察上一节的搬移过程,会发现其实没必要开两个队列。辅助队列 q2 的作用只是“提供一个空位置,让新元素插进来”,但队列里的元素本来就可以从队首取出后重新放到队尾,这相当于让队列自己转圈。
单队列的 push 思路是:先把 x 正常入队,放在队尾,然后从队首开始,把前面的元素一个一个拿出来塞回队尾。需要搬多少个?如果队列现在总共有 n 个元素,x 进来之后排在最后,为了让 x 到队首,必须把排在 x 前面的 n-1 个元素都移到它后面。
举个例子,当前队列从队首到队尾是 [1, 2, 3],现在 push(4):
- 4 入队,队列变成 [1, 2, 3, 4]
- 把队首 1 移到队尾,变成 [2, 3, 4, 1]
- 把队首 2 移到队尾,变成 [3, 4, 1, 2]
- 把队首 3 移到队尾,变成 [4, 1, 2, 3]
此时 4 到了队首,pop 一下就能拿到它,完美模拟栈顶。这两个版本的 push 都是 O(n) 的,区别只是一个用了物理上的第二个队列,一个利用了队列天然的环形属性。
2.3 两队列 push 预处理的过程模拟
为了更直观看清两个队列的搬移,我模拟一下连续 push 1、2、3 的过程:
| 操作 | q1 内容(队首到队尾) | q2 内容(队首到队尾) | 栈顶(应返回的值) |
|---|---|---|---|
| push(1) | [1] | [] | 1 |
| push(2) | [2, 1] | [] | 2 |
| push(3) | [3, 2, 1] | [] | 3 |
| pop() | [2, 1] | [] | 3 |
| top() | [2, 1] | [] | 2 |
注意 q1 内部的实际顺序其实已经是一条从“新的栈顶”到“旧的栈底”的链。每次 push 时,q2 先接收新元素,再接住从 q1 搬来的老元素,最后交换。等所有操作结束后,q2 一定是空的,所以下一次 push 还可以继续把它当中转区用。
3. 解法二:push 不预处理,pop 时翻出队尾
3.1 另一种常见套路:把“队尾”当“栈顶”翻出来
第二种思路更符合第一直觉:push 的时候老老实实入队,不做任何额外工作,保持 O(1)。但等到 pop 或者 top 时,问题来了——栈顶在队尾,可队列只能从队首出队。没关系,我可以把队尾之前的元素全部搬到另一个队列里,只留下队尾一个元素。此时这个孤零零留在队首的元素就是栈顶,直接弹出它就能完成 pop。
搬移之后,主队列空了,新栈顶藏在辅助队列的队尾。为了让后续操作继续统一,需要把两个队列交换一下,让辅助队列变成新的主队列。这个 swap 是很多人容易漏掉的一步,漏掉之后后续所有判断都会乱套。
以 push(1)、push(2)、push(3) 为例,此时 q1 从队首到队尾是 [1, 2, 3]。执行 pop():
- 把 q1 中除队尾 3 以外的元素 1、2 搬到 q2,q2 变成 [1, 2],q1 只剩 [3]
- 弹出 3,这就是栈顶
- 交换 q1 和 q2,q1 变成 [1, 2],q2 为空
此时新的栈顶是 2,它正好在 q1 的队尾。下一次 pop 时,再把队尾 2 之前的所有元素搬走即可。整个过程就是反复“翻一次队尾”,pop 的时间复杂度为 O(n)。
3.2 top 和 pop 的处理差异
很多题解会把 top 直接写成“把 pop 的结果再 push 回去”,这种方法虽然能过,但不够优雅,容易因为重复入队改变顺序。更好的做法是:top 时同样把主队列里除队尾外的元素搬到辅助队列,然后记录队尾值,接着把这个队尾元素也搬到辅助队列,最后交换两个队列。
这样做的原因是:top 只读不删,我不能把队尾丢掉。把它也搬到辅助队列,再通过交换让所有元素回到主队列,顺序和搬移前完全一致。如果只把栈顶取出来查看而不搬回,队列里的元素就会少一个,后续操作直接出错。
对比下来,pop 是把队尾弹出后丢弃,top 是把队尾搬到辅助队列后保留。两者都需要遍历到队尾,所以都是 O(n) 时间,空间上始终只靠一个额外队列,额外空间为 O(n)。虽然这个方案 push 很轻快,但如果你在实际工程里需要频繁读取栈顶,每次 top 都 O(n) 其实比较亏。选哪种方案,要看你更在意哪个操作的频次。
3.3 操作过程的逐步走查
用一个表格再看一次 pop 方案的完整流程,假设初始为空:
| 操作 | q1 内容(队首到队尾) | q2 内容(队首到队尾) |
|---|---|---|
| push(1) | [1] | [] |
| push(2) | [1, 2] | [] |
| push(3) | [1, 2, 3] | [] |
| pop() | [1, 2] | [] |
| top() | [1, 2] | [] |
| pop() | [1] | [] |
| pop() | [] | [] |
注意看 pop 的瞬间:第一次 pop 的是 3,第二次 pop 的是 2,第三次 pop 的是 1,完全符合栈的后进先出。而且每次 pop 完成后,数据确实都留在了 q1 里,q2 是空的,因此 empty() 只需要判断 q1 是否为空。
4. 三种方案的 C++ 落地与复杂度对比
4.1 完整代码:push 预处理的两队列实现
cpp复制class MyStack {
private:
std::queue<int> q1; // 主队列:队首位置始终等于栈顶
std::queue<int> q2; // 辅助队列:接收新元素,等待交换
public:
void push(int x) {
// 1. 新元素先进入辅助队列
q2.push(x);
// 2. 把主队列的所有老元素搬到辅助队列
while (!q1.empty()) {
q2.push(q1.front());
q1.pop();
}
// 3. 交换两个队列,让 q1 继续持有全部数据
std::swap(q1, q2);
}
int pop() {
int res = q1.front();
q1.pop();
return res;
}
int top() {
return q1.front();
}
bool empty() {
return q1.empty();
}
};
这种做法最省心的地方在于,push 一旦完成,后面三个操作全是 O(1)。即使连续调用 top 也不会拖慢速度,适合“写多读多”的测试场景。q2 在每次 push 结束、swap 完成后一定是空的,这保证了下一轮 push 仍然可以从空队列开始接收新元素。
4.2 完整代码:一个队列原地转圈
cpp复制class MyStack {
private:
std::queue<int> q;
public:
void push(int x) {
q.push(x);
// 把 x 前面的 n-1 个元素依次搬到队尾
int count = q.size() - 1; // 注意先固定次数
while (count--) {
q.push(q.front());
q.pop();
}
}
int pop() {
int res = q.front();
q.pop();
return res;
}
int top() {
return q.front();
}
bool empty() {
return q.empty();
}
};
这段代码是整个题最精炼的版本,没有第二个队列,却能完成和 4.1 完全一样的行为。关键在 count = q.size() - 1 这一句,必须先把要搬移的次数取出来,而不能直接把 q.size() - 1 写在循环条件里,否则循环过程中 q.size() 会不断变化,很容易造成执行次数错误。虽然队列里的真实数据方向是旋转了一圈,但对外暴露的接口行为完全正确。
4.3 完整代码:pop 时才翻队尾
cpp复制class MyStack {
private:
std::queue<int> q1;
std::queue<int> q2;
public:
void push(int x) {
q1.push(x); // push 时完全不处理,保持 O(1)
}
int pop() {
// 把队尾以外的元素搬到 q2
while (q1.size() > 1) {
q2.push(q1.front());
q1.pop();
}
int res = q1.front(); // 此时队首就是真正的栈顶
q1.pop();
// 交换,让 q1 重新持有剩余元素
std::swap(q1, q2);
return res;
}
int top() {
// 同样的搬移逻辑,但把队尾也保下来
while (q1.size() > 1) {
q2.push(q1.front());
q1.pop();
}
int res = q1.front();
// 把栈顶搬到 q2,不能丢掉
q2.push(res);
q1.pop();
std::swap(q1, q2);
return res;
}
bool empty() {
return q1.empty();
}
};
这个版本 top 的实现比“先 pop 再 push 回去”更安全。因为直接调用 pop 方法会产生副作用,如果你在语言层面拿不到被 pop 的元素就会很尴尬;而显式把栈顶也搬到 q2 再 swap,可以保证所有元素仍然在 q1 中,顺序完全不乱。
4.4 复杂度对照:怎么选才合适
| 方案 | push | pop | top | 额外空间 | 代码复杂度 |
|---|---|---|---|---|---|
| 两个队列 + push 预处理 | O(n) | O(1) | O(1) | O(n) | 低 |
| 一个队列 + push 转圈 | O(n) | O(1) | O(1) | O(n) | 最低 |
| 两个队列 + pop 预处理 | O(1) | O(n) | O(n) | O(n) | 中 |
空间上其实都额外需要一个队列的容量,所以都是 O(n)。如果面试或工作中强调 push 要高频率且轻量,可以选择第三种;如果频繁 pop/top,第一种和第二种明显更舒服。我个人在刷题时最喜欢单队列转圈的写法,因为它用最少的变量把“旋转队列”这个思想表达得最直接,也最考验你是否真正理解为什么新元素需要被顶到队首。
5. 细节陷阱、调试实录和面试追问
5.1 三个容易翻车的代码点
第一个是循环次数没有提前固定。很多人写单队列转圈时,会下意识写成 while (q.size() > 1) 或者 for (int i = 0; i < q.size() - 1; i++),结果队列每转一次,size 也在变,执行次数跟着乱掉。要么先把 int count = q.size() - 1 存下来,要么用 for (int i = 1; i < q.size(); ++i) 这类固定初始终点的写法。
第二个是忘记交换两个队列。在 pop 预处理方案里,如果不执行 swap(q1, q2),所有剩余元素会留在 q2 里,而主队列 q1 变成空队列。下次 pop 时,循环里的 q1.size() > 1 永远不成立,栈就直接失效了。这个错误在本地运行时很隐蔽,因为如果你只在最后调用 empty 没检查元素内容,可能根本发现不了。
第三个是直接用某些 Queue 类型自带的尾端访问操作。LeetCode 原题明确限制只能用队列的标准操作,某些语言的 Queue 实现可能提供了访问队尾的方法,但是为了符合题意和训练抽象能力,还是不要用。你把队列想象成一根只能从尾部进、从头部出的管道,所有特殊操作都必须自己通过搬移来完成。
5.2 我的调试过程分享
我最初做这道题时,写的是两队列 pop 预处理版本。当时 top 我偷懒直接写了 int v = pop(); push(v); return v;,结果最后一次提交时发现一个边界用例过不了。问题在于,如果栈为空时调用 top,pop 实现里没有判空就会发生未定义行为;即使判空了,push 又改变数据结构,逻辑容易混乱。后来我改成了先搬移、再记录、再搬回的结构化写法,整个逻辑才顺了。
调试这种队列题,麻烦在于很多语言的队列容器没有遍历接口,没法直接打印内部内容。我习惯打印每个操作方法前后的关键状态:每次 push 或 pop 之后,用一个临时队列把当前队列的元素全部导出来打印,再把元素放回去。这个辅助打印函数看起来笨,但在核对“新元素到底有没有被顶到队首”时特别有效。
5.3 面试官最喜欢追问的三个问题
第一个问题是:为什么要用两个队列,一个队列行不行?这时候你如果能直接说出单队列转圈的思路,并且解释清楚为什么搬移 n-1 次,面试官通常会比较满意。第二个问题是:两种做法的复杂度差在哪里?你不能只背结论,要说清楚 O(n) 的 n 到底是从哪里来的,本质上是“要让最后一个元素变成队首,就必须越过排在它前面的所有元素”。第三个问题经常反向来:如果是 LeetCode 232 用栈实现队列,你会怎么处理?
232 的思路和 225 是对偶的:用一个栈充当入队栈,另一个栈充当出队栈。入队时直接压入 input 栈;出队时如果 output 栈为空,就把 input 栈全部弹出并压入 output 栈,此时 output 栈顶就是最早入队的元素。这个“两个栈倒两遍”的操作,本质上是把 LIFO 行为在两次倒转后抵消成 FIFO。把 225 和 232 放在一起对照学习,你对线性容器进出顺序的理解会一下子立体起来。
6. 从 225 题延伸出去:栈和队列在工程里到底长什么样
6.1 栈的经典应用场景
很多人在学校学过栈的定义,但只记得“先进后出”四个字。真实工程里,函数调用时的调用栈、递归执行的现场保存、JVM 虚拟机栈、浏览器的后退页面,背后全是栈。编辑器里的撤销功能也是栈,每做一次操作就压栈,按下 Ctrl+Z 就弹出最近一次操作。表达式求值里,算符优先级处理同样依赖栈:遇到左括号压栈,遇到右括号弹栈,直到匹配为止。
如果你刷过树和图的深度优先搜索,会发现 DFS 可以显式用栈实现。递归写法本身就在隐式使用系统栈,如果把递归改成迭代,就需要自己维护一个栈来模拟系统调用过程。LeetCode 225 练的就是这种“自己模拟某种 LIFO 行为”的能力,别看题小,背后是系统栈的思想雏形。
6.2 队列的经典应用场景
队列的应用同样无处不在。操作系统的进程调度需要排队,多个任务同时请求 CPU 时必须先到先得;打印机任务、磁盘 IO 请求也靠队列缓冲。树和图的宽度优先搜索,也就是 BFS 层序遍历,天然要求先进先出,每一层节点按顺序入队、按顺序出队,才能保证按层处理。
工程领域你听到的各种“消息队列”,本质上仍然是排队思想:生产者往队列尾部塞消息,消费者从队列头部取消息,消息之间保持顺序。比如 Java 里的 BlockingQueue、Redis 的 Stream、Kafka 的分区消费,底层都有队列或类似队列的结构。很多并发场景里的线程池任务队列,也是用一个阻塞队列把待执行任务缓存起来,避免请求直接打到线程上。理解了最基本的队列行为,再去看这些中间件就不会觉得它们是孤立的知识点。
6.3 容器行为模拟是一种通用设计能力
回到 LeetCode 225,我觉得它真正的价值不是让你背代码,而是提醒你:很多系统看起来复杂,本质上是若干基础容器在特定规则下互相配合。用队列实现栈、用栈实现队列,这两种题的共同点在于“站在使用者角度定义接口,站在实现者角度选择容器”。当你以后做设计,需要把一个组件抽象成接口时,这种能力就会直接派上用场。
如果你接着往下刷,还会遇到双端队列、循环队列、优先队列这些变种。它们不是要替代栈和队列,而是在特定约束下做了增强。比如优先队列能让你以 O(logn) 取到最大或最小元素,但它的底层通常是个堆。队列和栈是一对非常基础的抽象,把 225 题做透,后续理解单调栈、单调队列、滑动窗口这些进阶题型会更顺。
我自己反复做过几次这题后,最大的体会是永远不要硬背解法。每次刷题前先在纸上把操作序列画一遍,比如 push 3 次后再 pop 1 次,队列内部到底发生了什么。画完这个过程,哪怕面试时紧张一时想不起代码,也能从模拟步骤里推出该搬几个元素、什么时候该交换队列。这道题就是一个很好的“画图大于背题”的例子,它把所有数据结构的关系都藏在几次入队出队的动作里了。
