我正在参与掘金会员专属活动-源码共读第一期,点击参与。
前言
本文主要分析的是 yocto-queue 队列链表的源码,GitHub 地址github.com/sindresorhu…。
队列
在分析源码之前,首先需要了解队列这种数据结构。
队列是只允许在尾部进行插入操作,在头部进行删除操作的数据结构。队列的尾部又叫队尾,头部又叫队头。插入操作又称入队,删除操作又称出队。
如上图所示,如果想添加数据只能从 9 后面开始添加,因为这时候 9 是队尾;删除数据只能从 1 开始删除,因为 1 是 队头。
队列的这种特性又叫先进先出(First In First Out),简称 FIFO,因为先添加进来的元素先被删除。
在 JavaScript 当中,可以通过数组的 push() 和 shift() 方法让数组成为队列。
shift()方法从数组中删除第一个元素,并返回该元素的值。
push()方法将一个或多个元素添加到数组的末尾。
仔细一看,push() 方法就是入队,shift() 方法就是出队。我们利用这两个方法来简单地实现一个队列:
const queue = [];
queue.push(1); // 入队
queue.push(2); // 入队
queue.push(3); // 入队
queue.push(4); // 入队
console.log(queue); // [1,2,3,4]
queue.shift(); // 出队
queue.shift(); // 出队
console.log(queue) // [3,4]
但是我们知道,数组的优点是,获取第 i 个数据的效率高;缺点是,插入和删除第 i 个数据的效率低(i 是数组的索引)。
shift() 方法是指删除第一个数据,也就意味需要向前移动后面的每一个数据,时间复杂度为 O(n) 。
push() 方法是在最后一个位置添加数据,其他元素不需要做任何操作,时间复杂度为 O(1)。
由于 shift() 方法的时间复杂度不是常数(O(1)),所以数组并不适合用来实现队列。
那有没有一种数据结构,能让删除第一个数据的时间复杂度将为 O(1) 呢?有的,那就是下文要介绍的链表数据结构。
链表
什么是链表?最直观的理解就是像一条链子,每一个元素通过某种方式与下一个元素进行相连,一环扣一环。
在 JavaScript 当中,我们可以通过对象来实现链表,这个对象又叫节点对象,有两个属性,value 和 next,value 表示当前元素的值,相当于上图的1,2,3,4数值,next 表示指向下一个节点对象,相当于和下一个元素进行相连(下文都用元素来代替节点对象的说法)。具体实现代码如下:
class Node {
value;
next;
constructor(value) {
this.value = value;
this.next = null;
}
}
let head; // 表示链表的头部
let tail; // 表示链表的尾部
const node1 = new Node(1);
const node2 = new Node(2);
const node3 = new Node(3);
const node4 = new Node(4);
// 每个元素与下一个元素相连
node1.next = node2;
node2.next = node3;
node3.next = node4;
head = node1;
tail = node4;
变量 head 和 tail 表示链表的头部和尾部,这样就可以通过这两个变量来表示整个链表。
试想一下,如果我们要删除链表的第一个元素,是不是只需要两步操作:
- 将变量
head赋值为第二个元素(node2)。 - 将第一个元素的
next属性赋值为null(node1.next赋值为null),第一个元素(node1)与整个链表断开。
其他元素不需要做任何操作,这两步操作的时间复杂度都是 O(1),因此链表删除第一个元素的效率非常高。
同样,在链表尾部增加元素也只需要进行两步操作:
- 将变量
tail.next赋值为下一个节点对象node5 - 将变量
tail赋值为node5,node5成为链表尾部的元素。
所以,链表插入和删除元素的效率高,适合用来实现队列(yocto-queue 源码内部同样是利用了链表,且看下文分析~)。
由于链表并没有像数组那样有索引,所以如果想获取第 i 个元素,只能一个一个获取,直到第 i 个元素,由此可以得出结论:链表查找元素的时间复杂度是 O(n),效率比较低。
通过与数组的比较,我们可以很强烈地感受到,数组的优点就是链表的缺点,数组的缺点就链表的优点。
源码分析
先来看看 yocto-queue 的使用方式:
import Queue from 'yocto-queue';
const queue = new Queue();
queue.enqueue('🦄');
queue.enqueue('🌈');
console.log(queue.size);
//=> 2
console.log(...queue);
//=> '🦄 🌈'
console.log(queue.dequeue());
//=> '🦄'
console.log(queue.dequeue());
//=> '🌈'
我们可以直观地感受到,enqueue() 方法对应着数组的 push() 方法,dequeue() 方法对应着数组的 shift() 方法,size 属性对应数组的 length 属性。
大部分朋友会产生疑问,dequeue() 方法的使用方式跟 shift() 方法那么像,效率会很高吗?作者在 readme.md 中说到了,dequeue() 方法的时间复杂度为 O(1)。
那么他是怎么做的呢?让我们来看看源代码:
// 链表的节点对象
class Node {
value; // 元素的值
next; // 指向下一个元素
constructor(value) {
this.value = value;
}
}
export default class Queue {
#head; // 链表的头部
#tail; // 链表的尾部
#size; // 链表中元素的个数
constructor() {
this.clear();
}
// 入队
enqueue(value) {
const node = new Node(value);
if (this.#head) {
this.#tail.next = node;
this.#tail = node;
} else {
this.#head = node;
this.#tail = node;
}
this.#size++;
}
// 出队
dequeue() {
const current = this.#head;
if (!current) {
return;
}
this.#head = this.#head.next;
this.#size--;
return current.value;
}
clear() {
this.#head = undefined;
this.#tail = undefined;
this.#size = 0;
}
get size() {
return this.#size;
}
* [Symbol.iterator]() {
let current = this.#head;
while (current) {
yield current.value;
current = current.next;
}
}
}
类 Node和上文链表介绍的节点对象是同一个实现形式。
主要来分析类 Queue 都干了什么:
#head表示链表的头部,#tail表示链表的尾部,#size表示链表元素的个数,它们三个都是静态属性,只能在类内部访问,不能通过实例对象访问。constructor()方法调用了clear()方法,并不是做清除数据的作用,反而是初始化数据的作用。enqueue()方法中if分支和上文链表尾部增加元素的两步操作是一样的,else分支表示第一次添加元素时,链表头部和尾部都应该指向该元素。dequeue()方法直接将第二个元素作为链表头部了,没有把第一个元素的next属性赋值为null,这当然是没问题的,因为#head和#tail已经代表了整个链表。
到目前为止,enqueue() 和 dequeue() 方法已经实现了一个高效率的队列,那如果我想遍历队列中的元素该怎么办呢?
* [Symbol.iterator]() 方法就是为解决遍历而存在的,它是一个生成器函数。
什么是生成器函数?
形式上,它就是一个普通函数,只不过函数名前面会加 *。
生成器函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用生成器函数后,该函数内部并不执行,返回的也不是函数运行结果,而是一个遍历器对象,因此生成器函数又称为 遍历器对象生成函数(有关遍历器的分析可以参考文章 — Iterator(遍历器)),只有当遍历器对象调用 next() 方法时,生成器函数内部才开始执行。比如:
function* generatorFn() {
console.log('start');
}
const gen = generatorFn(); // 不会输出 start
gen.next(); // start
生成器函数内部使用的 yield 表达式可以理解为暂停执行,函数执行到 yield 表达式的位置就暂停了,不会继续往下执行,除非再次调用遍历器对象的 next() 方法恢复执行,并且 yield 表达式的值为 next() 方法返回值对象中的 value 属性值。举个例子:
function* generatorFn() {
console.log('start');
yield 'hello';
console.log('hello restore');
yield 'world';
console.log('world restore');
return 'ending';
}
const gen = generatorFn();
gen.next() // start, {value: 'hello', done: false}
gen.next() // hello restore, {value: 'world', done: false}
gen.next() // world restore, {value: 'ending', done: true}
如果执行到 return 语句,那么会将 return 的返回值赋值给 value 属性,done 设置为 true。
所以总得来说,生成器函数就是一个遍历器生成函数,因此可以被扩展运算符(...)或 for...of 循环遍历。
我们也可以将* [Symbol.iterator]() 方法改写成一个普通的遍历器生成函数:
[Symbol.iterator]() {
let current = this.#head;
return {
next() {
if (current) {
const val = current.value;
current = current.next;
return { value: val, done: false };
}
return { value: undefined, done: true };
},
};
}
至此,一个能高效地执行插入和删除元素的可遍历队列就诞生啦!
总结
- 队列的特点是先进先出(FIFO),只能在队头删除元素,队尾插入元素。
- 数组不适合用来实现队列,因为删除元素的时间复杂度是
O(n)。 - 链表就是一条链子,每个元素都有
next属性连着下一个元素,插入和删除元素的时间复杂度是O(1),所以适合用来实现队列。 - 生成器函数需要在函数名前面加
*,形式上和普通函数一样。调用生成器函数后,该函数内部并不执行,返回的也不是函数运行结果,而是一个遍历器对象,遍历器对象初次调用next()方法,才开始执行函数内部,遇到yield表达式就暂定执行,直到再次调用next()方法。yield表达式的值为value属性的值。