写JavaScript,要用到队列你还会用数组吗?

2,805 阅读6分钟

本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。

在JS世界中,数组真是好东西,除了正常作为数组使用,还能当做栈或队列来用。然而学完今天的yocto-queue源码之后,在需要用到队列时你可能就要慎重选择到底用不用数组喽!

1.数据结构基础回顾

1.1 数组的shift操作

数组当做队列用时,入队可以使用push方法,在数组末尾增加了一个元素;出队可以使用shift方法,在数组首部删除一个元素。

如下代码展示了使用shift方法操作数组:

shift方法操作小数组时,问题不大,效率问题可以忽略。但是操作大数组时,就要考虑效率问题了。为什么呢?在展开具体解释之前我们先回顾一下数据结构的知识。

1.2 曾经的数据结构“第一课“

学过数据结构的同学应能理解如下内容:

(1)研究数据结构要研究数据的逻辑结构和物理结构(也叫存储结构)。

(2)逻辑结构中线性数据结构有线性表,栈,队列。

(3)线性表在实现时根据储存方式不同又分为顺序表和链表。

(4)顺序表中的元素在物理地址上要求是连续的,一个挨一个的存储。链表则不要求元素在物理地址上连续,只要前一个元素知道下一个元素在哪存放即可,即有一个指针指向后继元素的存储地址。

(5)数组、字符串属于顺序表,元素物理地址是连续的。

说了这些概念没懵圈吧,来张图:

1.3 线性表和链表的不同

选对了数据结构才能写出正确高效的算法和程序,所以我们要知道不同数据结构有什么样的特点,那么对于增、删、改、查以及插入操作,顺序表和链表有什么不同呢?

①链表比较方便插入和删除操作,线性表中插入一个元素,那么后面的元素地址都要往后移,删除同理。而链表只需要修改结点中的指针信息,不需要修改结点地址。

②线性表内容可以随机访问,因为是连续的内存单元,地址连续所以在这个区间内可以进行随机,链表存储地址不一定时连续的,所以不能随机访问;查找速度由于存储地址原因也快于链表。

通过以上内容我们就知道了,如果插入和删除操作比较多,那么最好使用链表,这样会更加高效。而对于队列而言出队操作就是从队首删除元素。如果在JS中我们用数组来当做队列使用,并且存在大量的出队操作时,效率就会低。这就是yocto-queue诞生的理论背景~

2.yocto-queue介绍

在yocto-queue的readme文档中说明了yocto-queue的是什么以及如何使用。

2.1 是什么

定义:yocto-queue是一个小型队列数据结构

适用场景:用yocto-queue代替数组,当你遇到在大数组上执行大量的push和shift方法时。原因是shift方法的执行是线性复杂度的,而yocto-queue入队操作是常数复杂度。

2.2 怎么用

上代码:

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());
//=> '🌈'

通过这段示例代码我们看到:

(1)使用队列时需要new 一个队列实例,从这里可以猜测其源码应该是导出了一个Class

(2)有一个size属性记录队列长度

(3)可以对队列实例执行展开操作(...),说明队列是可迭代的

(4)提供了入队(enqueue)和出队的方法(dequeue)并且出队的时候返回出队元素

通过示例代码,我们只能对其做简单了解,下面就通过阅读源码了解其详细信息和实现原理。

3.yocto-queue源码

源码行数共计67行,比较简明清晰,看源码时推荐先折叠起来整体了解一下然后再详细看,这里我们先看折叠起来的:

首先定义了一个Node类用来描述队列元素的。然后是Queue类的定义。#head为队头指针,#tail为队尾指针,#size为队列长度。除了通过示例代码看到的enqueue和dequeue方法,还有clear方法还有获取size的getter, 还有Symbol.iterator。

我们逐一看一下。

3.1 Node类

class Node {
	value;
	next;

	constructor(value) {
		this.value = value;
	}
}

Node节点的定义是典型的链表节点的定义,一个节点两个字段,value存储本节点的值,next存储后继节点的地址。

3.2 constructor和clear

constructor() {
  this.clear();
}
clear() {
  this.#head = undefined;
  this.#tail = undefined;
  this.#size = 0;
}

constructor用于初始化,clear用于清空队列,constructor调用了clear方法做到了代码的复用,妙啊!其实clear的清空队列只是把size置0,把队首队尾置undefined。这样剩下的工作交给了JS垃圾回收机制。

3.3 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++;
}

入队操作要判断当前队列是否为空,如果队列为空则当前入队节点成为队首元素;否则当前入队元素作为队尾,符合队列是FIFO(先进先出)的。示意图如下:

3.3 dequeue

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

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

出队时要把队首元素删除,队首元素的后继节点成为新的队首,队列元素个数减一,返回出队元素的值。

3.4 Symbol.iterator

* [Symbol.iterator]() {
    let current = this.#head;

    while (current) {
            yield current.value;
            current = current.next;
    }
}

Symbol.iterator 为每一个对象定义了默认的迭代器。该迭代器可以被 for...of 循环使用。这就是为什么队列实例可以使用...展开或者for-of循环的原因。

4.感想、收获与总结

感想:真正的大佬不光是把功能给实现了,还考虑性能要好~

收获:学完本期源码,我们巩固了JS中Class定义类的语法、数组的常用API; 复习了链表和数组在各种操作下时间复杂度的差异;了解了Symbol.iterator的使用等。

总结:yocto-queue使用链表来实现了一个简单的队列,可以在数组较大且shift操作非常多的场景下替代数组,降低时间复杂度从而提高性能。

5.留给读者——思考题

学完本期源码和本文,您可以思考如下问题:

1.数组和链表的异同?(从对于查找,删除,插入等操作的时间复杂度方面来谈)

2.JS 数组常用API有哪些?

3.用JS实现一个简单的链表?

4.用JS实现一个队列?

5.谈谈你对Symbol.iterator的理解?

6.yocto-queue的适用场景?