LeetCode 225 这道题,光看标题就有点绕:“用队列实现栈”。熟悉数据结构的朋友第一反应都是:队列先进先出,栈后进先出,这两个东西的方向天生是反的,怎么能用队列把栈的行为模拟出来?可它偏偏是 LeetCode 栈与队列专题里非常经典的一道题,也是面试里出现频率不低的一道“思维题”。
题目本身不复杂,但它很巧妙地刺中了一个点:你到底是真的理解栈和队列的进出顺序,还是只是会背“栈顶、队尾”几个名词。如果你正在刷 LeetCode 准备面试,或者刚学完数据结构想找点题巩固一下对队列的理解,这道题都值得认真做一遍。它能让你看清楚一件事:在不改变底层容器的情况下,通过调整操作顺序,就能让一个 FIFO(先进先出)结构表现出 LIFO(后进先出)的语义。
我最早刷这题时也走了弯路,总想着“给队列加点什么能力”,或者直接拿双端队列取队尾作弊。后来想通了才明白:这题考的不是“怎么修改队列”,而是“怎么利用队列的基本操作,把最后进来的元素推到最前面”。这篇文章我就把三种主流解法、复杂度对比、容易踩的坑,以及栈和队列在真实工程中的对应场景一次说清楚。
1. 题目到底在问什么:接口约束才是核心
1.1 把题意翻成人话
先翻译一下题目。LeetCode 225 要求你设计一个类 MyStack,它对外提供四个方法:
push(x):将元素 x 压入栈顶pop():移除并返回栈顶元素top():返回栈顶元素,但不移除empty():返回栈是否为空
关键限制是:你只能使用“队列的标准操作”。什么意思?在 Java 里,如果你把队列声明为 Queue<Integer>,你只能用这些方法:
offer(e):在队尾加入元素poll():移除并返回队首元素peek():返回队首元素但不移除size():返回队列元素个数isEmpty():判断队列是否为空
你不能用 getLast(),不能用 pollLast(),更不能直接声明一个 Deque 然后说“这不也是队列吗”。题目要求的就是在最朴素的 FIFO 接口下,模拟出 LIFO 的语义。
这本质上是一个“适配器问题”。栈和队列对外暴露的接口很像,都是“往里放一个元素、往外取一个元素”,区别只在于取的时候到底轮到谁。栈取的是最后一个进来的,队列取的是最早进来的。要做的事情只有一个:把元素的出场顺序反转一下。
1.2 前置知识:栈和队列的本质对比
栈和队列可能是数据结构里最容易被轻视的两个概念,因为代码太简单了。但它们在计算机系统里的地位非常重,几乎所有程序的运行都依赖栈,几乎所有系统间的通信都依赖队列。
先看基础定义:
- 栈:后进先出(LIFO),只能在“栈顶”插入和删除。就像一叠盘子,后放上去的盘子总是最先被拿走。
- 队列:先进先出(FIFO),只能从“队尾”入队,从“队首”出队。就像食堂排队,先来的先打饭。
这道题“用队列实现栈”,就是在明确告诉你:你的存储容器底层只有 FIFO 这一种能力,但用户要求它表现出 LIFO 的行为。这就像服务员只有一排椅子,但顾客非要“后到先坐”。
用生活类比来感受一下:你在奶茶店排队,队首的人先点单。这时候来了一个 VIP,想插队到最前面,但规则不允许直接从队首插入。怎么办?最简单的办法是让前面所有人先挪到另一个等待区,让 VIP 站到队首,再把其他人按顺序排回 VIP 后面。这个“挪人”的过程,恰恰就是双队列实现栈的核心思想。
1.3 和“用栈实现队列”做对照
LeetCode 还有一道镜像题:232. 用栈实现队列。这两道题经常被放在一起对比。
用栈实现队列,思路很直观:准备两个栈,一个负责入队,一个负责出队。入队时直接往 inStack 里压,出队时如果 outStack 是空的,就把 inStack 里的元素全部倒进 outStack。因为栈是 LIFO,倒一次之后,底部的元素变成了顶部,顺序就反转过来了。这里用了一个很自然的“负负得正”思路:第一个栈逆序一次,再逆序一次就变回正序。
而用队列实现栈,反而没那么直观。因为队列本身就是正序的,你没法通过“把元素倒到另一个队列”来反转顺序——两个队列各倒一次,顺序还是正的。所以这道题真正的解法思路,不是“反转顺序”,而是“让新元素想办法排到队首”。理解这个区别,是解开这道题的第一步。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方法一:push 时倒腾,让栈顶永远在队首
2.1 思路推导:谁最晚进,谁就应该在队首
既然栈顶元素必须是“最后进来的”那个,而队列里能最先被取出的位置是队首,那么最自然的想法就出来了:如果我能保证新入栈的元素永远排在队首,那 pop() 和 top() 就都是 O(1) 的操作,直接操作队首即可。
问题是,队列只允许从队尾入队。新元素来了之后天然排在最后面,怎么才能让它跑到最前面?
答案是:借助一个辅助队列,把“旧元素”整体挪到“新元素”后面。
具体过程拆开看:
- 新元素 x 来了,先放在辅助队列
helper里。此时helper中只有 x。 - 把主队列
queue里的所有元素,按顺序依次挪到helper的队尾。 - 交换
queue和helper的引用。这时queue里的顺序是 x 在最前面,接着是之前所有旧元素。x 变成了队首,也就是栈顶。
这里有个非常关键的观察:辅助队列 helper 在交换完成后一定是空的,因为它的所有元素都已经通过交换“变成”了新的主队列。下一次 push 时,辅助队列又可以继续使用。这也解释了为什么只需要两个队列,而不是“每个元素临时建一个队列”。
整个过程像什么?像你在食堂排队打饭,窗口只有一个,但你特别想插队。规则不允许你直接站到窗口前,但允许你把后面的人先引到旁边重新排队。于是你站在窗口前,原来的队伍从你身后重新排起来。你(新元素)自然成了第一个。
2.2 参考实现:Java 代码逐行解释
按上面的思路写代码,可以是下面这样:
java复制import java.util.LinkedList;
import java.util.Queue;
public class MyStack {
private Queue<Integer> queue;
private Queue<Integer> helper;
public MyStack() {
queue = new LinkedList<>();
helper = new LinkedList<>();
}
public void push(int x) {
// 新元素先进辅助队列
helper.offer(x);
// 把主队列中的全部元素挪到新元素后面
while (!queue.isEmpty()) {
helper.offer(queue.poll());
}
// 交换引用,让主队列始终包含正确顺序的元素
Queue<Integer> tmp = queue;
queue = helper;
helper = tmp;
}
public int pop() {
return queue.poll();
}
public int top() {
return queue.peek();
}
public boolean empty() {
return queue.isEmpty();
}
}
这里有几个细节要说清楚。
第一,offer 和 poll 的返回值。Java 里 offer 在容量不足时会返回 false,但 LinkedList 没有容量限制,所以这题里不需要检查返回值。poll 和 peek 在队列为空时会返回 null,这题在正常使用下队列不会为空,因为题目保证不会对空栈调用 pop 和 top。
第二,引用交换。核心代码是:
java复制Queue<Integer> tmp = queue;
queue = helper;
helper = tmp;
这一步不是必须用第三方变量,写成这样最直观。交换之后,原辅助队列(现在装满了元素)变成了主队列,原主队列变成辅助队列,它是空的。这里要特别注意:如果没有交换,下一次 push 时新元素就会被加到旧队列里,顺序就乱了。
第三,top() 返回的是 peek(),不是 poll()。peek 只读不移除,正好符合需求。
我刚开始写的时候容易犯一个错:总想着用一个队列存旧数据,另一个队列存新数据,最后合并。其实不需要合并,直接通过交换引用让“满队列”和“空队列”角色互转,这样写最省心。
2.3 复杂度与适用场景分析
这个方法的时间复杂度是多少?
push:需要把主队列里已有的 n 个元素全部搬到辅助队列,所以是 O(n)。pop:直接取队首,O(1)。top:直接看队首,O(1)。empty:O(1)。- 空间复杂度:两个队列中的所有元素最多为 n,所以是 O(n)。
值得注意的是,这里的 O(n) 是单次操作的最坏复杂度。如果连续执行 n 次 push,总代价是 1 + 2 + ... + n = O(n²)。不过也有另一种视角:每个元素在入栈时,最多被“从主队列搬到辅助队列”一次。也就是说,每个元素入栈时移动一次,之后 pop 就是常数时间。这种成本分配方式,在“多次 push 后高频 pop”的场景下是很划算的。
如果面试官问你“push 能不能也优化成 O(1)”,那就可以引出第二种方法。
3. 方法二:pop 时倒腾,把队列尾当作栈顶
3.1 思路推导:入栈直接排,出栈再找队尾
和方法一相反,我们干脆不给 push 找麻烦。新元素来了之后,直接 offer 到主队列的队尾。这种状态下,队列里元素的排列顺序和入栈顺序完全一致。
那栈顶是哪个?是队列里最后一个元素,也就是队尾元素。可队列不允许直接从队尾取元素。怎么办?在 pop 的时候,把队尾之前的所有元素暂时挪到辅助队列,让队尾元素暴露在队首,然后把它取出。
过程拆开看:
push(x):直接queue.offer(x)。pop():把主队列里的前 n-1 个元素依次挪到辅助队列,此时主队列只剩最后一个元素,也就是栈顶。取出它并返回。- 交换
queue和helper的引用,让主队列成为包含剩余元素的队列。 top():和前一步类似,但最后一个元素不能丢掉,取出后要放回辅助队列。
这里的核心操作就是:把队列末尾的元素“翻”到队首来。这一招在后面的单队列解法里也会用到。
为什么要挪 n-1 个而不是全部?因为队列底下的最后一个元素才是栈顶。如果你把全部元素都挪走,就丢失了栈顶元素。保留最后一个元素在队首,它自然就成为下一个被 poll 返回的对象。
3.2 参考实现:top() 才是最隐蔽的坑
这版实现里,pop() 和 top() 长得有点像,但有一个关键区别。pop() 要把最后一个元素取走,而 top() 只是看一下,看完还得放回去。别小看这个区别,我见过很多同学把 top() 写成 pop(),结果调用一次 top 之后栈少了一个元素。
java复制import java.util.LinkedList;
import java.util.Queue;
public class MyStack {
private Queue<Integer> queue;
private Queue<Integer> helper;
public MyStack() {
queue = new LinkedList<>();
helper = new LinkedList<>();
}
public void push(int x) {
queue.offer(x);
}
public int pop() {
while (queue.size() > 1) {
helper.offer(queue.poll());
}
int top = queue.poll(); // 此时主队列只剩一个元素
Queue<Integer> tmp = queue;
queue = helper;
helper = tmp;
return top;
}
public int top() {
while (queue.size() > 1) {
helper.offer(queue.poll());
}
int top = queue.poll();
helper.offer(top); // 关键区别:把栈顶元素也放回去
Queue<Integer> tmp = queue;
queue = helper;
helper = tmp;
return top;
}
public boolean empty() {
return queue.isEmpty();
}
}
看上边的 top():先把前 n-1 个挪到 helper,然后从主队列取出最后一个元素 top。执行到这里,主队列已经空了。接下来必须把 top 也放进 helper,让 helper 包含原来的全部元素,然后交换引用。这样主队列恢复原样,只是各个元素在队列里的排列仍然保持原序。
对比一下 pop():取完最后一个元素后直接交换,因为最后一个元素已经被移除,不需要放回去。
这个方法的时间复杂度正好和方法一反过来:
push:O(1)。pop/top:O(n),因为要搬运前 n-1 个元素。- 空间复杂度:O(n)。
如果你能预判业务场景是“入栈次数多,出栈次数少”,那方法二更合适;如果“入栈少,出栈多”,方法一更合适。
3.3 两种双队列方法的取舍
到这里你可能会问:既然两种方法都能实现,面试该写哪种?我给你一个实用建议:先说思路,再选一种实现,但要把另一种的复杂度说出来。
方法一的优势是让“栈顶”保持在队首,概念上更好理解,代码也更短;缺点是把成本压在入栈操作上。方法二把成本放在出栈和查看栈顶的操作上,如果业务里 top() 调用频繁,每次都要 O(n) 就比较亏。
如果面试官再追问:“能不能只用一次 O(n) 的搬运让所有操作平均下来都能接受?”那自然就引出单队列方法。
4. 方法三:单队列版本与三种写法横向对比
4.1 新元素入队后“原地转圈”
既然第二个队列的作用只是“临时暂存旧元素”,那能不能不用第二个队列?可以。方法是:新元素入队后,把队列前 n-1 个元素依次出队并重新入队。这样新元素就会随着“旋转”移动到队首。
想象一个环形传送带,新物件放到传送带上,然后把传送带顺时针转一圈,让最早上来的物件重新经过起始点。转完一圈后,后放上来的物件反而先到达取件口。
这就像一群人排队,新来的直接站到队尾,然后从队首开始,让每个人依次绕一圈走到队尾,直到新来的人站到队首。整个过程只需要一个队列就能完成。
4.2 单队列参考实现:注意 size 的取值时机
代码比前两版都短:
java复制import java.util.LinkedList;
import java.util.Queue;
public class MyStack {
private Queue<Integer> queue;
public MyStack() {
queue = new LinkedList<>();
}
public void push(int x) {
queue.offer(x);
int size = queue.size();
// 把前 size-1 个元素从队首搬到队尾
while (size > 1) {
queue.offer(queue.poll());
size--;
}
}
public int pop() {
return queue.poll();
}
public int top() {
return queue.peek();
}
public boolean empty() {
return queue.isEmpty();
}
}
这段代码里最容易踩的坑是循环条件。千万不能写成:
java复制for (int i = 0; i < queue.size() - 1; i++) {
queue.offer(queue.poll());
}
为什么?因为 queue.size() 是动态变化的。每执行一次 poll 再加 offer,队列的总大小其实没有变,但由于 offer 发生在 poll 之后,循环的次数会出错。比如原来 size 是 5,按这种写法,第一次循环后 size 是 5,i 变成 1,比较的是 1 < 4 成立;第二次循环后 i 变成 2,比较 2 < 5 仍然成立;循环到中间时元素的顺序已经变了,最终转的次数不够,新元素到不了队首。最稳妥的做法是先把初始 size 存到局部变量里,然后每次减一,或者用 size-- 控制固定次数。
4.3 三种实现方式横向对比
我把三种方法的复杂度整理成表格,方便你面试前快速回忆:
| 实现方式 | push 复杂度 | pop 复杂度 | top 复杂度 | 空间复杂度 | 代码量 | 思路特点 |
|---|---|---|---|---|---|---|
| 双队列,push 时倒腾 | O(n) | O(1) | O(1) | O(n) | 中 | 栈顶永远在队首,符合直觉 |
| 双队列,pop 时倒腾 | O(1) | O(n) | O(n) | O(n) | 中 | 入栈高效,出栈低效 |
| 单队列旋转 | O(n) | O(1) | O(1) | O(n) | 最少 | 只用原地轮转,代码最优 |
| 双端队列作弊法 | O(1) | O(1) | O(1) | O(n) | 最少 | 违反题目限制,不推荐 |
关于表格最后一行我要多说一句。很多人在评论区会说:Java 里直接用 ArrayDeque,用 addLast 和 pollLast 不就能实现栈了吗?严格来说,ArrayDeque 本身就是双端队列,它的能力已经超过了“队列”的范畴,用它的“队尾操作”来实现栈属于作弊。不过它也提示了一个方向:如果底层数据结构是双端队列,你确实可以同时拥有队首操作和队尾操作,那实现栈就是天然的事。
从练习角度,我建议你写单队列版本。原因很简单:代码最短,思路最隐蔽,一旦你理解了为什么“转 size-1 次”,你才算真正理解这题的灵魂。而且单队列的“旋转”思路在很多算法题里都会复用,比如循环队列、双向 BFS、令牌桶刷新等。
5. 实战中的常见问题与自查清单
5.1 常见错误与排查思路
这道题代码不多,但实际写起来,错误往往出现在几个细节位置。
第一个常见错误:忘记交换引用。双队列实现中,交换引用这一步保证了“满队列”和“空队列”的角色稳定。如果漏掉交换,下一次 push 时新元素会加到旧的空队列里,或者旧元素残留在辅助队列中,导致数据错乱。排查方法很简单:用一个例子手动推演,如果发现某次 pop 返回的不是最近入栈的元素,先检查引用是否交换。
第二个常见错误:pop() 和 top() 之间的细节混淆。pop() 要真正移除元素,top() 只是看一眼。在双队列 pop 时倒腾的写法中,两者的区别尤其明显。top() 必须把最后从主队列取出的元素放到 helper 里,否则元素就丢了。如果你在提交后出现“top 一次之后元素消失”的现象,基本就是这个原因。
第三个常见错误:单队列版本里动态 size 导致的轮转次数不对。前面已经提过,解决方法是在进入循环之前记录固定次数,然后逐次递减。这个错误隐蔽在不会直接报错,但 push 后的栈顶状态不对,比如 push(1)、push(2)、push(3) 之后 top 返回的不是 3 而是 1,多半就是这里出了问题。
第四个需要留意的地方:Java 泛型与装箱。题目输入是 int,但队列中存储的是 Integer。queue.offer(x) 会自动装箱,queue.poll() 返回的是 Integer,函数声明返回 int 时会自动拆箱。只有一种情况会出问题:对空栈调用 poll() 得到 null,再自动拆箱成 int 就会抛空指针异常。虽然题目通常保证不会对空栈操作,但你自己写测试用例时最好加一层判断。
5.2 用一个操作序列快速验证
每次写完解法,不要只盯着代码看,自己在草稿纸上推演一个操作序列。推荐用下面这个序列:
code复制push(1)
push(2)
top() // 应该返回 2
push(3)
top() // 应该返回 3
pop() // 应该返回 3
pop() // 应该返回 2
push(4)
top() // 应该返回 4
empty() // 应该返回 false
pop() // 应该返回 4
pop() // 应该返回 1
empty() // 应该返回 true
如果你能手动把这个序列在三种方法下都推演一遍,基本就不会写错。这也是我自己的习惯:不只看结果对不对,还要看每一步之后,队列里的元素顺序到底是什么。比如方法一在 push(1)、push(2) 之后,queue 内部应该是 [2, 1],队首是 2。方法二在同样的操作后,queue 内部是 [1, 2],但 pop 时会搬运前一个元素得到 2。这种“内部状态不同但对外语义一致”的现象,恰恰是这题最有意思的地方。
5.3 刷题之外的工程延伸:阻塞队列、消息队列与调用栈
如果你觉得“用队列实现栈”只是一道面试题,那就小看它背后的工程价值了。LeetCode 里所有看似“造轮子”的题,其实都是在训练一种能力:在给定接口约束下设计数据流。
栈在真实系统中无处不在。程序运行时的函数调用栈、递归回溯、浏览器的后退按钮、编辑器的撤销功能、表达式求值里的括号匹配,全是 LIFO 语义。Java 虚拟机里的“栈帧”就是每次方法调用的上下文记录,异常信息打印出来的“栈回溯”也是从当前调用点一层层往外翻。
队列更是后端的常客。线程池的任务队列、消息队列、Redis Stream、延时队列,它们的底层都是 FIFO 结构配合各种策略。你在热搜词里看到的“阻塞队列”“消息队列重复消费”“线程池的阻塞队列选择”,本质上都是在解决同一个问题:任务被放进了队列,谁先被消费、什么时候被消费、消费失败怎么办。很多后端方案之所以让人头疼,是因为它不只是简单的“进一个出一个”,而是要在队列的基础上加入优先级、延迟、重试、批量、有序性等约束。这跟“用队列实现栈”要解决的问题完全同构:底层只有一种基础能力,上层要表达更复杂的语义,那就通过控制操作顺序和中间状态来达成。
举个例子,假如你的业务里有这样一个场景:用户的请求先进入一个待处理队列,但系统希望“同一个人最近的请求优先处理”,也就是按 LIFO 方式处理同一个 key 下的请求。你怎么在消息队列的 FIFO 语义上实现这个 LIFO 需求?你可以给每个 key 分配一个独立的“小栈”,或者通过重新入队的旋转方式把新消息推到队首。虽然真实工程不会这么粗暴,但背后的思路和这题是一模一样的:在队列的先进先出约束下,通过调整位置来改变取出顺序。
再往大了说,做前后端项目的人常提“全栈”“接口契约”,其实这个概念在这道题里就有微缩版。MyStack 对外承诺了栈的语义,内部实现是队列,但只要对外接口稳定,使用方完全不需要关心内部是队列还是别的什么结构。Java 里的 Collections.asLifoQueue(Deque)、各种适配器模式,都体现出这种“不改变底层实现、只调整接口语义”的设计思想。所以别小看这 20 行代码,它训练的是抽象思维。
6. 写在最后:关于这道题我的几点体会
这道题我前后写过不下五遍,每次写都有新的体会。最开始我只是背答案,记住了“两个队列、入栈倒腾”这个模式;后来意识到,真正该记住的不是代码,而是“让新元素出现在队首”这个目标。一旦目标清晰了,单队列旋转、双队列倒腾写法自然都能推出来。
如果你正在准备面试,我给你一个建议:不要急着把三种方法都背下来,先只吃透方法一,然后想想如果 push 是高频操作该怎么做,顺着这个思路推出方法二。理解到现在,再去实现单队列版本,把代码压到最短。整个过程下来,你对栈和队列的进出顺序理解会扎实很多。
最后再分享一个小技巧:LeetCode 的调试很麻烦,本地也不好打日志,所以在纸上手写队列状态图是最快的排错方式。每执行一次 push 或 pop,就把两个队列的元素按顺序写下来,和预期结果比对。代码是骗不了人的,手推两三个用例,几乎所有逻辑问题都能暴露出来。
