数据结构:用两个栈模拟队列(Queue Using 2 Stacks)
目录
第一步:分析本质矛盾——LIFO vs. FIFO
第二步:设计两个栈的角色分工
第三步:推导核心操作的实现逻辑
1. 入队 (Enqueue)
2. 出队 (Dequeue)
性能分析(摊还分析)
第四步:一步步实现代码
这是一个非常经典且有趣的问题。我们同样从第一性原理出发,推导如何只用两个“后进先出”的栈,来模拟一个“先进先出”的队列。
第一步:分析本质矛盾——LIFO vs. FIFO
我们手上有什么工具?
-
两个栈 (Stack):
它们的行为是 LIFO (Last-In, First-Out),像一个死胡同,后进去的必须先出来。
放入顺序 1, 2, 3
,取出顺序 3, 2, 1
。
我们的目标是什么?
-
一个队列 (Queue):它的行为是 FIFO (First-In, First-Out),像一个通道,先进去的先出来。
放入顺序 1, 2, 3
,取出顺序 1, 2, 3
。
核心矛盾:栈会颠倒元素的顺序,而队列需要维持元素的顺序。
如何解决这个矛盾?让我们从最基本的思想实验开始。
一个栈会把 1, 2, 3
变成 3, 2, 1
。 如果我们能把 3, 2, 1
这个序列再颠倒一次,会得到什么?
3, 2, 1
颠倒后就是 1, 2, 3
。这正是我们想要的队列顺序!
第一性推论:对一个序列进行两次颠倒,其结果等于维持原序列。
而我们手上有两个栈,一个栈可以实现一次颠倒。那么,用两个栈就能实现两次颠倒。
LIFO( LIFO(序列) ) = FIFO(序列)
这个推论,就是我们整个算法设计的灵魂。
第二步:设计两个栈的角色分工
既然要进行两次颠倒,我们可以给两个栈分配不同的角色:
所有 enqueue
操作都直接进入这个栈。它扮演了第一次颠倒的角色。
-
inStack
(输入栈):负责接收所有新加入的元素。 -
outStack
(输出栈):负责所有弹出的元素。
所有 dequeue
操作都从这个栈获取。它必须含有我们期望的、正确顺序的元素。
第三步:推导核心操作的实现逻辑
1. 入队 (Enqueue)
当一个新元素,比如 4
,要加入队列时,我们应该怎么做?
为了将来能进行颠倒,我们必须先把它存起来。最直接的地方就是 inStack
。
Enqueue
操作逻辑:
-
将新元素
push
到inStack
中。
这个操作非常简单,时间复杂度永远是 O(1)。
【操作模拟 1:连续入队】
-
enqueue(1)
->inStack
中压入1
。inStack: [1]
-
enqueue(2)
->inStack
中压入2
。inStack: [2, 1]
-
enqueue(3)
->inStack
中压入3
。inStack: [3, 2, 1]
(3在栈顶)
outStack
此时还是空的。
2. 出队 (Dequeue)
这是整个设计的精髓所在。根据 FIFO 原则,我们现在 dequeue
,应该得到元素 1
。
1
在哪里?它在 inStack
的最底部。我们无法直接拿到它。
但是,根据我们的推论,outStack
应该是负责“出”的栈。所以我们应该尝试从 outStack
中 pop
元素。
情况一:outStack
不为空。
如果 outStack
里已经有按正确顺序排列的元素,那么我们直接从 outStack
中 pop
一个出来就行了。这是最理想的情况。
情况二:outStack
为空。
如果 outStack
是空的,我们不能直接返回“队列为空”,因为 inStack
里可能还有很多元素。
这个时候,就到了执行第二次颠倒的时刻了! 我们需要把 inStack
中的所有元素,全部“倒入” outStack
。
“倾倒”过程:
-
从
inStack
弹出一个元素 (例如3
)。 -
将这个弹出的元素压入
outStack
。 -
重复此过程,直到
inStack
为空。
【操作模拟 2:第一次出队】
当前状态: inStack: [3, 2, 1]
, outStack: []
我们需要 dequeue()
。
-
检查
outStack
,发现是空的。 -
执行“倾倒”操作:
-
pop
3 frominStack
,push
3 tooutStack
.inStack: [2, 1]
,outStack: [3]
-
pop
2 frominStack
,push
2 tooutStack
.inStack: [1]
,outStack: [2, 3]
-
pop
1 frominStack
,push
1 tooutStack
.inStack: []
,outStack: [1, 2, 3]
(1在栈顶)
-
-
倾倒完成。
outStack
里的元素顺序已经是1, 2, 3
(从顶到底),完全符合队列的顺序。 -
现在可以从
outStack
中pop
了。pop
出1
。返回1
。
Dequeue
操作逻辑总结:
-
检查
outStack
是否为空。 -
如果不为空,直接从
outStack
中pop
并返回。 -
如果为空,则先将
inStack
的所有元素依次pop
并push
到outStack
中。 -
然后再从
outStack
中pop
并返回。 -
如果两个栈都为空,则说明整个队列为空。
性能分析(摊还分析)
enqueue
永远是 O(1)。 dequeue
有时是 O(1),有时是 O(N)(在需要“倾倒”时)。
但从长远来看,每个元素一生中最多只会:
-
被
push
进inStack
一次。 -
被
pop
出inStack
一次。 -
被
push
进outStack
一次。 -
被
pop
出outStack
一次。
对于 N 个元素,总的操作次数是 4N。
平均到每个元素的入队和出队操作上,其摊还时间复杂度 (Amortized Time Complexity) 是 O(1)。这是一个非常高效的设计。
第四步:一步步实现代码
首先,我们需要一个可用的栈。这里我们快速给出一个基础实现。
【代码实现 1:基础栈的实现】
#include <stdio.h>
#include <stdlib.h>#define STACK_CAPACITY 100typedef struct {int data[STACK_CAPACITY];int top; // 指向栈顶元素的下标
} Stack;Stack* createStack() {Stack* s = (Stack*)malloc(sizeof(Stack));s->top = -1; // -1 表示栈空return s;
}int isStackEmpty(Stack* s) {return s->top == -1;
}void push(Stack* s, int value) {if (s->top >= STACK_CAPACITY - 1) return; // 栈满s->data[++s->top] = value;
}int pop(Stack* s) {if (isStackEmpty(s)) return -1; // 错误值return s->data[s->top--];
}
现在,我们用这个栈来构建我们的队列。
【代码实现 2:队列结构体与创建】
// 队列的蓝图:由两个栈构成
typedef struct {Stack* inStack;Stack* outStack;
} QueueWithStacks;// 创建队列
QueueWithStacks* createQueue() {QueueWithStacks* q = (QueueWithStacks*)malloc(sizeof(QueueWithStacks));q->inStack = createStack();q->outStack = createStack();return q;
}
【代码实现 3:入队操作】
// 入队操作
void enqueue(QueueWithStacks* q, int value) {// 逻辑非常简单:直接压入 inStackpush(q->inStack, value);
}
【代码实现 4:出队操作与“倾倒”辅助函数】
// “倾倒”操作,这是一个内部辅助函数
void pourInToOut(QueueWithStacks* q) {// 仅在 outStack 为空时调用if (!isStackEmpty(q->outStack)) return;while (!isStackEmpty(q->inStack)) {push(q->outStack, pop(q->inStack));}
}// 出队操作
int dequeue(QueueWithStacks* q) {// 如果两个栈都为空,队列才是真的空if (isStackEmpty(q->inStack) && isStackEmpty(q->outStack)) {printf("出队失败:队列为空。\n");return -1; // 错误值}// 检查 outStack,如果为空,则执行倾倒pourInToOut(q);// 现在可以安全地从 outStack 弹出return pop(q->outStack);
}// 检查队列是否为空
int isQueueEmpty(QueueWithStacks* q) {return isStackEmpty(q->inStack) && isStackEmpty(q->outStack);
}
通过这个从“矛盾”到“原理”再到“实现”的推导过程,我们就用两个 LIFO 的栈,成功构建了一个功能正确且性能高效的 FIFO 队列。这个方案的巧妙之处在于,它不是在每次操作时都去维护队列的顺序,而是在必要时(当 outStack
为空时)才进行一次性的“批量”顺序调整,从而实现了整体的高效率。