栈和队列专题(二):LeetCode 225. 用队列实现栈 | 双链表队列从零实现 + 完整做题复盘

写在前面

前面已经分别手写了顺序栈和链式队列,这次终于开始把两个结构放到一道题里综合使用。

LeetCode 225「用队列实现栈」要求我们只能使用队列允许的标准操作,模拟出一个完整的栈,支持 push 入栈、pop 出栈、top 获取栈顶、empty 判空四个核心接口。

乍一看有点“反着来”——栈是后进先出(LIFO),队列却是先进先出(FIFO)。队列每次只能从队头删除,而栈偏偏要求最后进入的元素最先出来。

所以这道题真正考察的并不是某个复杂算法,而是:如何在不破坏队列操作规则的前提下,通过数据搬运改变元素被访问的顺序。

我这里采用两个自己实现的链式队列完成模拟,也正好把前面学习的 front/rear/size 重新实际用了一遍。

对应代码仓库位置

数据结构/8.18 LeetCode 225. 用队列实现栈 · Luminous/Code_2026 - 码云 - 开源中国


一、题目要求:只能使用“真正的队列能力”

假设依次执行:

push(1)
push(2)
push(3)
push(4)

对于栈来说,此时结构为:

栈底           栈顶
 1 -> 2 -> 3 -> 4

第一次 pop() 理应得到 4

但如果把它们直接依次放入队列:

front           rear
  ↓              ↓
  1 -> 2 -> 3 -> 4

队列能够直接删除的却是队头的 1,这就是整道题的核心矛盾。

题目允许使用的只有标准队列行为:队尾入队、队头出队、获取队头、获取大小、判断是否为空,不能为了方便直接读取队尾元素

于是问题就转化为:既然不能直接拿最后一个元素,那就想办法把它前面的元素全部搬走。这就是双队列解法的起点。


二、核心思路:一个存数据,一个当临时缓冲区

我采用的方案始终维护一个非常重要的不变量:两个队列中,一个保存当前全部有效元素,另一个保持为空。

例如当前栈逻辑上为 1 2 3 4,可能对应 q1 存数据、q2 为空;也可能经过一次 pop 之后,数据整体切换到 q2,q1 变为空。到底是 q1 还是 q2 保存数据并不重要,真正重要的是始终只有一个队列存数据。

最开始我习惯先默认“q1 是空队列,q2 存数据”,再通过判断动态切换;后面写熟了发现可以直接判断非空队列,写法更简洁。下面会分别放出我最初的实现和后续优化的写法。


三、四个栈接口分别应该怎么做?

3.1 push:入栈直接进入非空队列

我的初始写法

最开始写的时候,我严格按照自己的思路:默认空队列是 q1,如果 q1 不为空,就把空队列切换成 q2,数据队列换成 q1。专门定义了两个指针分别标记角色:

void myStackPush(MyStack* obj, int x) {
    Queue* Emptyqueue = &obj->q1;//空队列
    Queue* Objectqueue = &obj->q2;
    if(QueueSize(&obj->q1)!=0)//q1不为空队列
    {
        Emptyqueue = &obj->q2;//空队列
        Objectqueue = &obj->q1;
    }
    QueuePush(Objectqueue, x);
}

这个思路本身完全正确,先假定默认状态,再根据实际情况切换指针,逻辑非常直观,新手很容易顺着这个思路写出来。

简化写法

但写完整段代码回头看会发现:push 操作根本不需要关心谁是空队列。我们只需要找到非空的那个队列,直接把元素塞进去就行。于是可以简化成:

void myStackPush(MyStack* obj, int x)
{
    if (!QueueEmpty(&obj->q1))
    {
        QueuePush(&obj->q1, x);
    }
    else
    {
        QueuePush(&obj->q2, x);
    }
}

两种写法效果完全一致,后者少了两个中间指针变量,代码更短,可读性也更强。如果两个队列最开始都为空,这段代码会先放进 q2,完全不影响逻辑。

链式队列已经维护了 rear 尾指针,队尾插入为 O(1),因此 push 操作时间复杂度为 O(1)


四、pop:搬运 n-1 个元素,弹出栈顶

