这篇文章将会花费你的五分钟。
循环队列原理详解
循环队列(Circular Queue)是一种线性数据结构 ,它的核心思想是将顺序队列臆造成一个环状空间。当指针到达数组末尾时,通过取模运算自动回到数组开头,实现空间复用。
一、为什么要用循环队列?
要理解循环队列,首先需要知道普通顺序队列存在的问题。
1. 普通顺序队列的"假溢出"问题
假设我们用一个普通数组实现队列:
初始状态(空队列):
[ ][ ][ ][ ][ ]
↑
front=0, rear=0
入队A、B、C、D:
[A][B][C][D][ ]
↑ ↑
front=0 rear=4
出队A、B:
[ ][ ][C][D][ ]
↑ ↑
front=2 rear=4
问题 :此时数组前两个位置(下标0和1)是空闲的,但新元素E却无法入队 !因为rear已经指向数组末尾(下标4),按照普通队列的规则,我们会认为队列已满(rear == MAXSIZE)。
但实际上,数组前面还有空间!这种现象称为**"假溢出"**------队列逻辑上没满,但存储空间看起来满了。
2. 循环队列的解决方案
循环队列的思想很简单:把数组首尾相连,形成一个环 。当rear到达数组最后一个位置时,如果数组前面还有空闲位置,就让rear绕回到数组开头。
二、循环队列的核心原理
1. 环状映射:取模运算
循环队列的关键技术是取模运算(%):
新位置 = (当前指针 + 1) % 数组大小
这个公式保证了指针始终在 [0, MAXSIZE-1] 范围内循环移动。
示例(数组大小=5):
- 当前
rear = 4(最后一个位置) - 执行
rear = (4 + 1) % 5 = 0→ 指针回到开头
2. 指针的"追赶"游戏
循环队列中有两个关键指针:
front:指向队首元素rear:指向队尾元素的下一个位置(即新元素将要插入的位置)
这两个指针的关系,就像两个人在环形跑道上跑步:
- 入队:
rear向前移动 - 出队:
front向前移动 - 当
rear追上front:队列满 - 当
front追上rear:队列空
三、核心操作的实现原理
1. 初始化
cpp
#define MAXSIZE 5
int queue[MAXSIZE];
int front = 0;
int rear = 0; // 指向队尾的下一个位置
2. 入队操作
入队时,需要做三件事:
- 检查队列是否已满
- 在
rear位置放入新元素 - 将
rear指针循环后移
cpp
bool enqueue(int value) {
// 判断队满:(rear+1)%MAXSIZE == front
if ((rear + 1) % MAXSIZE == front) {
return false; // 队列满
}
queue[rear] = value;
rear = (rear + 1) % MAXSIZE; // 关键:循环后移
return true;
}
执行过程示例(MAXSIZE=5):
初始:front=0, rear=0
[ ][ ][ ][ ][ ]
1. 入队A:queue[0]=A, rear=(0+1)%5=1
[A][ ][ ][ ][ ]
↑ ↑
f r
2. 入队B、C、D:
[A][B][C][D][ ]
↑ ↑
f r
3. 此时rear=4,再入队E:
判断(rear+1)%5 == front? → (4+1)%5=0 == front=0 ✓ 队满!
无法入队(这里牺牲了一个空间来区分空/满)
3. 出队操作
出队时:
- 检查队列是否为空
- 取出
front位置的元素 - 将
front指针循环后移
cpp
bool dequeue(int &value) {
// 判断队空:front == rear
if (front == rear) {
return false; // 队列空
}
value = queue[front];
front = (front + 1) % MAXSIZE; // 关键:循环后移
return true;
}
接上例:
当前状态:[A][B][C][D][ ]
↑ ↑
f r
出队A:value=queue[0]=A, front=(0+1)%5=1
当前状态:[A][B][C][D][ ] (A逻辑上已删除)
↑ ↑
f r
此时再入队E:rear=4, front=1, 判断队满?(4+1)%5=0 != 1 → 不满
queue[4]=E, rear=(4+1)%5=0
当前状态:[E][B][C][D][ ] (E放在了原来A的位置!)
↑ ↑
r f
rear < front 的情况出现了!
4. 判断队空与队满
这是循环队列最容易混淆的地方。由于front和rear相等时可能表示空也可能表示满,需要明确区分。
常用方法一:牺牲一个存储单元
- 队空条件:
front == rear - 队满条件:
(rear + 1) % MAXSIZE == front
这种方法下,队列最多存储 MAXSIZE-1 个元素。
常用方法二:增设计数器
cpp
int size; // 记录当前元素个数
- 队空条件:`size == 0`
- 队满条件:`size == MAXSIZE`
这种方法可以存满MAXSIZE个元素,但需要额外维护size变量。
5. 计算队列长度
由于rear可能小于front,不能简单用rear - front计算:
cpp
int getLength() {
// 方法一:分情况讨论
if (rear >= front) {
return rear - front;
} else {
return rear + MAXSIZE - front;
}
// 方法二:统一使用取模(推荐)
// return (rear - front + MAXSIZE) % MAXSIZE;
}
四、完整示例演示
让我们完整演示一个循环队列的工作过程(MAXSIZE=5,牺牲一个单元法):
操作 front rear 数组状态 说明
初始化 0 0 [ ][ ][ ][ ][ ] 空队列
入队A 0 1 [A][ ][ ][ ][ ]
入队B 0 2 [A][B][ ][ ][ ]
入队C 0 3 [A][B][C][ ][ ]
入队D 0 4 [A][B][C][D][ ] rear到末尾
判断队满: (4+1)%5=0 == front? 否
入队E 0 0 [A][B][C][D][E] (4+1)%5=0, rear=0
此时队满? (0+1)%5=1 != 0? 实际上是满的!
出队A 1 0 [ ][B][C][D][E] front=(0+1)%5=1
出队B 2 0 [ ][ ][C][D][E] front=(1+1)%5=2
入队F 2 1 [F][ ][C][D][E] rear=(0+1)%5=1
当前队列: 2 1 [F][ ][C][D][E] rear<front,元素分两段
五、循环队列的优缺点
优点:
- 空间复用:有效利用数组空间,解决假溢出问题
- 高效操作:入队出队都是O(1)时间复杂度
- 无需数据移动:比普通顺序队列的优化版本(出队后移动数据)高效得多
缺点:
- 大小固定:基于数组实现,容量固定,不能动态增长
- 实现稍复杂:需要正确处理循环和边界条件
- 空间浪费:如果用牺牲单元法,总会浪费一个存储空间
六、GESP六级考试重点
在GESP六级考试中,关于循环队列你需要掌握:
- 手写核心操作 :能独立写出
enqueue、dequeue、isEmpty、isFull、getLength函数 - 理解取余本质 :知道为什么用
%,以及如何用%实现循环 - 区分空/满条件:能正确判断不同实现方式下的队空队满
- 指针关系分析 :能根据
front和rear的关系推断队列状态(是否绕环、有多少元素) - 边界条件处理:特别注意指针在0和MAXSIZE-1时的计算
循环队列是理解更复杂数据结构(如环形缓冲区、双端队列)的基础,掌握它的原理对后续学习非常重要。
时间复杂度
以下是C++中循环队列各种操作在最坏情况和平均情况下的时间复杂度:
-
入队 (Enqueue)
- 最坏时间复杂度:O(1)
- 平均时间复杂度:O(1)
- 说明:只需移动尾指针并写入数据,无需移动元素。
-
出队 (Dequeue)
- 最坏时间复杂度:O(1)
- 平均时间复杂度:O(1)
- 说明:只需移动头指针,无需移动元素。
-
取队首 (Front)
- 最坏时间复杂度:O(1)
- 平均时间复杂度:O(1)
- 说明:直接通过数组下标访问头指针指向的元素。
-
取队尾 (Back)
- 最坏时间复杂度:O(1)
- 平均时间复杂度:O(1)
- 说明:直接通过数组下标访问尾指针指向的元素。
-
判空 (IsEmpty)
- 最坏时间复杂度:O(1)
- 平均时间复杂度:O(1)
- 说明:只需比较头尾指针或检查计数器。
-
判满 (IsFull)
- 最坏时间复杂度:O(1)
- 平均时间复杂度:O(1)
- 说明:只需比较头尾指针或检查计数器。
-
求队列大小 (Size)
- 最坏时间复杂度:O(1)
- 平均时间复杂度:O(1)
- 说明:若维护了计数器,直接返回;若通过指针计算,也是简单的算术运算。
-
清空队列 (Clear)
- 最坏时间复杂度:O(n)
- 平均时间复杂度:O(n)
- 说明:如果存储的是对象指针,可能需要遍历释放资源;如果是基本数据类型,只需重置指针。
在通常情况中,不会有 log。
END.