☰
C语言实现栈与队列互转:双栈和双队列原理详解
2026/9/26 3:11:12 网站建设 项目流程

如果你刷过 LeetCode,应该对这两道题印象很深:232. 用栈实现队列,225. 用队列实现栈。它们经常出现在数据结构入门章节里,看起来就像是“互相套娃”的脑筋急转弯——队列明明先进先出(FIFO),栈明明后进先出(LIFO),为什么非要拿一个结构去模拟另一个结构?我见过不少同学能把答案默写下来,但被问到“为什么这样写”就卡壳了。

这篇文章我就用 C 语言把这两题完整过一遍,把背后的直觉、完整代码、复杂度分析,以及我实际调试时踩过的坑全部摊开讲。适合正在学数据结构、准备面试,或者想从“背答案”过渡到“懂原理”的读者。C 语言在这里有个天然优势:没有内置的栈和队列,你必须自己用malloc搭出来,反而能逼你理解这些结构到底是怎么回事。

1. 正着放再倒着放:双栈模拟队列的直觉来源

1.1 栈和队列的本质差别,再唠叨一遍

栈和队列的区别其实一句话就能说清:栈是后进先出,队列是先进先出。生活里最常见的类比是叠盘子和排队。一叠盘子,你最后放上去的那个最先被拿走,这就是栈;食堂打饭的队伍,先来的人先打饭,这就是队列。

但很多人忽略了一个更本质的点:这两种结构不是“两个世界”的东西,它们都是“线性容器”,区别只在于出口位置。栈的出口在“顶”,队列的出口在“底”。如果有一种方式能把容器的“顶”和“底”翻转过来,那栈和队列就可以互相转化。双栈模拟队列的核心原理恰恰就是这么一句话:把一个栈里的元素整体倒进另一个栈,顺序就反了。

1.2 为什么“两个相反”就能拼出“一个正的”

假设你用栈模拟队列,主要有两个操作:入队、出队。入队好办,直接压进第一个栈s1。但出队怎么办?如果直接从s1弹,出来的永远是最后入队的元素,这和队列要求“先进先出”完全相反。

这时候第二个栈出场了。你只需要在出队之前,把s1里的所有元素全部弹出,依次压进s2。仔细看这个过程:原本s1栈底是最先入队的元素,栈顶是最后入队的元素;倒进s2之后,最先入队的元素就成了s2的栈顶。再从s2弹出,弹出的就是队首元素,正确。

这就是“一正一倒”的直觉。你可以把s2想象成一个“输出货架”,s1每次进货都只管往货架上倒,倒的时候顺序自然反过来了。真正的队列也是这个逻辑,只是它自己内部就完成了“倒”的动作,不需要第二个容器。

1.3 双队列模拟栈的直觉完全相反

用队列模拟栈,就不能靠“倒”了,因为队列往另一个队列倒,顺序不变。那怎么把最后入队的元素变成最先出队?办法只有一个:挪。把队尾元素变成队头,需要把前面的所有元素先挪走。

这里有两种思路:一种是入栈的时候就调整,让新元素永远待在队头,出栈时直接出队即可;另一种是出栈的时候再调整,把前 n-1 个元素挪到另一个队列,留下最后一个就是栈顶。两种思路都可以,代价不同,后面我会给出完整代码。

记住这个差别:栈模拟队列靠“反转”,队列模拟栈靠“移动”。

2. 双栈模拟队列:完整C语言实现与三个易错点

2.1 先自己写一个 Stack,别偷懒

C 语言没有现成的Stack类,所以第一步是自己造一个。用数组加栈顶索引是最直观的写法,top从 -1 开始表示空栈。

#include <stdio.h> #include <stdlib.h> #include <stdbool.h> typedef struct { int *data; int top; // 栈顶索引,-1 表示空栈 int capacity; } Stack; Stack *stackCreate(int cap) { Stack *s = (Stack *)malloc(sizeof(Stack)); s->data = (int *)malloc(sizeof(int) * cap); s->top = -1; s->capacity = cap; return s; } void stackPush(Stack *s, int x) { if (s->top + 1 >= s->capacity) { // 扩容,C 语言里 realloc 是常规操作 s->capacity *= 2; s->data = (int *)realloc(s->data, sizeof(int) * s->capacity); } s->data[++s->top] = x; } int stackPop(Stack *s) { return s->data[s->top--]; } int stackTop(Stack *s) { return s->data[s->top]; } bool stackEmpty(Stack *s) { return s->top == -1; } void stackFree(Stack *s) { free(s->data); free(s); }

