数据结构——队列
队列
一、队列的定义
1. 逻辑结构
队列(Queue)是只允许在一端进行插入、在另一端进行删除的线性表。它也是一种操作受限的线性表。
- 允许插入的一端称为队尾(Rear)
- 允许删除的一端称为队头(Front)
队列的操作规则是先进先出(First In First Out, FIFO)——就像排队买东西,先来的人排在前面先买到,后来的人只能排在队尾等待。
当 $n = 0$ 时,称为空队列。
2. 基本运算
队列上常见的操作有:
| 运算 | 含义 |
|---|---|
置空队 InitQueue(&Q) |
初始化一个空的队列 |
判队空 QueueEmpty(Q) |
判断队列是否为空 |
取队头元素 GetFront(Q, &e) |
读取队头元素的值(不删除) |
入队 EnQueue(&Q, e) |
将元素 $e$ 插入到队尾 |
出队 DeQueue(&Q, &e) |
删除队头元素 |
其中
&Q表示需要修改队列本身,不加&表示只读取不修改。
二、循环队列
1. 顺序队列
顺序队列(Sequential Queue)是指用一组地址连续的存储单元(即数组)依次存储队列中的元素。通常使用一维数组和两个指针(或整型变量)来实现:
#define MAXSIZE 100
typedef struct {
int data[MAXSIZE]; // 用数组存储队列元素
int front; // 队头指针(指向队头元素)
int rear; // 队尾指针(指向队尾元素的下一个位置)
} SqQueue;
front指向队头元素的位置rear指向队尾元素的下一个位置
顺序队列的基本操作:
- 入队:
Q.data[Q.rear] = e; Q.rear++ - 出队:
e = Q.data[Q.front]; Q.front++ - 队空条件:
Q.front == Q.rear - 队满条件:
Q.rear == MAXSIZE
2. 循环队列的概念
循环队列(Circular Queue)是一种将顺序队列的数组在逻辑上视为一个首尾相接的环的存储结构。
(1)什么是循环队列?
在普通的顺序队列中,front 和 rear 指针只能从数组下标 0 向 MAXSIZE-1 方向单向移动。而循环队列打破了这种"直线"思维——当指针移动到数组末尾(下标 MAXSIZE-1)时,再前进一个位置就折回到数组开头(下标 0),从而将数组首尾连接成一个环。
这个"折回"操作通过取模运算(Modulo)来实现:
- 入队后队尾指针移动:
rear = (rear + 1) % MAXSIZE - 出队后队头指针移动:
front = (front + 1) % MAXSIZE
假设
MAXSIZE = 6,数组下标范围是0 ~ 5。如果当前rear = 5(已到数组末尾):
- 用
rear + 1→ 得到6,但数组根本没有下标6,访问越界了- 用
(rear + 1) % 6→(5 + 1) % 6 = 0,正确回到数组开头
(2)为什么要用循环队列?——解决"假溢出"问题
普通的顺序队列存在一个严重缺陷——假溢出(False Overflow):
随着元素不断入队和出队,rear 指针会逐渐移动到数组末尾。当 rear == MAXSIZE 时,就算数组中 front 前面还有很多空位,也无法再入队新元素了。
比如以下的一个具体过程(MAXSIZE = 6):
| 步骤 | 操作 | 数组变化(下标 0~5) | front |
rear |
说明 |
|---|---|---|---|---|---|
| ① | 初始 | [ ][ ][ ][ ][ ][ ] |
0 | 0 | 空队列 |
| ② | 入队 a |
[a][ ][ ][ ][ ][ ] |
0 | 1 | 新元素放到 rear 处,rear 后移 |
| ③ | 入队 b |
[a][b][ ][ ][ ][ ] |
0 | 2 | |
| ④ | 入队 c |
[a][b][c][ ][ ][ ] |
0 | 3 | 此时队列有 a、b、c 三个元素 |
| ⑤ | 出队 | [ ][b][c][ ][ ][ ] |
1 | 3 | 删除 front 处元素,front 后移 |
| ⑥ | 出队 | [ ][ ][c][ ][ ][ ] |
2 | 3 | 删除 b,front 后移 |
| ⑦ | 入队 d |
[ ][ ][c][d][ ][ ] |
2 | 4 | d 放到 rear 处 |
| ⑧ | 入队 e |
[ ][ ][c][d][e][ ] |
2 | 5 | rear 已到数组末尾 |
| ⑨ | 想入队 f |
[ ][ ][c][d][e][?] |
2 | 5 | rear == MAXSIZE 无法入队 |
从上面可以看到:
- 队头和队尾不是数组的固定端,而是
front和rear两个移动的指针 - 下标 0 并不总是队尾,下标 5 也不总是队尾——队尾就是
rear当前指向的位置 - 步骤 ⑧ 之后,数组前两个位置(下标 0、1)明明空着,却因为
rear已到数组末尾而无法再入队——这就是假溢出(False Overflow)。
(3)队空与队满的判定
在循环队列中,front == rear 既可能是队空,也可能是队满。为了区分,通常采用牺牲一个存储单元的方法:
| 条件 | 含义 |
|---|---|
front == rear |
队空 |
(rear + 1) % MAXSIZE == front |
队满(牺牲一个元素空间) |
即队列的最大有效长度为 MAXSIZE - 1。
为什么队满条件是
(rear + 1) % MAXSIZE == front?如果队列已满,
rear的下一个位置恰好就是front。由于我们故意空出一个位置不存数据,所以当rear紧挨着front时(中间隔了一个空位),就说明队列满了。

