队列这东西,在数据结构里属于最基础、却也是实际工程里出场率极高的一批。写过几年 C 的人应该都有体会:搞懂链表、栈、队列这三板斧,再去碰操作系统、网络协议栈或者消息中间件,很多抽象概念瞬间就有了落点。今天想结合我自己的实战经验,把“队列(Queue)的C语言实现”这件事从头到尾捋一遍,从最简单的数组模拟开始,到环形队列、链式队列,再到实际开发里容易踩的坑,把这一个点彻底讲透。这篇文章适合正在学数据结构的同学、准备面试的开发者,也适合工作中需要自己封装底层数据结构的嵌入式或后端工程师。不管你现在在哪个阶段,只要能跟着敲一遍代码,队列从理论到实践这一关就算过了。
1. 队列是什么:从生活场景到程序世界的先进先出
队列的核心规则一句话就能说完:先进先出,First In First Out,简称 FIFO。这和栈的先进后出正好相反。生活中最常见的例子就是排队买饭,先到的人先打饭,后到的人只能排在后面。在程序世界里,队列做的事情和这个一模一样:先进入队列的数据元素,先被取出。
1.1 队列的两个关键操作视角
学习队列时,很多人只记住了“入队”和“出队”两个词,但真正理解队列必须从三个维度看:队头(front)、队尾(rear)、以及数据元素的流动方向。
- 入队(enqueue):只允许在队尾添加元素,新元素永远排在最后面。
- 出队(dequeue):只允许从队头移除元素,最先进入的元素最先被处理。
- 查看队头(peek/front):只读取队头元素但不移除,这个操作在实际业务中非常常用,比如消息队列里“看一眼下一条是什么”的场景。
从这个设计能看出来,队列天然就是一个“缓冲地带”。生产者把数据塞进队尾,消费者从队头取数据,两边互不干扰。这种解耦思路是队列在系统设计中价值最大的地方。
1.2 两种存储结构:顺序队列 vs 链式队列
用 C 语言实现队列,第一步要选存储结构。两条路线各有各的舞台。
顺序队列:底层是一块连续的内存空间,通常用数组实现。优点是内存紧凑、缓存友好、随机访问快;缺点是在普通数组实现下,队头出队后留下的空间没法复用,需要额外的取模运算或数据搬移设计才能绕过去。
链式队列:底层用链表节点,每个节点包含数据域和指向下一个节点的指针。优点是可以无痛扩容,不会出现“数组满了但队头还有空位”的尴尬;缺点是多了一个指针开销,并且每次入队要动态分配内存,在实时性要求高的场景里可能造成性能抖动。
实际工程里两种队列都很常见。嵌入式设备里常用环形数组队列,因为内存是固定的,不能随便 malloc;而通用服务器程序里链式队列更灵活,因为业务规模可能随时膨胀。没有绝对的好坏,只有适不适合场景。
2. 顺序队列的内存模型与初始实现
顺序队列用数组实现,需要关注的核心变量有四个:存储元素的数组、容量、队头位置、队尾位置。很多教材给的代码是一个结构体包着五个成员,看起来复杂,其实拆开看就清楚了。
2.1 数组模拟队列的基础版
先写一个最朴素版本。这个版本的功能是完整的,但存在严重缺陷,后面会详细说。
#include <stdio.h> #include <stdlib.h> #include <stdbool.h> #define MAX_SIZE 100 typedef struct { int data[MAX_SIZE]; int front; // 队头下标 int rear; // 队尾下标,指向最后一个元素的下一个位置 } Queue; // 初始化队列 void initQueue(Queue *q) { q->front = 0; q->rear = 0; } // 判断队列是否为空 bool isEmpty(Queue *q) { return q->front == q->rear; } // 判断队列是否已满 bool isFull(Queue *q) { return q->rear == MAX_SIZE; } // 入队 bool enQueue(Queue *q, int value) { if (isFull(q)) { printf("队列已满,无法入队\n"); return false; } q->data[q->rear] = value; q->rear++; return true; } // 出队 bool deQueue(Queue *q, int *value) { if (isEmpty(q)) { printf("队列为空,无法出队\n"); return false; } *value = q->data[q->front]; q->front++; return true; }这个版本用起来很方便:front 指向队头元素,rear 指向队尾元素的下一个空位,入队时数据写到q->data[q->rear],然后 rear 自增;出队时读取q->data[q->front],然后 front 自增。判空条件front == rear,这个条件在初始状态和所有元素出队后都成立,逻辑上是自洽的。
2.2 基础版的致命缺陷:假溢出
上面这个版本最大的问题就是“假溢出”。假设队列容量是 5,你连续入队 5 个元素,再出队 4 个元素,此时排序上看起来队列里只剩下 1 个元素,但 front 和 rear 的指针已经走到数组末尾,rear == MAX_SIZE成立,再入队就会提示“队列已满”。
看清楚,这根本不是真的满了,数组前面 4 个位置全是空的。这种现象就叫假溢出。
假溢出的本质是:我们只把数组当成了线性空间,没有把首尾连接起来。解决思路也很经典:把数组想象成一个环形跑道,front 和 rear 的位置到了数组末尾后,自动绕回开头继续走。这种实现就是环形队列,也叫循环队列。
3. 环形队列:解决假溢出的经典方案
环形队列是顺序队列在工程实践中的标准形态。它用取模运算实现下标绕回,核心逻辑不复杂,难就难在边界条件的处理上。写这块代码的时候我踩过不少坑,这次直接把每个关键点都摊开讲。
3.1 环形队列的核心思想
环形队列依然是数组,只是在指针移动时加了一层取模逻辑。队尾指针更新公式变为:
q->rear = (q->rear + 1) % MAX_SIZE;队头指针同理:
q->front = (q->front + 1) % MAX_SIZE;比如容量是 5,rear 当前在 4,入队后 rear 就变成(4 + 1) % 5 = 0,绕回数组开头。
下一个问题是判空和判满。判空仍然是front == rear。但如果判满也写成front == rear,就会和判空发生冲突。
考虑一个容量为 5 的环形队列,从空开始不断入队,当第 5 个元素入队后,rear 移动了 5 次,正好又回到 front 的位置,此时front == rear,但队列实际是满的。这就导致“空”和“满”无法区分。
解决这个矛盾的方案有三种:
- 牺牲一个存储单元:当
(rear + 1) % MAX_SIZE == front时认为队列满。这意味着队列最多只能存MAX_SIZE - 1个元素,让 rear 永远不追上 front。 - 增设计数器:用 size 变量记录元素个数,入队加一,出队减一。空和满就看 size 是 0 还是 MAX_SIZE。
- 增设标志位:用 flag 变量区分是删除导致的相等还是插入导致的相等。
教科书里最常用的是方案一,因为它不浪费额外变量。实际工程里方案二更直观,避免了空满边界思考。我这里先重点讲方案一,因为它是理解整个环形队列的关键所在。
3.2 完整实现:结构体定义、初始化、判空判满
这里给出一个完整的环形队列实现,后面所有代码都是围绕这一版展开的。
#include <stdio.h> #include <stdlib.h> #include <stdbool.h> #define MAX_SIZE 5 typedef struct { int data[MAX_SIZE]; int front; // 队头下标,指向队头元素 int rear; // 队尾下标,指向队尾元素的下一个位置 } CircularQueue; // 初始化 void initQueue(CircularQueue *q) { q->front = 0; q->rear = 0; } // 判空 bool isEmpty(CircularQueue *q) { return q->front == q->rear; } // 判满(牺牲一个存储单元) bool isFull(CircularQueue *q) { return (q->rear + 1) % MAX_SIZE == q->front; } // 获取当前队列元素个数 int queueSize(CircularQueue *q) { return (q->rear - q->front + MAX_SIZE) % MAX_SIZE; }这里queueSize的计算公式需要重点理解。因为 rear 和 front 谁大谁小不确定,所以不能直接用q->rear - q->front,而是先加 MAX_SIZE 再取模,保证结果落在 0 到 MAX_SIZE-1 之间。这是环形队列里最容易被写错的一行代码,建议直接记下来。
3.3 入队、出队、获取队头元素
入队和出队的实现,关键是操作顺序和取模运算。
// 入队 bool enQueue(CircularQueue *q, int value) { if (isFull(q)) { printf("队满,入队失败\n"); return false; } q->data[q->rear] = value; q->rear = (q->rear + 1) % MAX_SIZE; return true; } // 出队 bool deQueue(CircularQueue *q, int *value) { if (isEmpty(q)) { printf("队空,出队失败\n"); return false; } *value = q->data[q->front]; q->front = (q->front + 1) % MAX_SIZE; return true; } // 获取队头元素 bool peekFront(CircularQueue *q, int *value) { if (isEmpty(q)) { printf("队空,无法获取队头元素\n"); return false; } *value = q->data[q->front]; return true; }入队的过程拆开看就是三步:先判断满,再把数据写到 rear 位置,最后让 rear 绕圈走一步。出队类似:先判断空,读取 front 位置数据,再让 front 绕圈走一步。
我在实际写代码时,出队函数有一段小纠结:到底是先读取数据再移动 front,还是先移动 front 再读取数据?答案必须选前者。如果先移动 front,第二次出队时读到的就是后一个元素,数据全乱了。这个顺序问题在链式队列里同样存在,后面会再提到。
3.4 关键边界条件:为什么牺牲一个存储单元
很多人刚接触环形队列时不理解“牺牲一个存储单元”这个操作,总觉得浪费。我换个方式解释。
想象一个圆桌,桌上摆了 5 个座位,但没有编号。两个人在玩一个游戏:一个人只能往顺时针方向移动,另一个人只能逆时针方向移动,两个人不能在同一个位置。这两个人的位置如果相同,就说明“追尾了”。
front和rear就是这两个人。front 负责从队头消费位置,rear 负责从队尾生产位置。当 rear 追上 front 时,队列确实满了。但在环形结构里,满和空都表现为两个指针重合,如果不牺牲一格,就无法判断到底是谁先占了那个位置。
牺牲一格的意思是:rear 永远不允许指向 front 的位置,当(rear + 1) % MAX_SIZE == front时就强制停止入队。这样队列最多存储MAX_SIZE - 1个元素。以容量 5 为例,最多存 4 个。多出的那一格就像一个“安全隔离带”,把满和空这两个状态彻底分开了。
如果不想牺牲这一格,就用计数器方案。这个方案很直接:
typedef struct { int data[MAX_SIZE]; int front; int rear; int size; } CountQueue; bool isFull(CQueue *q) { return q->size == MAX_SIZE; } bool isEmpty(CQueue *q) { return q->size == 0; }入队时q->size++,出队时q->size--。不用再绕脑子理解取模边界了。工程里如果内存不是卡得特别死,我反而更推荐这种方式,因为团队协作时别人读你的代码不会迷路。
4. 链式队列:不固定容量的另一种选择
环形队列的容量是写死的,在嵌入式、操作系统内核这些内存受限场景里没问题。但如果你写的是一个通用服务,业务高峰期和低峰的访问量可能相差几百倍,固定容量就不好办了。这时候链式队列上场,它不限制元素个数,队列能长能短,动态适应。
4.1 链式队列的结构设计
链式队列的每个节点是一个链表节点,有两个指针:一个指向队头,一个指向队尾。这里有一个常见设计分歧:队头用头结点还是直接指向第一个数据节点。
二叉树的兄弟都知道,链式队列空不空、入队出队时头尾指针怎么变化,这问题如果不画图,光靠脑子想很容易漏。标准做法是带头结点。头结点是哨兵,不存数据,只作为标记。这样做的最大好处是:空队列和非空队列的入队操作可以统一处理,不需要分支判断。
结构体定义如下:
typedef struct Node { int data; struct Node *next; } LinkNode; typedef struct { LinkNode *front; // 队头指针,指向头结点 LinkNode *rear; // 队尾指针,指向最后一个节点 } LinkQueue;注意,这里front和顺序队列里的 front 含义不同。顺序队列的 front 是一个下标,链式队列的 front 是一个指向头结点的指针。头结点的 next 才指向真正的队头元素。
4.2 完整实现:初始化、入队、出队、释放
初始化时创建头结点,front 和 rear 都指向头结点。
// 初始化链式队列 bool initLinkQueue(LinkQueue *q) { q->front = (LinkNode *)malloc(sizeof(LinkNode)); if (q->front == NULL) { return false; } q->front->next = NULL; q->rear = q->front; return true; }入队操作在队尾插入新节点。这里发现一个细节:因为入队只动 rear,所以尾插的代码非常短。如果要入队第一个元素,rear 本来就指向头结点,把新节点挂到 rear->next 上,然后移动 rear,照样成立。
// 入队 bool enLinkQueue(LinkQueue *q, int value) { LinkNode *newNode = (LinkNode *)malloc(sizeof(LinkNode)); if (newNode == NULL) { printf("内存申请失败,入队失败\n"); return false; } newNode->data = value; newNode->next = NULL; q->rear->next = newNode; q->rear = newNode; return true; }出队要删除的是头结点之后的第一个节点。这里有一个很容易翻车的点:如果要删除的节点恰好是队尾节点,那么删除后 rear 指针就成了悬空指针。所以出队必须加一个判断:如果出队后队列为空,要把 rear 重新指向头结点。
// 出队 bool deLinkQueue(LinkQueue *q, int *value) { if (q->front == q->rear) { printf("队列为空,出队失败\n"); return false; } LinkNode *temp = q->front->next; *value = temp->data; q->front->next = temp->next; if (q->rear == temp) { q->rear = q->front; } free(temp); return true; }释放整个队列时,要从头结点往后逐个释放,不能只释放头结点。这里我习惯用一个遍历指针循环来释放,简单直接。
// 销毁队列 void destroyLinkQueue(LinkQueue *q) { LinkNode *curr = q->front; while (curr != NULL) { LinkNode *next = curr->next; free(curr); curr = next; } q->front = NULL; q->rear = NULL; }链式队列的扩容过程完全依赖 malloc 和 free,明显的好处是理论上没有容量上限,只要内存够,数据可以一直往里加。坏处是每个节点多了 8 字节(64 位系统指针大小)的额外开销,而且频繁 malloc/free 会产生内存碎片。所以链式队列更适合节点数量不确定、波动大的场景。
5. 队列的应用场景与面试常考变形
队列远不只是教学用的,实际工程中用到的队列比很多人想象中要多得多。写到这里,我觉得有必要把“学了队列能干嘛”这件事说清楚。
5.1 实际应用领域
1. 任务调度与消息缓冲
CPU 任务调度是操作系统教科书里第一个用到队列的地方。多个进程排队等待 CPU 时间片,新来的进队列尾部,当前执行完的进程决定是重新排队还是出队。消息中间件更典型,生产者发的消息进队列,消费者独立地从队列头取消息,通过队列实现生产者消费者解耦。
2. 广度优先搜索 BFS
图算法里,BFS 的实现必须用队列。从起点开始,把相邻节点依次入队,再从队头取节点继续扩展。这个过程天然就是 FIFO,先发现的节点先被处理,保证按层扩展。换个深度优先的话,BFS 就变成 DFS 了。
3. 缓冲区(Buffer)
嵌入式开发里,串口和网络数据的接收通常会用环形队列做缓冲区。比如传感器数据不断进来,主循环抽空处理,中间用队列存住。如果不用队列,数据来了处理不了就会丢,用了队列就有了缓冲时间。
4. 线程池任务队列
线程池实现里一般有一个任务队列,新任务先入队,空闲线程从队头取任务执行。这本质上就是一个多生产者多消费者的队列模型。
5.2 双端队列和循环队列的变形
面试时经常在队列基础上出变形题,最常遇到的有两种:
双端队列(deque):两端都可以入队和出队。底层实现可以用带头尾指针的双向链表,更极致的实现是用数组加头尾下标,此时就是“循环双端队列”。LeetCode 上的“滑动窗口最大值”就是依托双端队列的经典题。
用循环队列实现栈或其他结构:这类问题考的是对两种结构本质的理解。以循环队列为底层实现一个类似栈的结构,核心思路是:入栈时在队尾插入,出栈时把除队尾外的所有元素出队再重新入队,最后队尾元素就是栈顶,出队返回。空间换时间,但逻辑上很有意思。
面试时做这类题,重点不是代码多炫,而是能把空、满、边界条件表达清楚。对出队导致 rear 变化的情况,如果能主动说明“这里要处理链表尾节点删除后指针修正”,面试官就会知道你真正理解了。
6. 常见问题与排查技巧实录
前面讲的是原理和代码,最后这部分我想把平时排查问题时积累的经验分享出来。这些问题我踩过,也在帮别人 review 代码时见过太多次。
6.1 问题速查表
| 现象 | 可能原因 | 解决建议 |
|---|---|---|
| 入队总是提示队满 | 判满逻辑写错,或数组容量太小 | 检查是否用了(rear+1)%MAX_SIZE,打印 front/rear 定位 |
| 出队数据错乱 | 出队时先移动 front 再取数据 | 改成先读取数据,再移动指针 |
| 队列实际还有空间却显示已满 | 没有正确处理取模,rear 越界 | 检查所有 rear/front 更新是否带取模 |
| 链式队列出队后访问出错 | 删除队尾节点后 rear 悬空 | 删除后判断rear == 被删除节点,修正 rear |
| 动态内存申请后没判断为空 | malloc 失败会返回 NULL | 每个 malloc 后立即检查返回值 |
| 数组下标变成负数 | 取模运算时用错了变量类型或符号 | 统一改成(rear - front + MAX_SIZE) % MAX_SIZE形式 |
6.2 实战注意事项
第一点,不要用 size_t 存下标再参与取模。size_t 是无符号类型,如果计算出现负数结果,会变成很大的正数,取模结果完全错误。我见过一个真实案例:用for循环配合size_t遍历队列,访问到负数下标后程序直接崩溃。如果你需要用下标做减法,建议使用int类型,或者先加 MAX_SIZE 再取模。
第二点,内存泄漏在链式队列里比顺序队列更隐蔽。链式队列销毁时如果只 free 头结点和尾节点,中间的节点全部漏掉。我习惯用遍历法,逐个释放,并且释放后把指针置 NULL,避免“悬空指针害死人”的经典事故。
第三点,接口设计要预留返回值。很多教科书示例里的入队函数是void,没有返回值,满了直接打印提示。这在教学环境可以,工程里绝对不行。调用方的逻辑需要知道入队是否成功,否则队列满了就把数据悄悄丢掉,数据丢失会很难查。所有操作函数的建议签名都是返回 bool,配合指针参数带出数据。
最后分享一个小习惯
我在写队列相关代码时,不管逻辑多简单,都有一个习惯:写完后用一组边界测试数据自测。具体是这么测的:先入队元素到满,再出队到空,再入队一个元素,打印每一步的 front、rear 和数据。这组测试能一次性覆盖队满、队空、绕回、单元素等几乎所有边界情况。很多隐藏 bug 都是在“从满到空再来一个”的过程中暴露的。你要是也觉得队列代码简单到不用测,可以试着跑一遍这个用例,多半会有意外的收获。