真正关键的是出栈。队列只能从队头删除,想拿到队尾的栈顶,就必须把前面的元素全部搬走。

我的初始写法

和 push 保持同样的风格:先默认 q1 是空队列,q2 存数据;如果 q1 非空,就交换两个指针的角色,再执行搬运逻辑:

int myStackPop(MyStack* obj) {
    Queue* Emptyqueue = &obj->q1;//空队列
    Queue* Objectqueue = &obj->q2;
    if(QueueSize(&obj->q1)!=0)//q1不为空队列
    {
        Emptyqueue = &obj->q2;//空队列
        Objectqueue = &obj->q1;
    }
    while(QueueSize(Objectqueue) > 1)
    {
        TypeData num = QueueFront(Objectqueue);
        QueuePush(Emptyqueue, num);
        QueuePop(Objectqueue);
    }
    TypeData ret = QueueFront(Objectqueue);
    QueuePop(Objectqueue);
    return ret;
}

核心逻辑很清晰:把前 n-1 个元素依次搬到空队列,剩下的最后一个就是栈顶,读取并删除后返回。

写法优化

这里的 if 判断可以用三目运算符简化,一行就能确定 dataQemptyQ,逻辑和上面完全一致:

int myStackPop(MyStack* obj)
{
    Queue* dataQ =
        !QueueEmpty(&obj->q1)
        ? &obj->q1
        : &obj->q2;

    Queue* emptyQ =
        QueueEmpty(&obj->q1)
        ? &obj->q1
        : &obj->q2;

    while (QueueSize(dataQ) > 1)
    {
        int val = QueueFront(dataQ);
        QueuePush(emptyQ, val);
        QueuePop(dataQ);
    }

    int top = QueueFront(dataQ);
    QueuePop(dataQ);

    return top;
}

两种写法没有性能差别,只是后者更紧凑,变量名也更通用。重点是 while (QueueSize(dataQ) > 1) 这行——不是搬空整个队列,而是故意留下最后一个元素作为栈顶。

操作结束后,原数据队列变空,有效数据整体切换到另一个队列,仍然维持“一空一非空”的不变量。


五、top:和 pop 几乎一样,区别只有最后一步

这是这道题最容易混淆的地方。

  • pop:获取栈顶,并把它删除
  • top:获取栈顶,但整个栈结构不能发生逻辑变化

前面的搬运过程完全相同,区别只在最后一个元素的处理上。

我的初始写法

最开始我差点在这里踩坑:照搬了 pop 的逻辑,拿完栈顶直接删掉,跑完测试发现元素总少一个。后来才补上了“把栈顶也推进空队列”的逻辑:

int myStackTop(MyStack* obj) {
    Queue* Emptyqueue = &obj->q1;//空队列
    Queue* Objectqueue = &obj->q2;
   if(QueueSize(&obj->q1)!=0)//q1不为空队列
    {
        Emptyqueue = &obj->q2;//空队列
        Objectqueue = &obj->q1;
    }
    while(QueueSize(Objectqueue)>1)
    {
        TypeData num = QueueFront(Objectqueue);// 获取队列头部元素
        QueuePush(Emptyqueue,num);// 队尾入队列
        QueuePop(Objectqueue);// 队头出队列
    }
    TypeData ret = QueueFront(Objectqueue);
    QueuePush(Emptyqueue, ret);
    QueuePop(Objectqueue);
    return ret;
}

核心区别总结

把 pop 和 top 放在一起对比,差别会非常清楚:

  • pop:前 n-1 个搬走,最后一个读取 + 删除
  • top:前 n-1 个搬走,最后一个读取 + 也搬走

top 操作结束后,数据只是从一个队列整体搬到了另一个队列,逻辑上的栈内容完全没有变化。

优化写法

同样可以用三目运算符简化指针判断,逻辑不变:

int myStackTop(MyStack* obj)
{
    Queue* dataQ =
        !QueueEmpty(&obj->q1)
        ? &obj->q1
        : &obj->q2;

    Queue* emptyQ =
        QueueEmpty(&obj->q1)
        ? &obj->q1
        : &obj->q2;

    while (QueueSize(dataQ) > 1)
    {
        int val = QueueFront(dataQ);
        QueuePush(emptyQ, val);
        QueuePop(dataQ);
    }

    int top = QueueFront(dataQ);

    QueuePush(emptyQ, top);
    QueuePop(dataQ);

    return top;
}

