资讯动态

链式队列实战:C语言实现先进先出结构,彻底搞懂指针与动态内存分配

发布时间:2026/9/17 17:56:25 来源:尧图企业网站定制
链式队列说到底就是用链表实现的先进先出结构。我在大学第一次啃数据结构的时候把顺序表、链表、栈都写得比较顺但写到链式队列还是卡了一小段时间。后来反复画图、单步调试才把 front 和 rear 两个指针的配合彻底弄明白。写完之后回头看链式队列其实是一座很值得认真搭的桥它能把 C 语言里指针、结构体、动态内存分配这几个最容易翻车的点全部串起来。今天这篇就把链式队列从设计思路到每一行代码完整拆一遍包括带头结点和不带头结点的取舍、为什么初始化要传指针、出队时那个 free 的顺序错一点都不行以及我在实际调试中遇到的各种典型问题。适合正在学数据结构的学生、准备考研刷代码题的人还有想自己封装一个通用队列模块的开发者参考。1. 为什么链式队列值得认真写一遍1.1 队列的本质先来后到的逻辑队列是一种只允许在一端插入、在另一端删除的线性表。插入的一端叫队尾删除的一端叫队头规则就是先进先出。这个模型在现实里到处都是食堂排队打饭就是最典型的样子先排到窗口的人先打到饭后来的人只能排在后面。在程序里队列承担的角色也差不多。打印机任务、消息缓冲区、操作系统里的任务调度、图的广度优先遍历底层都是队列在工作。理解了队列再往上看这些系统就不会觉得它们是什么黑魔法。链表实现队列的核心思路是用节点来存储元素节点之间通过指针串联。队头指针 front 指向队列的头部位置队尾指针 rear 指向最后一个元素。入队操作在 rear 那边挂一个新节点出队操作从 front 那边摘掉一个节点。听起来很简单但真正实现的时候很多细节值得展开讲。1.2 顺序队列的痛假溢出和内存浪费很多人学队列的时候第一个接触的是基于数组的顺序队列。思路很简单开一个定长数组用 front 和 rear 两个下标标记队头和队尾。入队时 rear 后移出队时 front 后移。但问题马上来了数组的长度是固定的而且 front 之前的空间永远不会再被用到。假设数组长度是 10你入队 8 个元素出队 6 个此时 front 等于 6rear 等于 8队列里只有 2 个元素但 rear 已经到数组末尾了再入队就会溢出。可实际上数组前 6 个位置是空的这就是典型的假溢出。有人会说那就用循环队列让 rear 到末尾后回绕到数组开头。这个思路确实解决了一部分假溢出问题但循环队列的容量依然固定一旦队列满了要扩容就得申请新数组、搬运旧数据代价不小。而且搬运的时机、新数组多大、腾挪期间的并发问题都是麻烦。链式队列没有这些烦恼。每个元素都是独立 malloc 出来的节点需要多少就申请多少不存在数组容量固定的问题。入队就是在队尾挂一个节点出队就是从队头摘一个节点两个操作都只涉及指针变更时间复杂度是严格的 O(1)。维度顺序队列数组链式队列链表容量固定需要预分配动态按需分配假溢出存在不存在入队下标移动满时需处理尾部挂节点出队下标移动头部摘节点扩容需要搬移数据无需扩容额外开销无指针开销每节点多一个指针顺序队列在数据规模确定、追求极致性能的场合依然有优势比如实时系统里的固定缓冲区。但作为学习数据结构、训练指针能力的入门题链式队列的含金量高得多。1.3 链式队列的优势动态扩容零成本链式队列最大的底气来自动态内存分配。入队时 new 一个节点出队时 free 那个节点队列的长度完全跟着数据量走。写网络消息处理的时候经常会遇到消息数量忽多忽少的情况用数组队列要么预留大量空间造成浪费要么就得靠循环队列反复腾挪链式队列在这种场景下就非常从容。当然动态分配也有代价每次入队都要调用 malloc出队要调用 free这些系统调用本身有时间开销。但在普通应用里这个开销完全在可接受范围内。真正需要极端性能的场景工程上一般会自己维护一个内存池来复用节点。这一点我们放到最后的实战经验部分细说。2. 链式队列的设计两个指针和头结点2.1 结构体定义数据和指针的搭配链式队列通常需要两个结构体一个是节点结构体用来存放数据和指向下一个节点的指针一个是队列结构体用来存放队头和队尾指针。typedef struct QNode { int data; // 数据域这里以 int 为例 struct QNode *next; // 指针域指向下一个节点 } QNode; typedef struct { QNode *front; // 队头指针 QNode *rear; // 队尾指针 } LinkQueue;节点结构体里next 必须写成 struct QNode *因为 typedef 是在结构体定义完成后才生效的结构体内部还不能直接用 QNode 这个别名。这个细节初学者很容易写错编译器报 unknown type name 的时候先检查这里。队列结构体只存放两个指针不存放具体数据。这样设计的用意很清晰对队列的操作全部通过 front 和 rear 两个入口完成外部调用者不需要关心节点内部长什么样。数据域用 int 只是为了演示方便实际工程里你完全可以把它改成 void *让节点持有任意类型的数据指针这样队列就变成通用容器了。我见过不少项目就是这么干的结构体里放一个 void *data配合函数指针传入自定义的释放函数就能实现一个支持任意类型的队列。2.2 为什么 front 和 rear 是一对好搭档队列的入队操作发生在队尾出队操作发生在队头。如果链表只有一个头指针入队的时候就得从头遍历到尾部时间复杂度变成 O(n)这显然不能接受。所以必须额外用一个 rear 指针始终指向队尾让入队直接在尾部操作做到 O(1) 入队。同样如果只有 rear 指针没有 front 指针出队的时候不知道队头在哪操作同样做不了。这就是为什么链式队列必须同时持有 front 和 rear 两个指针的原因。理解这两个指针的配合有一个很关键的直觉front 永远指向头结点rear 永远指向最后一个有效节点。空队列的时候front 和 rear 指向同一个位置。每入队一个节点rear 就往后移动一次每出队一个节点front 后移一次。把这句话记在脑子里后面的代码全部是这句话的翻译。2.3 带头结点让代码逻辑统一起来链式队列的实现有带头结点和不带头结点两种版本。我这里强烈建议学习阶段用带头结点的写法源码层面更简洁也不容易出 bug。带头结点的意思是front 指针指向一个额外的头结点这个头结点不存储实际数据只是作为哨兵存在。真正的队头元素在头结点的 next 后面。这样设计的好处是空队列和非空队列的处理逻辑完全统一了。如果不用头结点空队列时 front 和 rear 都得置为 NULL入队第一个元素要单独处理 front 和 rear 的赋值出队也要考虑删掉最后一个节点后把两个指针都置空。分支一多代码就容易写错。带头结点之后front 永远不为 NULL判空条件简化为 front rear所有边界情况都统一处理了。这里再顺带解释一个经常被问到的点为什么有的代码初始化函数写成 InitQueue(LinkQueue *Q)有的写成 InitQueue(LinkQueue **Q)关键在于调用方如何分配队列结构体。如果队列结构体是 main 函数里声明的局部变量函数内只需要修改结构体的成员传 LinkQueue * 就够如果需要函数内部为整个队列结构体分配内存然后把地址返回给外部指针那才需要二级指针。我下面给出的版本采用第一种方案调用简单也符合大部分教材的写法。3. 核心操作逐行拆解3.1 初始化让两个指针先站好位初始化的目标是创建头结点并让 front 和 rear 都指向这个头结点。void InitQueue(LinkQueue *Q) { Q-front Q-rear (QNode *)malloc(sizeof(QNode)); if (Q-front NULL) { printf(内存分配失败\n); exit(1); } Q-front-next NULL; }malloc 之前先检查返回结果这是一条不能省的规矩。内存分配失败的情况下后续所有解引用操作都是对空指针操作程序直接崩溃。exit(1) 是粗暴但有效的处理方式工程代码里可以换成更优雅的错误上报但学习阶段这样写没问题。Q-front-next NULL 这一行容易被忽略但很重要。头结点的 next 必须初始化否则它就是一个不确定的野指针后续判空和遍历全部会出错。3.2 入队尾部挂节点入队分三步创建新节点、把节点挂到队尾、移动 rear 指针。void EnQueue(LinkQueue *Q, int x) { QNode *s (QNode *)malloc(sizeof(QNode)); if (s NULL) { printf(内存分配失败\n); exit(1); } s-data x; s-next NULL; Q-rear-next s; // 把新节点链接到当前队尾的后面 Q-rear s; // rear 后移指向新的队尾 }这里有个顺序问题。必须先让原队尾节点的 next 指向新节点再让 rear 指针指向新节点。反过来写就会丢掉新节点和整个链表的连接rear 指过去了链表却接不上数据就丢了。新节点的 next 必须初始化为 NULL。链表插入操作最常见的 bug就是忘记把新节点的 next 置空导致后面遍历的时候指针乱飞打印出根本不存在的数据。3.3 出队删头结点出队是链式队列里最容易写错的操作。因为栈和线性表的删除操作都比较直白而队列的出队要同时维护 front、逻辑队头、以及可能变化的 rear 三个角色。int DeQueue(LinkQueue *Q, int *x) { if (Q-front Q-rear) { return 0; // 队列为空出队失败 } QNode *p Q-front-next; // p 指向真正的队头元素 *x p-data; // 把队头元素的值带出去 Q-front-next p-next; // 让头结点跨过 p指向下一个节点 if (Q-rear p) { Q-rear Q-front; // 如果 p 恰好是队尾说明队列已空 } free(p); // 释放节点内存 return 1; }关键点在这几行第一判空条件 Q-front Q-rear。空队列时 front 和 rear 同时指向头结点完全相等这是带头结点设计带来的最直接红利。第二p Q-front-next。队头元素不是 front 直接指向的节点而是 front-next因为 front 指向的是不存数据的头结点。这个关系搞明白了出队逻辑就清晰了。第三Q-front-next p-next。这行代码让头结点直接跳过 p指向 p 的下一个节点。即使 p-next 是 NULL赋值也安全因为链表本来就是以 NULL 结尾的。第四也是最容易忽略的一步判断 Q-rear p。当队列只有一个元素时p 既是队头也是队尾。free(p) 之后如果 rear 还指向 prear 就变成了野指针。以后再入队的时候通过 rear 挂载和移动都基于一个失效的地址程序行为无法预测。所以必须先让 rear 重新指向头结点再执行 free。第五把要删除的元素值通过指针参数 *x 带出。为什么不用返回值直接带出数据因为出队本身可能失败返回值的语义最好保留给调用成功与否。用指针参数传回数据返回值表示状态是比较标准的 C 语言接口设计。3.4 取队头、统计长度取队头和出队很像但只读不删。int GetFront(LinkQueue *Q, int *x) { if (Q-front Q-rear) { return 0; } *x Q-front-next-data; return 1; }统计长度需要一个遍历过程从队头走到队尾计数。int QueueLength(LinkQueue *Q) { int count 0; QNode *p Q-front-next; while (p ! NULL) { count; p p-next; } return count; }长度统计的时间复杂度是 O(n)因为链表不支持下标随机访问。如果需要频繁获取队列长度可以专门加一个 length 字段维护入队时加一出队时减一复杂度降到 O(1)。但代价是每次入队出队都要记得更新字段忘记一次就整个崩溃。我个人的习惯是先用遍历版本确保逻辑正确后再做优化。4. 一个能直接跑的完整示例4.1 demo 的设计思路单独的函数拆开看容易懂但连起来跑一遍才能真正验证逻辑。我写了一个完整的 demo覆盖以下场景初始化空队列、连续入队 3 个元素、查看队头、出队 2 个元素、再入队 1 个元素、查看队列长度。每一步都打印当前队列内容方便对照验证。为了调试方便我额外加了一个 PrintQueue 函数从头结点开始遍历并打印每个节点的数据。4.2 完整代码#include stdio.h #include stdlib.h typedef struct QNode { int data; struct QNode *next; } QNode; typedef struct { QNode *front; QNode *rear; } LinkQueue; // 初始化队列创建头结点front 和 rear 都指向它 void InitQueue(LinkQueue *Q) { Q-front Q-rear (QNode *)malloc(sizeof(QNode)); if (Q-front NULL) { printf(内存分配失败\n); exit(1); } Q-front-next NULL; } // 判空 int IsEmpty(LinkQueue *Q) { return Q-front Q-rear; } // 入队 void EnQueue(LinkQueue *Q, int x) { QNode *s (QNode *)malloc(sizeof(QNode)); if (s NULL) { printf(内存分配失败\n); exit(1); } s-data x; s-next NULL; Q-rear-next s; Q-rear s; } // 出队 int DeQueue(LinkQueue *Q, int *x) { if (IsEmpty(Q)) { return 0; } QNode *p Q-front-next; *x p-data; Q-front-next p-next; if (Q-rear p) { Q-rear Q-front; } free(p); return 1; } // 从队头读取数据不删除节点 int GetFront(LinkQueue *Q, int *x) { if (IsEmpty(Q)) { return 0; } *x Q-front-next-data; return 1; } // 队列长度遍历版 int QueueLength(LinkQueue *Q) { int count 0; QNode *p Q-front-next; while (p ! NULL) { count; p p-next; } return count; } // 打印队列全部内容不含头结点 void PrintQueue(LinkQueue *Q) { QNode *p Q-front-next; printf(队列内容: ); while (p ! NULL) { printf(%d , p-data); p p-next; } printf(\n); } int main() { LinkQueue Q; int x; InitQueue(Q); printf(初始化完成队列为空: %s\n, IsEmpty(Q) ? 是 : 否); EnQueue(Q, 10); EnQueue(Q, 20); EnQueue(Q, 30); PrintQueue(Q); printf(队列长度: %d\n, QueueLength(Q)); GetFront(Q, x); printf(队头元素: %d\n, x); DeQueue(Q, x); printf(出队元素: %d\n, x); DeQueue(Q, x); printf(出队元素: %d\n, x); PrintQueue(Q); EnQueue(Q, 40); PrintQueue(Q); if (DeQueue(Q, x)) { printf(出队元素: %d\n, x); } if (DeQueue(Q, x)) { printf(出队元素: %d\n, x); } if (DeQueue(Q, x)) { printf(出队元素: %d\n, x); } else { printf(队列为空出队失败\n); } return 0; }4.3 运行结果说明这段代码编译运行后输出应该是初始化完成队列为空: 是 队列内容: 10 20 30 队列长度: 3 队头元素: 10 出队元素: 10 出队元素: 20 队列内容: 30 队列内容: 30 40 出队元素: 30 出队元素: 40 队列为空出队失败main 函数里的操作序列覆盖了普通情况、队列被清空后继续出队、空队列失败处理等多个场景。读者自己动手跑一遍把输出和代码逻辑一一对照比光看文章理解要扎实得多。5. 链式队列的坑和调试经验5.1 常见错误速查表下面的表格是我自己在帮同学调试和实际写代码过程中遇到的典型问题每种问题对应一套排查思路。症状出错原因排查思路程序打印出随机大数字新节点 next 未置 NULL给所有新节点初始化 next入队后打印没数据忘记把新节点链到 rear-next检查挂载顺序出队后打印失败删的是 front 而不是 front-next画图确认头结点关系出队后 rear 变成野指针队列只有一个元素时没处理 rear判断 Q-rear p反复跑操作后内存不断上涨出队时没有 free 节点检查 free 是否遗漏程序直接崩溃对空队列执行出队或取队头操作前增加 IsEmpty 判断5.2 边界条件测试链式队列的边界条件集中在三个点空队列、只有一个元素的队列、反复入队出队之后的状态。我写代码的习惯是每个函数完成后先跑一遍边界用例再跑常规用例。测试的时候重点盯着这几个场景空队列出队应该返回失败而不是崩溃。这一步验证了判空逻辑是否靠谱。队列只有一个元素时出队front-next 会变成 NULLrear 必须被重置回 front。如果这一步没有做对接下来任何入队操作都会改写野指针地址的数据程序表现千奇百怪有时候当场就崩有时候跑几十轮才崩最难排查。还有一种容易被忽视的情况入队再出队循环很多次。这会反复调用 malloc 和 free如果某个分支没有正确释放节点内存泄漏就会累积。可以用 valgrind 或者 AddressSanitizer 跑一遍工具会直接告诉你哪一行分配的内存没有被释放。5.3 调试技巧把指针可视化链式队列的 bug 之所以难找是因为问题出在指针之间的连接关系上而代码看起来又都合情合理。我的做法是写一个专门打印指针状态的调试函数。void DebugQueue(LinkQueue *Q) { printf(front%p rear%p\n, (void *)Q-front, (void *)Q-rear); QNode *p Q-front; while (p ! NULL) { printf([%p(%d)] - , (void *)p, p-data); p p-next; } printf(NULL\n); }这个函数把每个节点的地址、数据、next 指向都打出来。入队一次打一次出队一次打一次。指针的关系一眼就能看出来哪个节点是野指针哪个 next 没接上都藏不住。另外一个亲测有效的经验画图。说得俗一点链表题就是画图题。拿一张纸把头结点、队头元素、队尾元素分别画成方框每个方框里写清楚 next 指向谁然后模拟一遍入队出队代码就是照着图翻译。几乎所有链表的 bug画一遍图就能定位。6. 链式队列的应用场景和复杂度分析6.1 它到底快在哪复杂度分析链式队列的核心操作复杂度如下操作时间复杂度说明初始化O(1)只建一个头结点判空O(1)比较两个指针入队O(1)尾部挂节点出队O(1)头部摘节点取队头O(1)访问 front-next统计长度O(n)需要遍历入队和出队都是 O(1)这是链式队列最重要的性能特征。相比顺序队列普通版本可能面临扩容和数据搬移链式队列在这两种操作上都避开了最坏情况。空间上链式队列每个节点多了一个指针字段这是它的额外开销。数组队列如果提前知道元素总数可以做到零额外空间。但链式队列的优势在于空间按需分配数据量波动大的场景下不会出现大量浪费。这引出一个选型建议如果队列的大小基本可控、要求极致的缓存友好和高吞吐用基于数组的循环队列更合适如果数据量动态变化、节点类型复杂、需要频繁插入删除链式队列更从容。没有银弹只有合适不合适。6.2 BFS、缓冲区、任务队列链式队列在真实系统里到处都是。图的广度优先遍历需要队列记录待访问节点候补队列里的节点数量完全不可预测链式队列天然适配。二叉树的层次遍历也是一样的逻辑处理完一层的节点把它们的子节点全部入队这个过程中队列的长度动态变化数组版反而难做。操作系统里的许多任务队列也是链式队列的变体。进程调度需要一个就绪队列新任务到达就入队CPU 空闲时就取一个任务执行。任务数量随时在变每个任务的数据量也不一样链表实现更灵活。消息队列、打印任务队列、生产者消费者模型里的缓冲区本质上都是同一套先进先出的逻辑。学习链式队列的意义不只是会写一个数据结构而是理解这些系统背后的最小通用模型。你把链表队列的代码写明白了再去看操作系统源码里的相关结构会发现它们都是这个基本模型加上各种工程化改造。6.3 后续可以怎么扩展链式队列本身已经可以独立使用但想进一步提升还可以做几件事。第一把数据域改成 void *队列就能存任意类型。这需要调用方在使用时自己做类型转换接口上做得好的话通用性会很强。第二增加节点内存池。每次 malloc 和 free 的系统调用是有开销的如果队列的入队出队频率极高可以先预分配一批节点放入空闲链表入队时从空闲链表取节点出队时把节点还回去。这种方式能显著减少系统调用次数在实时系统和高频消息处理中很常见。第三加入线程安全。多线程环境下入队和出队会有竞争关系给队列加一个互斥锁或者使用无锁队列的 CAS 操作让队列变成线程安全版本。这部分已经涉及到并发编程的领域但基础仍然是链式队列的指针操作。最后的个人体会我在带学弟学妹写链式队列的时候见过最多的卡点不是代码语法而是没想清楚 front 和 rear 在每一时刻到底指向哪里。很多人试图背代码但数据结构这个方向背代码是最亏的学习方式。把指针的关系画在纸上每一个操作走一遍等到图像在脑子里能动了代码自然就写出来了。链式队列写完再去对比数组实现的循环队列你会发现它们解决的是同一个问题的不同侧面。数组版省内存、快、缓存友好但容量固定链表版灵活、逻辑统一但每次操作都有动态分配的开销。以后在项目里遇到队列需求先问自己一句话数据量是相对稳定的还是波动很大的答案基本就能帮你选定方案。最后再分享一个小技巧调试链表相关代码不要只盯着代码本身。把节点的地址、data 值、next 指向全部打印出来每一步操作后都对比一次大部分的指针问题都能在十分钟内定位。我用这个方法排查了无数个链表 bug希望你也能少踩一些坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价