数据结构:用两个栈模拟队列(Queue Using 2 Stacks)

目录

[第一步:分析本质矛盾------LIFO vs. FIFO](#第一步:分析本质矛盾——LIFO vs. FIFO)

第二步:设计两个栈的角色分工

第三步:推导核心操作的实现逻辑

[1. 入队 (Enqueue)](#1. 入队 (Enqueue))

[2. 出队 (Dequeue)](#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 操作都直接进入这个栈。它扮演了第一次颠倒的角色。

  1. inStack (输入栈):负责接收所有新加入的元素。

  2. outStack (输出栈):负责所有弹出的元素。

所有 dequeue 操作都从这个栈获取。它必须含有我们期望的、正确顺序的元素。


第三步:推导核心操作的实现逻辑

1. 入队 (Enqueue)

当一个新元素,比如 4,要加入队列时,我们应该怎么做?

为了将来能进行颠倒,我们必须先把它存起来。最直接的地方就是 inStack。

Enqueue 操作逻辑:

  • 将新元素 push 到 inStack 中。

这个操作非常简单,时间复杂度永远是 O(1)。

【操作模拟 1:连续入队】

  1. enqueue(1) -> inStack 中压入 1。 inStack: [1]

  2. enqueue(2) -> inStack 中压入 2。 inStack: [2, 1]

  3. 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。

"倾倒"过程:

  1. 从 inStack 弹出一个元素 (例如 3)。

  2. 将这个弹出的元素压入 outStack。

  3. 重复此过程,直到 inStack 为空。

【操作模拟 2:第一次出队】

当前状态: inStack: [3, 2, 1], outStack: [] 我们需要 dequeue()。

  1. 检查 outStack,发现是空的。

  2. 执行"倾倒"操作:

    • pop 3 from inStack, push 3 to outStack. inStack: [2, 1], outStack: [3]

    • pop 2 from inStack, push 2 to outStack. inStack: [1], outStack: [2, 3]

    • pop 1 from inStack, push 1 to outStack. inStack: [], outStack: [1, 2, 3] (1在栈顶)

  3. 倾倒完成。outStack 里的元素顺序已经是 1, 2, 3 (从顶到底),完全符合队列的顺序。

  4. 现在可以从 outStack 中 pop 了。pop 出 1。返回 1。

Dequeue 操作逻辑总结:

  1. 检查 outStack 是否为空。

  2. 如果不为空,直接从 outStack 中 pop 并返回。

  3. 如果为空,则先将 inStack 的所有元素依次 pop 并 push 到 outStack 中。

  4. 然后再从 outStack 中 pop 并返回。

  5. 如果两个栈都为空,则说明整个队列为空。


性能分析(摊还分析)

enqueue 永远是 O(1)。 dequeue 有时是 O(1),有时是 O(N)(在需要"倾倒"时)。

但从长远来看,每个元素一生中最多只会:

  • 被 push 进 inStack 一次。

  • 被 pop 出 inStack 一次。

  • 被 push 进 outStack 一次。

  • 被 pop 出 outStack 一次。

对于 N 个元素,总的操作次数是 4N。

平均到每个元素的入队和出队操作上,其摊还时间复杂度 (Amortized Time Complexity) 是 O(1)。这是一个非常高效的设计。


第四步:一步步实现代码

首先,我们需要一个可用的栈。这里我们快速给出一个基础实现。

【代码实现 1:基础栈的实现】

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>

#define STACK_CAPACITY 100

typedef 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:队列结构体与创建】

cpp 复制代码
// 队列的蓝图:由两个栈构成
typedef struct {
    Stack* inStack;
    Stack* outStack;
} QueueWithStacks;

// 创建队列
QueueWithStacks* createQueue() {
    QueueWithStacks* q = (QueueWithStacks*)malloc(sizeof(QueueWithStacks));
    q->inStack = createStack();
    q->outStack = createStack();
    return q;
}

【代码实现 3:入队操作】

cpp 复制代码
// 入队操作
void enqueue(QueueWithStacks* q, int value) {
    // 逻辑非常简单:直接压入 inStack
    push(q->inStack, value);
}

【代码实现 4:出队操作与"倾倒"辅助函数】

cpp 复制代码
// "倾倒"操作,这是一个内部辅助函数
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 为空时)才进行一次性的"批量"顺序调整,从而实现了整体的高效率。