六、为什么这种方案能够一直工作?

关键就在于前面提到的不变量:每次操作结束后,仍然保持一个队列存全部数据、另一个为空。

  • push 后:数据仍在原队列,另一个保持空
  • pop 后:数据整体切换到空队列,原队列变空
  • top 后:数据整体搬迁到另一个队列,数量不变

所以后面的操作完全不用记“上一次到底用的是 q1 还是 q2”,每次重新判断哪个非空就可以了。这也是双队列解法最清晰、最不容易出错的地方。


七、先实现底层链式队列

C 标准库中没有像 C++ queue 那样现成的容器,所以我直接复用了前面自己实现的链式队列。

节点定义:

typedef int QDataType;

typedef struct QueueNode
{
    QDataType data;
    struct QueueNode* next;
} QueueNode;

队列管理结构:

typedef struct Queue
{
    QueueNode* front;
    QueueNode* rear;
    int size;
} Queue;

三个成员各司其职:

  • front:负责获取、删除队头
  • rear:实现 O(1) 队尾入队
  • size:实现 O(1) 获取当前元素个数

这道题中 while (QueueSize(dataQ) > 1) 会频繁查询队列长度,之前给队列增加的 size 成员在这里真正派上了用场。如果没有 size,每搬一个元素之前都重新遍历链表求长度,整个实现的时间成本会被大幅放大。


八、封装 MyStack

题目要求定义 MyStack,我选择内部直接放两个完整的队列实体,而不是队列指针:

typedef struct
{
    Queue q1;
    Queue q2;
} MyStack;

这样创建 MyStack 时,只需要给整个结构体申请一次空间,两个 Queue 管理结构就已经包含在内了,真正的链表节点则在后续 QueuePush 时按需动态申请。

结构上可以理解成:

MyStack
│
├── q1
│   ├── front
│   ├── rear
│   └── size
│
└── q2
    ├── front
    ├── rear
    └── size

九、完整可直接 AC 代码

#include <stdbool.h>
#include <stdlib.h>
#include <assert.h>
#include <stdio.h>

typedef int QDataType;

typedef struct QueueNode
{
    QDataType data;
    struct QueueNode* next;
} QueueNode;

typedef struct Queue
{
    QueueNode* front;
    QueueNode* rear;
    int size;
} Queue;

typedef struct
{
    Queue q1;
    Queue q2;
} MyStack;


// 队列函数前置声明
void QueueInit(Queue* q);
void QueuePush(Queue* q, QDataType data);
void QueuePop(Queue* q);
QDataType QueueFront(Queue* q);
int QueueSize(Queue* q);
int QueueEmpty(Queue* q);
void QueueDestroy(Queue* q);


// 创建栈
MyStack* myStackCreate()
{
    MyStack* obj =
        (MyStack*)malloc(sizeof(MyStack));

    QueueInit(&obj->q1);
    QueueInit(&obj->q2);

    return obj;
}


// 初始化队列
void QueueInit(Queue* q)
{
    assert(q);

    q->front = NULL;
    q->rear = NULL;
    q->size = 0;
}


// 入队
void QueuePush(Queue* q, QDataType data)
{
    assert(q);

    QueueNode* newNode =
        (QueueNode*)malloc(sizeof(QueueNode));

    if (newNode == NULL)
    {
        printf("malloc fail\n");
        return;
    }

    newNode->data = data;
    newNode->next = NULL;

    if (q->front == NULL)
    {
        q->front = newNode;
        q->rear = newNode;
    }
    else
    {
        q->rear->next = newNode;
        q->rear = newNode;
    }

    q->size++;
}


// 出队
void QueuePop(Queue* q)
{
    assert(q && q->front);

    if (q->front != q->rear)
    {
        QueueNode* del = q->front;
        q->front = q->front->next;
        free(del);
    }
    else
    {
        free(q->front);

        q->front = NULL;
        q->rear = NULL;
    }

    q->size--;
}


// 获取队头
QDataType QueueFront(Queue* q)
{
    assert(q && q->front);

    return q->front->data;
}