当前的队列长度计算公式( 不论 $rear$ 和 $front$ 哪个大都成立 ):
$$ \text{length} = (\text{rear} - \text{front} + \text{MAXSIZE}) % \text{MAXSIZE} $$
3. 循环队列的运算
(1)初始化
void InitQueue(SqQueue *Q) {
Q->front = 0;
Q->rear = 0;
}
(2)判队空
int QueueEmpty(SqQueue Q) {
return Q.front == Q.rear;
}
(3)入队
int EnQueue(SqQueue *Q, int e) {
if ((Q->rear + 1) % MAXSIZE == Q->front) // 队满
return 0;
Q->data[Q->rear] = e;
Q->rear = (Q->rear + 1) % MAXSIZE; // 队尾指针循环后移
return 1;
}
(4)出队
int DeQueue(SqQueue *Q, int *e) {
if (Q->front == Q->rear) // 队空
return 0;
*e = Q->data[Q->front];
Q->front = (Q->front + 1) % MAXSIZE; // 队头指针循环后移
return 1;
}
(5)取队头元素 (只读,所以不传指针)
int GetFront(SqQueue Q, int *e) {
if (Q.front == Q.rear) // 队空
return 0;
*e = Q.data[Q.front];
return 1;
}
4. 性能分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 置空队 | $O(1)$ | 只需重置两个指针 |
| 判队空 | $O(1)$ | 比较 front 和 rear |
| 取队头元素 | $O(1)$ | 直接通过下标访问 |
| 入队 | $O(1)$ | 只需修改 rear 指针 |
| 出队 | $O(1)$ | 只需修改 front 指针 |
总结:队列是一种先进先出的线性表,所有操作都发生在两端——入队在队尾,出队在队头。循环队列通过取模运算解决了顺序队列的假溢出问题,且所有基本运算的时间复杂度均为 $O(1)$,是一种高效、实用的数据结构,广泛应用于任务调度、缓冲区管理、广度优先搜索等场景。
三、链队列
1. 链队列的定义
链队列(Linked Queue)是队列的链式存储结构,即用链表来实现队列。它由队头指针 front 和队尾指针 rear 两部分组成。
链队列本质上就是一个带有头结点的单链表,只不过限制了操作范围——只能在头部删除(出队),在尾部插入(入队)。
为什么用链队列? 循环队列虽然解决了假溢出问题,但大小仍然是固定的(
MAXSIZE定死)。链队列可以动态增长,没有容量上限,适合元素个数不确定的场景。
(1)结点结构
与单链表相同,每个结点包含数据域和指针域:
typedef struct QNode {
int data; // 数据域
struct QNode *next; // 指针域,指向下一个结点
} QNode;
(2)链队列结构
链队列本身是一个结构体,包含两个指针:
typedef struct {
QNode *front; // 队头指针,指向头结点
QNode *rear; // 队尾指针,指向队尾结点
} LinkQueue;
关于两个指针的含义:
-
front—— 指向头结点(头结点是一个固定的哑结点,不存储有效数据,入队出队过程中它永远不变)。第一个数据结点是front->next,出队时删除的就是它。之所以保留头结点,是为了统一操作——即使队列为空,front也有一个确定的指向,不需要特殊处理空指针。 -
rear—— 指向最后一个数据结点本身(即aₙ),而不是像循环队列那样指向最后一个元素的下一个位置。因为链式存储不需要预留空位——入队时直接malloc一个新结点,然后通过rear->next = s链接上去即可,不需要像数组那样预先占一个空位等着。
2. 链队列的基本运算
(1)初始化
创建头结点,front 和 rear 都指向它:
void InitQueue(LinkQueue *Q) {
Q->front = (QNode *)malloc(sizeof(QNode)); // 创建头结点
Q->front->next = NULL;
Q->rear = Q->front; // 队尾也指向头结点(此时队列为空)
}
(2)判队空
队空条件:front == rear,即两个指针都指向头结点。
int QueueEmpty(LinkQueue Q) {
return Q.front == Q.rear;
}
(3)入队
新结点插入到链表的尾部,然后更新 rear 指针:
void EnQueue(LinkQueue *Q, int e) {
QNode *s = (QNode *)malloc(sizeof(QNode));
s->data = e;
s->next = NULL;
Q->rear->next = s; // 当前队尾结点指向新结点
Q->rear = s; // 更新队尾指针
}
(4)出队
删除头结点之后的第一个数据结点。需要特别注意:如果删除的是最后一个结点,要同时更新 rear 指向头结点。
int DeQueue(LinkQueue *Q, int *e) {
if (Q->front == Q->rear) // 队空
return 0;
QNode *p = Q->front->next; // p 指向要删除的结点(第一个数据结点)
*e = p->data;
Q->front->next = p->next; // 头结点跳过 p
if (Q->rear == p) // 如果删除的是最后一个结点
Q->rear = Q->front; // 队尾指针回到头结点
free(p);
return 1;
}
为什么出队需要特殊处理
rear?当队列中只剩一个数据结点时,
rear指向它。删除该结点后,队列变为空,如果不把rear指回头结点,那么rear就成了野指针(指向已被释放的内存),再判队空或入队就会出错。
(5)取队头元素
读取第一个数据结点的值,不删除:
int GetFront(LinkQueue Q, int *e) {
if (Q.front == Q.rear) // 队空
return 0;
*e = Q.front->next->data;
return 1;
}
3. 性能分析与对比
| 操作 | 循环队列(顺序) | 链队列 |
|---|---|---|
| 置空队 | $O(1)$ | $O(1)$ |
| 判队空 | $O(1)$ | $O(1)$ |
| 取队头元素 | $O(1)$ | $O(1)$ |
| 入队 | $O(1)$ | $O(1)$ |
| 出队 | $O(1)$ | $O(1)$ |
| 对比项 | 循环队列 | 链队列 |
|---|---|---|
| 存储空间 | 固定大小,事先分配 | 动态分配,按需增长 |
| 假溢出 | 用取模解决 | 无 |
| 存储密度 | 高(只存数据) | 较低(额外存指针) |
| 空间开销 | 可能浪费预留空间 | 有多少用多少 |
| 适用场景 | 数据量可预测、性能要求高 | 数据量不确定、需要灵活扩容 |

浙公网安备 33010602011771号