前言
大家好!我是 嘟老板。本文是 「前端应该了解的算法与数据结构」系列第 5 篇,我们继续数据结构的学习,今天的主题是 - 队列。
配合系列文章阅读,效果更佳。
阅读本文您将收获:
- 了解多种 队列 结构定义及特性。
- 了解多种存储方式实现 队列 结构及复杂度分析。
定义
大家应该都听说过 队列,消息队列、任务队列 等应用场景相当普遍。那么 队列 到底是怎样的一种数据结构呢?
队列(queue
)是只允许在一端进行插入操作,而在另一端进行删除操作的 线性表。
首先,队列 是一种 线性表,具有 先进先出(FIFO,First In First Out) 的特性;其次,队列 只允许在一端插入元素,这一端称为 队尾,在另一端删除元素,这一端称为 队头。
如我们排队买票的场景,后来的人只能站在队伍最后面,而队伍最前面的人才能买票,离开。
实现
抽象数据类型
队列 是一种特殊的线性表,理论上具备 线性表 的所有特性。然而由于 队列 在插入、删除元素方面的特殊性,我们为 队列 定义以下操作:
length
:队列元素个数。initQueue
:初始化队列结构。clearQueue
:清空队列元素。getHead
:获取队头元素。enQueue
:队尾插入元素。deQueue
:队头元素出队列。
队列类定义如下:
/**
* 队列
*/
class Queue {
// 队列元素个数
length = 0
constructor(data) {
this.initQueue(data)
}
// 初始化队列结构
initQueue(data) {}
// 清空队列
clearQueue() {}
// 获取队头元素
getHead() {}
// 元素e入栈
enQueue(e) {}
// 队头元素出栈
deQueue() {}
}
顺序存储结构
我们用 数组 来实现队列的顺序存储结构。
动手前先思考一下,数组的哪端作队头,哪端作队尾呢?很明显,下标为 0 的一端更适合作为队头。
基础队列
基础队列 指尾部插入元素,头部移除元素,未经任何优化的队列结构。
属性定义
基础队列需要具备以下属性:
MAX_SIZE
:队列最大容量,默认10
。data
:存储队列元素的数组,默认 空数组。length
:队列元素数量,默认0
。tail
:指向队尾元素下一个位置的指针,默认0
。
队列类定义如下:
class BaseSqQueue {
// 队列最大容量
MAX_SIZE = 10
// 元素数组
data = []
// 队列元素个数
length = 0
// ...
}
进队列(enQueue
)
进队列大致逻辑如下:
- 边界判断,元素个数是否已达容量上限,若是,则报错 “队列已满”。
- 将新元素添加到队尾,并将指针
tail
后移1
位。 - 队列长度
length
加1
。
实现如下:
enQueue(e) {
if (this.length >= this.MAX_SIZE) {
console.error('队列已满')
return
}
this.tail++
this.data[this.tail] = e
this.length++
}
出队列(deQueue
)
出队列大致逻辑如下:
- 边界判断,当前队列是否为空,若是,返回
null
。 - 获取下标为
0
的队头元素e
,作为返回值。 - 除队头元素外的所有元素,下标依次减
1
。 - 清空原队尾数据,队尾指针向前移动
1
位。 - 队列长度减
1
。 - 返回被删除的队头元素
e
实现如下:
deQueue() {
if (this.length === 0) {
console.warn('队列已空')
return null
}
const e = this.data[0]
for (let i = 0; i < this.data.length; i++) {
this.data[i] = this.data[i + 1]
}
this.data[this.tail] = null
this.tail--
this.length--
return e
}
时间复杂度分析
进队列操作时,直接在队尾加入元素即可,时间复杂度 O(1)
;
出队列操作时,需要将剩余元素依次向前移动 1
位,时间复杂度为 O(n)
;
循环队列
为了避免 基础队列 移除元素的性能问题,更合理的利用队列空间,衍生出了 循环队列 结构。
所谓 循环队列,就是不局限于仅在队尾插入元素,从数组尾部到头部,顺序寻找空间,并通过指针来定位队头和队尾元素。与 基础队列 最大的不同在于移除元素时不再需要移动队列中剩余元素,提升操作性能。
循环队列示意图如下:
属性定义
从以上示意图可见,循环队列 相较于 基础队列 多了一个队头指针 head
,指向对头元素的下标,默认为 0
。
队列类定义如下:
class CircleSqQueue {
// 队列最大容量
MAX_SIZE = 10
// 元素数组
data = []
// 队列元素个数
length = 0
// 队尾指针
head = 0
// 队尾指针
tail = 0
}
循环队列 有以下可能的状态:
- 当队列为空时,
head
指针和tail
指针指向同一位置,即head
===tail
。
- 当队列满时,由于循环特性,队尾元素的下一个位置,就是对头元素,所以
head
和tail
指针依然相等。
- 队列有元素,但未满,
tail
指针指向下一个空置的位置。
进队列
进队列大致逻辑如下:
- 边界判断,元素个数是否已达容量上限,若是,则报错 “队列已满”。
- 将新元素添加到
tail
指针处,并移动tail
指针。 - 队列长度
length
加1
。
实现如下:
enQueue(e) {
if (this.length >= this.MAX_SIZE) {
console.error('队列已满')
return
}
this.data[this.tail] = e
/**
* 若 tail 指针指向队列最后一个位置(下标 this.MAX_SIZE - 1),表示已没有后移的空间,将 tail 指向下标 0 的位置;
* 否则,后移 1 位。
*/
if (this.tail === this.MAX_SIZE - 1) this.tail = 0
else this.tail++
this.length++
}
出队列
出队列大致逻辑如下:
- 边界判断,当前队列是否为空,若是,返回
null
。 - 获取
head
指针指向的队头元素e
,作为返回值,然后清空head
位置的数据。 - 移动
head
指针,指向下一个元素。 - 队列长度减
1
。 - 返回被删除的队头元素
e
实现如下:
deQueue() {
if (this.length === 0) {
console.warn('队列已空')
return null
}
const e = this.data[this.head]
this.data[this.head] = null
/**
* 若 head 指针指向队列最后一个位置(下标 this.MAX_SIZE - 1),表示已没有后移的空间,将 head 指向下标 0 的位置;
* 否则,则后移 1 位
*/
if (this.head === this.MAX_SIZE - 1) this.head = 0
else this.head++
this.length--
return e
}
时间复杂度分析
进队列操作时,在 tail
指针位置插入元素即可,时间复杂度 O(1)
;
出队列操作时,直接移除队头元素,不需要移动位置,时间复杂度为 O(1)
;
链式存储结构
队列的链式存储结构相当于单链表,只不过队列只允许从尾部插入元素,从头部移除元素,我们可以将链式存储的队列称为 链队列。
定义结构
链队列 的元素不是纯数据数据,而是一个 节点结构,包含 数据域 和 指针域,分别用来存储 数据 及指向下一节点的指针 next
。
链队列节点类如下:
class ChainQueueNode {
constructor(data) {
this.data = data
this.next = undefined
}
}
其中,data
用来存储数据,next
指针指向当前节点的后继节点。
由于队列操作涉及到队头和队尾,所以链队列包含两个指针 - head
和 next
。
链队列类如下:
class ChainQueue {
// 队头指针,指向头节点
head
// 队尾指针,指向队尾元素节点
tail
// 队列长度
length = 0
constructor() {
// 空状态 head 和 tail 指针同时指向头节点
this.head = this.tail = new ChainQueueNode(null)
this.length = 0
}
}
其中,head
指针指向链队列的 头节点,头节点的 next
指针指向队列队头元素;tail
指针指向队尾元素;当队列为空时,head
和 tail
都指向头节点。
进队列
链队列实现进队列的大致流程如下:
- 根据参数传递的数据,生成新节点
e
。 - 将新节点
e
赋值给当前队尾元素的后继。 - 将队尾指针
tail
指向新节点e
。 - 队列长度加
1
。
以下是操作示意图:
代码实现如下:
enQueue(data) {
// 生成新节点 e
const e = new ChainQueueNode(data)
// 将当前队尾元素的后继节点赋值为 e
this.tail.next = e
// 将队尾指针指向新节点 e
this.tail = e
this.length++
}
出队列
链队列实现出队列的大致流程如下:
- 获取队头节点
e
。 - 将节点
e
的后继节点,赋值给头结点的后继。 - 若原队列中仅有一个节点,则调整队尾指针指向头节点。
- 队列长度减
1
。
以下是操作示意图:
代码实现如下:
deQueue() {
// 获取待移除的队头元素
const e = this.head.next
// 将头节点的后继赋值为 e 的后继
this.head.next = e.next
// 若当前队尾元素是 e,则删除后,队列为空,tail 指针指向头节点
if (this.tail === e) {
this.tail = this.head
}
this.length--
return e.data
}
时间复杂度分析
链队列进队列和出队列的操作都是常数时间,时间复杂度均为 0(1)
。
结语
本文重点介绍了多种 队列 结构的设计思路,包括 基础队列、循环队列 及 链式队列 等,并实现其核心方法,enQueue
和 deQueue
,旨在帮助同学们加深对于 队列 数据结构的理解。希望对您有所帮助!相关代码已上传至 GitHub,欢迎 star。
如您对文章内容有任何疑问或想深入讨论,欢迎评论区留下您的问题和见解。
技术简而不凡,创新生生不息。我是 嘟老板,咱们下期再会。