
数据结构系列写到第十篇终于轮到队列里最绕的一个知识点环形队列。有同学会觉得队列不就是先进先出吗数组模拟一个就行了为什么要搞“环”这个问题其实问到了点子上。我第一次用数组写队列的时候也这么想结果写了个入队五次、出队四次、然后想再入队却被告知“队列满了”的程序——明明数组前面空着一大片。这篇就把这个坑彻底讲透环形队列的原理、判断条件、C语言实现、还有那些考试和面试里反复出现的边界细节。不管你是考研复习数据结构还是学校课设要用C语言写队列或者只是自己写代码时想搞明白阻塞队列的内部结构这篇都值得认真读完。1. 普通顺序队列的“假溢出”环形队列为什么会出现1.1 数组队列的基本形态先把最普通的顺序队列拿出来。用数组存储数据用两个整形变量做指针front指向队头元素的下标rear指向队尾元素的下一个位置。初始化时front rear 0入队时把数据写到data[rear]然后rear出队时读取data[front]然后front。这是很多教材上的标准姿势实现起来很简单二十行代码就能跑通。但这种写法有一个隐藏问题光看代码很难发现。假设数组容量MAX_SIZE 5你连续入队 A、B、C、D、E 五个元素入队到第五个时rear已经变成5越过了数组的有效下标4需要扩容或者报错。这时候如果连续出队三次A、B、C被取走front从0变成3数组下标0、1、2的三个位置空了出来。队列里还剩D和E但rear仍然等于5。此时你尝试再入队一个F判断逻辑发现rear MAX_SIZE条件不满足直接返回“队列已满”。可是明明白白看到下标0到2都空着难道不能再放三个元素吗这就是顺序队列最被人诟病的地方空间明明还有却因为指针只能往后走导致数组前部的位置永远无法复用。教科书把这种现象叫作“假溢出”。1.2 用手算走一遍“能出不能进”的过程下面用一组具体数字演示建议你在纸上跟着画一遍。数组长度5初始状态front 0rear 0队列空入队Adata[0] Arear 1入队Bdata[1] Brear 2入队Cdata[2] Crear 3入队Ddata[3] Drear 4入队Edata[4] Erear 5现在出队三次出队A读取data[0]front 1出队B读取data[1]front 2出队C读取data[2]front 3此时front 3rear 5队列里还有D和E。你尝试入队F代码检查rear是否等于5是于是返回失败。但数组下标0、1、2明明都是空的F、G、H放进去完全没问题。这就是假溢出的完整过程。细想一下问题根本不是数组容量不够而是rear指针只能单向递增到末尾以后就无路可走了。1.3 假溢出的本质让数组首尾“接起来”假溢出的本质是线性数组的物理结构与队列指针需要循环使用之间产生了矛盾。数组本身是线性的下标0到4排成一条线但队列的逻辑要求是出队留下的空间要能被后来的元素使用。如果只让指针往后走不回到数组头部空间浪费就在所难免。解决办法也很直观把数组想象成一个首尾相接的环。下标4的下一个位置不是越界而是回到下标0。这样rear走到数组末尾后继续往前走就能复用之前出队留下的空间。实现这一步只需要在指针移动时加一个取模运算rear (rear 1) % MAX_SIZE; front (front 1) % MAX_SIZE;这个取模操作就是环形队列的核心所在。它把线性数组强行掰成了逻辑上的环让每一个位置都可以被反复使用。别小看这一步它同时引出了一个新的难题当数组被放入环以后队空和队满的判断不再像普通队列那么直观了因为front和rear的相等状态既可能是空队列也可能是满队列。2. 环形队列的判空判满原理三种主流方案对比2.1 头尾指针如何绕圈取模运算的细节先明确一下环形队列的指针约定。我用的是最常见的一种front指向队头元素的存储位置rear指向队尾元素的下一个位置。入队时先执行data[rear] x再让rear (rear 1) % MAX_SIZE出队时先取出data[front]再让front (front 1) % MAX_SIZE。这里有个容易模糊的点为什么入队前不需要先移动rear因为初始化时rear指向的是“下一个空位”而不是队尾元素本身。这个约定直接影响后续判断条件的写法如果你看的教材用的是另一种约定比如rear指向队尾元素本身判断条件会略有变化但核心思想完全一样。举个例子容量为5的环形队列写入三个元素后front 0、rear 3。如果继续写入元素rear会依次变成4、0、1……当rear从4变成0时等于完成了一次“绕环”。很多初学者在这里卡住rear怎么还能变小因为在环里下标大小已经没有绝对意义了它只是一个模5的余数。2.2 方案一牺牲一个存储单元最常用队空和队满都可能导致front rear所以必须想个办法区分。最简单实用的办法是让队列最多存储MAX_SIZE - 1个元素故意空出一个位置。这样当front rear时一定是队空而当(rear 1) % MAX_SIZE front时一定是队满。判断条件队空front rear队满(rear 1) % MAX_SIZE front用一个具体状态来理解容量5的队列当front 0、rear 4时队列里有data[0]、data[1]、data[2]、data[3]四个元素data[4]被空出来。为什么非要空这一个因为如果data[4]也放上元素rear就会变成0此时front 0、rear 0看起来像空队列逻辑就彻底乱了。空出一个位置相当于用“一个格子的代价”换取了判断条件的简洁性。这个方案在考研数据结构题里出现频率极高代码也是三种方案里最简练的。缺点就是容量打了折扣明明数组能放5个元素实际只能放4个。2.3 方案二size计数器如果不愿意牺牲一个存储空间可以用一个额外的计数器size来记录当前队列中的元素个数。入队成功时size出队成功时size--。这样判断起来非常直观队空size 0队满size MAX_SIZE这种方案的优点是容量能用到百分之百不需要空位代码逻辑也好懂。缺点是每次入队出队都要维护size变量多了一个额外开销。而且从判断思路上它更像是用“记录”代替“推导”没有方案一那种“环上位置关系”的优雅感。实际工程项目里如果队列容量不大、且没有强烈要求省空间用size计数器很常见。2.4 方案三tag标志位另一个区分队空队满的方法是加一个标志位tag。思路是这样每次入队成功后把tag置为1表示“最近一次操作是入队”每次出队成功后把tag置为0表示“最近一次操作是出队”。那么在front rear时根据tag的值就能判断当前状态front rear tag 0队空front rear tag 1队满这个方案同样不需要牺牲存储空间容量利用率是满的。它利用的是“上一次操作”这个历史信息来消除二义性。比较起来方案三的判断逻辑比方案二稍微绕一些但某些场景下更符合实际操作的语义你刚执行完入队后指针就重合了说明队列满了刚执行完出队后指针重合了说明队列空了。2.5 三种方案对比与队列长度计算把三种方案放在一起对比会更清楚它们的适用场景方案判空条件判满条件额外开销适用场景牺牲一个存储单元front rear(rear 1) % MAX_SIZE front无教科书、考研题、简单实现size计数器size 0size MAX_SIZE一个int变量工程代码、容量要求高的场景tag标志位front rear tag 0front rear tag 1一个int变量希望满容量使用的场景不管用哪种方案队列中的元素个数公式是统一的length (rear - front MAX_SIZE) % MAX_SIZE;这个公式要特别注意很多人会写成rear - front但当rear已经绕到front前面时这个差值会是负数。加上MAX_SIZE再取模才能保证结果落在0到MAX_SIZE - 1之间。举个数front 3、rear 2、MAX_SIZE 5此时队列中有四个元素如果用rear - front得到-1明显不对用公式(2 - 3 5) % 5 4正确。3. C语言实现环形队列完整可运行的代码与验证过程3.1 结构体定义与初始化先定义环形队列的结构体。为了让代码有通用性我用ElemType作为元素类型别名方便以后改成其他数据类型。#include stdio.h #define MAX_SIZE 5 typedef int ElemType; typedef struct { ElemType data[MAX_SIZE]; int front; int rear; } CircularQueue;初始化时把front和rear都设为0。从空队列开始此时front和rear指向同一个位置符合“front rear为空队”的判断条件。void initQueue(CircularQueue *q) { q-front 0; q-rear 0; }这段代码看起来简单但有两处细节值得提。第一结构体里的data数组大小是固定的这意味着队列容量在编译期就确定了无法动态扩容。第二我选择用指针参数传结构体而不是直接传值是为了避免整个结构体在函数调用时被复制一份浪费时间和内存。3.2 入队、出队与辅助函数入队函数先判断队列是否已满满了就返回0表示失败没满则把元素写到rear指向的位置然后让rear后移。int enQueue(CircularQueue *q, ElemType x) { if ((q-rear 1) % MAX_SIZE q-front) { return 0; } q-data[q-rear] x; q-rear (q-rear 1) % MAX_SIZE; return 1; }出队函数先判断队列是否为空空了返回0否则把front指向的元素赋给*x然后让front后移。int deQueue(CircularQueue *q, ElemType *x) { if (q-front q-rear) { return 0; } *x q-data[q-front]; q-front (q-front 1) % MAX_SIZE; return 1; }再写两个辅助函数获取队头元素和获取队列长度。注意获取队头时只读取不移动指针获取长度时直接用之前说的长度公式。int getHead(CircularQueue *q, ElemType *x) { if (q-front q-rear) { return 0; } *x q-data[q-front]; return 1; } int getLength(CircularQueue *q) { return (q-rear - q-front MAX_SIZE) % MAX_SIZE; }3.3 完整测试代码与运行结果下面写一个完整的测试程序把入队、出队、遍历、验证“假溢出空间被复用”这几个关键场景全跑一遍。#include stdio.h #define MAX_SIZE 5 typedef int ElemType; typedef struct { ElemType data[MAX_SIZE]; int front; int rear; } CircularQueue; void initQueue(CircularQueue *q) { q-front 0; q-rear 0; } int isEmpty(CircularQueue *q) { return q-front q-rear; } int isFull(CircularQueue *q) { return (q-rear 1) % MAX_SIZE q-front; } int enQueue(CircularQueue *q, ElemType x) { if (isFull(q)) { return 0; } q-data[q-rear] x; q-rear (q-rear 1) % MAX_SIZE; return 1; } int deQueue(CircularQueue *q, ElemType *x) { if (isEmpty(q)) { return 0; } *x q-data[q-front]; q-front (q-front 1) % MAX_SIZE; return 1; } int getHead(CircularQueue *q, ElemType *x) { if (isEmpty(q)) { return 0; } *x q-data[q-front]; return 1; } int getLength(CircularQueue *q) { return (q-rear - q-front MAX_SIZE) % MAX_SIZE; } void printQueue(CircularQueue *q) { int i q-front; while (i ! q-rear) { printf(%d , q-data[i]); i (i 1) % MAX_SIZE; } printf(\n); } int main() { CircularQueue q; initQueue(q); ElemType x; printf(入队: 1 2 3 4\n); enQueue(q, 1); enQueue(q, 2); enQueue(q, 3); enQueue(q, 4); printf(当前队列长度: %d\n, getLength(q)); printf(队列内容: ); printQueue(q); printf(尝试入队: 5\n); if (enQueue(q, 5)) { printf(入队成功\n); } else { printf(入队失败队已满\n); } printf(出队两个元素\n); deQueue(q, x); printf(出队: %d\n, x); deQueue(q, x); printf(出队: %d\n, x); printf(当前队列长度: %d\n, getLength(q)); printf(队列内容: ); printQueue(q); printf(正在入队: 5 6\n); enQueue(q, 5); enQueue(q, 6); printf(当前队列长度: %d\n, getLength(q)); printf(队列内容: ); printQueue(q); printf(front %d, rear %d\n, q.front, q.rear); return 0; }运行结果如下入队: 1 2 3 4 当前队列长度: 4 队列内容: 1 2 3 4 尝试入队: 5 入队失败队已满 出队两个元素 出队: 1 出队: 2 当前队列长度: 2 队列内容: 3 4 正在入队: 5 6 当前队列长度: 4 队列内容: 3 4 5 6 front 2, rear 1看懂这个运行结果你就悟到了环形队列的核心价值1和2出队后下标0和1空了出来5和6没有因为rear已经走到末尾而“拒绝入队”而是通过取模运算重新写回了数组前部的位置。3 4 5 6四个元素的物理存储位置是data[2]、data[3]、data[4]、data[0]形成了一个逻辑上的环。3.4 容量验证为什么牺牲的那一格如此重要注意测试程序里MAX_SIZE为5但最多存储的是4个元素。当队列中已经有4个元素时第5个入队会失败。这是因为判断队满的条件是(rear 1) % MAX_SIZE front也就是rear的下一个位置与front重合。用状态推演一下入队1、2、3、4后front 0、rear 4。此时(4 1) % 5 0与front相等判断为满。如果强行再入队5data[4] 5rear变成0此时front 0、rear 0从判空条件来看队列已经是“空”了。这显然是一个逻辑灾难。所以牺牲一个存储单元不是可选项而是使用方案一时的必要条件。提示如果你用的是size计数器或tag标志位方案测试代码里的isFull和enQueue判断条件要做相应修改否则队满判断错误会导致元素覆盖或状态异常。4. 边界条件与高频踩坑点4.1 三个最容易翻车的场景第一忘记取模导致下标越界。很多同学写习惯了普通队列的rear到了环形队列仍然只写rear结果数组下标直接越界。这个错在编译阶段不会报错运行时数据错乱非常难排查。正确的写法是每次移动都要取模或者先判断rear是否等于MAX_SIZE - 1再手动归零。第二队列长度计算时忽略负数。比如front 4、rear 1时如果用rear - front得到-3打印出来的长度竟然是负数。公式(rear - front MAX_SIZE) % MAX_SIZE里的 MAX_SIZE不能省它就是为了处理这种“rear已经绕到front前面”的情况。第三遍历时死循环或漏元素。遍历环形队列的正确姿势是从front开始逐个访问到rear为止int i q.front; while (i ! q.rear) { printf(%d , q.data[i]); i (i 1) % MAX_SIZE; }如果把循环条件写成i q.rear一旦rear绕到前面循环就会提早结束或者根本进不去如果把步进写成i而不是i (i 1) % MAX_SIZE直接越界。这两个细节面试手写代码时经常有人翻车。4.2 判断条件为什么不能随便改有些同学会想当然地把队满条件改成rear front以为这样可以多存一个元素。但前面已经推演过rear front本身就是队空的标志用它判断队满会让两个状态完全混淆。要嘛用“牺牲一个位置”的方案要嘛加额外的计数器或标志位两条路必须选一条不存在既不牺牲空间又不加额外变量的第四种纯指针方案。还有一个容易忽略的边界MAX_SIZE 1时牺牲一个存储单元的方案会让队列一个元素都放不下。因为(0 1) % 1 0入队前判断永远队满。实际应用中如果队列最多只能容纳1个元素应该改用size计数器或tag方案或者直接把MAX_SIZE定义为至少2。4.3 调试环形队列的方法小容量手推 插桩打印环形队列的bug往往不是语法错误而是逻辑边界问题。我调试这种代码有一个屡试不爽的办法先把MAX_SIZE改成一个很小的数比如4或者5然后每执行一次入队或出队就打印front、rear和整个数组的内容手工在纸上画环对照。比如初始front 0, rear 0入队一个元素X后front 0, rear 1data[0] X。再入队Yfront 0, rear 2。出队一次取走data[0]front 1, rear 2。在纸上画一个五格环标出front和rear的位置你会发现整个流程和代码执行的逻辑完全对应。一旦哪个环节对不上立刻就能定位。提示生产环境的队列通常被多个线程并发访问环形队列的入队出队必须结合锁或原子操作才能保证安全。数据结构课上手写的简单版本不能直接用于多线程场景这一点在面试回答“如何设计一个线程安全的阻塞队列”时要注意区分。5. 环形队列在真实项目中的位置5.1 生产者-消费者模型中的环形缓冲区环形队列最常见的工程应用是生产者-消费者模型中的环形缓冲区。生产者和消费者之间数据传递需要一个临时存储区这个区域既要能快速写入又要能快速读取还要避免频繁的内存分配。数组实现的环形队列天然满足这些要求入队出队只做一次赋值和一次取模时间复杂度O(1)且不需要动态分配内存。Linux内核里有一个经典的实现叫kfifo它的核心思想就是带size计数器的环形缓冲区配合无锁读写的技巧可以在单生产者单消费者场景下做到极高性能。很多网络驱动、字符设备的数据通路就是靠这种环形缓冲区实现的。如果你以后看内核源码或者中间件源码看到那种“数组读指针写指针取模”的结构基本就是环形队列的变体。5.2 线程池阻塞队列与Java的ArrayBlockingQueue线程池里的阻塞队列是环形队列思想在并发场景下的直接用户。以Java的ArrayBlockingQueue为例它的底层就是一个环形数组内部维护了takeIndex、putIndex和count三个字段本质上就是“环形数组 size计数器 锁和条件变量”的组合。用环形数组而不是链表有两个明显好处一次内存分配可以反复使用减少了GC压力数组的缓存局部性好遍历和访问时CPU缓存命中率高。当线程池的任务数超过核心线程数时新任务会被放入这个阻塞队列。队列一满线程池才会执行拒绝策略。你选用的阻塞队列类型会直接影响系统的吞吐量和延迟特性基于环形数组的ArrayBlockingQueue性能稳定基于链表的LinkedBlockingQueue则更适合任务大小不确定的场景。能把这层关系讲清楚面试官基本就能确认你对数据结构不是停留在背概念层面。5.3 消息队列中的顺序消费与重复消费问题消息队列和环形队列之间没有必然的代码关系但消息队列要解决的“顺序性”和“公平性”问题其底层基础就是FIFO队列语义。比如消息被消费者取出后如果处理失败需要重新入队或者进入重试队列多个消费者竞争同一个队列时如何保证一条消息只被一个消费者消费这些都是队列数据结构在分布式场景下的延伸。热词里提到的“消息队列重复消费问题”本质上是因为消费者处理完消息后还没来得及提交消费位移就发生了宕机或重启消息被再次拉取。解决思路是在消费端做幂等处理在服务端记录消费位移。这个问题的复杂度已经从数据结构层面上升到了分布式共识层面但如果你没有先理解队列的先进先出语义后面这些概念就更难建立了。5.4 环形队列思想的其他变形学完环形队列你还能顺带把几个相关的概念串起来。单调队列就是队列思想的一个进阶应用它并不强调“环”而是强调队列中元素具有单调性常用于滑动窗口最大值问题。双端队列允许从头部和尾部两端入队出队Java里有ArrayDeque底层也是环形数组实现。优先队列则把FIFO换成了按优先级出队用堆结构实现语义完全不同。从考研和面试的角度环形队列是队列章节的“分水岭”能理解假溢出的来龙去脉说明你真正理解了顺序存储的局限性能手写正确的判空判满条件说明你掌握了状态设计的核心能联想到阻塞队列和消息队列说明你知道这套东西在工业界不是摆设。数据结构课程里学到的每一个抽象最终都要落到这样具体的场景中才有意义。我个人在实际学习中的体会是环形队列最考验人的不是代码本身而是你能不能跳出一维数组的线性思维把“下标大小”转换成“环上的位置关系”。如果你也被rear绕回0时搞晕过别怀疑自己这是每个人都会经历的过程。拿小容量数组手推几遍把所有状态都画一遍这个坎很快就会过去。