// 获取队列长度
int QueueSize(Queue* q)
{
    assert(q);

    return q->size;
}


// 判空
int QueueEmpty(Queue* q)
{
    assert(q);

    return q->size == 0 ? 1 : 0;
}


// 销毁队列
void QueueDestroy(Queue* q)
{
    assert(q);

    while (q->front)
    {
        QueueNode* del = q->front;

        q->front = q->front->next;

        free(del);
    }

    q->rear = NULL;
    q->size = 0;
}


// 入栈
void myStackPush(MyStack* obj, int x)
{
    if (!QueueEmpty(&obj->q1))
    {
        QueuePush(&obj->q1, x);
    }
    else
    {
        QueuePush(&obj->q2, x);
    }
}


// 出栈
int myStackPop(MyStack* obj)
{
    Queue* dataQ =
        !QueueEmpty(&obj->q1)
        ? &obj->q1
        : &obj->q2;

    Queue* emptyQ =
        QueueEmpty(&obj->q1)
        ? &obj->q1
        : &obj->q2;

    while (QueueSize(dataQ) > 1)
    {
        int val = QueueFront(dataQ);

        QueuePush(emptyQ, val);
        QueuePop(dataQ);
    }

    int top = QueueFront(dataQ);

    QueuePop(dataQ);

    return top;
}


// 获取栈顶
int myStackTop(MyStack* obj)
{
    Queue* dataQ =
        !QueueEmpty(&obj->q1)
        ? &obj->q1
        : &obj->q2;

    Queue* emptyQ =
        QueueEmpty(&obj->q1)
        ? &obj->q1
        : &obj->q2;

    while (QueueSize(dataQ) > 1)
    {
        int val = QueueFront(dataQ);

        QueuePush(emptyQ, val);
        QueuePop(dataQ);
    }

    int top = QueueFront(dataQ);

    QueuePush(emptyQ, top);
    QueuePop(dataQ);

    return top;
}


// 判断栈是否为空
bool myStackEmpty(MyStack* obj)
{
    return QueueEmpty(&obj->q1)
        && QueueEmpty(&obj->q2);
}


// 销毁栈
void myStackFree(MyStack* obj)
{
    QueueDestroy(&obj->q1);
    QueueDestroy(&obj->q2);

    free(obj);
}

十、复杂度分析

假设当前栈中有 N 个元素:

接口时间复杂度原因
pushO(1)直接队尾入队
popO(N)最多搬运 N-1 个元素
topO(N)同样需要搬运全部元素
emptyO(1)直接判断两个队列状态
空间复杂度O(N)所有有效元素最终仍只存一份

需要注意:虽然存在两个队列,但每个数据元素在某一个时刻只会位于其中一个队列里,因此并不是 O(2N) 的额外开销,大 O 化简后仍然是 O(N)。


十一、这道题几个真正值得复盘的问题

11.1 QueuePop 清空最后一个节点时,rear 必须同步置空

这是底层队列中最值得注意的 Bug,也是这次写题时挖出来的“历史遗留问题”。

当队列只有一个节点时,front 和 rear 同时指向它。执行 free(q->front) 以后,这块节点内存已经无效。如果只写 q->front = NULL; 而漏掉 q->rear = NULL;,那么 rear 依然指向已经释放的内存,成为悬空的野指针。

正确处理必须成对置空,并同步 size 减一,让队列重新满足 front == NULL && rear == NULL && size == 0 的空状态。

这个bug并不是写这道题时新增的,而是上一篇手写链式队列时就遗留的边界问题。当时只做了常规入队出队测试,没有覆盖“队列仅剩一个节点时出队”的极端场景,所以一直没暴露出来。这次复用队列代码来实现栈,因为 pop/top 会频繁把队列搬空,刚好触发了这个边界条件,才定位到 rear 悬空的问题。

发现问题后我同步修复了仓库中基础队列的代码,本次栈实现的版本也一并修正,相关改动已提交到 Gitee 仓库。

这也说明链式队列不是只维护一个指针,而是在维护一组状态,头、尾、计数必须同步更新,任何一个掉队都会留下隐蔽隐患。


11.2 不能为了做题方便直接留下 QueueBack

