1. 为什么顺序队列明明"能存数据",却总被人嫌弃浪费空间
队列这东西,大家都不陌生。生活里买奶茶排队、食堂打饭、银行叫号,全是队列的活生生例子。在计算机里,队列就是**先进先出(FIFO, First In First Out)**的线性表:一端负责进(队尾rear),一端负责出(队头front),数据排着队走,谁先来谁先走,规则简单粗暴。
但问题是,队列用顺序存储实现的时候,有个让人非常难受的毛病——假溢出。
什么叫假溢出?我先给你还原一个场景。
假设你开了一个长度为5的数组当队列,初始时front和rear都指向下标0。你现在依次入队A、B、C、D、E,rear一路加到了5,此时rear == MaxSize,数组满了,不能再入队了,这没问题。
但你紧接着出队A、B、C、D,front从0走到了4。这时候数组里明明只剩E一个元素,可rear已经在数组的最后一个位置上了。如果你再想入队F,程序一看 rear == MaxSize,直接报"队列已满"——可你数组下标0到3的位置全是空的啊!
这就是假溢出:数组有空间,但rear指针已经跑到末尾,逻辑上判满了。解决办法有两种:
- 数据搬移:每次入队前检查,如果rear到顶但front前面还有空位,把整个队列整体往前搬。代价是O(n)的时间开销,频繁搬移性能差。
- 循环队列:把数组在逻辑上首尾相接,让rear到顶后自动"绕"回下标0,继续利用前面空出来的位置。
循环队列,就是从方案2里长出来的东西,也是这篇要讲的重点。
它本质上就是一个头尾相接的顺序存储结构,靠取模运算让指针"转圈圈"。你可能觉得这有什么好讲的?不就是 (rear + 1) % MaxSize 嘛。但真正动手写的时候,坑还挺多的——判空判满的两种方案怎么选、队列长度公式怎么推、最后一个存储单元到底能不能用、遍历的时候边界在哪……这些细节没搞清楚,笔试面试一写代码就露馅。
这篇围绕循环队列,把顺序存储这条线彻底讲透。从底层设计到完整可运行的C语言实现,再到实际工程里的应用场景(线程池的任务队列、BFS的辅助队列,全都跟它脱不开关系),一步步拆给你看。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 循环队列的核心设计:一个数组,两个指针,转圈圈
2.1 怎么让数组"首尾相接"
数组本身是线性的,物理内存上不可能真的弯成一个环。所以循环队列的做法是:在逻辑上把array[0]和array[MaxSize-1]看成相邻的位置,指针在向后移动时,如果已经到了数组末尾,就通过取模运算跳回数组开头。
入队时rear指针的移动,从普通的 rear++ 变成:
c复制rear = (rear + 1) % MaxSize;
出队时front指针同理:
c复制front = (front + 1) % MaxSize;
用生活化的类比,这就好比一个环形跑道,跑完一圈自动回到起点,不用掉头。取模运算在这里干的事,就是"到边界自动回绕",保证任何时刻front和rear都在 [0, MaxSize-1] 这个合法区间内。
2.2 判空和判满:循环队列最容易翻车的点
队列初始时,front = rear = 0,此时队列为空,这是共识。
但问题来了:队列满了之后,front 和 rear 是什么关系?
你往一个长度为5的数组里连续入队5个元素,rear先加1,再取模。当最后一个元素入队后,rear会从4变成0,而这个0恰好等于front。所以队列满时,front == rear,和队列空时一模一样。
这就尴尬了:同一个条件,既判空又判满,程序无法区分。
解决这个问题,工程上有三种主流方案,我一个个说清楚,因为笔试题里三种都有可能考到。
方案一:牺牲一个存储单元
这是最经典、教材最常用、面试最常考的一种。主动浪费数组的一个元素空间,让"rear的下一个位置是front"时判定为满。具体条件:
c复制// 判断队满
(rear + 1) % MaxSize == front
// 判断队空
front == rear
说得直白一点:数组长度为MaxSize,但队列最多只能存 MaxSize - 1 个元素。空一个位子出来,专门用来打破"空满同条件"的僵局。这个空位不存数据,它存在的意义就是让rear永远追不上front——最多只能"挨着"它。
我这个队列可以存多少元素?数组开了MaxSize个格子,实际能用的是MaxSize-1个。这个"浪费一格"的设计,从一开始就要想清楚,不然后面写代码容易绕晕。
方案二:增设size计数器
结构体里多维护一个 int size,入队时size++,出队时size--。判空判满直接看size:
c复制// 判断队空
size == 0
// 判断队满
size == MaxSize
这个方案的好处是不浪费存储空间,数组里每一个格子都能用来存数据。代价是结构体多一个字段,每次入队出队要多维护一次size。逻辑上更简单直观,很多实际项目里喜欢用这种。
方案三:增设tag标志位
结构体里加一个 int tag,入队操作后tag置1,出队操作后tag置0。因为front和rear相等时,只有两种情况:要么是入队后追平(队满),要么是出队后追平(队空)。用tag记录"最后一次操作是入队还是出队",就能区分了:
c复制// 判断队满
front == rear && tag == 1
// 判断队空
front == rear && tag == 0
这个方案同样不浪费存储空间,但多一个字段,每次操作都要维护tag值。笔试里出现的频率比方案二低一些,但作为扩展知识了解不亏。
下面用一张表把这三种方案放在一起对比,方便你记忆:
| 方案 | 判空条件 | 判满条件 | 空间利用率 | 额外开销 |
|---|---|---|---|---|
| 牺牲一个单元 | front == rear | (rear + 1) % MaxSize == front | 较低(能存MaxSize-1个) | 无 |
| 增设size计数器 | size == 0 | size == MaxSize | 高(能存MaxSize个) | 维护size字段 |
| 增设tag标志位 | front == rear && tag == 0 | front == rear && tag == 1 | 高(能存MaxSize个) | 维护tag字段 |
我个人的建议是:考试和面试默认写方案一,因为它最能考察你对循环队列取模逻辑的理解。如果项目里需要最大化的空间利用,再考虑方案二。
2.3 队列长度到底怎么算
不画图你可能觉得这是个弱智问题——rear减去front不就行了吗?但rear回绕之后,直接相减可能得到负数。
比如MaxSize = 5,front = 3,rear = 2(rear已经绕到数组前面了),你直接算2 - 3 = -1,明显不对。
正确的公式是:
c复制int length = (rear - front + MaxSize) % MaxSize;
为什么要加MaxSize?因为rear - front可能是负数,加上MaxSize之后把它拉回正数区间,再取模保证结果落在 [0, MaxSize-1]。
验证一下:front=3,rear=2时,(2 - 3 + 5) % 5 = 4 % 5 = 4,队列里确实有4个元素,正确。
这个公式本质上和"插队的人排到队伍后面绕了一圈"是一个道理。两个人在环形跑道上跑步,想知道前面的人领先多少米,不能直接把人家的位置相减——得考虑跑完一圈回绕的情况。
3. 循环队列的完整实现:C语言版本,逐行带注释
3.1 结构体定义与基础状态判断
我按照**方案一(牺牲一个存储单元)**来写完整实现,因为这是最经典、最考察理解的一种。
c复制#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#define MaxSize 6
typedef struct {
int data[MaxSize];
int front; // 队头指针,指向队头元素的位置
int rear; // 队尾指针,指向队尾元素的下一个位置
} SqQueue;
这里有一个非常重要的设计约定需要你知道:rear指向的是队尾元素的下一个位置,而不是队尾元素本身。也就是说,入队时先把数据放到rear指向的位置,然后rear再往后移。front指向的是队头元素本身。
为什么要这么设计?因为如果你让rear直接指向最后一个元素,初始化和判空判满的逻辑都会变得更绕。教材和工程实践里,"rear指向队尾下一个位置"是默认约定,你写代码时最好也遵循,不然读别人代码时容易对不上。
初始化、判空、判满:
c复制// 初始化队列
void InitQueue(SqQueue *Q) {
Q->front = 0;
Q->rear = 0;
}
// 判断队空
bool IsEmpty(SqQueue *Q) {
return Q->front == Q->rear;
}
// 判断队满
bool IsFull(SqQueue *Q) {
return (Q->rear + 1) % MaxSize == Q->front;
}
这三个函数没什么玄妙,关键就是记住:空=相等,满=rear的下一个位置撞上front。
3.2 入队操作:先放数据,再移指针
c复制// 入队
bool EnQueue(SqQueue *Q, int x) {
if (IsFull(Q)) {
printf("队列已满,无法入队 %d\n", x);
return false;
}
Q->data[Q->rear] = x; // 元素放到rear指向的位置
Q->rear = (Q->rear + 1) % MaxSize; // rear后移,到末尾自动回绕
return true;
}
这段代码的顺序千万别搞反。如果你先让rear后移再放数据,那就把元素放到下一个位置去了,逻辑就错了。先存数据,再移动指针,这是入队的铁律。
3.3 出队操作:取走数据,再移指针
c复制// 出队
bool DeQueue(SqQueue *Q, int *x) {
if (IsEmpty(Q)) {
printf("队列为空,无法出队\n");
return false;
}
*x = Q->data[Q->front]; // 取出队头元素
Q->front = (Q->front + 1) % MaxSize; // front后移,到末尾自动回绕
return true;
}
出队和入队对称,同样是先取数据,再移动指针。x是输出参数,通过指针把出队的元素带回调用处。注意出队只是逻辑上删除数据,数组里那个位置的值还在,但下次入队会被覆盖掉,不用刻意清空。
3.4 取队头、求长度、遍历
c复制// 获取队头元素(不出队)
bool GetHead(SqQueue *Q, int *x) {
if (IsEmpty(Q)) {
return false;
}
*x = Q->data[Q->front];
return true;
}
// 获取队列长度
int QueueLength(SqQueue *Q) {
return (Q->rear - Q->front + MaxSize) % MaxSize;
}
// 遍历队列
void PrintQueue(SqQueue *Q) {
if (IsEmpty(Q)) {
printf("队列为空\n");
return;
}
int i = Q->front;
printf("队列元素:");
while (i != Q->rear) {
printf("%d ", Q->data[i]);
i = (i + 1) % MaxSize;
}
printf("\n");
}
遍历这里有个很关键的技巧:不能直接用for循环从front到rear,因为rear可能小于front。用while (i != Q->rear),配合i = (i + 1) % MaxSize,从front出发沿着环形方向走,直到遇到rear为止。这样不管指针绕了几圈,都能正确遍历完所有元素。
3.5 测试运行
c复制int main() {
SqQueue Q;
InitQueue(&Q);
// 依次入队1~5
for (int i = 1; i <= 5; i++) {
if (EnQueue(&Q, i)) {
printf("入队成功:%d\n", i);
}
}
// 此时队满吗?MaxSize=6,最多存5个,应该正好满
printf("队列长度:%d\n", QueueLength(&Q));
PrintQueue(&Q);
// 出队2个元素
int x;
DeQueue(&Q, &x);
printf("出队元素:%d\n", x);
DeQueue(&Q, &x);
printf("出队元素:%d\n", x);
// 再入队2个元素,验证回绕
EnQueue(&Q, 6);
EnQueue(&Q, 7);
printf("再次入队后长度:%d\n", QueueLength(&Q));
PrintQueue(&Q);
printf("队头元素:%d\n", Q.data[Q.front]);
return 0;
}
运行结果大概是:
code复制入队成功:1
入队成功:2
入队成功:3
入队成功:4
入队成功:5
队列长度:5
队列元素:1 2 3 4 5
出队元素:1
出队元素:2
再次入队后长度:5
队列元素:3 4 5 6 7
队头元素:3
注意看这个测试的设计意图:我故意把MaxSize设为6,先入队5个元素填满,然后出队2个让出位置,再入队2个新元素。这时rear已经绕回到数组前面去了,而遍历依然能正确输出3 4 5 6 7,说明循环回绕没问题。你在自己测试时,也建议构造这种先填满、再出队、再入队的场景,才能验证循环逻辑是否真的正确。
3.6 如果不想牺牲一个存储单元:size版本的核心代码
方案二的size版本,核心代码其实只改动几个地方。结构体加一个size字段,入队出队时同步维护:
c复制typedef struct {
int data[MaxSize];
int front;
int rear;
int size; // 当前队列长度
} SqQueueSize;
初始化时 Q->size = 0,入队成功 Q->size++,出队成功 Q->size--。判空判满:
c复制bool IsEmpty(SqQueueSize *Q) {
return Q->size == 0;
}
bool IsFull(SqQueueSize *Q) {
return Q->size == MaxSize;
}
size版本的入队代码里,就不用再担心"满的条件会不会和空冲突"的问题了。写法上,size版本和牺牲单元版最大的区别在于:size版本不需要浪费那个格子,MaxSize是多少就能存多少。其余的取模逻辑、遍历逻辑完全一样。
4. 循环队列在真实项目里到底用在哪:不只是课本考点
你可能会想:这玩意儿考试考完就完了,实际开发里还能见到吗?我的回答是:不仅能看到,而且到处都是。
4.1 线程池的阻塞队列:LinkedBlockingQueue和ArrayBlockingQueue
Java的线程池,构造参数里有个BlockingQueue
你去看ArrayBlockingQueue的源码,会发现它有:
final Object[] items数组int takeIndex(对应front)int putIndex(对应rear)int count(对应size)
这不就是循环队列的经典结构吗?takeIndex和putIndex通过自增后取模来回绕,count维护当前元素个数,入队和出队时通过takeIndex/putIndex定位数组位置。和上面写的size版本循环队列,思路完全一致。
所以别觉得课本知识没用。你在面试时说一句"ArrayBlockingQueue本质上就是一个基于循环数组的阻塞队列",面试官就知道你是真懂底层,不是背了八股文。
提示:Java里LinkedBlockingQueue用的是链表实现,ArrayBlockingQueue用的是循环数组实现。两者的区别是:链表队列的"容量"可以动态扩展(不传容量就是Integer.MAX_VALUE),而数组队列的容量必须在构造时固定。这也是为什么你设计线程池时,通常用ArrayBlockingQueue来限制任务队列的最大长度,防止任务无限堆积把内存撑爆。
4.2 广度优先搜索(BFS)的辅助队列
写算法题的时候,BFS必备一个队列来记录待访问的节点。树的层序遍历、图的无权图最短路径、迷宫的最短步数,全都要靠队列。
在竞赛和算法题里,很多人会直接用C++ STL的queue,但它底层是deque,会有一定的额外开销。当数据规模特别大、你需要极致的性能时,手写一个循环队列来替代STL queue,可以显著减少动态内存分配带来的耗时。
我举一个具体例子,二叉树的层序遍历:
c复制// 二叉树的层序遍历,用循环队列辅助
void LevelOrder(BiTree T) {
if (T == NULL) return;
SqQueue Q;
InitQueue(&Q);
EnQueue(&Q, T);
while (!IsEmpty(&Q)) {
BiTree p;
DeQueue(&Q, &p);
visit(p);
if (p->lchild) EnQueue(&Q, p->lchild);
if (p->rchild) EnQueue(&Q, p->rchild);
}
}
在这个场景里,队列要频繁地入队出队,而且队列长度有上限(树的节点数最多为n),用固定容量的循环队列刚好合适——一次初始化,全程复用,不需要反复创建销毁节点。
4.3 操作系统里的进程调度和消息队列
CPU的时间片轮转调度算法,核心就是一个队列:就绪队列里的进程轮流获得CPU,用完时间片重新排到队尾。这个"队尾重新排队"的操作,就是循环队列的典型用法。
消息队列其实也有类似的影子。不管是Redis的Stream还是RabbitMQ这类消息中间件,消费者拉取消息的顺序性、回头重新消费老消息的机制,底层都离不开"先进先出"这个队列模型。你在热搜词里看到"spring boot redis stream 如何拉取队列消息"、"消息队列重复消费问题",追根溯源,还是要先理解队列这个基础结构是怎么运转的。
4.4 为什么不用普通的顺序队列(非循环)
回到这篇最开始的问题:为什么非要用"循环"的?
如果直接用普通顺序队列,出队后front前面的空间就永远空着,队列越来越往后"缩",表现为rear动不动就触顶、判满,但前面一堆空位。这就是假溢出。每次都要搬移数据或者频繁扩容,性能极差。
循环队列用一个取模就解决了假溢出问题,让数组空间能被反复利用。它本质上是"用相对简单的数学运算,换取了O(1)的时间复杂度",不需要搬移任何数据。
这就是它在工程中能活下来的根本原因。
5. 写循环队列最容易踩的坑:我替你踩过了
5.1 取模优先级: (rear + 1) % MaxSize 和 rear + 1 % MaxSize 完全不是一回事
C语言里,%取模运算符的优先级高于+。所以如果你写成:
c复制rear + 1 % MaxSize
它实际执行的是:
c复制rear + (1 % MaxSize)
也就是 rear + 1,完全失去了取模回绕的作用。在边界处,这个错误会导致rear越界,访问到数组之外的内存,程序崩溃或者数据错乱。
正确写法永远是加括号:
c复制(rear + 1) % MaxSize
这种错误编译器不会报错,只有运行到数组边界时才会出问题,排查起来很痛苦。我的建议是:所有取模运算,不管多简单,一律加括号,不要省。
5.2 队列长度公式的边界测试
(rear - front + MaxSize) % MaxSize 这个公式,看似简单,但有几类边界情况你最好亲手验证一遍:
- 队列为空时:front == rear,(rear - front + MaxSize) % MaxSize = MaxSize % MaxSize = 0,正确。
- 队列满时:例如MaxSize=6,front=0,rear=5,(5 - 0 + 6) % 6 = 11 % 6 = 5,正确(能存5个)。
- 回绕后:front=3,rear=1,(1 - 3 + 6) % 6 = 4,正确。
如果你不用这个公式,而是直接写 rear - front,在回绕场景下就会得到负数。这是新手最容易写错的地方。
5.3 遍历循环队列的终止条件不能是 i <= rear
很多人第一次写遍历,习惯写成:
c复制for (int i = front; i < rear; i++) { ... }
这在普通顺序队列里是对的,但循环队列一旦发生回绕,rear的数值比front小,这个循环直接就不执行了。
正确做法是用while循环,以 i != rear 为终止条件,并且在循环体内用 i = (i + 1) % MaxSize 更新。这样不管front和rear谁大谁小,都能完整绕一圈回来。
5.4 注意"牺牲一个单元"方案下最大容量是MaxSize-1
用方案一时,很多人会写一个"入队MaxSize个元素"的测试,然后奇怪为什么第MaxSize个入队失败。
因为按方案一的设计,队列满的条件是 (rear + 1) % MaxSize == front,也就是说rear走到front前一个位置就宣告满了,最后那个格子是"哨兵",永远不能存数据。MaxSize=6的数组,最多存5个元素。
如果你确实需要存满6个,就改用size版本或tag版本。这属于设计取舍:要么牺牲空间换取简单的判满逻辑,要么多维护一个字段换取完整空间。没有绝对的好坏,全看你的场景。
5.5 初始化后front和rear到底指哪
记住这个约定:初始时front = rear = 0,front指向第一元素的位置,rear指向最后一个元素的下一个位置。
这个约定意味着数组下标0的位置在空队列里是"可写"的。入队第一个元素时,它会被放到data[0],然后rear变成1。如果你搞反了,让rear指向队尾元素本身,那么空队列时rear应该等于-1,初始化就要写成rear = -1,后面所有逻辑都会变得别扭。
很多教材和源码都用"rear指向队尾下一个位置"的约定,建议入乡随俗,减少理解成本。
5.6 动态扩容的循环队列:一个进阶思考
如果你自己写一个通用循环队列库,数组长度就不能是固定的MaxSize,而是要支持扩容。扩容的思路是:申请一个更大的新数组,把原来队列里的元素按"从队头到队尾"的顺序搬到新数组的前面,然后重置front和rear。
核心代码逻辑:
c复制void ResizeQueue(SqQueue *Q, int newSize) {
int *newData = (int *)malloc(sizeof(int) * newSize);
int len = QueueLength(Q);
for (int i = 0; i < len; i++) {
newData[i] = Q->data[(Q->front + i) % MaxSize];
}
free(Q->data);
Q->data = newData;
Q->front = 0;
Q->rear = len;
MaxSize = newSize;
}
注意这个搬移过程:从front开始,逐个按环形顺序搬,搬到新数组的0,1,2,...位置。这样一来,原本可能前后错位的元素,在新数组里重新变得连续,front=0,rear=len,整整齐齐。这个技巧在实现可变长循环队列时非常实用。
6. 进阶练习:手写几个题,验证你是不是真懂了
光看不练假把式。我出几个题目,你把代码写出来跑一遍,基本就能确认自己掌握到什么程度了。
第1题:设计一个支持获取队列中最大值的循环队列
要求入队、出队、取最大值三个操作的平均时间复杂度都是O(1)。思路是用一个辅助的双端队列(deque)维护当前窗口的最大值候选。入队时,如果新元素比辅助队列队尾元素大,就把队尾元素弹出,直到小于等于新元素,再把新元素下标压入辅助队列队尾。出队时,如果出队的元素恰好是辅助队列的队头元素,辅助队列队头也弹出。
这题综合性很强,既考循环队列的操作,又考单调队列的思想,非常值得练手。
第2题:用循环队列实现一个固定长度的滑动窗口平均值计算器
给定一个窗口大小k,不断往队列里加入数字,每次加入后返回当前窗口内所有数字的平均值。这题很简单,但如果窗口满了之后要"先出队再入队",你正好可以趁机练熟队列不满时的回绕逻辑。
第3题:判断一个字符串是否是回文,用队列和栈配合实现
把字符串依次入队和入栈,然后不断从队列和栈里各取一个字符比较。这题本质上没什么难度,但能验证你对栈和队列两种结构的操作是否熟练。
第4题:用数组模拟双端队列(deque)
这是进阶中的进阶。双端队列要求front和rear两端都能插入和删除。实现时,front和rear的移动方向不同:front端删除时front++,front端插入时front--;rear端插入时rear++,rear端删除时rear--。取模回绕逻辑更加灵活,写一遍能加深你对"指针移动方向"和"取模回绕"的理解。
这几个题做完,循环队列这块基本就焊死了。
7. 从循环队列延伸出去:顺序存储的队列族谱
循环队列并不是终点。顺着顺序存储这条线往深了走,有几样东西和循环队列一脉相承,理解起来会非常省力。
优先队列是队列的一种变体,出队顺序不是按入队时间,而是按优先级。顺序存储的优先队列,一般用堆来实现,堆本质上也是一个数组,只是下标之间有特定的父子关系。你理解了数组下标和逻辑结构之间的映射,再看堆就会觉得很简单。
阻塞队列是线程安全版本的队列,在循环队列的基础上加了锁和条件变量。当一个线程从空队列中取元素时,会被阻塞,直到另一个线程放入元素。RabbitMQ、Kafka这些消息中间件,内部就有类似的设计。
双端队列是两端都能插入删除的队列,Java里ArrayDeque的底层就是循环数组。如果你理解了循环队列的front/rear移动逻辑,再去理解双端队列的四端操作,就是水到渠成的事。
所以循环队列这个"点",实际上是连接了数组、指针回绕、线性表三种基础概念的枢纽。学透它,后面很多高级数据结构都会顺畅很多。
我个人写了这么多年代码,最大的体会是:像循环队列这种"看起来简单、写起来全是细节"的数据结构,真正拉开差距的不是你能不能背出判空判满的条件,而是你知不知道为什么这么设计、在什么场景下选哪种方案、踩坑时怎么快速定位。这些都是靠动手写、画图推演、反复调试才能沉淀下来的。建议你拿到这篇文章后,别光看,把代码敲一遍,然后自己画一个环形图,手动模拟front和rear的每一次移动。等你能不假思索地说出"满的条件是什么""长度为MaxSize的数组最多存几个元素",循环队列你就彻底拿下了。
