引言
在数据结构的学习中,利用一种结构模拟另一种结构是锻炼算法思维的绝佳方式。之前我们探讨过如何用队列模拟栈,今天我们来聊聊它的对称问题:如何用两个栈(LIFO)来优雅地模拟队列(FIFO)。
这不仅是理解栈与队列本质的关键案例,也是面试中的高频考点。本文将深入剖析双栈模型的设计哲学、核心实现细节以及摊还时间复杂度的数学之美。
一、设计哲学与架构
1.1 核心思想:LIFO 到 FIFO 的转换
队列要求先进先出(FIFO),而栈是后进先出(LIFO)。要用栈模拟队列,核心挑战在于如何反转元素的顺序。
双栈模型巧妙地将队列职责拆分给两个独立的栈:
- 输入栈(s1): 专门负责入队操作。所有新元素无条件压入此栈,充当'原料仓库'。
- 输出栈(s2): 专门负责出队操作。它维护了队列的正确顺序,仅在需要提供队头元素时使用,充当'售货柜台'。
1.2 数据流向比喻
想象一个餐厅系统:
- s1 是厨房: 订单(数据)依次堆叠在工作台上。最晚下的订单在最上面。
- s2 是出菜口: 当需要上菜时,从出菜口取。
- 转移是配菜: 当出菜口空了,厨房将工作台上的所有订单一次性倒扣到出菜口的托盘上。由于栈的反转特性,原本最底下的最早订单,现在变成了出菜口托盘的最上面,等待被取出。
这种职责分离确保了 s1 永远只做 O(1) 的入队,而 s2 只在必要时才进行昂贵的转移操作。
二、核心函数深度解析
2.1 myQueuePush:入队操作
// [O(1)] 队列入队操作:将元素 x 压入输入栈 s1
void myQueuePush(MyQueue* obj, int x) {
assert(obj);
// 直接将新元素压入输入栈
STPush(&(obj->s1), x);
}
为什么只需简单压入? 体现了职责分离原则。s1 的唯一职责就是接收入队请求。新元素 x 压入栈顶后,未来若需出队,所有元素会被转移到 s2,此时 x 将位于 s2 的栈底,保证它是最晚出队的元素,完美满足 FIFO 要求。
2.2 myQueuePeek:查看队头
// [摊还 O(1),最坏 O(N)] 队列查看队头元素操作
int myQueuePeek(MyQueue* obj) {
assert(obj);
// 懒惰转移策略:当 s2 为空时,才执行数据转移
if (STEmpty(&(obj->s2))) {
while (!STEmpty(&(obj->s1))) {
int data = STTop(&(obj->s1));
STPop(&(obj->s1));
STPush(&(obj->s2), data);
}
}
return STTop(&(obj->s2));
}
这是性能优化的关键。数据转移不是在 Push 时立即执行,也不是每次 Peek 都执行,而是(即 s2 为空时)。