我原来的完整链式队列中本身有 QueueBack() 接口,可以直接得到队尾。但这道题明确要求只能使用标准队列操作模拟栈。

如果直接读取队尾拿栈顶,整个“两个队列模拟栈”的核心问题就被绕过去了,完全失去了题目的考察意义。所以在提交代码中,我把 QueueBack 整个接口去掉。

这也让我感觉到算法题里的限制其实很重要:不是结果做出来就够了,还要在题目规定的能力范围里做出来。否则看似 AC 了一道题,其实没练到对应的知识点。


11.3 top 和 pop 的区别不是“读还是删”这么简单

从接口语义上当然可以概括成“pop 读取并删除,top 只读取”,但放到双队列实现中,真正的区别发生在最后一个元素上:

  • pop:前 n-1 个搬走,第 n 个读出来、删除掉
  • top:前 n-1 个搬走,第 n 个读出来、也搬走

如果写 top 时忘记把最后一个元素也 push 进空队列,栈顶元素就会被永久丢失。把两段代码放在一起对比之后,这个区别会比单纯背接口定义清楚很多。


十二、为什么这里的 size 成员非常好用?

前一篇实现链式队列时,我专门加入了 int size;,当时最主要的目的是让 QueueSize() 从 O(N) 变成 O(1)。这道题正好验证了它的实际价值。

核心循环 while (QueueSize(dataQ) > 1) 需要不断检查当前队列还剩多少元素。如果没有 size,每次查询长度都要从头遍历到尾,循环本身的成本会被进一步放大。而现在只需要读取一个成员变量就能拿到结果。

所以之前看起来只是一个“小优化”的设计,在真正的上层算法中开始体现价值。


十三、拓展:一个队列也可以实现

这道题除了两个队列之外,其实还可以只使用一个队列。思路是:每次 push(x) 后,把之前的元素依次从队头取出,再重新放回队尾,让最新元素旋转到队头。

例如原队列 1 2 3,入栈 4 后先变成 1 2 3 4,再把前三个元素依次移到队尾,得到 4 1 2 3。于是最新入栈的 4 就被提前放到了队头,后续 pop 直接删除队头即可。

这种方案相当于把成本从 pop/top 转移到了 push 上:

  • 双队列方案:push O(1),pop/top O(N)
  • 单队列旋转方案:push O(N),pop/top O(1)

两种方案本质都是通过重新排列队列中的访问顺序,模拟栈顶位置。我这次先使用双队列方案,因为它和刚刚完成的链式队列实现衔接得最自然,也更容易把 FIFO → LIFO 的转换过程直观看出来。


十四、工程化拆分

LeetCode 提交时,为了方便会把所有代码放在一个 .c 文件中。但本地保存时,我还是按照数据结构项目的习惯拆成了三个文件:

  • my_stack.h:结构体定义、类型定义、接口声明
  • my_stack.c:链式队列实现 + MyStack 实现
  • main.c:创建栈、多组 push/pop/top 测试

分层之后,队列层、栈模拟层、测试层的关系会更加清晰,后续调试和复用也更方便。

完整工程代码已同步。

Gitee 仓库:

数据结构/8.18 LeetCode 225. 用队列实现栈 · Luminous/Code_2026 - 码云 - 开源中国


十五、写在最后

LeetCode 225 标注为简单题,算法本身确实不复杂。但如果不是直接调用现成的容器,而是用 C 从链式节点到 Queue,再到两个 Queue 封装成 MyStack,一层一层搭出来,整个过程还是很有价值。

这道题让我更加清楚地看到:数据结构的“规则”本身就是算法可以利用的条件。队列只能队尾进、队头出,看起来是限制;但通过两个队列反复搬运,又可以在这个限制之上模拟出后进先出的行为。

更重要的是,这次已经不是单独练习“栈怎么写”或者“队列怎么写”,而是第一次比较明显地把两个已经实现过的数据结构组合起来解决新的问题。

我觉得这也是数据结构刷题开始有意思的地方:不只是学会一个结构,而是开始思考怎样利用一个结构的特性,去模拟、转换或者解决另一个问题。后续继续做栈和队列专题时,也会沿着这条线继续整理经典题目。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值