数据结构:队列(Queue)与循环队列(Circular Queue)

发布于:2025-08-17 ⋅ 阅读:(17) ⋅ 点赞:(0)

目录

理解核心需求——什么是队列?

最直观的实现方式——用数组试试?

操作入队操作 (Enqueue):

模拟出队操作 (Dequeue):

发现重大缺陷!

改进出队操作

发现新缺陷!

最终方案——循环队列

发现最后一个,也是最微妙的缺陷!

解决方案(两种主流方案):

牺牲一个数组单元

最终实现:构建完整的循环队列代码

第 1 步:最终的结构体和创建函数

第 2 步:核心判断逻辑

第 3 步:入队和出队函数

第 4 步:收尾工作(销毁队列)


理解核心需求——什么是队列?

想象一下在食堂排队打饭。每个人都得老老实实地排在队伍的末尾,而食堂师傅每次只给排在队伍最前面的人打饭。

这个生活中的场景,就完美地描述了“队列”的核心思想:

  1. 先进先出 (First-In, First-Out, FIFO):最早进入队列的元素,也最早离开队列。就像排队,先来的人先打到饭。

  2. 两个基本操作:

    • 入队 (Enqueue):在队伍的末尾(队尾)加入一个新元素。

    • 出队 (Dequeue):从队伍的开头(队头)取出一个元素。

所以,我们需要设计一个数据结构,它必须高效地支持在“尾部添加”和在“头部移除”这两个操作。


最直观的实现方式——用数组试试?

我们最先想到的、最简单的数据结构就是数组。我们来尝试用数组实现队列。

我们需要两个“指针”(这里先用数组下标代替)来标记队头和队尾。

  • front:指向队头元素。

  • rear:指向下一个可以插入新元素的位置(即队尾元素的下一个位置)。

我们定义一个结构体来表示这个队列:

// 使用C风格的struct,在C和C++中都适用
#define MAX_SIZE 5 // 为了演示,我们先假设队列最多能存5个元素

struct ArrayQueue {
    int data[MAX_SIZE]; // 用数组存储数据
    int front;          // 队头下标
    int rear;           // 队尾下标
};

// 初始化
// front=0, rear=-1 表示队列为空。为什么rear是-1?
// 因为这样第一个元素进来后,rear可以先++变成0,再赋值,逻辑比较统一。
// 我们就采用这种 front=0, rear=-1 的约定。

操作入队操作 (Enqueue)

假设我们依次入队 11, 22, 33。

假设我们创建一个空队列,front = 0, rear = -1

初始状态:
Queue: [ , , , , ... ]
        ^
      front
(rear 在 -1, 看不见)

入队 11:

rear 先加 1 变成 0,然后在 data[0] 放入 11。rear 现在指向了队尾元素。

Queue: [ 11,  ,  ,  , ... ]
         ^
       front, rear

入队 22:

rear 加 1 变成 1,在 data[1] 放入 22。

Queue: [ 11, 22,  ,  , ... ]
         ^   ^
       front rear

入队 33:

rear 加 1 变成 2,在 data[2] 放入 33。

Queue: [ 11, 22, 33,  , ... ]
         ^       ^
       front    rear

入队逻辑看起来很简单:

