王道C++班级参考资料
——
C语言部分卷5数据结构
节3队列

最新版本V3.0
王道C++团队
COPYRIGHT ⓒ 2021-2024. 王道版权所有

概述

Gn!

队列是另一种操作受限的线性结构。但它的受限和栈是不同的:

  1. 队列只能在一端添加元素(队尾)

  2. 在另一端访问、删除元素(队头)

队列就不用想象了,它就是我们生活中排队的场景,你只能在队尾插入一个元素,在队头删除一个元素。

在队列中,最先添加到队尾的元素总是最先在队头删除,遵循先进先出(FIFO,First In First Out)的原则。

如下图所示:

队列-概念模型

队列这种数据结构的基本操作主要包括以下几种:

  1. 入队:在队尾添加一个元素,新元素成为新的队尾元素。

  2. 出队:在队头删除一个元素,第二个元素称为新的队头元素。

  3. 访问队头元素

  4. 判空

  5. 判满

数组队列的实现

Gn!

队列在实现时同样可以选择基于数组或者链表,其中数组实现是更常用的选择,链表实现则交给大家自行做作业完成。

数组队列的实现方式

Gn!

基于数组来实现队列(使用普通数组,假设数组的长度是N),有以下几种方案:

注意:这里使用的都是索引,而不是指针。数组可以使用索引模拟指针,而不需要直接使用指针!!

第一种方案:

数组队列-方案一

这种实现方案,基本操作的时间复杂度是:

  1. 入队:在rear索引位置插入一个元素,rear索引加1。时间复杂度是O(1)

  2. 出队:删除数组首元素,但后续所有元素必须向前移动一位,rear索引减1。时间复杂度是O(n)

  3. 访问队头元素:也就是访问数组首元素,时间复杂度是O(1)

这种方案队列判空的条件是:

rear == 0

判满的条件是:

rear == N

显然这种方案链出入队的效率都无法保证,不是一个可用的解决方案。

第二种方案:

队列的出入队是最基本操作,O(n)的时间复杂度是不可取的。所以我们优化一下得到方案二:

数组队列-方案二

这种实现方案,基本操作的时间复杂度是:

  1. 入队:在rear索引位置插入一个元素,rear索引加1。时间复杂度是O(1)

  2. 出队:删除front位置元素,然后front索引加1。时间复杂度是O(1)

  3. 访问队头元素:也就是访问front索引位置的元素,时间复杂度是O(1)

这种方案队列判空的条件是:

front == rear

判满的条件是:

rear == N

这样的设计使得出入队的操作效率都得到了保障,但是也有一个明显的缺点:

随着不断地出队操作,数组前面的空间会变得闲置,而且不可以被重新利用,这显然也是不可取的。

第三种方案:

基于方案二的问题,我们实现一个循环队列,以解决空间浪费的问题。如下图所示:

数组队列-方案三

这种方案,最核心的问题就是:如何移动两个索引,使得它们能够在数组内部循环移动呢?

使用取余运算符"%"即可:

rear = (rear + 1) % N

front = (front + 1) % N

解释:

对于数组的下标运算,几乎都是要涉及对数组长度N的取余运算,现在我们希望往后移动索引,而且希望从最后一个索引向后移动是第一个索引,所以就这样计算。

方案三的设计就实现了对数组前面空间的利用,避免了空间浪费,同时出入队的效率也很高。下面我们思考一个问题:

这种设计方案,如何进行队列判空和判满呢?

实际上如果就单纯按照上述方案设计循环队列,那么它们的判空和判满条件是一样的:

rear == front

所以对于这两个操作我们还需要进行一点小优化,有两个选择:

  1. 牺牲一个存储单元不存储元素,只要rear指向front的前一个位置就表示队列满了。如下图所示:

    数组队列-方案三改进图1

    在这种情况下:

    1. 队列满了的条件是:(rear + 1) % N == front

  2. 队列为空的条件是:rear == front

这种办法是教科书中讲数据结构经常使用的方案,在实际开发中我们可以用一个更简单粗暴,更易于实现和使用的方式——计数器。

  1. 使用一个额外的计数器变量来统计数组中元素的个数,这样判空和判满都十分简洁了。具体而言可以参考下图的实现:

    数组队列-方案三改进图2

基于上述图中的模型,实现一个固定长度的数组队列,需要注意的是:

  1. front用于记录队头元素,出队时就将该元素出队。

  2. rear用于指示下一个元素入队的索引,也就是说入队操作时直接将元素插入rear索引位置就可以了。

  3. 规定front(包含)和rear(不包含)索引之间的元素就是队列元素,出队操作时,直接向后移动索引front就可以了,不需要任何赋值操作。

  4. front向后移动的方式是:(front + 1) % N

  5. rear向后移动的方式是:(rear + 1) % N

设计头文件

Gn!

我们可以创建一个头文件"my_queue.h",参考代码如下:

头文件-参考代码

下面就可以基于该头文件,从而实现这个循环队列。

实现创建和销毁队列

