这是我参与8月更文挑战的第3天,活动详情查看:8月更文挑战
前言
队列是一种线性表结构,队列的基本操作只有两个:入队和出队。入队是指:在队尾加入成员,出队是指:在队头去除成员。这种结构类似于现实中排队的场景,如果排除插队和中途离去的情况,我们永远都是从队尾开始排队,一直排到队头才走人,因此该结构称为“队列”。
入队流程例子:
- “数据1”从队尾入队,由于队列没有数据,因此“数据1”直接排在队头
- 同理,“数据2”从队尾入队,“数据2”则排在“数据1”后面
出队流程例子:
- 假设队列里已经存在“数据1”、“数据2”、“数据3”,现在进行出队操作,将会去除“数据1”
- 继续执行出队操作,将会去除“数据2”,队列最后只剩下“数据3”
在上面的入队流程和出队流程,我们可以找到一个规律:越早入队的数据,越早出队;越晚入队的数据,越晚出队。就像现实中在医院挂号排队一样,肯定是先排的人先就诊(不考虑插队、中途离去的情况)。
这就是队列的特点:“先进先出”(FIFO,first in first out),先进入的数据先出去。
队列和栈一样也有两种实现,一种是基于数组,另一种基于链表。使用数组实现的队列,我们称为“顺序队列”;使用链表实现的队列,我们称为“链式队列”。
顺序队列
使用 js 实现顺序队列。
// 顺序队列
class ArrayQueue {
#arr = []
#size = 0
// 入队
push(item) {
if (!item) {
return
}
this.#arr.push(item)
this.#size++
}
// 出队
pop() {
if (this.#size === 0) {
return
}
this.#size--
return this.#arr.shift()
}
size() {
return this.#size
}
print() {
console.log('队列成员:', this.#arr)
console.log('队列长度:' + this.#size)
}
}
可以看到 js 的数组原本就提供了 push()
和 shift()
方法,分别用于入队和出队,我们可以得知 js 数组本身也可用做一个队列。
在我的上一篇文章“js 数据结构 - 栈 (juejin.cn)”里就说过 js 数组本身就可以用做顺序栈,而且栈(里面的数组)的长度还是无上限的,不会遇到边界问题,但用其他语言来写顺序栈,栈里面的数组长度一般是有上限的,会出现边界问题。在这里也一样,从其他语言的角度来看,顺序队列(里面的数组)的长度一般是有上限的,仅从 js 的角度去写顺序队列是不够的,我们应该尝试从其他语言的角度去写顺序队列。
我们就从 java 来看待实现顺序队列吧。由于 java 的数组的长度需要初始化且长度不可变,导致入队会遇到边界问题,当队满时(数组满时),无法入队。因此在入队的操作里需要做队满的判断。
而且顺序队列还会遇到一个特殊的场景问题。当队满时,出队一个数据,再入队一个数据,你可能会发现无法入队这个数据,这是因为在数组里,出队的数据是在数组下标 0,数组下标0会变为空位,但是入队是从队尾进入的,导致这个队列即使有空位也无法进行入队操作。
解决这个问题很简单,我们只要将后面的数据向队头方向搬移,腾出队尾的空间就可以了,但这样就会让入队的最坏时间复杂度O(1) 提升为 O(n)。
下面的队列虽然还是用 js 来写,但是代码是和其他语言一样认为数组长度不可变的,并且初始化队列时需要给出长度上限。
// 其他语言眼中的顺序队列(数组长度不可变)
class OtherArrayQueue {
#arr
#head = 0
#tail = 0
#size = 0
#space = 0
constructor(space) {
if (!space || space <= 0) {
throw new Error('队列空间不可小于等于0')
}
this.#space = space
this.#arr = new Array(space)
}
// 入队
push(item) {
if (!item) {
return false
}
// 队满
if (this.#head === 0 && this.#tail === this.#space) {
console.log('队满,无法添加数据:', item)
return false
}
// 不是队满,但是尾指针指向的下标已经越界(等于数组的长度),需要将数据向前搬移
if (this.#tail === this.#space) {
for (var i=this.#head; i<this.#space; i++) {
this.#arr[i - this.#head] = this.#arr[i]
this.#arr[i] = null // 成功搬移数据后,需要将原来的位置数据清理,防止数组有冗余数据
}
this.#tail = this.#tail - this.#head
this.#head = 0
}
this.#arr[this.#tail] = item
this.#tail++
this.#size++
console.log(this.#tail)
return true
}
// 出队
pop() {
if (this.#size === 0) {
console.log('队空')
return
}
var temp = this.#arr[this.#head]
this.#arr[this.#head] = null
this.#head++
this.#size--
return temp
}
size() {
return this.#size
}
print() {
console.log('队列成员:', this.#arr)
console.log('队列长度:' + this.#size)
console.log('队列空间:' + this.#space)
}
}
我们可以看到顺序队列需要两个指针,一个是头指针,头指针总是指向队头元素的下标;一个是尾指针,尾指针总是指向队尾元素的后一个位置的下标。注意了,尾指针并不是指向队尾元素的下标。
如下:当队满时,头指针和尾指针的指向
链式队列
链式队列相比顺序队列实现简单很多,这得益于链式队列里面用于存储数据的链表的长度是无上限的。链式队列不会遇到像顺序队列那样有位置却没法入队的情况,因此不需要进行“数据搬移”操作。
链式队列也需要头指针和尾指针,不过链式链表的头指针是记录链表的头结点,尾指针是记录尾结点。
// 结点
class Node {
constructor(data, next) {
this.data = data
this.next = (next === undefined ? null : next)
}
}
// 链式队列
class LinkedQueue {
#head
#tail
#size = 0
// 入队
push(item) {
if (!item) {
return false
}
// 队伍没数据时
if (!this.#head) {
this.#head = new Node(item)
this.#tail = this.#head
} else {
this.#tail.next = new Node(item)
this.#tail = this.#tail.next
}
this.#size++
}
// 出队
pop() {
if (this.#size === 0) {
return
}
var temp = this.#head.data
this.#head = this.#head.next
// 如果这时头部为空,证明队列没任何数据,需要将 tail 指向空
if (!this.#head) {
this.#tail = this.#head
}
return temp
}
size() {
return this.#size
}
print() {
console.log('队列成员:', this.#head ? '':'为空')
var node = this.#head
while(node) {
console.log(node.data)
node = node.next
}
console.log('队列长度:' + this.#size)
}
}
循环队列
循环队列是一个环状队列,简单来说就是队头位置和队尾位置认为是相连的,整体看起来就像个圆环。循环队列只能基于数组实现,因为链表的尾结点连接头结点的结构叫做“循环链表”了。但在实现来看,我们确实可以用循环链表来实现循环队列,不过我没见过有人这样去实现循环队列,因此在这里不做这个实现。
由于循环队列是首尾相连,它不会存在顺序队列入队操作可能涉及搬移数据的问题,因此循环队列的使用比顺序队列更加广泛。
如下图:在循环队列中,数组的“下标3”的下一个位置认为是“下标0”,不再认为“下标3”是终点。
第一种写法
循环队列需要注意的地方有两点,入队的时候,#tail++
,如果 #tail
等于 #space
,则需要重置 #tail
为0;同样地,出队的时候,#head++
,如果 #head
等于 #space
,则需要重置 #head
为 0。
// 循环队列
class CycleQueue {
#arr
#head = 0
#tail = 0
#size = 0
#space = 0
constructor(space) {
if (!space || space <= 0) {
throw new Error('队列空间不可小于等于0')
}
this.#arr = new Array(space)
this.#space = space
}
// 入队
push(item) {
if (this.#size === this.#space) {
console.log('队满,无法添加数据:', item)
return false
}
this.#arr[this.#tail] = item
this.#tail++
this.#size++
if (this.#tail === this.#space) {
this.#tail = 0
}
return true
}
// 出队
pop() {
if (this.#size === 0) {
console.log('队空,无法出队')
return
}
var item = this.#arr[this.#head]
this.#arr[this.#head] = null
this.#head++
if (this.#head === this.#space) {
this.#head = 0
}
this.#size--
return item
}
size() {
return this.#size
}
print() {
console.log('队列成员:' + this.#arr)
console.log('队列长度:' + this.#size)
console.log('队列空间:' + this.#space)
console.log('队头指针:' + this.#head, '队尾指针:' + this.#tail)
}
}
第二种写法
但有些人喜欢不写 #size
(当前队列长度)属性,也就是说不会记录当前的队列长度。
也即是说无法用 this.#size === this.#space
判断队满,无法使用 this.#size === 0
判断队空。
那么当头指针等于尾指针时,如何去判断这是队满情况还是队空情况。如下图,不管是队空,还是队满,头指针和尾指针都有可能指向同一个位置。
这里可以通过浪费一个数组空间来进行区分队满和队空情况,如下图例子,就是浪费“下标3”的数组空间,队满时将会是下图这个样子。
那么队空和队满是不是可以这样区分判断?
队空判断:#head === #tail
队满判断:(#tail+1) % #space === head
队满的判断可能稍微有点难理解,我们直观地看,似乎 (#tail+1) === #head
就可以判断队满的情况,但是要记住 #tail
是往 #head
方向追赶,队列里面可能进行了 n 轮的入队出队操作,会发生多圈循环的情况。
比如,下图的队列是队满情况,但是 #tail+1
明显不等于 #head
因此需要 % #space
,解决多圈循环情况。
// 没有记录长度的循环队列(没有 size,浪费数组的一个空间用于辨别队满和队空的情况)
class NoSizeCycleQueue {
#arr
#head = 0
#tail = 0
#space = 0
constructor(space) {
if (!space || space <= 0) {
throw new Error('队列空间不可小于等于0')
}
this.#arr = new Array(space)
this.#space = space
}
// 入队
push(item) {
var temp = (this.#tail + 1) % this.#space
if (temp == this.#head) {
console.log('队满,无法添加数据:', item)
return false
}
this.#arr[this.#tail] = item
this.#tail = temp
return true
}
// 出队
pop() {
if (this.#head == this.#tail) {
console.log('队空,无法出队')
return
}
var item = this.#arr[this.#head]
this.#arr[this.#head] = null
this.#head = (this.#head + 1) % this.#space
return item
}
print() {
console.log('队列成员:' + this.#arr)
console.log('队列空间:' + this.#space)
console.log('队头指针:' + this.#head, '队尾指针:' + this.#tail)
}
}
双端队列
双端队列是一种特殊队列,队头和队尾都可以进行入队和出队操作。
有没发现,js 数组也可以作为一个双端顺序队列使用,队头入队使用 unshift()
方法,队头出队使用 shift()
方法,队尾入队使用 push()
方法,队尾出队使用 pop()
方法。
如果从 java 语言角度来看,数组的长度是有限制的,基于顺序队列去实现双端队列恐怕是一件很愚蠢的事。在前面的“顺序队列”章节里就已经说明了一种特殊情况,当队头有空位,队尾已经满员时,则需要向队头搬移数据。也就是说,由于队头和队尾都可以入队,除了考虑是否需要向队头方向搬移数据,也要考虑是否需要向队尾方向搬移数据,这就把入队操作复杂化了。
但双端队列使用同样基于数组实现的循环队列去实现是可以的,队头和队尾的入队、出队操作都不会复杂化。
双端队列当然也可以使用链表实现,只不过使用链表实现就不是环状结构了。