为什么top要初始化为 -1?因为数组下标从 0 开始,-1 表示“一个元素都没有”。第一次push时++top变成 0,元素落在data[0],符合直觉。这个设计在后面判断空栈时非常好用。

2.2 MyQueue 的四个接口:关键就在“懒倒”

有了 Stack,实现 MyQueue 就很直接了。结构体里放两个栈,in管入队,out管出队。重点在myQueuePop和myQueuePeek:它们都有一个前置动作——如果out是空的,就把in里的元素全部倒进去。

typedef struct { Stack *in; // 负责入队 Stack *out; // 负责出队 } MyQueue; MyQueue *myQueueCreate() { MyQueue *q = (MyQueue *)malloc(sizeof(MyQueue)); q->in = stackCreate(8); q->out = stackCreate(8); return q; } void myQueuePush(MyQueue *q, int x) { stackPush(q->in, x); } int myQueuePop(MyQueue *q) { if (stackEmpty(q->out)) { while (!stackEmpty(q->in)) { stackPush(q->out, stackPop(q->in)); } } return stackPop(q->out); } int myQueuePeek(MyQueue *q) { if (stackEmpty(q->out)) { while (!stackEmpty(q->in)) { stackPush(q->out, stackPop(q->in)); } } return stackTop(q->out); } bool myQueueEmpty(MyQueue *q) { return stackEmpty(q->in) && stackEmpty(q->out); } void myQueueFree(MyQueue *q) { stackFree(q->in); stackFree(q->out); free(q); }

这段代码里最核心的一个习惯是:只有out为空时才倒数据。举个例子,连续入队 1、2、3,然后出队一次,此时out里还剩 2、3。这时候再入队 4、5,也不要去碰out。只有当out被弹空了,下一次pop才会把in里的 4、5 倒进去。这样能保证元素的相对顺序始终正确,同时把“倒数据”的代价均摊到多次操作上。

下面是一个完整的测试用例:

int main() { MyQueue *q = myQueueCreate(); myQueuePush(q, 1); myQueuePush(q, 2); myQueuePush(q, 3); printf("peek = %d\n", myQueuePeek(q)); // 1 printf("pop = %d\n", myQueuePop(q)); // 1 myQueuePush(q, 4); while (!myQueueEmpty(q)) { printf("pop = %d\n", myQueuePop(q)); // 2, 3, 4 } myQueueFree(q); return 0; }

2.3 三个最容易写错的点,都是我自己踩过的

第一个错误:每次pop都强行把in倒进out,不管out里有没有剩余。这样会导致顺序错乱。比如入队 1、2、3,出队时倒一次得到 1;再入队 4,又倒一次,此时out本来还剩 2、3,新倒进去的 4 会压到 2、3 的上面,下一次pop拿到的就是 4,队列性质被破坏了。

第二个错误:peek和pop的逻辑不一致。有人写peek时复制了一份pop的逻辑,却把最后的stackPop(q->out)换成stackTop(q->out)后忘记先判断out是否为空。在小数据量测试下可能碰巧没问题,但一旦连续peek两次就会从空栈里取数据,行为未定义。

第三个错误是 C 语言特有的:扩容后忘记更新capacity,或者realloc之后继续用旧指针。前者会导致下一次push做了多余的扩容,后者在realloc移动了内存块之后会直接操作悬空指针。建议扩容时先保存旧指针,判断返回值,再统一赋值:

int *newData = (int *)realloc(s->data, sizeof(int) * s->capacity * 2); if (newData != NULL) { s->data = newData; s->capacity *= 2; }

提示:刷题时可以用固定大小数组免去扩容逻辑,但在工程代码里,扩容是少不了的,别省这一手。

3. 双队列模拟栈:先想清楚“栈顶该在哪头”

3.1 队列也得自己写,用环形数组最舒服

队列的 C 语言实现比栈稍微绕一点,因为出队发生在头部。如果每次出队都把后面的元素往前挪,时间复杂度就是 O(n)。更好的办法是环形数组:用head和tail两个下标配合取模运算。

typedef struct { int *data; int head; int tail; int size; int capacity; } Queue; Queue *queueCreate(int cap) { Queue *q = (Queue *)malloc(sizeof(Queue)); q->data = (int *)malloc(sizeof(int) * cap); q->head = 0; q->tail = 0; q->size = 0; q->capacity = cap; return q; } void queuePush(Queue *q, int x) { if (q->size >= q->capacity) { int oldCap = q->capacity; q->capacity *= 2; int *newData = (int *)malloc(sizeof(int) * q->capacity); for (int i = 0; i < q->size; i++) { newData[i] = q->data[(q->head + i) % oldCap]; } free(q->data); q->data = newData; q->head = 0; q->tail = q->size; } q->data[q->tail] = x; q->tail = (q->tail + 1) % q->capacity; q->size++; } int queuePop(Queue *q) { int x = q->data[q->head]; q->head = (q->head + 1) % q->capacity; q->size--; return x; } int queueFront(Queue *q) { return q->data[q->head]; } bool queueEmpty(Queue *q) { return q->size == 0; } void queueFree(Queue *q) { free(q->data); free(q); }

环形数组的好处是出队时只需要移动head指针,不需要搬动任何元素。扩容时因为环形结构可能让元素“绕过”数组末尾,所以不能用简单的realloc,而是线性化拷贝到新数组,再重置head和tail。这段代码在 push 很多次、触发扩容时依然能保持正确顺序。

3.2 方案A:入栈时调整,让新元素永远在队头

大部分题解用的是“入栈时调整”的思路:新元素先进q2,然后把q1里所有元素搬过来,最后交换q1和q2的角色。为什么这么做?因为队列只能从队头出,为了让最后入栈的元素最先被弹出,就必须让新元素站在队头。

typedef struct { Queue *q1; Queue *q2; } MyStack; MyStack *myStackCreate() { MyStack *s = (MyStack *)malloc(sizeof(MyStack)); s->q1 = queueCreate(8); s->q2 = queueCreate(8); return s; } void myStackPush(MyStack *s, int x) { queuePush(s->q2, x); while (!queueEmpty(s->q1)) { queuePush(s->q2, queuePop(s->q1)); } Queue *tmp = s->q1; s->q1 = s->q2; s->q2 = tmp; } int myStackPop(MyStack *s) { return queuePop(s->q1); } int myStackTop(MyStack *s) { return queueFront(s->q1); } bool myStackEmpty(MyStack *s) { return queueEmpty(s->q1); }

用一个例子走一遍:push 1,q1 = [1];push 2,2 进q2 = [2],q1的 1 搬过去变成q2 = [2, 1],交换后q1 = [2, 1]。此时pop弹掉队头的 2,完全符合栈的后进先出。

这种写法的代价在push:每次入栈都要搬动已有元素,时间复杂度 O(n)。好处是pop和top都是 O(1)。如果业务场景是“入栈少、出栈多”,这很合适。

3.3 方案B:出栈时调整,把代价留给 pop

另一个思路是入栈只管进q1,出栈时再把前 n-1 个元素全部搬到q2,剩下的最后一个就是栈顶。搬完记得交换,让q1继续扮演主队列。

void myStackPush(MyStack *s, int x) { queuePush(s->q1, x); } int myStackPop(MyStack *s) { while (s->q1->size > 1) { queuePush(s->q2, queuePop(s->q1)); } int x = queuePop(s->q1); Queue *tmp = s->q1; s->q1 = s->q2; s->q2 = tmp; return x; } int myStackTop(MyStack *s) { while (s->q1->size > 1) { queuePush(s->q2, queuePop(s->q1)); } int x = queuePop(s->q1); queuePush(s->q2, x); // 把栈顶也放进 q2,交换后顺序不变 Queue *tmp = s->q1; s->q1 = s->q2; s->q2 = tmp; return x; }

注意这里top的实现比pop多一步:弹出栈顶x之后,不能丢掉它,要把它也放进q2,再交换。否则栈顶元素会被“弄丢”在辅助队列里,下次操作就乱套了。我第一次写这个top时就是忘了这一步,导致同一个元素从栈里消失,调试了半天。

方案B的push是 O(1),pop和top是 O(n)。适合“入栈多、出栈少”的场景。LeetCode 225 两种写法都能过,你选哪种取决于想强调哪个方向的代价。

3.4 单队列也能模拟栈,其实就是“自我搬运”

其实用两个队列不是必须的,一个队列也能完成同样的工作。核心操作仍然是把新元素送到队头:入队x之后,把队列前面原来所有的 n 个元素依次出队再入队,转一圈,x就跑到队头了。

void pushSingle(Queue *q, int x) { int n = q->size; queuePush(q, x); while (n-- > 0) { queuePush(q, queuePop(q)); } } int popSingle(Queue *q) { return queuePop(q); } int topSingle(Queue *q) { return queueFront(q); }

这个版本的push同样是 O(n),但是代码量少了一半。面试时如果你能先说出双队列方案,再补一句“其实一个队列也能做,把前面的元素绕一圈塞到新元素后面”,面试官通常会眼前一亮。

4. 复杂度分析与面试追问:均摊不是平均

4.1 双栈队列的“均摊 O(1)”到底是怎么来的

很多人看到双栈队列的pop里有个while循环,觉得最坏情况明明是 O(n),凭什么说它是 O(1)?这里涉及一个非常重要的概念:均摊分析(amortized analysis)。

看全局:每个元素入队时,先进in栈;出队前如果被倒了一次,那么它会从in弹出去、压进out,最后从out弹出。也就是说,一个元素最多经历“进 in、出 in、进 out、出 out”四步常数操作。假设总共操作了 n 次入队和 n 次出队,所有元素加起来只倒了一次,总工作量约 3n 次常数操作。平均到 n 次出队,每次就是 O(1)。

生活类比:你把家里的书一箱箱搬去新家。单看某个时刻,你可能累得半死,但把所有书搬完的总工作量是固定的。双栈队列的 “倒一次” 也是这样,不管中间穿插多少次操作,整批元素最多倒一遍。

4.2 双队列栈两种方案怎么选,看场景

两种方案的时间复杂度正好相反,用一张表看就很清楚:

实现方案pushpoptop适用场景
入栈时调整(方案A)O(n)O(1)O(1)出栈频繁,入栈少
出栈时调整(方案B)O(1)O(n)O(n)入栈频繁,出栈少
单队列版本O(n)O(1)O(1)最简实现,代码量最小

实际面试中,如果题目只要求“实现栈”,方案A更讨喜,因为pop和top是高频操作,用户感知上是 O(1)。但如果面试官追问“为什么不用方案B”,你能说出两者的差异,就会比只会背一种答案的人强很多。

4.3 C 语言特有问题:内存释放、空指针、防御式编程

用 C 写这类题,除了算法本身,还会被问内存管理。我建议在free时遵循一个原则:先释放内部资源,再释放结构体本身。比如stackFree里先free(s->data)再free(s),不要反过来,否则会访问已经释放的内存。

还有一点是防御式编程。pop之前要不要判断栈空?在 LeetCode 的测试用例里不会出现非法操作,但真实工程里必须考虑。可以加一句简单的判断:

if (stackEmpty(q->out)) { // 如果 in 也空,说明整个队列就是空的 return -1; // 或者用更合适的错误处理方案 }

很多人写 C 容易忽略realloc失败的情况,追求“先跑通”可以理解,但面试时如果能主动提一句“这里应判断返回值”,会显得更有工程意识。

5. 跳出题目:栈和队列在真实工程里的那些事

5.1 系统里到处是栈,不止刷题用到

栈在计算机系统里实在太常见了。函数的每一次调用,都会把返回地址、参数、局部变量压入调用栈,函数返回时再弹出。这就是为什么递归太深会“爆栈”——调用栈的空间是有限的,压得太深就溢出了。嵌入式 C 里经常讨论“堆栈”,像是单片机上的栈通常就是一块固定大小的片上 SRAM,编译器在启动文件里分配好,不是说“没有栈”,而是栈区很小,必须省着用。

排查程序崩溃时常用的backtrace(栈回溯)也是顺着栈帧里的返回地址一层层往外扒,打印出当前调用链。理解了“栈是后进先出”,你就明白为什么backtrace打印出来的顺序是最新调用在前、最早调用在后。

5.2 消息队列、阻塞队列和数据结构的队列是一回事吗

工程里说的“消息队列”,比如 Kafka、RabbitMQ、RocketMQ,和算法课上的队列不完全是一回事,但思想一脉相承:都是先进先出的缓冲结构,用来解耦生产者和消费者。你在线程池里也会看到阻塞队列——任务排成队,空闲线程从队头取任务执行,这就是经典的 FIFO。

为什么这些中间件都强调“队列”?因为 FIFO 语义能让任务按照提交顺序被处理,天然适合削峰填谷。理解了数据结构里的栈和队列,你去读这些中间件的设计文档,会发现核心概念并不陌生。

5.3 真正有用的思维:把容器抽象出来

做完这两道题,我最大的收获不是“会用两个栈了”,而是明白了“容器只是工具,操作语义才是本质”。栈和队列都是线性表,改变出口规则就能变出不同的行为。这也是为什么后面你会看到更进阶的结构,比如单调栈、单调队列,它们都是在基本容器上加了额外的约束,用来解决滑动窗口最大值、下一个更大元素这类问题。

刷题时试着多问自己一句:这个结构换成另一个容器行不行?代价是什么?带着这种思维去看算法题,比单纯背代码要有用得多。

我在实际写这些代码时,最大的体会是把s2当成“输出货架”,把q1当成“主队列”,然后强迫自己画一遍数据流动图,而不是直接抄代码。只要你画清楚了一次“倒”和“挪”的过程,后面所有类似题目都会顺手很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询