1. 先把链式队列的底层逻辑讲明白队列这个东西,大学数据结构课上几乎都逃不掉。但说实话,很多人学完也就记得“先进先出”四个字,真要让手写一个链式队列,能一次写对的人并不多。我见过不少同学在作业里用数组模拟队列,逻辑上倒是对了,但一旦涉及到动态数据或者长生命周期场景,数组方案就会变得束手束脚。而这篇文章要聊的链式队列,是真正适合工程落地的那一种。链式队列的核心,说白了就是用链表来存储队列中的元素,队列的头部指向链表的第一个节点,尾部指向链表的最后一个节点。这样每次入队就是往链表尾部追加节点,出队就是从链表头部摘除节点。整个过程不涉及元素的搬移,理论上只有常数级的时间复杂度。但链式队列真正的意义并不是“能跑”,而是它天然解决了顺序队列的两个老大难问题:一是假溢出,即数组队尾满了但队首还有空位;二是动态扩容,数组需要预先分配固定大小,而链式队列的容量只受堆内存限制。这两点在实际项目中非常关键。比如在做多线程任务调度、消息队列、层次遍历这类场景时,数据量的波动可能非常大,用数组要么浪费空间,要么频繁翻倍拷贝,而链式队列可以随时按需申请节点,代价仅仅是每次创建节点时的开销。我把话放这儿:如果你准备面试或者想真正掌握数据结构,链式队列是性价比极高的一个知识点。它不复杂,却能把指针操作、动态内存管理、边界条件处理这些基本功串起来。下面我直接按工程实现的思路,把完整过程拆开讲。2. 结构体定义和接口设计——先想清楚再动手很多人一上来就写代码,结果写到一半发现结构体少了个字段,又回去改,来回折腾。其实链式队列的结构设计非常固定,只要记住一个原则:队列本身只保存头和尾两个指针,节点单独定义。2.1 节点结构节点是链表的基本单元。在队列里,节点只需要两个成员:数据域和指针域。数据域的类型我习惯用泛型思路,也就是在接口层把数据类型抽象出来,C语言里用typedef就能很轻松地做到。typedef int QueueData; // 或者其他类型,按需求改 typedef struct QNode { QueueData data; struct QNode *next; } QNode;这里有个细节:struct QNode *next之所以能指向自身,是因为在结构体声明时,C语言允许使用指向自身类型的指针。这算是链表的基础知识,但不少初学者会在这里卡住,以为还没定义完就不能引用。实际上编译器在处理struct QNode *next时会先标记struct QNode这个标签,指针只需要4或8个字节的空间,与是否完整定义无关。2.2 队列结构队列结构体的设计有两种流派。一种是指向节点的指针,也就是QNode *front, *rear;另一种是包含头节点的“带头节点”做法。我个人强烈推荐带头节点,因为可以让空队列和非空队列的代码路径统一,省去大量if (front == NULL)的特殊分支。typedef struct { QNode *front; // 指向头节点,头节点的next指向首个元素 QNode *rear; // 指向尾节点 int size; // 记录元素个数,可选但推荐 } LinkQueue;size字段不是必须的,但有了它,GetSize接口就是O(1)的复杂度,否则需要遍历链表才能数出元素个数。代价仅仅是每次入队和出队时对size做增减操作,几乎可以忽略。尤其是写业务代码时,经常需要判断队列是否为空、长度是多少,没有这个字段会很难受。2.3 接口设计动手写代码之前,先把对外暴露的接口列出来:void InitQueue(LinkQueue *Q); // 初始化,创建头节点 void DestroyQueue(LinkQueue *Q); // 销毁队列,释放所有节点 int EnQueue(LinkQueue *Q, QueueData val); // 入队 int DeQueue(LinkQueue *Q, QueueData *val); // 出队,用指针返回数据 int IsEmpty(LinkQueue *Q); // 判空 int GetSize(LinkQueue *Q); // 返回元素个数接口设计的原则是语义清晰、命名统一。像EnQueue、DeQueue这种命名风格虽然有点老派,但胜在简短,很多工程代码里都有类似写法。如果你愿意,也可以写成QueuePush、QueuePop,并不强制。3. 完整实现与逐步拆解——每个函数的细节都能写一篇文章这里给出一个完整可运行的链式队列实现。我用的开发环境是Linux + GCC,但代码本身是跨平台的,放到Windows的VS里也直接能跑。3.1 初始化队列带头节点的队列,初始化就是创建一个头节点,让前后指针都指向它。void InitQueue(LinkQueue *Q) { Q->front = (QNode *)malloc(sizeof(QNode)); if (Q->front == NULL) { fprintf(stderr, "内存分配失败\n"); exit(EXIT_FAILURE); } Q->front->next = NULL; Q->rear = Q->front; Q->size = 0; }为什么要让rear也指向头节点?因为空队列的特殊状态就是“头和尾都指向同一个头节点”,这样头节点就充当了哨兵角色。后续入队时,永远在rear后面挂新节点;出队时,永远操作front->next。空队列时front->next为NULL,但因为front是合法的头节点,所以DeQueue函数里就不用再特判front==NULL这种反人类的操作了。不要忽略exit(EXIT_FAILURE)这种分支。很多人觉得malloc失败概率低,干脆不判断。在作业里可能无所谓,但工程化一定要处理,因为内存耗尽在任何系统里都可能发生。更温和的方式是把返回结果作为函数返回值,让调用方决定如何处理,例如:int InitQueue(LinkQueue *Q) { Q->front = (QNode *)malloc(sizeof(QNode)); if (Q->front == NULL) { return -1; } ... return 0; }两种方式各有适用场景,测试代码里用exit简单粗暴,库代码里用返回值更靠谱。3.2 入队操作入队的本质就是“尾插法”。先申请新节点,把数据写进去,然后让当前的rear->next指向新节点,再更新rear为新节点。这个顺序绝对不能错:如果先把rear改成新节点,那新节点就悬空了,原来的尾结点找不到了。int EnQueue(LinkQueue *Q, QueueData val) { QNode *newNode = (QNode *)malloc(sizeof(QNode)); if (newNode == NULL) { return -1; // 入队失败 } newNode->data = val; newNode->next = NULL; Q->rear->next = newNode; Q->rear = newNode; Q->size++; return 0; }注意这里的newNode->next必须先置为NULL,否则它在分配出来时是个随机值,最后挂在队尾时会导致链表尾部悬挂一个野指针。我知道有些经验老道的程序员会写calloc来代替malloc,因为calloc会把内存清零,可以省一步赋值。但calloc在性能上略微慢一些,而且会把整个节点全部清零,对链表节点来说没必要。手动置NULL才是最清晰的。3.3 出队操作出队操作比入队稍麻烦一点,因为要考虑“队列只剩一个元素”和“队列空”的区别。int DeQueue(LinkQueue *Q, QueueData *val) { if (Q->front->next == NULL) { return -1; // 队列为空 } QNode *delNode = Q->front->next; if (val != NULL) { *val = delNode->data; } Q->front->next = delNode->next; // 如果要删的是最后一个元素,需要把rear指回头节点 if (Q->rear == delNode) { Q->rear = Q->front; } free(delNode); Q->size--; return 0; }这里的val参数用了指针传递,目的是把出队元素的数据带回给调用者。如果调用方只关心出队不关心数据,可以传入NULL。这种设计在C语言里非常常见,比如很多系统调用就是这么干的。为什么删除最后一个元素时要单独处理rear?假设队列只有一个元素A,那么front->next指向A,rear也指向A。出队时如果只执行front->next = delNode->next,那么front->next会变成NULL,但rear还指向已经释放的内存,之后一旦入队,rear->next就会访问非法地址,程序直接崩溃。所以必须判断rear == delNode,如果是,就把rear重置为front,恢复空队列状态。这个边界问题是链式队列最容易踩的坑,我在下文“坑”的部分还要提一次。3.4 判空与求长度有了size字段,这两个接口极其简单:int IsEmpty(LinkQueue *Q) { return Q->front->next == NULL; } int GetSize(LinkQueue *Q) { return Q->size; }如果不用size字段,GetSize就得遍历整个链表:int GetSizeWithoutSize(LinkQueue *Q) { int count = 0; QNode *p = Q->front->next; while (p != NULL) { count++; p = p->next; } return count; }两种方式的复杂度差异很明显:前者O(1),后者O(n)。在一个频繁调用长度统计的系统里,后者会白白消耗大量CPU时间。但也不是说szie字段永远完美,如果代码里某处不小心漏维护了size,那么它就会失真。所以每一步入队、出队,都要牢记同步调整size。3.5 销毁队列销毁队列要把所有节点(包括头节点)全部释放,否则会造成内存泄漏。这里有一个常见误区:循环遍历时直接把当前指针p推进,却不保存下一个节点的地址,导致释放p之后再访问p->next,成为悬空指针。正确做法是先保存next,再释放p。void DestroyQueue(LinkQueue *Q) { QNode *p = Q->front; while (p != NULL) { QNode *next = p->next; free(p); p = next; } Q->front = NULL; Q->rear = NULL; Q->size = 0; }销毁之后,最好再把front和rear置为NULL,防止野指针残留。别小看这一步,很多“玄学崩溃”就是因为悬空指针被再次解引用。4. 测试与实测——跑通代码只是一半,你得会看现象我来模拟一段真实测试场景。假设我们要往队列里依次入队5个数字:10, 20, 30, 40, 50,然后出队3次,打印每次出队的数据,最后再把剩余元素全部出队。以下是一个简单的测试主函数:#include <stdio.h> #include <stdlib.h> int main() { LinkQueue q; InitQueue(&q); EnQueue(&q, 10); EnQueue(&q, 20); EnQueue(&q, 30); EnQueue(&q, 40); EnQueue(&q, 50); printf("size = %d\n", GetSize(&q)); // 预期 5 int val; DeQueue(&q, &val); printf("dequeue: %d\n", val); // 10 DeQueue(&q, &val); printf("dequeue: %d\n", val); // 20 DeQueue(&q, &val); printf("dequeue: %d\n", val); // 30 printf("size = %d\n", GetSize(&q)); // 预期 2 while (!IsEmpty(&q)) { DeQueue(&q, &val); printf("dequeue: %d\n", val); } DestroyQueue(&q); return 0; }运行结果应该如下:size = 5 dequeue: 10 dequeue: 20 dequeue: 30 size = 2 dequeue: 40 dequeue: 50如果你得到了这个结果,说明基本路径通了。但这只是“能用”,离“好用”还有距离。假如我在出队完成后,故意再加一行:DeQueue(&q, &val);此时队列已经是空的,DeQueue应该返回-1,表示出队失败。这种异常路径必须手动去测,因为很多bug就是藏在空操作里。我在自己的工程实践中会专门写一个“异常分支测试”,把队列空、队列满(链式队列一般无法构造满,除非内存耗尽)、传NULL指针这些情况全部测一遍。4.1 用内存检测工具验证C语言内存泄露是大事。我在Linux下测试时,会顺手用Valgrind检查一遍:gcc -g -o queue_test queue_test.c valgrind --leak-check=full ./queue_test如果输出里有“definitely lost”之类的字样,说明有节点没释放。正常情况下,运行完上面的test程序,Valgrind输出中关于堆内存的部分应该是没有leak的。这不是可选项,而是写C程序的习惯。尤其将来你要是去做嵌入式或服务端开发,内存问题会被放大得无比明显。现在就养成这个习惯,后面能省很多事。5. 链式队列那几个著名的坑——我挨个踩过5.1 尾指针忘记更新这是最常见的逻辑错误。很多人在入队时执行了rear->next = newNode,然后忘记rear = newNode。结果下一次入队时,新节点又挂到原来的尾结点后面,造成链表结构错乱。表现的现象就是出队顺序完全不对,或者链表里出现“分叉”。排查方法很简单:每次入队后打印rear->data,如果始终是第一个入队的元素,那说明rear没有移动。5.2 出队最后一个元素后,尾指针变野指针这个坑我在前文3.3节重点提过。我见过不止一个同事在Code Review时漏掉这个判断。如果代码里要有意识地防御,还可以统一用一句逻辑:if (Q->front->next == NULL) { Q->rear = Q->front; }也就是不管是不是删除最后一个节点,都在出队后检查“如果队列空了,就把rear归位”。这句逻辑放在free之前或者之后都行,关键是千万别漏。5.3 内存分配后忘记初始化next如果newNode->next没有手动置NULL,它就会是一个随机地址。入队时rear->next = newNode还好,之后新节点被当作队尾,它的next却不知道指向哪。等到再入队一个节点时,逻辑上会更新rear,而原队尾的野指针并没有被触发,似乎没问题。但如果你不小心遍历了链表或者调用了DestroyQueue,就会对野指针调用free,导致程序崩溃或Segmentation fault。预防办法就是我在3.2节强调过的:newNode->next = NULL,一行代码,能挡住九成以上的野指针困扰。5.4 从队列里取值后没有判空DeQueue接口的设计里,我特别要求先判空。但还有一种使用方式容易被坑:出队时把一个变量地址传进去,比如:int val; DeQueue(&q, &val);如果队列为空,DeQueue返回-1,但val的值不会改变。如果你没检查返回值,就继续使用val,会得到上一次的值或是未初始化的随机值。所以正确用法一定是:if (DeQueue(&q, &val) == 0) { // 处理val } else { // 队列为空,执行备选逻辑 }这个习惯一旦养成,对后续写任何带有“获取”语义的函数都非常有用。5.5 线程安全没有考虑链式队列本身不是线程安全的。如果两个线程同时入队,就可能出现竞争条件,导致链表断开或者数据错乱。实际项目中如需线程安全,可以加互斥锁,或者使用无锁队列。这属于并发编程的范畴,但我想强调一点:先写出正确的单线程链式队列,再谈并发优化。我见过有人一上来就引用各种无锁队列技巧,结果实现内部逻辑都是错的。基础不牢,地动山摇。6. 链式队列的顺序表对手——什么时候该选谁链式队列虽然很灵活,但也不是万能的。在内存分配极其频繁的场景下,链式队列的每次入队/出队都涉及动态内存的申请和释放,性能开销不可忽略。而顺序队列(数组实现)使用预先分配好的内存,入队出队不会触发系统调用,性能更高。我把两者的关键差异整理成一张表,方便你选择时做判断。对比维度链式队列顺序队列(动态数组)容量理论上仅受堆内存限制需要预分配,动态扩容时要搬移数据入队复杂度O(1),但包含内存分配开销均摊O(1),扩容时O(n)出队复杂度O(1),包含内存释放开销O(1),用环形队列实现时空间利用率每个节点有额外的指针开销空间紧凑,但可能有已出队位置的空白缓存友好度节点内存不连续,对CPU缓存不友好连续内存,缓存友好实现难度指针操作多,边界情况多更简单,但环形队列要小心下标回绕以我个人的经验看,这样选:如果队列的长度变化剧烈,或者无法预估上限,用链式队列。如果队列长度相对稳定,并且对性能有极严苛的要求,用顺序环形队列。如果数据元素本身比较大(比如结构体很大),链式队列的节点里存指向数据的指针可能更合适,而不是把整个元素拷贝到队列里。很多操作系统的任务队列就是用链式结构实现的,因为它允许任务数量动态变化,每个任务节点按需分配。而在网络框架里的缓冲区,通常用环形数组,那样可以利用局部性原理减少cache miss。7. 实战应用:怎么把链式队列用到实际需求里数据结构不是背的,是用的。我拿一个最简单但很经典的场景来演示:广度优先搜索(BFS)的队列实现。以树的层序遍历为例,队列负责暂时存放每一层的节点。假设我们有如下二叉树:1 / \ 2 3 / \ \ 4 5 6层序遍历的顺序是1, 2, 3, 4, 5, 6。用一个链式队列,节点类型是TreeNode*,过程如下:根节点1入队。出队1,打印;然后把左子2和右子3入队。出队2,打印;把4和5入队。出队3,打印;把6入队(右子为空就不入)。出队4,打印;无子节点。出队5,打印;无子节点。出队6,打印;无子节点。你来写代码,本质就是把QueueData的类型从int改成void *或具体的节点指针。这就是链式队列在算法题里最常见的用法。包括图的最短路径(无权图)、拓扑排序、以及很多业务里的任务调度,都离不开队列。如果你肯再往前迈一步,你会发现链式队列的思想在CPU任务调度、线程池、消息中间件、Redis的发布订阅里都能找到影子。数据结构就是一层窗户纸,捅破了,后面的代码就是按图纸盖楼。8. 优化扩展——链式队列还能往哪些方向走8.1 减少内存分配次数入队时一次malloc可能还扛得住,但如果每秒十几万次入队,效率就很低。工程上常见的优化是“节点池”或“内存池”技术。提前分配一大块内存,切成多个节点,放进一个空闲链表中。入队时从空闲链表摘节点,出队后还回空闲链表,不再调用系统的malloc/free。这种技术在长连接服务器、游戏引擎中非常常见。实现思路非常简单:先申请一个数组,把它串成一个链表作为freeList,然后让EnQueue从freeList取出节点,DeQueue把删除的节点重新挂回freeList。虽然底层还是连续内存,但上层看起来就是动态的链式队列,性能却提升很多。如果你继续深入研究,会发现C++的STL内存分配器、Linux内核的slab分配器都是类似思想。8.2 双向队列(Deque)如果想在队列两端都能插入删除,就把单向链表改成双向链表。结构体里加一个prev指针即可。出队时如果从rear一侧出,就可以直接用rear->prev找到前一个节点,而不需要遍历。双向队列在滑动窗口最大值之类的算法里也很有用,C++ STL里直接有std::deque,但理解它的底层会更踏实。8.3 优先队列的启发将链式队列与堆结合,就演化出了优先队列。虽然优先队列通常用数组实现堆,但在某些特殊场景(比如定时器)下,也可以用链表维护一个有序队列。每次插入时按优先级找到合适位置,代价是插入O(n)、取出头部O(1)。适用于插入不频繁、但取出非常频繁的场景。8.4 无锁队列在多线程编程中,链式队列可以用原子操作实现lock-free版本。原理是利用CAS(Compare-and-Swap)在插入时先修改尾节点的next,再用CAS把tail更新到新节点,整个过程不阻塞。这个方向很深,但不建议在没吃透指针和内存序概念前贸然尝试。9. 一个完整可复制的模板工程结构最后,我直接给出一份合理的工程组织方式。别把全部代码都塞在一个文件里,至少拆成头文件和源文件。下面是常规结构:queue/ ├── queue.h // 头文件,包含结构体和接口声明 ├── queue.c // 实现文件 └── test.c // 测试代码9.1 queue.h#ifndef LINK_QUEUE_H #define LINK_QUEUE_H #include <stdio.h> #include <stdlib.h> typedef int QueueData; typedef struct QNode { QueueData data; struct QNode *next; } QNode; typedef struct { QNode *front; QNode *rear; int size; } LinkQueue; void InitQueue(LinkQueue *Q); void DestroyQueue(LinkQueue *Q); int EnQueue(LinkQueue *Q, QueueData val); int DeQueue(LinkQueue *Q, QueueData *val); int IsEmpty(LinkQueue *Q); int GetSize(LinkQueue *Q); #endif9.2 queue.c#include "queue.h" void InitQueue(LinkQueue *Q) { Q->front = (QNode *)malloc(sizeof(QNode)); if (!Q->front) { fprintf(stderr, "malloc failed\n"); exit(EXIT_FAILURE); } Q->front->next = NULL; Q->rear = Q->front; Q->size = 0; } void DestroyQueue(LinkQueue *Q) { QNode *p = Q->front; while (p) { QNode *next = p->next; free(p); p = next; } Q->front = Q->rear = NULL; Q->size = 0; } int EnQueue(LinkQueue *Q, QueueData val) { QNode *node = (QNode *)malloc(sizeof(QNode)); if (!node) return -1; node->data = val; node->next = NULL; Q->rear->next = node; Q->rear = node; Q->size++; return 0; } int DeQueue(LinkQueue *Q, QueueData *val) { if (Q->front->next == NULL) return -1; QNode *tmp = Q->front->next; if (val) *val = tmp->data; Q->front->next = tmp->next; if (Q->rear == tmp) { Q->rear = Q->front; } free(tmp); Q->size--; return 0; } int IsEmpty(LinkQueue *Q) { return Q->front->next == NULL; } int GetSize(LinkQueue *Q) { return Q->size; }9.3 编译方式如果是类Unix环境:gcc -c queue.c -o queue.o gcc queue.o test.c -o test或者直接一条命令:gcc queue.c test.c -o test如果你是Windows下的Visual Studio用户,直接建立C控制台项目,把queue.c添加到源文件,把queue.h放到头文件目录,然后在主函数里写测试代码即可。但无论哪个环境,我建议都要手动在UCRT64或MSVC下分别编译一次,防止语法兼容问题。很多人写出来的代码在GCC下跑得欢,到MSVC就报错,多半是用了某些GCC扩展。上述代码里没有这种问题,直接可移植。10. 再叮嘱几句关于C语言功底的提升如果你认为链式队列很简单,那就试着迅速写出不查资料的版本。限时20分钟,必须包含销毁队列和内存检查。能做到无遗漏、无警告,才算真正掌握。我当年面试的时候被要求在白板上写链式队列,紧张状态下写漏了rear回退的判断,面试官立刻指出。从那以后,我才真正学会“在写任何数据结构操作时,心里先过一遍边界条件”。链式队列是实现复杂数据结构的一块跳板。树、图、哈希表里都藏着指针操作,链式队列是你打地基的最好练习场。真要说有什么诀窍,那就是多画图。每操作一个节点,就把指针连线画出来,然后在代码里跟着箭头走。理解了指针是怎么改变指向的,很多问题都会迎刃而解。这个内容其实还能继续扩展出不少方向,比如加一个遍历函数打印所有元素,或者把QueueData从int换成void *做成通用队列。但我建议不要在第一次学习时就引入太多花活,先把基础版本写熟,之后怎么扩展都顺手。如果你在实际调试过程中也遇到了什么奇怪的问题,欢迎自己动手画图分析,这个过程比读十篇文章都管用。
1. 先把链式队列的底层逻辑讲明白队列这个东西,大学数据结构课上几乎都逃不掉。但说实话,很多人学完也就记得“先进先出”四个字,真要让手写一个链式队列,能一次写对的人并不多。我见过不少同学在作业里用数组模拟队列,逻辑上倒是对了,但一旦涉及到动态数据或者长生命周期场景,数组方案就会变得束手束脚。而这篇文章要聊的链式队列,是真正适合工程落地的那一种。链式队列的核心,说白了就是用链表来存储队列中的元素,队列的头部指向链表的第一个节点,尾部指向链表的最后一个节点。这样每次入队就是往链表尾部追加节点,出队就是从链表头部摘除节点。整个过程不涉及元素的搬移,理论上只有常数级的时间复杂度。但链式队列真正的意义并不是“能跑”,而是它天然解决了顺序队列的两个老大难问题:一是假溢出,即数组队尾满了但队首还有空位;二是动态扩容,数组需要预先分配固定大小,而链式队列的容量只受堆内存限制。这两点在实际项目中非常关键。比如在做多线程任务调度、消息队列、层次遍历这类场景时,数据量的波动可能非常大,用数组要么浪费空间,要么频繁翻倍拷贝,而链式队列可以随时按需申请节点,代价仅仅是每次创建节点时的开销。我把话放这儿:如果你准备面试或者想真正掌握数据结构,链式队列是性价比极高的一个知识点。它不复杂,却能把指针操作、动态内存管理、边界条件处理这些基本功串起来。下面我直接按工程实现的思路,把完整过程拆开讲。2. 结构体定义和接口设计——先想清楚再动手很多人一上来就写代码,结果写到一半发现结构体少了个字段,又回去改,来回折腾。其实链式队列的结构设计非常固定,只要记住一个原则:队列本身只保存头和尾两个指针,节点单独定义。2.1 节点结构节点是链表的基本单元。在队列里,节点只需要两个成员:数据域和指针域。数据域的类型我习惯用泛型思路,也就是在接口层把数据类型抽象出来,C语言里用typedef就能很轻松地做到。typedef int QueueData; // 或者其他类型,按需求改 typedef struct QNode { QueueData data; struct QNode *next; } QNode;这里有个细节:struct QNode *next之所以能指向自身,是因为在结构体声明时,C语言允许使用指向自身类型的指针。这算是链表的基础知识,但不少初学者会在这里卡住,以为还没定义完就不能引用。实际上编译器在处理struct QNode *next时会先标记struct QNode这个标签,指针只需要4或8个字节的空间,与是否完整定义无关。2.2 队列结构队列结构体的设计有两种流派。一种是指向节点的指针,也就是QNode *front, *rear;另一种是包含头节点的“带头节点”做法。我个人强烈推荐带头节点,因为可以让空队列和非空队列的代码路径统一,省去大量if (front == NULL)的特殊分支。typedef struct { QNode *front; // 指向头节点,头节点的next指向首个元素 QNode *rear; // 指向尾节点 int size; // 记录元素个数,可选但推荐 } LinkQueue;size字段不是必须的,但有了它,GetSize接口就是O(1)的复杂度,否则需要遍历链表才能数出元素个数。代价仅仅是每次入队和出队时对size做增减操作,几乎可以忽略。尤其是写业务代码时,经常需要判断队列是否为空、长度是多少,没有这个字段会很难受。2.3 接口设计动手写代码之前,先把对外暴露的接口列出来:void InitQueue(LinkQueue *Q); // 初始化,创建头节点 void DestroyQueue(LinkQueue *Q); // 销毁队列,释放所有节点 int EnQueue(LinkQueue *Q, QueueData val); // 入队 int DeQueue(LinkQueue *Q, QueueData *val); // 出队,用指针返回数据 int IsEmpty(LinkQueue *Q); // 判空 int GetSize(LinkQueue *Q); // 返回元素个数接口设计的原则是语义清晰、命名统一。像EnQueue、DeQueue这种命名风格虽然有点老派,但胜在简短,很多工程代码里都有类似写法。如果你愿意,也可以写成QueuePush、QueuePop,并不强制。3. 完整实现与逐步拆解——每个函数的细节都能写一篇文章这里给出一个完整可运行的链式队列实现。我用的开发环境是Linux + GCC,但代码本身是跨平台的,放到Windows的VS里也直接能跑。3.1 初始化队列带头节点的队列,初始化就是创建一个头节点,让前后指针都指向它。void InitQueue(LinkQueue *Q) { Q->front = (QNode *)malloc(sizeof(QNode)); if (Q->front == NULL) { fprintf(stderr, "内存分配失败\n"); exit(EXIT_FAILURE); } Q->front->next = NULL; Q->rear = Q->front; Q->size = 0; }为什么要让rear也指向头节点?因为空队列的特殊状态就是“头和尾都指向同一个头节点”,这样头节点就充当了哨兵角色。后续入队时,永远在rear后面挂新节点;出队时,永远操作front->next。空队列时front->next为NULL,但因为front是合法的头节点,所以DeQueue函数里就不用再特判front==NULL这种反人类的操作了。不要忽略exit(EXIT_FAILURE)这种分支。很多人觉得malloc失败概率低,干脆不判断。在作业里可能无所谓,但工程化一定要处理,因为内存耗尽在任何系统里都可能发生。更温和的方式是把返回结果作为函数返回值,让调用方决定如何处理,例如:int InitQueue(LinkQueue *Q) { Q->front = (QNode *)malloc(sizeof(QNode)); if (Q->front == NULL) { return -1; } ... return 0; }两种方式各有适用场景,测试代码里用exit简单粗暴,库代码里用返回值更靠谱。3.2 入队操作入队的本质就是“尾插法”。先申请新节点,把数据写进去,然后让当前的rear->next指向新节点,再更新rear为新节点。这个顺序绝对不能错:如果先把rear改成新节点,那新节点就悬空了,原来的尾结点找不到了。int EnQueue(LinkQueue *Q, QueueData val) { QNode *newNode = (QNode *)malloc(sizeof(QNode)); if (newNode == NULL) { return -1; // 入队失败 } newNode->data = val; newNode->next = NULL; Q->rear->next = newNode; Q->rear = newNode; Q->size++; return 0; }注意这里的newNode->next必须先置为NULL,否则它在分配出来时是个随机值,最后挂在队尾时会导致链表尾部悬挂一个野指针。我知道有些经验老道的程序员会写calloc来代替malloc,因为calloc会把内存清零,可以省一步赋值。但calloc在性能上略微慢一些,而且会把整个节点全部清零,对链表节点来说没必要。手动置NULL才是最清晰的。3.3 出队操作出队操作比入队稍麻烦一点,因为要考虑“队列只剩一个元素”和“队列空”的区别。int DeQueue(LinkQueue *Q, QueueData *val) { if (Q->front->next == NULL) { return -1; // 队列为空 } QNode *delNode = Q->front->next; if (val != NULL) { *val = delNode->data; } Q->front->next = delNode->next; // 如果要删的是最后一个元素,需要把rear指回头节点 if (Q->rear == delNode) { Q->rear = Q->front; } free(delNode); Q->size--; return 0; }这里的val参数用了指针传递,目的是把出队元素的数据带回给调用者。如果调用方只关心出队不关心数据,可以传入NULL。这种设计在C语言里非常常见,比如很多系统调用就是这么干的。为什么删除最后一个元素时要单独处理rear?假设队列只有一个元素A,那么front->next指向A,rear也指向A。出队时如果只执行front->next = delNode->next,那么front->next会变成NULL,但rear还指向已经释放的内存,之后一旦入队,rear->next就会访问非法地址,程序直接崩溃。所以必须判断rear == delNode,如果是,就把rear重置为front,恢复空队列状态。这个边界问题是链式队列最容易踩的坑,我在下文“坑”的部分还要提一次。3.4 判空与求长度有了size字段,这两个接口极其简单:int IsEmpty(LinkQueue *Q) { return Q->front->next == NULL; } int GetSize(LinkQueue *Q) { return Q->size; }如果不用size字段,GetSize就得遍历整个链表:int GetSizeWithoutSize(LinkQueue *Q) { int count = 0; QNode *p = Q->front->next; while (p != NULL) { count++; p = p->next; } return count; }两种方式的复杂度差异很明显:前者O(1),后者O(n)。在一个频繁调用长度统计的系统里,后者会白白消耗大量CPU时间。但也不是说szie字段永远完美,如果代码里某处不小心漏维护了size,那么它就会失真。所以每一步入队、出队,都要牢记同步调整size。3.5 销毁队列销毁队列要把所有节点(包括头节点)全部释放,否则会造成内存泄漏。这里有一个常见误区:循环遍历时直接把当前指针p推进,却不保存下一个节点的地址,导致释放p之后再访问p->next,成为悬空指针。正确做法是先保存next,再释放p。void DestroyQueue(LinkQueue *Q) { QNode *p = Q->front; while (p != NULL) { QNode *next = p->next; free(p); p = next; } Q->front = NULL; Q->rear = NULL; Q->size = 0; }销毁之后,最好再把front和rear置为NULL,防止野指针残留。别小看这一步,很多“玄学崩溃”就是因为悬空指针被再次解引用。4. 测试与实测——跑通代码只是一半,你得会看现象我来模拟一段真实测试场景。假设我们要往队列里依次入队5个数字:10, 20, 30, 40, 50,然后出队3次,打印每次出队的数据,最后再把剩余元素全部出队。以下是一个简单的测试主函数:#include <stdio.h> #include <stdlib.h> int main() { LinkQueue q; InitQueue(&q); EnQueue(&q, 10); EnQueue(&q, 20); EnQueue(&q, 30); EnQueue(&q, 40); EnQueue(&q, 50); printf("size = %d\n", GetSize(&q)); // 预期 5 int val; DeQueue(&q, &val); printf("dequeue: %d\n", val); // 10 DeQueue(&q, &val); printf("dequeue: %d\n", val); // 20 DeQueue(&q, &val); printf("dequeue: %d\n", val); // 30 printf("size = %d\n", GetSize(&q)); // 预期 2 while (!IsEmpty(&q)) { DeQueue(&q, &val); printf("dequeue: %d\n", val); } DestroyQueue(&q); return 0; }运行结果应该如下:size = 5 dequeue: 10 dequeue: 20 dequeue: 30 size = 2 dequeue: 40 dequeue: 50如果你得到了这个结果,说明基本路径通了。但这只是“能用”,离“好用”还有距离。假如我在出队完成后,故意再加一行:DeQueue(&q, &val);此时队列已经是空的,DeQueue应该返回-1,表示出队失败。这种异常路径必须手动去测,因为很多bug就是藏在空操作里。我在自己的工程实践中会专门写一个“异常分支测试”,把队列空、队列满(链式队列一般无法构造满,除非内存耗尽)、传NULL指针这些情况全部测一遍。4.1 用内存检测工具验证C语言内存泄露是大事。我在Linux下测试时,会顺手用Valgrind检查一遍:gcc -g -o queue_test queue_test.c valgrind --leak-check=full ./queue_test如果输出里有“definitely lost”之类的字样,说明有节点没释放。正常情况下,运行完上面的test程序,Valgrind输出中关于堆内存的部分应该是没有leak的。这不是可选项,而是写C程序的习惯。尤其将来你要是去做嵌入式或服务端开发,内存问题会被放大得无比明显。现在就养成这个习惯,后面能省很多事。5. 链式队列那几个著名的坑——我挨个踩过5.1 尾指针忘记更新这是最常见的逻辑错误。很多人在入队时执行了rear->next = newNode,然后忘记rear = newNode。结果下一次入队时,新节点又挂到原来的尾结点后面,造成链表结构错乱。表现的现象就是出队顺序完全不对,或者链表里出现“分叉”。排查方法很简单:每次入队后打印rear->data,如果始终是第一个入队的元素,那说明rear没有移动。5.2 出队最后一个元素后,尾指针变野指针这个坑我在前文3.3节重点提过。我见过不止一个同事在Code Review时漏掉这个判断。如果代码里要有意识地防御,还可以统一用一句逻辑:if (Q->front->next == NULL) { Q->rear = Q->front; }也就是不管是不是删除最后一个节点,都在出队后检查“如果队列空了,就把rear归位”。这句逻辑放在free之前或者之后都行,关键是千万别漏。5.3 内存分配后忘记初始化next如果newNode->next没有手动置NULL,它就会是一个随机地址。入队时rear->next = newNode还好,之后新节点被当作队尾,它的next却不知道指向哪。等到再入队一个节点时,逻辑上会更新rear,而原队尾的野指针并没有被触发,似乎没问题。但如果你不小心遍历了链表或者调用了DestroyQueue,就会对野指针调用free,导致程序崩溃或Segmentation fault。预防办法就是我在3.2节强调过的:newNode->next = NULL,一行代码,能挡住九成以上的野指针困扰。5.4 从队列里取值后没有判空DeQueue接口的设计里,我特别要求先判空。但还有一种使用方式容易被坑:出队时把一个变量地址传进去,比如:int val; DeQueue(&q, &val);如果队列为空,DeQueue返回-1,但val的值不会改变。如果你没检查返回值,就继续使用val,会得到上一次的值或是未初始化的随机值。所以正确用法一定是:if (DeQueue(&q, &val) == 0) { // 处理val } else { // 队列为空,执行备选逻辑 }这个习惯一旦养成,对后续写任何带有“获取”语义的函数都非常有用。5.5 线程安全没有考虑链式队列本身不是线程安全的。如果两个线程同时入队,就可能出现竞争条件,导致链表断开或者数据错乱。实际项目中如需线程安全,可以加互斥锁,或者使用无锁队列。这属于并发编程的范畴,但我想强调一点:先写出正确的单线程链式队列,再谈并发优化。我见过有人一上来就引用各种无锁队列技巧,结果实现内部逻辑都是错的。基础不牢,地动山摇。6. 链式队列的顺序表对手——什么时候该选谁链式队列虽然很灵活,但也不是万能的。在内存分配极其频繁的场景下,链式队列的每次入队/出队都涉及动态内存的申请和释放,性能开销不可忽略。而顺序队列(数组实现)使用预先分配好的内存,入队出队不会触发系统调用,性能更高。我把两者的关键差异整理成一张表,方便你选择时做判断。对比维度链式队列顺序队列(动态数组)容量理论上仅受堆内存限制需要预分配,动态扩容时要搬移数据入队复杂度O(1),但包含内存分配开销均摊O(1),扩容时O(n)出队复杂度O(1),包含内存释放开销O(1),用环形队列实现时空间利用率每个节点有额外的指针开销空间紧凑,但可能有已出队位置的空白缓存友好度节点内存不连续,对CPU缓存不友好连续内存,缓存友好实现难度指针操作多,边界情况多更简单,但环形队列要小心下标回绕以我个人的经验看,这样选:如果队列的长度变化剧烈,或者无法预估上限,用链式队列。如果队列长度相对稳定,并且对性能有极严苛的要求,用顺序环形队列。如果数据元素本身比较大(比如结构体很大),链式队列的节点里存指向数据的指针可能更合适,而不是把整个元素拷贝到队列里。很多操作系统的任务队列就是用链式结构实现的,因为它允许任务数量动态变化,每个任务节点按需分配。而在网络框架里的缓冲区,通常用环形数组,那样可以利用局部性原理减少cache miss。7. 实战应用:怎么把链式队列用到实际需求里数据结构不是背的,是用的。我拿一个最简单但很经典的场景来演示:广度优先搜索(BFS)的队列实现。以树的层序遍历为例,队列负责暂时存放每一层的节点。假设我们有如下二叉树:1 / \ 2 3 / \ \ 4 5 6层序遍历的顺序是1, 2, 3, 4, 5, 6。用一个链式队列,节点类型是TreeNode*,过程如下:根节点1入队。出队1,打印;然后把左子2和右子3入队。出队2,打印;把4和5入队。出队3,打印;把6入队(右子为空就不入)。出队4,打印;无子节点。出队5,打印;无子节点。出队6,打印;无子节点。你来写代码,本质就是把QueueData的类型从int改成void *或具体的节点指针。这就是链式队列在算法题里最常见的用法。包括图的最短路径(无权图)、拓扑排序、以及很多业务里的任务调度,都离不开队列。如果你肯再往前迈一步,你会发现链式队列的思想在CPU任务调度、线程池、消息中间件、Redis的发布订阅里都能找到影子。数据结构就是一层窗户纸,捅破了,后面的代码就是按图纸盖楼。8. 优化扩展——链式队列还能往哪些方向走8.1 减少内存分配次数入队时一次malloc可能还扛得住,但如果每秒十几万次入队,效率就很低。工程上常见的优化是“节点池”或“内存池”技术。提前分配一大块内存,切成多个节点,放进一个空闲链表中。入队时从空闲链表摘节点,出队后还回空闲链表,不再调用系统的malloc/free。这种技术在长连接服务器、游戏引擎中非常常见。实现思路非常简单:先申请一个数组,把它串成一个链表作为freeList,然后让EnQueue从freeList取出节点,DeQueue把删除的节点重新挂回freeList。虽然底层还是连续内存,但上层看起来就是动态的链式队列,性能却提升很多。如果你继续深入研究,会发现C++的STL内存分配器、Linux内核的slab分配器都是类似思想。8.2 双向队列(Deque)如果想在队列两端都能插入删除,就把单向链表改成双向链表。结构体里加一个prev指针即可。出队时如果从rear一侧出,就可以直接用rear->prev找到前一个节点,而不需要遍历。双向队列在滑动窗口最大值之类的算法里也很有用,C++ STL里直接有std::deque,但理解它的底层会更踏实。8.3 优先队列的启发将链式队列与堆结合,就演化出了优先队列。虽然优先队列通常用数组实现堆,但在某些特殊场景(比如定时器)下,也可以用链表维护一个有序队列。每次插入时按优先级找到合适位置,代价是插入O(n)、取出头部O(1)。适用于插入不频繁、但取出非常频繁的场景。8.4 无锁队列在多线程编程中,链式队列可以用原子操作实现lock-free版本。原理是利用CAS(Compare-and-Swap)在插入时先修改尾节点的next,再用CAS把tail更新到新节点,整个过程不阻塞。这个方向很深,但不建议在没吃透指针和内存序概念前贸然尝试。9. 一个完整可复制的模板工程结构最后,我直接给出一份合理的工程组织方式。别把全部代码都塞在一个文件里,至少拆成头文件和源文件。下面是常规结构:queue/ ├── queue.h // 头文件,包含结构体和接口声明 ├── queue.c // 实现文件 └── test.c // 测试代码9.1 queue.h#ifndef LINK_QUEUE_H #define LINK_QUEUE_H #include <stdio.h> #include <stdlib.h> typedef int QueueData; typedef struct QNode { QueueData data; struct QNode *next; } QNode; typedef struct { QNode *front; QNode *rear; int size; } LinkQueue; void InitQueue(LinkQueue *Q); void DestroyQueue(LinkQueue *Q); int EnQueue(LinkQueue *Q, QueueData val); int DeQueue(LinkQueue *Q, QueueData *val); int IsEmpty(LinkQueue *Q); int GetSize(LinkQueue *Q); #endif9.2 queue.c#include "queue.h" void InitQueue(LinkQueue *Q) { Q->front = (QNode *)malloc(sizeof(QNode)); if (!Q->front) { fprintf(stderr, "malloc failed\n"); exit(EXIT_FAILURE); } Q->front->next = NULL; Q->rear = Q->front; Q->size = 0; } void DestroyQueue(LinkQueue *Q) { QNode *p = Q->front; while (p) { QNode *next = p->next; free(p); p = next; } Q->front = Q->rear = NULL; Q->size = 0; } int EnQueue(LinkQueue *Q, QueueData val) { QNode *node = (QNode *)malloc(sizeof(QNode)); if (!node) return -1; node->data = val; node->next = NULL; Q->rear->next = node; Q->rear = node; Q->size++; return 0; } int DeQueue(LinkQueue *Q, QueueData *val) { if (Q->front->next == NULL) return -1; QNode *tmp = Q->front->next; if (val) *val = tmp->data; Q->front->next = tmp->next; if (Q->rear == tmp) { Q->rear = Q->front; } free(tmp); Q->size--; return 0; } int IsEmpty(LinkQueue *Q) { return Q->front->next == NULL; } int GetSize(LinkQueue *Q) { return Q->size; }9.3 编译方式如果是类Unix环境:gcc -c queue.c -o queue.o gcc queue.o test.c -o test或者直接一条命令:gcc queue.c test.c -o test如果你是Windows下的Visual Studio用户,直接建立C控制台项目,把queue.c添加到源文件,把queue.h放到头文件目录,然后在主函数里写测试代码即可。但无论哪个环境,我建议都要手动在UCRT64或MSVC下分别编译一次,防止语法兼容问题。很多人写出来的代码在GCC下跑得欢,到MSVC就报错,多半是用了某些GCC扩展。上述代码里没有这种问题,直接可移植。10. 再叮嘱几句关于C语言功底的提升如果你认为链式队列很简单,那就试着迅速写出不查资料的版本。限时20分钟,必须包含销毁队列和内存检查。能做到无遗漏、无警告,才算真正掌握。我当年面试的时候被要求在白板上写链式队列,紧张状态下写漏了rear回退的判断,面试官立刻指出。从那以后,我才真正学会“在写任何数据结构操作时,心里先过一遍边界条件”。链式队列是实现复杂数据结构的一块跳板。树、图、哈希表里都藏着指针操作,链式队列是你打地基的最好练习场。真要说有什么诀窍,那就是多画图。每操作一个节点,就把指针连线画出来,然后在代码里跟着箭头走。理解了指针是怎么改变指向的,很多问题都会迎刃而解。这个内容其实还能继续扩展出不少方向,比如加一个遍历函数打印所有元素,或者把QueueData从int换成void *做成通用队列。但我建议不要在第一次学习时就引入太多花活,先把基础版本写熟,之后怎么扩展都顺手。如果你在实际调试过程中也遇到了什么奇怪的问题,欢迎自己动手画图分析,这个过程比读十篇文章都管用。