LeetCode 剑指 Offer 09. 用两个栈实现队列
题目描述

题意分析
要求设计一个类
CQueue,只借助两个栈实现队列的两个操作:appendTail(value)在队尾插入元素,deleteHead()删除并返回队首元素。这是一道设计题,难点不在写代码而在选择「什么时候做搬运」。题面给出的两个信号必须抓住:
- 性能要求是摊还复杂度:题目对操作次数的规模给到 $10^4$ 量级,并期望
appendTail与deleteHead的摊还时间为 $O(1)$。注意是摊还而不是最坏——这句话本身就在暗示允许存在个别昂贵的操作,只要它们的代价能被分摊到大量廉价操作上。如果按最坏 $O(1)$ 去想,反而会觉得这题无解。- 空队列的返回值有明确约定:当队列中没有任何元素时,
deleteHead必须返回-1,而不是抛异常、也不是返回 0。这个约定要落到代码里成为一个显式分支。边界:删除操作可能在任何时刻到来,包括一次插入都没有做过的时候,以及把所有元素都删完之后又继续删;插入和删除可以任意交错,不能假设「先全插入再全删除」这种理想序列。
解法:双栈延迟搬运
核心思路
问题关键:栈是后进先出,队列是先进先出。元素从一个栈逐个弹出并压入另一个栈后,顺序会反转;需要决定的只是何时反转,才能既保证顺序又避免反复搬运。
为什么选择双栈:
inStack只接收新元素,outStack只提供队首元素。只有当outStack为空且需要删除时,才把inStack全部倒入outStack。这样最早进入inStack的元素会落到outStack栈顶。状态与不变量:从
outStack栈顶到栈底,再接上inStack栈底到栈顶,始终等于当前队列从头到尾的顺序。只要outStack非空,队首一定在它的栈顶;此时若搬入较新的元素,反而会让新元素压在旧元素上方,破坏先进先出。正确性:入队只把新元素放到队尾对应的
inStack栈顶,不变量保持。出队时,若outStack非空,直接弹出的就是最老元素;若为空,一次倒栈会反转inStack,其最老元素来到栈顶。两个栈都空时队列才为空,按题意返回-1。延迟搬运还保证每个元素只会从
inStack移到outStack一次,因此单次删除虽然最坏为 $O(n)$,连续操作的摊还代价是常数。
解题步骤
appendTail(value):直接把value压入inStack。deleteHead():若outStack为空,把inStack中的元素全部逐个搬过去。- 搬运后若
outStack仍为空,说明队列为空,返回-1。- 否则弹出并返回
outStack栈顶。例如依次执行“入队 1、入队 2、出队、入队 3、出队、出队”:第一次出队时倒栈并返回 1;此后
outStack中的 2 必须先于新进入inStack的 3 返回,结果依次是1、2、3。
代码实现
class CQueue {
private final Deque<Integer> inStack = new ArrayDeque<>();
private final Deque<Integer> outStack = new ArrayDeque<>();
public CQueue() {
}
public void appendTail(int value) {
inStack.push(value);
}
public int deleteHead() {
if (outStack.isEmpty()) {
while (!inStack.isEmpty()) {
outStack.push(inStack.pop());
}
}
return outStack.isEmpty() ? -1 : outStack.pop();
}
}
type CQueue struct {
inStack []int
outStack []int
}
func Constructor() CQueue {
return CQueue{}
}
func (q *CQueue) AppendTail(value int) {
q.inStack = append(q.inStack, value)
}
func (q *CQueue) DeleteHead() int {
if len(q.outStack) == 0 {
for len(q.inStack) > 0 {
last := len(q.inStack) - 1
q.outStack = append(q.outStack, q.inStack[last])
q.inStack = q.inStack[:last]
}
}
if len(q.outStack) == 0 {
return -1
}
last := len(q.outStack) - 1
value := q.outStack[last]
q.outStack = q.outStack[:last]
return value
}
复杂度分析
appendTail:时间复杂度 $O(1)$。deleteHead:单次最坏 $O(n)$,摊还时间复杂度 $O(1)$。每个元素至多压入、弹出两个栈各一次,$m$ 次操作的总成本是 $O(m)$。- 空间复杂度:$O(n)$。两个栈合计保存队列中的全部元素,每个元素只存在于一个栈中。
关键点总结
inStack管新元素,outStack管旧元素,职责不能混用。- 只有
outStack为空时才能倒栈,这是正确性条件,也是摊还 $O(1)$ 的来源。- 倒栈必须一次搬完,才能让这一批元素恢复正确的先进先出顺序。
- 复杂度应按元素整个生命周期记账,而不是只看某次搬运循环。
易错点总结
outStack非空时仍倒栈:先入队 1、2,出队 1,再入队 3;若此时搬运,3 会压到 2 上面并被错误地先返回。- 只搬一个元素:入队
1、2、3后会先搬出并返回最新的 3,而不是最早的 1。- 每次出队后把剩余元素倒回去:结果可能正确,但同一元素会反复搬运,最坏退化为 $O(n^2)$。
- 搬运前只检查
outStack就返回-1:元素可能仍在inStack;空队列必须在尝试搬运后判断。- Go 弹栈后不缩短切片:同一个栈顶会被重复返回,队列状态没有真正改变。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 155. 最小栈 | 中等 | 辅助栈同步维护历史最小值 |
| 173. 二叉搜索树迭代器 | 中等 | 用栈把中序遍历改写成惰性输出 |
| 225. 用队列实现栈 | 简单 | 反向模拟,靠队列旋转对齐顺序 |
| 232. 用栈实现队列 | 简单 | 本题同款双栈,另需实现 peek
|
| 341. 扁平化嵌套列表迭代器 | 中等 | 栈保存展开状态,延迟到取值时展开 |
| 622. 设计循环队列 | 中等 | 定长数组加双下标取模,容量受限 |
| 1188. 设计有限阻塞队列 | 中等 | 并发场景下的队列,需锁与条件变量 |
| 剑指 Offer 30. 包含min函数的栈 | 简单 | 栈上附加 $O(1)$ 查询,非结构转换 |
| 剑指 Offer 59 - I. 滑动窗口的最大值 | 困难 | 单调双端队列维护窗口最值 |
| 剑指 Offer 59 - II. 队列的最大值 | 中等 | 队列加单调辅助队列,同样摊还 $O(1)$ |