☰
C语言手写栈与队列:从顺序栈到阻塞队列的完整实现与工程应用
2026/10/2 14:08:27 网站建设 项目流程

1. 先搞清楚:C语言里的"栈"其实有两层含义

学栈的时候最容易懵的一件事,就是发现"栈"这个词在C语言世界里居然有两个完全不同的意思——一个是数据结构层面的栈,另一个是程序运行时内存布局里的栈。这俩虽然都叫栈,底层思想也确实同源,但如果不先把它们分开,后面学起来会一直绕。

数据结构里的栈是一种抽象的逻辑结构,它只规定了一件事:数据只能从一端进出,后进先出(LIFO)。至于底层是用数组存、用链表存、还是用文件里的随机存取模拟,都不重要,重要的是操作规则。我们这篇要用C语言手写的栈,就是这个东西——它是一门"逻辑课",不是"硬件课"。

而程序运行时的内存栈(也叫调用栈),是操作系统和编译器共同维护的一块真实内存区域,用来存放函数调用过程中的局部变量、函数参数、返回地址等。每次调用一个函数,系统就在栈上压入一个"栈帧";函数返回时,栈帧弹出。这个过程的LIFO特性和数据结构栈完全一致,所以取了同一个名字。

很多C语言教材会把这两个概念混着讲,导致新手问出"栈不是自动管理的吗,为什么还要自己写?"这种问题。答案就是:编译器帮你管理的是内存栈,而你自己要写的,是作为数据结构的栈。这两者在工程里都有极其广泛的应用,后面我会专门讲栈帧的形成过程,以及数据结构栈在算法和系统里的真实用途。现在,我们先专注一件事:用C语言把它从零写出来。

2. 设计一个栈之前,先把这几个问题想明白

写代码之前先做设计,这是C语言这种底层语言的规矩。C语言不像高级语言给你现成的容器类,你得自己决定存储结构、边界条件、出错处理。对于一个栈来说,核心设计点就三个。

2.1 顺序栈还是链式栈

栈的两种主流存储结构是顺序栈(数组实现)和链式栈(链表实现)。顺序栈的优点是缓存友好、访问快、实现简单,缺点是容量固定、扩容需要搬移数据。链式栈的优点是不怕容量上限、每次入栈只分配一个节点,缺点是每个节点有指针开销、内存碎片化更明显、访问局部性差。

实际工程里,绝大多数场景选顺序栈就够了,因为栈本身就不大,而且后进先出的特性决定了它不会像队列那样长期占着大块连续内存。链式栈主要在"无法预估上限而且上限可能很大"的场景使用,比如深度优先搜索的状态栈。我一般建议学习阶段两种都写一遍,理解它们的差异之后,再根据场景选型。

2.2 栈顶指针到底指向哪里

这是新手最容易写错的地方。顺序栈的栈顶指针有两种约定:

  • top指向栈顶元素的位置。栈空时top = -1。
  • top指向栈顶元素的下一个位置。栈空时top = 0。

两种写法都能用,但你必须从一而终,混用就出bug。我个人习惯用"top指向栈顶元素位置、空栈top=-1"的方案,原因只有一个:判断栈空就是top == -1,判断栈满就是top == MAX_SIZE - 1,写起来非常直观;入栈是data[++top] = val,出栈是val = data[top--],也很顺手。

另一种写法(top指向下一个空位)在某些教材里很流行,它的好处是"top的值就是栈中元素个数",遍历的时候可以直接用top做循环次数。两种各有千秋,没有谁更对,但你自己写的时候一定要定下来。

2.3 栈的大小怎么确定

顺序栈最让人头疼的就是容量。C语言里没有动态扩容的容器帮你兜底,所以初始化前必须想清楚:这个栈最多会装多少数据?

我的做法是:

  1. 根据业务场景估算峰值入栈数量。
  2. 留出至少一倍余量,因为栈满时的处理在C语言里比较麻烦。
  3. 把栈大小定义成宏或常量,方便以后改。
#define STACK_MAX_SIZE 100

