当前位置: 首页 > news >正文

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

目录

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

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

操作入队操作 (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+1q->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->rearreturn q->front == q->rear;
    }// 判满
    int isFull(CircularQueue* q) {// 公式: (q->rear + 1) % q->capacity == q->frontreturn (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);       // 再释放结构体}
    }

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

    http://www.lryc.cn/news/620335.html

    相关文章:

  1. linux_网络层-ip协议
  2. 力扣 hot100 Day72
  3. 深入理解 Cookie 与 Session —— Web 状态保持详解与实战
  4. SpringBoot 整合 Langchain4j 系统提示词与用户提示词实战详解
  5. JavaWeb(05)
  6. TCP客户端Linux网络编程设计详解
  7. 人工智能——CNN基础:卷积和池化
  8. HiSmartPerf使用WIFI方式连接Android机显示当前设备0.0.0.0无法ping通!设备和电脑连接同一网络,将设备保持亮屏重新尝试
  9. SAP Valuation Category在制造业成本核算中的使用场景与配置方案
  10. 基于C语言基础对C++的进一步学习_C和C++编程范式、C与C++对比的一些补充知识、C++中的命名空间、文件分层
  11. window显示驱动开发—多平面覆盖 VidPN 呈现
  12. 看懂 Linux 硬件信息查看与故障排查
  13. 力扣42:接雨水
  14. 人工智能入门①:AI基础知识(上)
  15. Python图像处理基础(十三)
  16. 《工程封装》(Python)
  17. 网络安全合规6--服务器安全检测和防御技术
  18. 3.Ansible编写和运行playbook
  19. 3DM游戏运行库合集离线安装包下载, msvcp140.dll丢失等问题修复
  20. ESP32_STM32_DHT20
  21. 三极管的基极为什么需要下拉电阻
  22. Vue3从入门到精通:4.1 Vue Router 4深度解析与实战应用
  23. vue实现模拟 ai 对话功能
  24. JS的学习5
  25. vue修改element的css属性
  26. 决策树回归:用“分而治之”的智慧,搞定非线性回归难题(附3D可视化)
  27. 北京JAVA基础面试30天打卡09
  28. uniapp授权登录
  29. 硬件工程师八月实战项目分享
  30. 8.13迎来联动:PUBG布加迪,新版本37.1内容资讯!低配置也能飙车吃鸡!