q->rear++; 
q->data[q->rear] = value;

    模拟出队操作 (Dequeue)

    根据先进先出的原则,队头元素 11 应该先出队。

    取出 data[front] 的值后,front 之后的所有元素(22, 33)都必须向前移动一个位置,来填补 11 留下的空位。

    出队前:
    Queue: [ 11, 22, 33,  , ... ]
            ^       ^
          front    rear
    
    取出 11 后,我们必须...
    将 22 移动到下标 0
    将 33 移动到下标 1
    更新 rear 到 1
    
    出队后:
    Queue: [ 22, 33,  ,  , ... ]
            ^   ^
          front rear

    发现重大缺陷!

    这个出队操作太慢了!如果队列里有 N 个元素,出队一个元素就需要移动 N-1 个元素。

    它的时间复杂度是 O(N)。这完全违背了我们希望操作尽可能快(O(1))的初衷。

    所以,这个方案因为出队效率低下,被否决。

    改进出队操作

    我们必须让出队操作也变成 O(1)。怎么做?

    核心思路:不出队时移动元素,而是移动 front 指针!

    让我们重新定义 frontrear 的含义,这非常重要:

    • front: 指向队头元素所在的下标。

    • rear: 指向下一个新元素应该被插入位置的下标。

    // 还是那个结构体
    typedef struct {
        int data[MAX_SIZE];
        int front;
        int rear;
    } BetterQueue;
    
    // 初始化:
    // 让 front 和 rear 都指向 0。
    // 约定:当 front == rear 时,队列为空。

    新约定下的入队和出队

    初始状态: front = 0, rear = 0

    Queue: [  ,  ,  ,  , ... ]
             ^
           front, rear

    入队 11: 在 data[rear] 处放值,然后 rear 后移。data[0]=11; rear=1;

    Queue: [ 11,  ,  ,  , ... ]
             ^   ^
           front rear

    入队 22: data[1]=22; rear=2;

    Queue: [ 11, 22,  ,  , ... ]
             ^       ^
           front   rear

    出队: 从 data[front] 取值,然后 front 后移。

    value = data[0]; front=1;

    Queue: [ 11, 22,  ,  , ... ] //11 所在的位置成了“僵尸数据”,我们不再关心
                 ^   ^
               front rear

    太棒了!现在入队和出队都只是移动一下下标,时间复杂度都是 O(1)。我们成功了吗?


    发现新缺陷!

    让我们继续操作。

    继续入队: 一直入队,直到 rear 到达数组的末尾 MAX_SIZE

    Queue: [ xx, xx, 33, 44, 55, 66, 77, 88, 99, 100 ]
                 ^                                  ^
               front                                rear (rear=10)

    现在 rear 已经到了数组的尽头,我们无法再入队了。但是!

    看数组的前面,front 已经向后移动了,下标 0 和 1 的位置其实是空闲的!

    我们遇到了 “假溢出”。数组明明还有空间,但我们却认为它满了。

    结论:这个方案虽然高效,但会浪费大量空间,有严重的设计缺陷。


    最终方案——循环队列

    如何解决“假溢出”?我们需要一种方法能重新利用数组前面被浪费掉的空间。

    核心思路:把数组的头和尾“连接”起来,形成一个环。

    rearfront 指针移动到数组末尾时,下一步就让它自动“绕”回数组的开头(下标0)。

    这个“绕回”的魔术,用一个简单的数学运算就能实现:取模运算 (%)

    • front 前进一步的公式: front = (front + 1) % MAX_SIZE

    • rear 前进一步的公式: rear = (rear + 1) % MAX_SIZE

    循环的威力

    假设 MAX_SIZE 是 5,之前已经出队了两个,又入队了三个,rear 绕回了0

    Queue: [ 33, 44, 55,  ,  ]
             ^         ^
           front(2)  rear(0)

    入队 66: data[rear] = 66; rear = (rear + 1) % 5; => data[0] = 66; rear=1;

    Queue: [ 66, 44, 55,  ,  ]
                  ^  ^
                rear front(2)

    入队 77: data[rear] = 77; rear = (rear + 1) % 5; => data[1] = 77; rear=2;

    Queue: [ 66, 77, 55,  ,  ]
                      ^
                     rear, front (都等于2)

    发现最后一个,也是最微妙的缺陷!

    看上面的最后一步。我们入队 77 之后,rear 的下标变成了 2,此时 front 的下标也是 2。

    front == rear

    这和我们最初“队列为空”的约定 front == rear 完全一样了!我们现在无法区分队列是“满”还是“空”。

    解决方案(两种主流方案):

    1. 牺牲一个存储单元:我们规定,当 rear 的下一个位置是 front 时,队列就算满了。这样 rear 就永远不会真正追上 front

      • 判满条件:(rear + 1) % MAX_SIZE == front

      • 判空条件:front == rear

      • 这是最常用、最巧妙的实现方式。

    2. 增加一个计数器:用一个额外的变量 size 来记录队列中元素的个数。

      • 判满条件:size == MAX_SIZE

      • 判空条件:size == 0

      • 这种方式很直观,但需要额外维护一个变量。

    我们采用第一种更经典的方法来构建我们的循环队列。


    牺牲一个数组单元

    • 重新定义“满” :我们不再认为“所有格子都占满”才叫满。我们换一个定义:

      当队列中只剩下最后一个空闲位置时,我们就认为队列已经满了。我们不允许使用这最后一个位置。

      这个“牺牲”掉的单元,就是用来区分“满”和“空”的关键。

    • 想象“满”的状态

      让我们在脑海里(或者纸上)构建这个新的“满”状态。假设 maxSize 还是 5。front 在下标 f 的位置。rear 指向队尾元素的下一个位置。

      当队列按照新定义变“满”时,数组里应该有 maxSize - 1 个元素,也就是 4 个元素。rear 指针应该在哪里?

      看下图,front 在位置 1。我们填入4个元素后,rear 最终会停在 0 的位置。

    寻找 frontrear 的数学关系

    观察上图这个“满”的状态,front1rear0。它们之间有什么关系?

    • rear 好像在 front 的“前一个”位置。

    • 换一种说法:从 rear 的位置再向后走一步,就碰到了 front

    这个“向后走一步”的操作,在循环队列里如何用数学表达?

    • 如果 rear 不是在数组末尾,那么就是 rear + 1

    • 如果 rear 恰好在数组末尾(比如 maxSize - 1),下一步就要回到 0

    这两个情况可以用一个统一的数学公式来表示:取模运算(Modulo)rear 的下一个位置是:(rear + 1) % maxSize

    形成判满公式

    我们把第2步的观察和第3步的数学表达结合起来。

    当队列“满”的时候(按我们的新定义),rear 的下一个位置恰好就是 front 所在的位置。

    所以,判满条件就是: rear 的下一个位置 == front

    翻译成代码和公式就是:

    (rear + 1) % capacity == front

    验证这个方案是否解决了矛盾

    • 判空条件:我们没有改变它,依然是 front == rear

    • 判满条件:现在是 (rear + 1) % maxSize == front

    在这套新规则下,rear 永远不会追上 front(因为一旦 rear 的下一个位置是 front,就不再允许入队了)。

    所以 front == rear 这个状态只可能在一种情况下出现:队列为空。

    核心矛盾被完美解决。

    这也意味着,一个容量为 k 的队列,其实需要一个大小为 k+1 的数组。


    最终实现:构建完整的循环队列代码

    现在,我们把所有正确的思想碎片拼接到一起,形成最终的代码。

    第 1 步:最终的结构体和创建函数

    为了灵活,我们不再用宏定义 MAX_SIZE,而是在创建时动态指定容量。

    #include <stdio.h>
    #include <stdlib.h> // for malloc and free
    
    // 最终版循环队列结构
    typedef struct {
        int* data;      // 指向数据数组的指针
        int capacity;   // 数组的总容量 (是我们想存的元素数 k + 1)
        int front;      // 队头指针
        int rear;       // 队尾指针
    } CircularQueue;
    
    // 创建队列
    CircularQueue* createQueue(int k) {
        CircularQueue* q = (CircularQueue*)malloc(sizeof(CircularQueue));
        if (!q) return NULL;
        
        q->capacity = k + 1; // 牺牲一个单元,所以容量要 k+1
        q->data = (int*)malloc(q->capacity * sizeof(int));
        if (!q->data) {
            free(q);
            return NULL;
        }
        q->front = 0;
        q->rear = 0;
        return q;
    }

    第 2 步:核心判断逻辑

    // 判空
    int isEmpty(CircularQueue* q) {
        // 公式: q->front == q->rear
        return q->front == q->rear;
    }
    
    // 判满
    int isFull(CircularQueue* q) {
        // 公式: (q->rear + 1) % q->capacity == q->front
        return (q->rear + 1) % q->capacity == q->front;
    }

    第 3 步:入队和出队函数

    // 入队
    int enqueue(CircularQueue* q, int value) {
        if (isFull(q)) {
            printf("Error: 队列已满,无法入队。\n");
            return 0; // 0 代表失败
        }
        q->data[q->rear] = value;
        q->rear = (q->rear + 1) % q->capacity; // rear 指针使用取模运算前进
        return 1; // 1 代表成功
    }
    
    // 出队
    int dequeue(CircularQueue* q, int* pValue) { // 用指针传出值
        if (isEmpty(q)) {
            printf("Error: 队列为空,无法出队。\n");
            return 0;
        }
        *pValue = q->data[q->front];
        q->front = (q->front + 1) % q->capacity; // front 指针使用取模运算前进
        return 1;
    }

    第 4 步:收尾工作(销毁队列)

    void destroyQueue(CircularQueue* q) {
        if (q) {
            free(q->data); // 先释放数组
            free(q);       // 再释放结构体
        }
    }

    通过这个从“直觉”到“缺陷”再到“完善”的推导过程,我们就从第一性原理出发,构建了一个健壮、高效的基于数组的循环队列。每一步代码的演进都是为了解决上一步遇到的问题,这正是数据结构设计的精髓所在。


    网站公告

    今日签到

    点亮在社区的每一天
    去签到