如果实在无法预估,那就用链式栈,或者实现一个"满时扩容"的顺序栈(realloc搬移)。扩栈这个功能并不复杂,但需要处理旧内存释放、数据搬移、指针更新,初学者容易写漏,所以我会在后面专门提一下坑。

3. C语言完整实现顺序栈:从结构体到API

设计定完了,代码就可以写了。我直接用最干净的C语言,不依赖任何平台特性,任何编译器都能编译通过。

3.1 头文件与常量定义

#include <stdio.h> #include <stdlib.h> #include <stdbool.h> #define STACK_MAX_SIZE 100 typedef struct { int data[STACK_MAX_SIZE]; int top; // 指向栈顶元素的位置,空栈时为 -1 } SeqStack;

这里我用的是固定数组,stdbool.h是为了让返回值语义更清晰。实际项目中你也可以把int data[]换成int* data然后用malloc动态分配,这样栈的大小可以在运行时决定,灵活性更高。

3.2 初始化与判空判满

void SeqStack_Init(SeqStack* stack) { if (stack == NULL) return; stack->top = -1; } bool SeqStack_IsEmpty(SeqStack* stack) { return stack == NULL || stack->top == -1; } bool SeqStack_IsFull(SeqStack* stack) { return stack != NULL && stack->top >= STACK_MAX_SIZE - 1; }

很多人写判空判满时不做空指针检查,这在小型程序里问题不大,但如果你打算把自己的代码封装成库给别人用,空指针检查是基本功——C语言的崩溃往往就发生在传入了一个野指针的时候。

3.3 入栈与出栈

bool SeqStack_Push(SeqStack* stack, int value) { if (stack == NULL || SeqStack_IsFull(stack)) { printf("错误:栈满,无法入栈 %d\n", value); return false; } stack->data[++stack->top] = value; return true; } bool SeqStack_Pop(SeqStack* stack, int* value) { if (stack == NULL || SeqStack_IsEmpty(stack)) { printf("错误:栈空,无法出栈\n"); return false; } *value = stack->data[stack->top--]; return true; }

入栈出栈的核心操作就一行代码,但边界检查和返回值设计决定了这个栈能不能拿去做工程。我特别强调一点:出栈时一定要通过指针参数把值带出来,而不是直接返回栈顶元素。因为出栈需要同时处理"栈是否为空""值是否有效"两个信息,只靠返回值表达不了两种状态。用bool做返回值、用出参带数据,这个模式在C语言里非常经典。

3.4 获取栈顶、遍历与销毁

