ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C语言数据结构队列详解:从循环队列到链式队列与工程应用

C语言数据结构队列详解:从循环队列到链式队列与工程应用 如果你写过任何一点带状态的服务端程序迟早会碰到这样一个尴尬场景上游一口气甩过来几百个请求下游处理接口每秒只能消化几十个你要是直接同步调用服务瞬间被拖垮数据全堆在内存里无处安放。这时候大部分人第一个想到的解法就是往中间塞一个队列。队列这个数据结构看起来不过就是“排队进出”四个字可真到了C语言里手写一遍你会发现边界条件、内存布局、扩容策略到处都是细节。这篇内容专门围绕“C语言数据结构——队列”展开我会用最直白的语言把顺序队列、循环队列、链式队列的原理和代码讲透再聊聊队列在消息中间件、线程池、单调队列优化这些高级话题里的位置。适合正在学数据结构的在校生、准备面试的求职者以及写底层服务的工程师。不管你现在停留在“会背定义”还是“能默写代码”的阶段读完这篇文章你至少能把队列真正用起来。1. 队列到底是什么先统一概念再动手写1.1 先进先出一个极其朴素却极其重要的约束队列的英文叫Queue它的核心约束只有一句话先进入的数据先被处理后进入的数据排在后面。这个“先进先出”FIFO听起来像是食堂排队打饭但你真把它当成一条铁律去设计代码时很多“看起来合理”的错误写法就会被自动排除。我见过不少人写队列时脑子里的第一反应是数组加一个遍历指针数据进来就往后放数据出去就把后面的数据全部往前搬。这种写法不是不能工作问题是每出队一次就要搬动O(n)个元素数据量一上来直接变成性能灾难。队列的精髓在于出队操作应该只移动头指针入队操作只移动尾指针两个动作都该是O(1)。任何多余的数据搬运都是在给系统添堵。1.2 队头、队尾、入队、出队四个基本操作搞定所有场景队列对外暴露的操作其实很少。标准库或者自己封装时通常只需要四类入队EnQueue把元素放到队尾。出队DeQueue把队头元素取走。查看队头Front / Peek只读不删看看下一个要处理的是什么。判空IsEmpty队列还有没有数据。再加一个可选操作判满或者获取当前长度方便在循环队列和缓冲区场景里做状态判断。为什么接口要这么克制因为队列在系统里大多数时候扮演的是“缓冲”角色它不负责排序、不负责随机访问、不负责按某个key查找。如果某个需求要求从队列中间删除元素那大概率说明你选错了数据结构该用双向链表或者树形结构。保持接口最小化是队列能稳定运行几十年的重要原因。1.3 为什么这个结构几十年都没被淘汰你可能会想数据结构那么多链表、哈希、树、图哪个不比队列“高级”为什么要单独拿出来写一篇因为队列解决的是系统中最常见的一类问题生产速度和消费速度不匹配。打印机任务排队、网络数据包缓冲、操作系统的就绪队列、消息中间件的topic分区全是队列在幕后排队。从1970年代的操作系统调度到今天的大数据流处理框架队列这个抽象一直没有变过。不是因为它简单才被一直用而是因为“先来先服务”在大多数业务场景里是最符合人性、最不容易出错、最好推导的规则。理解了队列再看阻塞队列、消息队列、单调队列这些变种你会突然觉得整个知识体系都串起来了。2. 用数组实现队列从朴素版本到循环队列2.1 朴素数组队列写起来简单但藏着致命缺陷最直觉的实现就是开一块固定大小的数组再维护一个头指针front和一个尾指针tail。入队时把数据写到tail位置tail自增出队时取front位置的数据front自增。#include stdio.h #include stdlib.h #include stdbool.h #define MAX_SIZE 10 typedef struct { int data[MAX_SIZE]; int front; int tail; } SeqQueue; void initQueue(SeqQueue *q) { q-front 0; q-tail 0; } bool enQueue(SeqQueue *q, int val) { if (q-tail MAX_SIZE) { return false; // 队满 } q-data[q-tail] val; return true; } bool deQueue(SeqQueue *q, int *val) { if (q-front q-tail) { return false; // 队空 } *val q-data[q-front]; return true; } bool isEmpty(SeqQueue *q) { return q-front q-tail; } bool isFull(SeqQueue *q) { return q-tail MAX_SIZE; }这段代码有几个很明显的问题。第一个是出队之后front会一直后移前面被移走的内存空间就永远空在那里队列整体向后“漂移”当tail到达数组末尾时即使前面还有大片空闲位置新元素也进不来。这就是教科书里说的“假溢出”。第二个问题是如果你把isFull和isEmpty的实现稍微改一改比如用tail - front的大小来判断一出队就搬数据时间复杂度立刻变成O(n)。2.2 循环队列绕一圈把浪费的空间捡回来解决假溢出的标准做法是把数组当成一个环tail到达末尾后如果前面还有空位就绕回到下标0继续存放。判断下标绕回只需要一行取模运算tail (tail 1) % MAX_SIZE;放在循环队列里入队和出队的索引更新都改成这种写法。这样数组前面被释放的空间就能被重新利用不会再出现“明明空着一大半却装不进新元素”的尴尬。循环队列的代码我直接给你这种写法经过多次实测最不容易出边界问题#include stdio.h #include stdlib.h #include stdbool.h #define MAX_SIZE 10 typedef struct { int data[MAX_SIZE]; int front; // 指向队头元素 int tail; // 指向队尾的下一个空位 } CircularQueue; void initQueue(CircularQueue *q) { q-front 0; q-tail 0; } bool isFull(CircularQueue *q) { return (q-tail 1) % MAX_SIZE q-front; } bool isEmpty(CircularQueue *q) { return q-front q-tail; } bool enQueue(CircularQueue *q, int val) { if (isFull(q)) { return false; } q-data[q-tail] val; q-tail (q-tail 1) % MAX_SIZE; return true; } bool deQueue(CircularQueue *q, int *val) { if (isEmpty(q)) { return false; } *val q-data[q-front]; q-front (q-front 1) % MAX_SIZE; return true; } int queueLength(CircularQueue *q) { return (q-tail - q-front MAX_SIZE) % MAX_SIZE; }2.3 队空队满判定的经典套路牺牲一个存储单元注意上面代码里isFull的判断用的是tail的下一个位置是否碰上front也就是把数组最后一个空位空出来不用。数组明明有MAX_SIZE个格子实际能装的数据量是MAX_SIZE - 1。这个“浪费一个格子”的设计是有道理的如果不浪费队空和队满时front和tail都相等程序就分不清到底队列是空的还是满的。当然也有别的方案比如额外加一个元素计数器size入队加一出队减一那队空队满就能严格区分也不需要牺牲空间。这个方案我自己在工程里也用过代码多写两行但语义很清晰。读者可以按自己的喜好选择不过在面试和考试场景下“牺牲一个存储单元”是流传最广的标答建议先把这个背熟再谈优化。队列长度怎么算上面queueLength里的加MAX_SIZE再取模就是为了处理tail已经绕回front前面的情况。这个公式你也可以记成队列元素个数 (队尾 - 队头 容量) % 容量2.4 扩容方案队列也会“满”满了怎么办循环队列满的时候最简单的回答是“入队失败”但在真实系统里队列满就意味着生产者被阻塞或者数据被丢弃业务上不可接受。所以实际工程里循环队列往往配合动态扩容。扩容的思路是先申请一块更大的新数组然后把老队列里的元素按逻辑顺序复制过去。注意不是按下标顺序复制而是从front开始依次复制到新数组下标0、1、2……复制完再重置front为0tail为元素个数。// 假设结构体里有一个 capacity 字段记录当前容量 bool enQueueDynamic(CircularQueue *q, int val) { if (isFull(q)) { int newSize q-capacity * 2; int *newData (int *)malloc(sizeof(int) * newSize); if (newData NULL) { return false; } int count queueLength(q); for (int i 0; i count; i) { newData[i] q-data[(q-front i) % q-capacity]; } free(q-data); q-data newData; q-capacity newSize; q-front 0; q-tail count; } q-data[q-tail] val; q-tail (q-tail 1) % q-capacity; return true; }一个知识点必须说明扩容复制时如果你直接memcpy整个数组顺序就乱了因为逻辑队头可能不在下标0。这也是数组实现队列时最容易忽略的细节。3. 链式队列把内存管理的主动权握在手里3.1 带头结点还是不带我的选择数组队列的容量是固定的哪怕写了扩容函数也免不了要搬运数据。链式队列没有这个问题节点是一个个动态分配的需要多少就分配多少天然支持无限增长只要内存够用。链式队列的节点定义长这样typedef struct QNode { int data; struct QNode *next; } QNode; typedef struct { QNode *front; // 队头指针 QNode *tail; // 队尾指针 } LinkedQueue;很多教程在链队里加了一个头结点dummy head好处是空队列和非空队列的插入删除逻辑可以统一。我个人的习惯是不带头结点因为队列的出队操作本来就只针对队头头结点的存在反而让指针操作多绕一层。只要在初始化时把front和tail都置为NULL每次操作前判空代码照样很清晰。3.2 入队出队细节尾指针滞后的经典翻车点链式队列入队新节点要接到tail后面然后tail指向新节点出队则把front指向的节点摘下来如果出队后队列为空tail也要更新为NULL。这最后一个细节很多人会漏。void initLinkedQueue(LinkedQueue *q) { q-front NULL; q-tail NULL; } bool enLinkedQueue(LinkedQueue *q, int val) { QNode *node (QNode *)malloc(sizeof(QNode)); if (node NULL) { return false; } node-data val; node-next NULL; if (q-tail NULL) { q-front node; q-tail node; } else { q-tail-next node; q-tail node; } return true; } bool deLinkedQueue(LinkedQueue *q, int *val) { if (q-front NULL) { return false; } QNode *tmp q-front; *val tmp-data; q-front tmp-next; if (q-front NULL) { q-tail NULL; } free(tmp); return true; }注意入队的老写法是在q-tail-next上直接赋值如果队列为空q-tail就是NULL直接访问NULL的next就会崩溃。所以必须先判空把第一个节点同时交给front和tail。3.3 链式队列的销毁别让 free 成为空操作链表最麻烦的是销毁。很多人写了Init和EnQueue忘了Dispose结果程序跑到最后队列里的节点全部泄漏。更隐蔽的问题是销毁函数写错比如只free了front一个节点就返回剩下的一整条链全泄漏。正确的销毁思路是循环出队直到空void destroyLinkedQueue(LinkedQueue *q) { QNode *p q-front; while (p ! NULL) { QNode *next p-next; free(p); p next; } q-front NULL; q-tail NULL; }一定要先保存p-next再free因为free之后这个节点的next再拿就属于未定义行为。这种“先备份、再释放、后转移”的套路在链表相关的所有代码里都通用。3.4 顺序队列 vs 链式队列什么时候该选谁我的选择标准很简单维度顺序队列循环队列链式队列容量固定需额外的扩容逻辑动态随用随分配性能入队出队只操作数组下标CPU缓存友好每次操作涉及malloc/free开销更高内存碎片无高频率分配释放容易产生碎片适用场景数据量可预估、要求低延迟数据量波动大、无法预估上限高并发场景下顺序队列配合无锁技巧往往比链式队列稳定得多。链式队列的malloc/free在低配机器上可能比数据库查询还慢。反过来如果业务队列的积压量忽大忽小链式队列的动态性就是救命稻草。没有绝对的好坏只有合不合适。4. 队列在真实系统里的那些身影4.1 生产者消费者模型缓冲区背后的秩序维护者队列最常见的应用就是生产者消费者模型。举一个实际业务采集服务往数据库写日志数据库写入速度跟不上采集速度直接同步调用会让采集线程阻塞在磁盘IO上采集程序越跑越慢。这时候在中间加一个内存队列采集线程只管往队列里塞真正落库的工作交给专门的后台线程从队列里取生产速度和消费速度就被解耦了。如果生产速度长期大于消费速度队列会积压这时候就需要再想后退策略丢弃旧数据、暂停生产、或者把队列改造成阻塞队列让生产者等待。C语言里实现一个有界阻塞队列需要配合互斥锁和条件变量这个思路和Java的ArrayBlockingQueue、C的std::condition_variable本质上是一码事。4.2 从数据结构到消息中间件Kafka、RabbitMQ、RocketMQ 的分层认知很多初学者看到“消息队列”这个词会疑惑消息队列和数据结构里的队列是一回事吗答案是底层都是队列但消息中间件在队列之上叠加了分布式、持久化、副本、消费组这些能力。拿实际做技术选型时最常见的三个组件说它们各有各的定位Kafka吞吐量极高日志型设计消息写入追加到分区文件适合离线的数据管道、日志收集、大数据场景。但因为不删消息也不做复杂的路由它不太擅长延时消息和死信重试。RabbitMQ基于Erlang路由机制非常灵活支持交换机绑定、优先级队列、死信队列在业务系统里做可靠投递很顺手性能和Kafka比不了极端高吞吐。RocketMQ阿里的中间件事务消息和延迟消息实现得比较自然Java生态友好团队本身熟悉Java的话上手成本低。选型的时候我的建议是先看业务是要高吞吐的流式数据管道还是要求强可靠、灵活路由的轻量消息调度。没有“哪个最好”只有“哪个和你的场景最匹配”。另外在消息队列的使用中被开发者提得最多的就是重复消费问题这个几乎无法完全杜绝标准做法是消费者侧做幂等而不是指望中间件帮你过滤。持久化、副本、确认机制这些概念一层层剥开以后底层还是经典队列那套“入队、出队、队列满、队列空”的抽象。所以把C语言的队列写明白再去理解中间件的源码你会感觉很多结构似曾相识。4.3 线程池任务队列阻塞队列为什么在这里是刚需线程池的内部一般有一个任务队列工作线程不断从队列里取任务。如果队列是普通的非阻塞队列取任务的线程一旦发现队列空就会疯狂轮询空转CPU白烧。于是阻塞队列应运而生队列空时消费者线程自动睡眠生产者入队时把它唤醒队列满时生产者线程等待消费者出队后再唤醒。在C语言里可以用pthread_cond_wait和pthread_cond_signal实现。Java的Executors默认使用的LinkedBlockingQueue也是这个设计。面试时被问到“线程池的核心参数为什么有阻塞队列”本质就是在考察你能不能理解“生产速度不平衡时需要一种让线程挂起的机制”。4.4 单调队列优化滑动窗口最大值的O(n)解法这是队列进阶玩法里含金量比较高的一个。题目背景是给你一个数组和一个窗口宽度k要求输出窗口每次滑动时的最大值。暴力做法是每次重新扫窗口O(nk)。单调队列的思路是维护一个从队头到队尾单调递减的序列队头永远是当前窗口最大值。窗口滑动时先把旧元素按索引从队头移除再把新元素从队尾加入加入前把所有比它小的队尾元素都弹出。#define MAX_N 100000 int data[MAX_N]; int monoQueue[MAX_N]; // 存的是下标 int head 0, tail 0; for (int i 0; i n; i) { // 移除窗口外的队头 while (head tail monoQueue[head] i - k) head; // 从队尾弹出所有不大于当前值的元素 while (head tail data[monoQueue[tail - 1]] data[i]) tail--; monoQueue[tail] i; if (i k - 1) { printf(%d , data[monoQueue[head]]); } }这个代码的实质就是队列只是队列的优先级规则变了从“先进先出”改成了“越大的越靠近队头”。单调队列能优化的DP问题还有不少最长递增子序列、多重背包的经典优化都可以用它属于队列在算法竞赛里最能体现价值的分支。4.5 双端队列当“先进先出”不再是唯一规则双端队列Deque允许从两头入队、两头出队灵活性更大。C语言标准库里虽然没有直接内置双端队列但你可以基于数组实现一个头尾都用指针维护操作全是O(1)。双端队列最常见的应用场景是在滑动窗口里同时维护最大和最小值也用于回文匹配、撤销重做系统这类“两头都要动”的逻辑。了解到它的存在就足够了核心思路和普通队列完全一致只是把“只能从尾部入、头部出”的限制放宽了。4.6 优先队列换个出队规则核心还是队列优先队列严格来说已经不是普通队列了它出队时取的是优先级最高的元素而不是最早进来的元素。C语言里实现优先队列最常用的底层结构是二叉堆入队O(log n)出队O(log n)性能上完全可以接受。为什么在这里要提它因为很多实际场景既不是单纯的先进先出也不是纯粹的按优先级调度而是两者结合。操作系统里的进程调度、网络请求的限流熔断、Uber式派单系统经常需要“先处理紧急的再处理早来的”。优先队列就是这些场景的基础设施。理解了队列的抽象再往优先队列走一步你会更容易把握住数据结构设计的本质。5. 新手最容易踩的坑问题排查与调试实录5.1 队空队满边界九个测试用例帮你堵住漏洞队列的错误有七八成出在边界上。我调试循环队列时会按下面这组用例逐个过空队列出队应该返回false空队列查看队头应该返回false入队一个后再出队队列重新为空tail、front都要复位连续入队到满最后一次入队应该失败满了再出队一个再入队一个能成功证明循环生效出队到空再入队front和tail的关系要正确队列长度计算随机入队n个再出队m个检查queueLength扩容后重新入队到满新容量边界没问题链式队列出队到空tail必须置NULL别嫌麻烦队列这种结构写出来简单想一次写对全靠这些边界用例兜底。5.2 取模运算的优先级括号写错下标直接飞出去C语言里%的优先级和乘除相同高于加减。也就是说(q-tail 1) % MAX_SIZE如果不加括号会被解析成q-tail (1 % MAX_SIZE)结果等于q-tail加1算出来碰巧是对的但如果是(q-tail - q-front MAX_SIZE) % MAX_SIZE这种表达式忘记加括号就会出现负数或者错误结果直接越界。我的建议是所有涉及取模的索引计算一律把运算整体用括号包起来不要依赖优先级。这类问题编译期不会报错运行时可能也不是每次都错是最典型的“玄学bug”来源。5.3 链式队列入队忘记更新尾指针链式队列翻车率最高的地方是新节点接到尾部之后忘记让tail跟上。现象是第一次入队正常第二次入队后tail还停在第一个节点上后续入队数据其实丢在了链表上但队列的tail怎么都追不上最新节点出队时又只能从头取数据就乱套了。调试方法很简单每次入队后打印q-tail-data如果一直不变检查你的tail更新语句是不是被写在某个分支外面没执行到。同理出队到空的时候front和tail都应为NULL如果没有同步清空tail下一次入队会走q-tail-next node这条分支访问NULL直接崩溃。5.4 内存泄漏Destroy 函数写了吗链式队列用完不释放短时间看不出来跑在长驻服务进程里内存会像漏水的船一样越沉越快。写队列的代码时必须给自己立一条规矩每个Init必须对应一个DestroyDestroy做完整遍历释放并且把front、tail、data指针都置NULL防止野指针残留。对于顺序队列如果data是malloc的销毁时一定要free(data)然后置NULL。C语言没有GC这个责任只能靠手工承担。5.5 调试套路画图 打印每一步状态最后分享一个我一直在用的调试思路队列出问题先别急着改代码拿纸把数组或者链表画出来把front和tail标在具体位置上模拟几轮入队出队很多逻辑错误会在画图过程中自己暴露。如果是在终端环境打一个dump函数把队列当前所有元素和head、tail的值一起打印调试效率能提升一大截。等队列调顺了再把这个dump函数删掉别留着影响线上日志整洁。我自己写队列的经历是前后翻了三次车才把循环队列的边界彻底吃透。第一次是队满判断抄错第二次是扩容忘记调整容量字段第三次是链队出队到空tail没有置NULL。每一次错都对应一个真实的运行崩溃现场。数据结构这个东西看书觉得都会只有亲手写完、跑完、错完才算是真的会。建议你也找几道经典题用今天这套代码打底自己跑一遍跑通了队列这一关才算真正过了。
返回列表