持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第12天,点击查看活动详情
前言
事情是这样的,我之前项目里有个需求时展示 逐笔成交量 列表,列表有一定的 数量限制 ,超过这个数量,就要 删除第一个,然后将最新的插入到数组末尾 。这个频率有多快呢,大家可以看看图,每秒甚至有好几次的成交。
于是面试的时候,面试官:你用的什么数据结构处理的?
我:是数组,我用了数组。超过数量时就 shift 删除第一个,然后 push 将新元素添加到数组末尾。
面试官:为什么你不愿意用 yocto-queue 代替数组?
我当时懵了,也不知道这是啥。
于是这场面试在其让我回家等通知后草草收场了。
事毕,我马上溜进卫生间百度了一下,才了解了这个库的好用之处。
yocto-queue 简介
You should use this package instead of an array if you do a lot of Array#push() and Array#shift() on large arrays, since Array#shift() has linear time complexity O(n) while Queue#dequeue() has constant time complexity O(1). That makes a huge difference for large arrays.
yocto-queue 到底是啥呢?
yocto-queue 首页描述里提到,对于 数据比较多的数组 来说,如果需要 频繁 使用 push 和 shift ,那么应该使用 yocto-queue 来代替数组。因为长数组的 shift 操作具有 O(n) 的时间复杂度,,而通过这个库,只需要 O(1)。
不了解队列的小伙伴,可以看看这篇文章:
实现一个 Array.prototype.shift
如果让你来写一个 shift 方法,你会怎么去实现这个逻辑呢?
作用
Aray.prototype.shift 的作用是移除数组的第一个元素,并且 length 长度会减一。
返回值
shift 方法的返回值为被删除的元素。
实现
知道了 shift 的手段后,我们就可以着手写一个低配版了。
Array.prototype._shift = function () {
const lens = this.length;
if (!lens) return;
const v = this[0]; // 保存数组第一个元素,作为返回值
for (let i = 1; i < lens; ++i) { // 从索引 1 开始,向前一个索引位覆盖
this[i - 1] = this[i];
}
this.length--; // length 长度减 1
return v;
}
const arr = [1, 2, 3, 4, 5];
arr._shift(); // 1
console.log(arr); // [2, 3, 4, 5]
可以发现,调用 shift 方法,除第一个元素外,所有的元素都要 往前覆盖 ,对于 长数组 来说时间复杂度爆表。
Aray.prototype.shift 的源码
关于 Array.prototype.shift 的更多细节,可以进入下方链接查看:
ECMAScript® 2023 Language Specification (tc39.es)
yocto-queue 为什么更快
那么 yocto-queue 为什么就能解决 长数组 shift 效率低 的问题呢?
结论为先,因为这个库它使用 链表 来存储数据,而不是数组,好家伙直接 把数据结构替换了 。
那为什么将长数组换为链表就能提高 shift 的效率呢?
不了解链表的小伙伴,可以看看这篇文章:
通过链表进行删除,实际上就是 修改变量的指向 ,赋值的效率肯定比遍历长数组快多了。
yocto-queue 源码分析
完整源码
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
我们先来分析链表的节点。
class Node {
value;
next;
constructor(value) {
this.value = value;
}
}
这里它用了一个 Node 类来实例化节点,节点有两个属性 value 和 next,其中 value 表示节点的值,而 next 是一个指向下一节点的指针。
Queue 实例的属性
class Queue {
#head;
#tail;
#size;
}
Queue 实例有三个属性,分别是 #head、#tail 和 #size,它们的作用如下:
#head指向头节点,用于模拟shift来删除第一个元素。#tail指向尾节点,用于模拟push向末尾添加新元素。#size用于记录节点个数。
Queue 构造函数
constructor(){
this.clear();
}
构造函数中调用了 clear 方法,我们看看它具体做了什么。
clear 方法
clear() {
this.#head = undefined;
this.#tail = undefined;
this.#size = 0;
}
在创建一个 Queue 实例时,会调用它的构造函数,而构造函数调用了 clear 方法,我们可以看到,该方法对实例的三个属性做了初始化。其中,指向首尾节点的指针 #head 和 #tail 被初始化为 undefined,表示一开始没有节点,是个空链表,因此 #size 理所当然也初始化为 0。
既然初始化后空节点,那么接下来我们就介绍怎么往链表中添加节点。
enqueue方法
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++;
}
- 首先以入参
value创建了一个Node实例(节点),然后进行添加。 - 这里它对
#head属性进行判断,如果是空节点(也就是说链表长度为 0),那么新创建的Node实例 即是头节点又是尾节点 。 - 否则,我们要让尾节点的
next属性指向新创建的节点,此时新的尾节点变成了刚刚创建的节点了,因此我们要将#tail指向它。 - 最后将
#size的长度加一。
dequeue
dequeue() {
const current = this.#head;
if (!current) {
return;
}
this.#head = this.#head.next;
this.#size--;
return current.value;
}
首先我们要知道,dequeue 方法是模拟数组 shift 方法的。
对于空数组,shift 方法的调用结果是返回 undefined。那么空链表,调用 dequeue 的结果,也应该返回 undefined。
前面提到了,如果 #head 指向为 undefined,就表示当前链表为空,此时返回值应当为 undefined,否则,用一个变量 current 保存当前 #head 的 value(否则在删除头节点时会丢失数据),用于函数返回值。
链表节点的删除,实际上就是 让待删除节点的前一个节点的 next 指针指向当前节点的 next 指针指向的节点。
这么说有点绕,直接看动图:
因此,删除头节点,我们只需要执行 this.#head = this.#head.next 就可以了。
最后记得将 #size 的值减一。
size
get size() {
return this.#size;
}
数组有 length 属性获取数组的长度,因此 Queue 实例也提供一个访问器属性 size 来模拟。
[Symbol.iterator]
实际上到上面也差不多了,但是数组最重要的遍历功能我们还没实现。那么链表怎么实现遍历呢?
我们先看看朴素的链表遍历方式:
let current = this.#head;
while(current) {
console.log(current.value);
current = current.next;
}
再看看 Queue 的遍历方式:
* [Symbol.iterator]() {
let current = this.#head;
while (current) {
yield current.value;
current = current.next;
}
}
我们可以看到其实也差不多,yocto-queue 对迭代的实现采用了 [Symbol.iterator] 这个特殊的方式。
看看 MDN 对它的描述:
Symbol.iterator 为每一个对象定义了默认的迭代器。该迭代器可以被
for...of循环使用。当需要对一个对象进行迭代时(比如开始用于一个
for..of循环中),它的@@iterator方法都会在不传参情况下被调用,返回的迭代器用于获取要迭代的值。
也就是为,如果想实现通过 for...of 来迭代某个对象,我们需要去定制 Symbol.iterator 接口,使得它能返回我们预期的结果。
需要注意的是, Symbol.iterator 返回的数据中,必须有 value 和 done 两个属性,熟悉 ES6 的小伙伴可能记得 *yield 返回的对象就是有这两个属性的。而源码中也正是通过这个方法进行迭代。
动手测试一下:
const q = new Queue();
q.enqueue(1)
q.enqueue(2)
q.enqueue(3)
for (let n of q) {
console.log(n); // 1 2 3
}
可以,毛闷台。
结束语
至此,yocto-queue 所有源码我们都分析完了,代码不长,百行不到,但是作用却不小,希望大家能够理解掌握,在碰到这种问题的时候可以学以致用。
如果小伙伴们有别的应用场景的例子,欢迎留言,让我们共同学习进步 💪💪。
如果文中有不对的地方,或是大家有不同的见解,欢迎指出 🙏🙏。
如果大家觉得所有收获,欢迎一键三连 💕💕。