Gn!

先来实现两个比较简单的,创建和销毁操作。这两个操作基本就是直接,分配内存和释放内存。参考代码如下:

创建和销毁队列-参考代码

注意用calloc函数是最合适的,省去了手动初始化0值的麻烦,错误处理则由函数调用者来完成。

实现判空和判满

Gn!

判空和判满在入队和出队操作时,都会用到,所以我们先来实现这两个操作。

由于我们在结构体对象中维护了一个size成员用于记录队列的元素数量,所以判断和判满的实现就非常容易了。

参考代码如下:

判空和判满-参考代码

实现入队操作

Gn!

入队的具体逻辑是:

  1. 首先判断队列是否已满,若队列已满则无法执行入队操作。

  2. 若队列未满,则在rear索引处新增一个元素,并将rear索引移到下一个位置。

  3. 更新size。

参考代码如下:

入队-参考代码

稍微需要注意的就是更新rear索引的逻辑,由于它向后移动1位,且数组长度为CAPACITY,所以rear索引更新的逻辑就是:

q->rear = (q->rear + 1) % CAPACITY;

实现出队操作

Gn!

出队的具体逻辑是:

  1. 首先判断队列是否为空,若队列为空则无法实现出队操作。

  2. 若队列不为空,则将front索引移到下一个位置,并返回此前的队首元素。

  3. 更新size。

参考代码如下:

出队操作-参考代码

这里同样有一个更新front索引的逻辑,和上面更新rear的逻辑是一致的。

实现访问队首元素

Gn!

这个操作就太简单了,判空处理一下,然后返回队首元素即可。参考代码如下:

访问队首元素-参考代码

扩展: 链式队列

Gn!

基于链表来实现队列也是可以的,但不如使用数组常见,因为数组来实现队列的访问效率会更高。

但链表实现的队列往往会更加灵活,且不会像数组那样存在较多的内存空间没有被利用,空间利用率高,没有闲置空间。所以链式队列也有它的可取之处。

注意:链式队列就没有队列满的概念了,它的基本操作中可以把判满去掉。

下面基于一个头文件,我们实现一下链式队列:

链式队列头文件-参考代码

下面是它的实现参考代码:

链式队列实现-参考代码

最后给出一段测试代码:

链式队列测试用例-参考代码

最终出队列的结果应该是:

1 2 3 4 5 6

扩展: 动态数组队列

Gn!

既然可以实现固定长度的队列,那么基于动态数组,就可以实现一个动态数组队列。

基于以下头文件"dynamic_queue.h"来实现:

扩展: 动态数组队列-演示代码1

下面是它的实现参考代码:

扩展: 动态数组队列-演示代码2

最后仍然是给出一个测试用例,代码如下:

扩展: 动态数组队列-演示代码3

以上。

队列的应用场景

Gn!

队列是一种非常实用的数据结构,由于其先进先出(FIFO)的特性,队列特别适用于那些需要按顺序处理元素的场景。

这种场景就太多了:

  1. 操作系统的任务调度,进程/线程按照到达顺序来排队等待CPU执行权。

  2. 各种数据处理系统中的缓冲区/缓存机制。比如stdin/stdout缓冲区,先输入缓冲区的数据,总是先从缓冲区输出。

  3. 打印机的任务管理,当多个打印任务同时到达时,它们在队列中排队,按照提交的顺序进行打印。

  4. 后端应用系统中的消息队列。

  5. 广度优先搜索(比如二叉搜索树的层次遍历)

  6. ...

消息队列的机制

在这里,我们不妨扩展讲解一下消息队列的机制。

首先消息队列是后端应用系统中,特别重要且几乎必然要使用的一种中间件。常用的消息队列有:Kafka、RabbitMQ、RocketMQ...

这些消息队列,消息中间件,它们的实现往往都非常复杂,但核心的概念都是基于数据结构中的队列的,也就是满足"先进先出"的特点。

这些消息中间件的作为是在多台服务器之间进行消息传递,发送消息,接收消息等。这些消息就会按照发送的顺序排队,形成队列,然后按照"先进先出"的顺序进行处理。

当用户量、并发水平达到一定程度,应用往往都要进行服务化,将不同的服务运行在不同的服务器上,由于这些服务本身就是一条流水线上的业务,所以常规的处理方式就是"同步处理":

  1. A机器处理完A业务,告诉B机器

  2. B机器处理完B业务,告诉C机器

  3. C机器处理完C业务

  4. ...

这种同步的处理方式,使得应用整体抗风险能力很差,某台机器出问题就意味着整体完蛋,并且由于"木桶效应",某台机器处理某业务的效率如果偏低,那么它将拖累整个业务流程的处理性能。

这时我们就可以通过引入一个中间层"消息中间件",让原本需要同步处理耦合严重的业务实现了分离解耦,从而就可以实现业务逻辑的异步处理,提高性能,也增强了抗风险性。

这里我们以一个订单处理的逻辑来举例说明为什么消息中间件这么重要:

消息队列-示意图

The End