yocto-queue,高效的队列

857 阅读8分钟

我正在参与掘金会员专属活动-源码共读第一期,点击参与

前言

本文主要分析的是 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 当中,我们可以通过对象来实现链表,这个对象又叫节点对象,有两个属性,valuenextvalue 表示当前元素的值,相当于上图的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;

变量 headtail 表示链表的头部和尾部,这样就可以通过这两个变量来表示整个链表。

试想一下,如果我们要删除链表的第一个元素,是不是只需要两步操作:

  1. 将变量 head 赋值为第二个元素(node2)。
  2. 将第一个元素的 next 属性赋值为 nullnode1.next 赋值为 null),第一个元素(node1)与整个链表断开。

其他元素不需要做任何操作,这两步操作的时间复杂度都是 O(1),因此链表删除第一个元素的效率非常高。

同样,在链表尾部增加元素也只需要进行两步操作:

  1. 将变量 tail.next 赋值为下一个节点对象 node5
  2. 将变量 tail 赋值为 node5node5 成为链表尾部的元素。

所以,链表插入和删除元素的效率高,适合用来实现队列(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 都干了什么:

  1. #head 表示链表的头部,#tail 表示链表的尾部,#size 表示链表元素的个数,它们三个都是静态属性,只能在类内部访问,不能通过实例对象访问。
  2. constructor() 方法调用了 clear() 方法,并不是做清除数据的作用,反而是初始化数据的作用。
  3. enqueue() 方法中 if 分支和上文链表尾部增加元素的两步操作是一样的,else 分支表示第一次添加元素时,链表头部和尾部都应该指向该元素。
  4. 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 };
    },
  };
}

至此,一个能高效地执行插入和删除元素的可遍历队列就诞生啦!

总结

  1. 队列的特点是先进先出(FIFO),只能在队头删除元素,队尾插入元素。
  2. 数组不适合用来实现队列,因为删除元素的时间复杂度是 O(n)
  3. 链表就是一条链子,每个元素都有 next 属性连着下一个元素,插入和删除元素的时间复杂度是 O(1),所以适合用来实现队列。
  4. 生成器函数需要在函数名前面加 *,形式上和普通函数一样。调用生成器函数后,该函数内部并不执行,返回的也不是函数运行结果,而是一个遍历器对象,遍历器对象初次调用 next() 方法,才开始执行函数内部,遇到 yield 表达式就暂定执行,直到再次调用 next() 方法。 yield 表达式的值为 value 属性的值。