为什么你不愿意用 yocto-queue 代替数组?

410 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第12天,点击查看活动详情

前言

事情是这样的,我之前项目里有个需求时展示 逐笔成交量 列表,列表有一定的 数量限制 ,超过这个数量,就要 删除第一个,然后将最新的插入到数组末尾 。这个频率有多快呢,大家可以看看图,每秒甚至有好几次的成交。

微信截图_20221002113351.png

于是面试的时候,面试官:你用的什么数据结构处理的?

我:是数组,我用了数组。超过数量时就 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 首页描述里提到,对于 数据比较多的数组 来说,如果需要 频繁 使用 pushshift ,那么应该使用 yocto-queue 来代替数组。因为长数组的 shift 操作具有 O(n) 的时间复杂度,,而通过这个库,只需要 O(1)

不了解队列的小伙伴,可以看看这篇文章:

队列|图解Javascript数据结构

实现一个 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 的效率呢?

不了解链表的小伙伴,可以看看这篇文章:

链表|图解Javascript数据结构

通过链表进行删除,实际上就是 修改变量的指向 ,赋值的效率肯定比遍历长数组快多了。

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 类来实例化节点,节点有两个属性 valuenext,其中 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++;
}
  1. 首先以入参 value 创建了一个 Node 实例(节点),然后进行添加。
  2. 这里它对 #head 属性进行判断,如果是空节点(也就是说链表长度为 0),那么新创建的 Node 实例 即是头节点又是尾节点
  3. 否则,我们要让尾节点的 next 属性指向新创建的节点,此时新的尾节点变成了刚刚创建的节点了,因此我们要将 #tail 指向它。
  4. 最后将 #size 的长度加一。

dequeue

dequeue() {
    const current = this.#head;
    if (!current) {
        return;
    }

    this.#head = this.#head.next;
    this.#size--;
    return current.value;
}

首先我们要知道,dequeue 方法是模拟数组 shift 方法的。

微信截图_20221002105546.png

对于空数组,shift 方法的调用结果是返回 undefined。那么空链表,调用 dequeue 的结果,也应该返回 undefined

前面提到了,如果 #head 指向为 undefined,就表示当前链表为空,此时返回值应当为 undefined,否则,用一个变量 current 保存当前 #headvalue(否则在删除头节点时会丢失数据),用于函数返回值。

链表节点的删除,实际上就是 让待删除节点的前一个节点的 next 指针指向当前节点的 next 指针指向的节点

这么说有点绕,直接看动图:

链表的删除.gif

因此,删除头节点,我们只需要执行 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 返回的数据中,必须有 valuedone 两个属性,熟悉 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 所有源码我们都分析完了,代码不长,百行不到,但是作用却不小,希望大家能够理解掌握,在碰到这种问题的时候可以学以致用。

如果小伙伴们有别的应用场景的例子,欢迎留言,让我们共同学习进步 💪💪。

如果文中有不对的地方,或是大家有不同的见解,欢迎指出 🙏🙏。

如果大家觉得所有收获,欢迎一键三连 💕💕。