数据结构——队列

队列

一、队列的定义

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)什么是循环队列?

在普通的顺序队列中,frontrear 指针只能从数组下标 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 删除 bfront 后移
入队 d [ ][ ][c][d][ ][ ] 2 4 d 放到 rear
入队 e [ ][ ][c][d][e][ ] 2 5 rear 已到数组末尾
想入队 f [ ][ ][c][d][e][?] 2 5 rear == MAXSIZE 无法入队

从上面可以看到:

  • 队头队尾不是数组的固定端,而是 frontrear 两个移动的指针
  • 下标 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 时(中间隔了一个空位),就说明队列满了。

img

当前的队列长度计算公式( 不论 $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)$ 比较 frontrear
取队头元素 $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)初始化

创建头结点,frontrear 都指向它:

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)$
对比项 循环队列 链队列
存储空间 固定大小,事先分配 动态分配,按需增长
假溢出 用取模解决
存储密度 高(只存数据) 较低(额外存指针)
空间开销 可能浪费预留空间 有多少用多少
适用场景 数据量可预测、性能要求高 数据量不确定、需要灵活扩容
posted @ 2026-07-14 16:03  Javenwww  阅读(15)  评论(0)    收藏  举报