bool SeqStack_Top(SeqStack* stack, int* value) { if (stack == NULL || SeqStack_IsEmpty(stack)) { return false; } *value = stack->data[stack->top]; return true; } void SeqStack_Traverse(SeqStack* stack) { if (stack == NULL || SeqStack_IsEmpty(stack)) { printf("栈为空\n"); return; } printf("栈底 -> 栈顶: "); for (int i = 0; i <= stack->top; i++) { printf("%d ", stack->data[i]); } printf("\n"); } void SeqStack_Destroy(SeqStack* stack) { if (stack == NULL) return; stack->top = -1; // 逻辑销毁,覆盖旧数据的行为留给上层决定 }

Top和Pop的区别值得多说一句:Pop是拿走数据,栈顶下降;Top只是看一眼栈顶是什么,不动栈。这两个操作在括号匹配、表达式求值、回溯算法里会被大量交替使用。比如括号匹配时,遇到右括号要先Top看一下栈顶是不是匹配的左括号,是才Pop。如果你图省事直接Pop,匹配失败时数据就丢了。

至于Destroy,固定数组的栈其实没什么需要释放的资源,把top置回-1就是逻辑清空。但如果你在Init里malloc了内存,这里就必须要free。很多初学者写的栈内存泄漏,就是初始化的时候malloc了,销毁的时候却只置了一个标记。

3.5 一个完整的测试程序

int main(void) { SeqStack stack; SeqStack_Init(&stack); SeqStack_Push(&stack, 10); SeqStack_Push(&stack, 20); SeqStack_Push(&stack, 30); SeqStack_Traverse(&stack); // 输出: 栈底 -> 栈顶: 10 20 30 int topValue = 0; SeqStack_Top(&stack, &topValue); printf("栈顶元素: %d\n", topValue); // 输出: 栈顶元素: 30 int value = 0; SeqStack_Pop(&stack, &value); printf("出栈元素: %d\n", value); // 输出: 出栈元素: 30 SeqStack_Traverse(&stack); // 输出: 栈底 -> 栈顶: 10 20 return 0; }

整个顺序栈的实现,满打满算也就七八十个函数行。但就是这么个"简单"的东西,几乎所有C语言面试都会考手写——因为它能一次性考察你对指针、结构体、边界条件的掌握程度。把上面这段代码自己默写一遍,比背十道概念题都有用。

4. 链表栈的写法与两种实现的取舍

顺序栈写完了,接下来看链式栈。链式栈的思想和顺序栈完全一样,只是把数组换成了单链表,把"头插头删"当作"入栈出栈"。

4.1 节点定义与入栈出栈

typedef struct StackNode { int data; struct StackNode* next; } StackNode; typedef struct { StackNode* top; // 栈顶指针 int size; // 栈中元素个数 } LinkedStack;

这里我额外保存了一个size字段,目的是让判空、查元素个数变成O(1)操作。链表本身没法快速知道长度,如果不存size,每次求栈大小都得遍历一遍。空间换时间的典型例子。

void LinkedStack_Init(LinkedStack* stack) { stack->top = NULL; stack->size = 0; } bool LinkedStack_Push(LinkedStack* stack, int value) { StackNode* node = (StackNode*)malloc(sizeof(StackNode)); if (node == NULL) return false; // 内存分配失败 node->data = value; node->next = stack->top; stack->top = node; stack->size++; return true; } bool LinkedStack_Pop(LinkedStack* stack, int* value) { if (stack->top == NULL) return false; StackNode* temp = stack->top; *value = temp->data; stack->top = temp->next; free(temp); // 释放节点内存 stack->size--; return true; }

链式栈入栈的出栈就四句话:新节点next指向旧栈顶、栈顶指向新节点;或者保存旧栈顶、栈顶下移、free旧节点。链表操作就是这样,想明白指针指来指去的关系,代码就一行都不会错。别看代码短,每次malloc都要配一个free,这是C语言内存管理的铁律。链式栈写多了容易泄漏,排查方法也简单——入栈100万次再全部出栈,看内存占用是否回到起点。

4.2 顺序栈和链式栈怎么选

维度顺序栈链式栈
访问速度快,数组连续内存,缓存友好较慢,每次访问要解引用指针
容量管理固定容量,需要预估;扩容成本高动态增长,无固定上限
内存开销仅有数据本身,几乎无额外开销每个元素多一个next指针
实现复杂度低中,要处理malloc/free
适用场景容量可预估、追求性能容量未知、需要频繁创建销毁

我自己的经验是:默认用顺序栈,除非有明确理由要用链式栈。顺序栈的性能优势在大量push/pop操作时非常明显,而且不用每次分配内存,不会有碎片化问题。链式栈的"动态扩容"优势在很多场景下其实是伪需求——如果你连栈最多装多少元素都预估不出来,那问题可能出在业务设计上,而不是数据结构选型上。

5. 栈在系统底层和算法里的真实应用场景

写完了实现,得聊聊这玩意儿到底干嘛用。很多人学完栈和队列觉得"就这?",是因为教材只教了怎么实现,没教它怎么被用起来的。实际上,栈是现代计算机系统的地基之一。

5.1 函数调用与栈帧形成过程

每个C程序员都应该知道,你写的每一个函数调用,背后都有一场"栈上的接力赛"。当main调用funcA,funcA再用参数调用funcB时,调用栈里依次压入了三个栈帧:main的栈帧、funcA的栈帧、funcB的栈帧。每个栈帧里存放着这个函数的局部变量、参数、返回地址、保存的寄存器状态。

funcB执行完毕,它的栈帧弹出,控制权交回funcA;funcA执行完,栈帧弹出,控制权回到main。这个过程和数据结构栈的LIFO规则一模一样。我之前在排查一个段错误(segmentation fault)时,用backtrace获取调用栈回溯,一眼就看到了崩溃发生在哪个函数的哪一层调用链上。没有栈帧这个东西,程序崩溃时你连"我是谁、我在哪、我怎么走到这一步的"都说不清楚。

递归函数为什么容易栈溢出?就是因为每一层递归调用都会压入一个新的栈帧,递归深度如果太大,1MB~8MB的栈空间很快就会被吃光。我见过一个新手写的无限递归,调用栈一路压到把栈空间耗尽,然后程序直接崩溃。理解了栈帧形成过程,这类问题不用调试器也能推断出原因。

5.2 括号匹配与表达式求值

这两个经典算法题,核心就是"后进先出"的直觉操作。

括号匹配:遇到左括号就压栈,遇到右括号就弹出栈顶看是否匹配。{[()]}这类嵌套结构天然就是递归嵌套的,而嵌套正对应栈的压入弹出。写完后你会发现,这题本质上是在模拟"最早遇到的左括号,最晚被匹配"这个次序。

表达式求值更复杂一些,分两步:中缀转后缀(也叫逆波兰表达式转换)、后缀表达式求值。转换过程用栈保存运算符,遇到(压栈,遇到)弹栈直到匹配,运算符按优先级决定压栈还是弹栈。后缀求值就更纯粹了——遇到数字压栈,遇到运算符弹出两个数字计算,结果再压回栈里。

如果你自己实现过一个完整的表达式求值器,你对栈的理解会直接上一个台阶。因为你会亲身体会到:栈不只是用来"存数据"的,它是在模拟一种计算过程的时序——先遇到的运算符不一定先执行,后看到的括号可能要最先处理。

5.3 深度优先搜索的回溯

DFS(深度优先搜索)的递归实现,本质就是系统帮你维护了一个栈。如果你想非递归实现DFS,就必须自己显式地维护一个栈——把待访问的状态压进去,循环弹出,再把后续状态压进去。为什么叫"深度优先"?因为栈的后进先出特性,会让算法沿着一条路径一直往下钻,直到走不通了才回头。

迷宫求解、全排列生成、八皇后问题,它们的非递归解法全都可以用栈实现。我建议每个学数据结构的人都手动写一遍"用栈实现迷宫路径搜索",这个过程会把栈的"回溯"语义深深刻在脑子里——栈里保存的不仅是位置,还有你抵达这个位置之前走过的路。

6. 从栈到队列:换个方向想问题

栈讲得差不多了,得聊聊它的兄弟——队列。栈是"后进先出",队列是"先进先出(FIFO)":就像排队打饭,先来的人先打到饭走人。这个规则在工程里太常用了——任何"先到先服务"的场景,底层都是队列。

6.1 队列的基本实现

队列有两个指针:队头front和队尾rear。入队时在队尾追加,出队时从队头取出。数组实现队列最麻烦的问题是"假溢出"——出队操作让队头前进,队尾到达数组末尾时,即便数组前面全是空位,也无法再入队了。

解决办法就是循环队列:把数组看成一个环,队尾到了末尾就绕回开头。实现上只多了一行取余运算(rear + 1) % MAX_SIZE。

#define QUEUE_MAX_SIZE 100 typedef struct { int data[QUEUE_MAX_SIZE]; int front; // 队头下标 int rear; // 队尾下标,指向队尾元素的下一个位置 } CircularQueue;

循环队列最精妙的地方在于区分空和满:如果front == rear是队空,那队满就必须牺牲一个存储单元,让(rear + 1) % MAX_SIZE == front作为队满条件。也就是说,容量是100的数组,循环队列实际只能存99个元素。这是用数组实现循环队列最常见的考点,也是新手最容易掉进去的坑。

6.2 队列的C语言实现

void CircularQueue_Init(CircularQueue* queue) { queue->front = 0; queue->rear = 0; } bool CircularQueue_IsEmpty(CircularQueue* queue) { return queue->front == queue->rear; } bool CircularQueue_IsFull(CircularQueue* queue) { return (queue->rear + 1) % QUEUE_MAX_SIZE == queue->front; } bool CircularQueue_Enqueue(CircularQueue* queue, int value) { if (CircularQueue_IsFull(queue)) { printf("错误:队列已满\n"); return false; } queue->data[queue->rear] = value; queue->rear = (queue->rear + 1) % QUEUE_MAX_SIZE; return true; } bool CircularQueue_Dequeue(CircularQueue* queue, int* value) { if (CircularQueue_IsEmpty(queue)) { printf("错误:队列为空\n"); return false; } *value = queue->data[queue->front]; queue->front = (queue->front + 1) % QUEUE_MAX_SIZE; return true; }

关键点就在每一次移动指针时都要取余。很多实现会在这里写出数组越界的bug,因为忘记取余、或者只在rear == MAX_SIZE - 1时才绕回,结果边界条件漏了。

队列同样有链式实现,思路和链式栈类似,只是要同时维护头尾两个指针。链式队列的好处是避免了"用取余区分空满"的麻烦,长度也不需要牺牲一个单元。但工程上,循环队列仍然是首选——它的内存是连续的,cache命中率高,性能比链表队列稳定得多。

7. 队列在真实工程中的升级版:阻塞队列与消息队列

如果你以为队列就只是教材里那个"先进先出"的小玩意儿,那可真小看它了。在真实的后端系统、并发编程、中间件架构里,队列已经演化出了好几个高级形态。

7.1 从普通队列到阻塞队列

普通队列在并发环境下有个致命问题:多个线程同时入队、出队时,必须保证操作原子性。否则就会出现数据竞争、读到中间状态等一堆问题。解决方式是给队列操作加锁,但这还不够——生产者在队列满时,与其傻乎乎地反复尝试入队失败,不如直接阻塞等待,直到队列有空位;消费者在队列空时,也应该阻塞等待,直到有数据进来。

这就是阻塞队列的由来。它在线程池、生产者-消费者模型里是核心组件。Java里ArrayBlockingQueue、LinkedBlockingQueue就是现成的实现,C语言里则要自己结合互斥锁和条件变量来写。阻塞队列的本质思想,其实还是那个循环队列——只是多了"满时等待、空时等待"的语义。

7.2 线程池的阻塞队列选择

线程池的任务队列,本质上就是一个阻塞队列。任务提交线程往里放任务,工作线程往外取任务执行。这里有一个很多人忽略的细节:应该选有界队列还是无界队列。

有界队列的好处是系统受到流量冲击时,队列满了以后新的任务会被拒绝,这其实是一种自我保护。无界队列看似"来者不拒",但当任务积压到数百万条时,内存会先爆掉,系统崩溃的方式更难看。我个人做项目时,一律选有界队列,宁可让上层感知到"队列满了"而触发降级或重试,也不愿意让整个进程因为内存耗尽而宕机。

7.3 消息队列选型:Kafka、RabbitMQ 与 RocketMQ

把阻塞队列放到独立的服务器上,让它跨进程、跨机器提供服务,就成了消息队列(Message Queue)。Kafka、RabbitMQ、RocketMQ是现在最主流的三个选择,每一个我都踩过坑。

维度KafkaRabbitMQRocketMQ
核心优势超高吞吐、日志型场景无敌功能全面、路由灵活金融级可靠性、事务消息
吞吐量极高(顺序写盘)中等高
消息可靠性靠副本机制,配置要小心支持多种确认机制同步复制,可靠性强
学习成本概念多,部署偏重相对简单,社区资料多概念适中,阿里出品
典型场景日志收集、大数据管道、流处理业务解耦、异步通知、RPC回调订单交易、金融类业务

选型时最容易踩的坑有三个:

  1. 拿Kafka当万能药。Kafka吞吐高,但它的高吞吐依赖于"批量"和"顺序写",换来的是消息处理的延迟不代表最低、且乱序场景处理麻烦。如果只是系统内部的小流量解耦,用Kafka纯属杀鸡用牛刀,运维成本还高。

  2. RabbitMQ的吞吐瓶颈。RabbitMQ胜在路由灵活、功能齐全,但吞吐量上限比Kafka低一个量级。我见过一个团队用RabbitMQ扛每秒几万条的消息流量,结果broker频繁堆积。不是说兔子不行,是场景不匹配。

  3. 忽略重复消费问题。消息队列几乎都会出现"消息重复投递"——消费者处理完消息但没来得及确认,队列重发了。如果你的业务逻辑不是幂等的,同样的消息处理两遍就会出事故。解决方式通常是消费端做幂等处理(比如用唯一ID查重),而不是单纯指望消息队列只投递一次。

理解了消息队列,再回头看操作系统的任务调度队列、网络协议栈里的数据包队列,思路是完全贯通的——队列是"缓冲"与"解耦"这两个核心思想最直接的代码体现。

8. 实操中容易踩的坑与我的调试建议

写了这么多栈和队列的代码,最后分享几个我实际写C语言代码时经常遇到的坑。这些坑不踩一次不会长记性,但知道了就能少花几个小时调bug。

8.1 栈溢出:不是只有递归才会遇到

说到栈溢出,很多人第一反应是无限递归。但顺序栈的数组越界写入同样会导致溢出——只是溢出的不是调用栈,而是你定义在main函数里的那个SeqStack结构体内部。如果Push时没做满检查,数据写到了data[100]、data[101],就会越过数组边界,把结构体里的top字段甚至其他变量给覆盖掉。

这类bug的表现通常非常诡异——比如top突然变成了垃圾值,入栈的数据取出来不对,程序偶尔崩溃。最有效的排查方式就是在每次操作前后打印stack->top和栈内容,看它是不是在按预期变化。我在调试这种问题时还会习惯性地把STACK_MAX_SIZE临时改小,比如改成5,然后故意多Push几个数据,逼着问题快速暴露。

8.2 返回局部变量的指针

这个坑我在指导新手时见得太多了:

int* getTopValue(SeqStack* stack) { int value; stack->Pop(stack, &value); return &value; // 错误:value 是局部变量,函数返回后内存已失效 }

C语言的局部变量存在栈帧里,函数一返回,整个栈帧就弹出了,这块内存随时可能被其他函数覆盖。正确做法只能是把值通过出参带出来,或者存入调用方提供的内存。这也是为什么我在前面坚持用bool+出参的设计模式——它在源头上堵死了这类错误。

8.3 链式栈的malloc忘记free

用C语言写链表结构,最烦的内存问题就是泄漏。链式栈每次Push都malloc一个节点,如果Pop时忘记free,程序跑的时间越长内存占用越高,最终被系统杀掉。检查方法很简单:在调试器里watch内存使用量,或者在Pop函数里做计数统计,假如push 10000次后全部pop完,size归零但进程内存没有回落,那就是有节点没释放。

8.4 循环队列容量"少一个"的坑

循环队列实现里牺牲一个存储单元是常见做法,但很多新手会在这个语义上出问题——初始化一个容量100的队列,以为能存100个元素,结果存到第99个就报满了。这其实不是bug,是设计约定。如果你真的需要恰好存100个,可以把数组容量定义为101。另一个替代方案是多用一个计数器记录元素个数,但这样会多一个字段要维护。我的经验是接受"容量减一"的约定,并把它写在注释里,避免后来的维护者为此困惑。

实践了这么多,我最大的体会是:栈和队列虽然是最基础的数据结构,但它们背后承载的"时序控制"思想,会一路渗透到你写的每一个程序里。函数调用要用栈,中断处理要用栈,消息缓存要用队列,任务调度要用队列。你把这两个结构的C语言底层实现彻底吃透,再去接触系统底层、并发编程、中间件架构,都会觉得格外顺手。

最后再给一个建议:别停留在"能看懂"的阶段,把上面的代码关掉屏幕默写一遍,然后自己加两个功能试试——比如给顺序栈加一个"容量不足时动态翻倍扩容",或者给循环队列加一个"查询当前元素个数"的接口。写出来、跑通、再故意制造几个边界条件测一测,这个知识才是真正长在你身上的。

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

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

立即咨询