我正在参与掘金会员专属活动-源码共读第一期,点击参与
前言
队列相信大家都不陌生,它是属于数据结构等一种,不同种类的数据结构适合不同种类的应用,部分数据结构甚至是为了解决特定问题而设计出来的。而对于我们前端技术人员来说,理解数据结构都非常重要。在我们解决一些问题的时候,使用不同的数据结构会带来不同性能,因此数据结构是这些问题的解决方案中不可或缺的一部分。
使用场景
之前在做IM相关产品的时候,会经常用到队列,比如说之前有遇到一个场景,进入到多人聊天室时会将未读消息批量加载,然后通过消息获取到对应信息后,为了保证消息顺序的正确,需要一条一条按序存入IndexDB
中。这里就会使用到队列,最先存进来的获取数据的消息会被最先放入到IndexDb
当中。
还有一个场景大家肯定不陌生,那就是事件循环Event Loop。先将执行栈中的宏任务和微任务分别放入队列中,再从队列中取出来依次执行。
定义
队列是遵循FIFO(First In First Out,先进先出,也称为先来先服务)原则的一组有序的项。队列在尾部添加新元素,并从顶部移除元素。最新添加的元素必须排在队列的末尾。跟它相反的是栈,遵循先入后出(LIFO,last-in-first-out) 的原则。
举个简单例子,我们做核酸排队,其实就是一个简单的队列。最先来的人最先捅嗓子眼,后面来的人需要在我后面排队,等我捅完之后再捅。我简单的画了张图,理解一下相关的定义。
简单版
利用数组的shift、push
方法,实现一个简单版的队列。
class Queue {
constructor() {
this.queueList = []
}
/**
* 入队
* @param {*} el
*/
dequeue(el) {
this.queueList.push(el)
}
/**
* 出队
* @returns
*/
enqueue() {
return this.queueList.shift()
}
/**
* 获取队列长度
* @returns 队列长队
*/
size() {
return this.queueList.length
}
/**
* 清空队列
*/
clear() {
this.queueList = []
}
/**
* 判断队列是否为空
* @returns 是否为空
*/
isEmpty() {
return this.queueList.length === 0
}
}
const queue = new Queue()
queue.dequeue(1)
queue.dequeue(2)
queue.dequeue(false)
console.log(queue.enqueue(), queue.isEmpty()) // 1 false
链式队列
理解
链式队列相对于上一版就要复杂些,它的结构就像一个链条一样,一环扣一环。在队列中,每一个元素通过next
指向下一个元素,下一个元素又会有next
指向它后面的,这样各元素就通过next
连接起来,形成一条链子。
同时还会有两个指针,一个指针指向队头
元素,另外一个指针指向队尾
元素。当元素位置发生变动时,这两个指针也会随之改变。这里我们用head
表示队头,tail
表示队尾,next
为当前元素的下一个元素,看一下整个流程。
执行enqueue
入队操作,此时A4
的next
指向空,tail
指向A[3]
。当A5
执行enqueue
入队操作时,A4
的next
就会指向A5
, 同时tail
也会指向,A5
的next
就会为null
执行dequeue
出队操作,首先A0
的next
不会指向A1
,因为A0
已经出去了,不在当前队列中。然后head
会指向A1
,成为新的队头。
Node节点
理解完上面一张图,我们来先来实现一下enqueue
入队操作。在入队前,我们需要先定义一个Node
节点,用来表示每一个元素。
class Node {
constructor(element) {
this.element = element
this.next = null
}
}
这个代码很简单,就是实现一个Node
节点的时候,将元素放入element
,同时给它添加一个next
属性,用来指向下一个节点。
创建队列
接下来创建队列,先声明一下类,定义属性。
class MyQueue {
constructor() {
// 当前队列长度
this.size = 0
// 头部指针
this.head = null
// 尾部指针
this.tail = null
}
}
enqueue
然后按照上面的思路,实现一下enqueue
操作,需要进行以下几个操作。
- 创建节点
Node
- 添加元素
- 如果当前队列为空,添加到
head
- 如果当前队列不为空,添加到
tail
- 如果当前队列为空,添加到
- 修改
tail
指向 - 修改长度
/**
* 入队
* @param {*} value 入队属性
* @returns
*/
enqueue(value) {
if (!value) return false
// 创建节点
const node = new Node(value)
// 判断队列是否为空
if (this.isEmpty()) { // 这里先不看 isEmpty 方法
// 队列为空 将节点元素添加到头部
this.head = node
} else {
// 队列不为空 将队尾的 next 指向新元素
const currentNode = this.tail // 做一下缓存 避免影响到后面
currentNode.next = node
}
// 设置新的队尾
this.tail = node
// 修改长度
this.size++
console.log('enqueue element', value)
}
测试代码
const testQueue = new MyQueue();
testQueue.enqueue('vue') // enqueue element vue
testQueue.enqueue('react') // enqueue element react
testQueue.enqueue('angular') // enqueue element angular
dequeue
接下来是出队,主要是以下几个步骤。
- 判断是否为空
- 队列为不为空,获取头部
head
元素返回。 - 队列为空,不执行。
- 队列为不为空,获取头部
- 修改队列长度
- 将头部指针指向
next
中的元素 - 清空队尾
tail
- 返回队头
head
元素
/**
* 出队 最先进入的最先出去
* @returns
*/
dequeue(){
// 判断是否为空
if (this.isEmpty()) {
return
}
const currentHead = this.head // 缓存head
// 将头部指针指向下一个元素
this.head = currentHead.next
// 修改长度
this.size--
// 清空尾部
this.tail = null
return currentHead.element
}
测试代码
const testQueue = new MyQueue();
testQueue.enqueue('vue') // enqueue element vue
testQueue.enqueue('react') // enqueue element react
console.log(testQueue.dequeue(), '---dequeue1') // vue ---dequeue1
console.log(testQueue.dequeue(), '---dequeue2') // react ---dequeue2
console.log(testQueue.dequeue(), '---dequeue3') // undefined ---dequeue3
testQueue.enqueue('angular') // enqueue element angular
console.log(testQueue.dequeue(), '---dequeue4') // angular ---dequeue4
其他方法
主要的功能已经实现了,剩下的几个简单方法,对应注释我都写上了。
/**
* 清空队列
*/
clear() {
this.size = 0
this.head = null
this.tail = null
}
/**
* 获取队列长度
* @returns queue size
*/
getSize(){
return this.size
}
/**
* 队列是否为空
* @returns 队列是否为空
*/
isEmpty() {
return this.size === 0 && this.head === null
}
/**
* 打印队列
* @returns 队列字符串
*/
print() {
let queueStr = ''
if (!this.isEmpty()) { // 不为空
let tempNode = this.head // 缓存 head 避免修改影响到队列
// 循环头部
while(tempNode) {
// 获取 element
queueStr += tempNode.element +( tempNode.next ? '--->' :'')
// 修改 tempNode 进入下一次循环
tempNode = tempNode.next
}
}
return queueStr
}
完整代码
class Node {
constructor(element) {
this.element = element
this.next = null
}
}
class MyQueue {
constructor() {
// 当前队列长度
this.size = 0
// 头部指针
this.head = null
// 尾部指针
this.tail = null
}
/**
* 入队
* @param {*} value 入队属性
* @returns
*/
enqueue(value) {
if (!value) return false
// 创建节点
const node = new Node(value)
// 判断队列是否为空
if (this.isEmpty()) {
// 队列为空 将节点元素添加到头部
this.head = node
} else {
// 队列不为空 将队尾的 next 指向新元素
const currentNode = this.tail // 做一下缓存 避免影响到后面
currentNode.next = node
}
// 设置新的队尾
this.tail = node
// 修改长度
this.size++
console.log('enqueue element', value)
}
/**
* 出队 最先进入的最先出去
* @returns
*/
dequeue(){
// 判断是否为空
if (this.isEmpty()) {
return
}
const currentHead = this.head // 缓存head
// 将头部指针指向下一个元素
this.head = currentHead.next
// 修改长度
this.size--
// 清空尾部
this.tail = null
return currentHead.element
}
/**
* 清空队列
*/
clear() {
this.size = 0
this.head = null
this.tail = null
}
/**
* 获取队列长度
* @returns queue size
*/
getSize(){
return this.size
}
/**
* 队列是否为空
* @returns 队列是否为空
*/
isEmpty() {
return this.size === 0 && this.head === null
}
/**
* 打印队列
* @returns 队列字符串
*/
print() {
let queueStr = ''
if (!this.isEmpty()) { // 不为空
let tempNode = this.head // 缓存 head 避免修改影响到队列
// 循环头部
while(tempNode) {
// 获取 element
queueStr += tempNode.element +( tempNode.next ? '--->' :'')
// 修改 tempNode 进入下一次循环
tempNode = tempNode.next
}
}
return queueStr
}
}
const testQueue = new MyQueue();
testQueue.enqueue('vue')
testQueue.enqueue('react')
// testQueue.enqueue('angular')
console.log(testQueue.dequeue(), '---dequeue1')
console.log(testQueue.dequeue(), '---dequeue2')
// console.log(testQueue.clear(), '---clear');
console.log(testQueue.dequeue(), '---dequeue3')
testQueue.enqueue('angular')
console.log(testQueue.dequeue(), '---dequeue4')
// console.log(testQueue.getSize(), '---getSize')
// console.log(testQueue.isEmpty(), '---isEmpty')
// console.log(testQueue.print(), '---print')
yocto-queue源码
看一下yocto-queue的实现逻辑,大体逻辑上是差不多,但多了几个知识点。
自定义迭代器
在print
打印队列函数,这里是用了是用了Symbol.iterator自定义了一个迭代器,可以遍历整个队列,提供外界使用。
Class getter setter
在size
函数前面有一个 get
关键字,可以直接获取到return
返回的值,。同理,如果前面是set
,就可以直接修改数据。
私有属性#
我们都知道,class
类的属性默认都是公有的,在实现类的后,可以访问到类里面的属性。但可以使用增加哈希前缀 #
的方法来定义私有类字段
class Add {
#count = 0;
value() {
return this.#count;
}
increment() {
this.#count++;
}
}
上面代码中,#count
就是私有属性,只能在类的内部使用(this.#count
)。如果在类的外部使用,就会报错,这个就很好理解。
const counter = new Add();
counter.#count // 报错
counter.#count = 42 // 报错
总结
看完yocto-queue的实现后不由感慨,原来队列实现还可以这么优雅简洁,迭代器的使用也是很巧妙,这个思路可以收藏✅。写完后发现,数组版本和链式版本有啥区别?明明链式版本更简单易懂,为什么还要实现链式版本?后来刷题的时候才想通。数组的特点是查询数据快,插入数据慢,查询的时间复杂度是O(1),插入的时间复杂度是O(n)。它插入元素时,移动的是整个数组。而链式结构刚好和它相反,在查询时的复杂度是O(n),插入的是O(1)。插入元素只需要修改next
指向就可以,比数组快上不少,这就是数据结构的魅力。