前言
本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
本篇是源码共读第32期 | yocto-queue队列链表,点击了解本期详情
准备
首先 yocto-queue是什么?官方说明是如果对大型数组执行大量push()和shift()操作,可以使用此包而不是数组,因为前者具有线性时间复杂度O(n),而dequeue()具有恒定时间复杂度0(1)
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());
//=> '🌈'
- 拉取代码
git clone https://github.com/sindresorhus/yocto-queue.git
回顾
在看源码之前先说一下数组、队列、链表和栈这几个的概念。
数组:是一种可以快速访问的数据结构,所有语言都内置支持数组。
在数组中,每一个数组都有一个地址,可以通过下标快速定位到数组中的元素,且时间复杂度是O(1)
当我们添加值的时候,往往都使用动态数组来解决这个问题,也就是说会自动进行数组扩容。打个比方数组容量原本为10当达到某个临界点例如6的时候,就会开辟一块内存去存放这个扩容后的更大的数组,然后将原来的元素复制过去。
当删除数组元素时也需要开辟内存进行元素复制,删除某一个元素后,把后面的元素都往前挪一格。
下面是简单扩展,可跳过
扩展
在C、C++、Java、Scala等语言中数组的实现,是通过在内存中划分一串连续的、固定长度的空间,来实现存放一组有限个相同数据类型的数据结构。和这些语言不同,在JavaScript中,数组可以容纳任何类型的值,可以是字符串、数字、对象(object),甚至是其他数组
从V8引擎的数组实现机制来看JavaScript 数组的内部类型有很多模式,如:
- PACKED_SMI_ELEMENTS
- PACKED_DOUBLE_ELEMENTS
- PACKED_ELEMENTS
- HOLEY_SMI_ELEMENTS
- HOLEY_DOUBLE_ELEMENTS
- HOLEY_ELEMENTS
PACKED是 “连续有值的数组”,HOLEY表示这个数组有很多无效项,这两个是互斥的。
SMI表示数据类型为 32 位整型,DOUBLE表示浮点类型,而中间这两者都无,表示数组的类型还杂糅了字符串、函数等,中间这个位置上的描述也是互斥的。
所以总的来看内部数据类型为[PACKED, HOLEY]_[SMI, DOUBLE, '']_ELEMENTS
const arr = []; // PACKED_SMI_ELEMENTS
const arr1 = [1, 2, 3]; // PACKED_SMI_ELEMENTS
const arr2 = [1, 2, 3];
arr2[300] = 4; // HOLEY_SMI_ELEMENTS
const arr3 = [1, 2, 3];
arr3.push(4.0); // PACKED_DOUBLE_ELEMENTS
const arr4 = [1, 2, 3]; // PACKED_SMI_ELEMENTS
arr4[300] = 4.0; // HOLEY_DOUBLE_ELEMENTS
const arr5 = [1, 2, 3]; // PACKED_SMI_ELEMENTS
arr5[300] = '4.0'; // HOLEY_ELEMENTS
从是否有Empty情况来看,PACKED > HOLEY 的性能
从类型来看,SMI > DOUBLE > 空类型。原因是类型决定了数组每项的长度,DOUBLE类型是指每一项可能为SMI也可能为DOUBLE,而空类型的每一项类型完全不可确认,在长度确认上会花费额外开销。
因此,HOLEY_ELEMENTS是性能最差的兜底类型。同时,降级也是不可逆的,例如arr3的这种情况由PACKED_SMI_ELEMENTS降到了PACKED_DOUBLE_ELEMENTS,哪怕pop()后,输出类型也依旧是PACKED_DOUBLE_ELEMENTS
数组有两种实现形式,一种是快数组(FAST ELEMENTS),快数组是一种线性存储方式,新创建的空数组,默认的存储方式是快数组,快数组长度是可变的,可以根据元素的增加和删除来动态调整存储空间大小。另一种是慢数组(Dictionary Elements),它用 HashTable 作为底层结构模拟数组的操作,用于数组长度非常大的时候,不需要连续开辟内存空间,而是用一个个零散的内存空间通过一个 HashTable 寻址来处理数据的存储
好了,扩展完毕。
栈:一种遵守后进先出(LIFO)原则的有序集合。栈既可以通过数组实现,也可以通过链表来实现。不管基于数组还是链表,入栈、出栈的时间复杂度都为O(1)
队列:最大的特点就是先进先出(FIFO),主要的两个操作是入队和出队。跟栈一样,它既可以用数组来实现,也可以用链表来实现。
链表:链表存储有序的元素集合,链表中的元素在内存中并不是连续放置的。每个元素由一个存储元素本身的节点和一个指向下一个元素的引用组成,不需要提前申请,随用随取,在大量push时效率高
回顾结束,那么在看源码之前,我们可以有这个概念:数组在大量push数据时需要不断扩容来存储更多数据,而链表不需要
源码
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类用来描述队列元素,value存储本节点的值,next存储后继节点的地址。
constructor初始化,清空队列,把队首队尾置为undefined,size为0
enqueue入队,判断下当前队列是否为空,如果队列为空则当前入队节点成为队首元素,否则为队尾
dequeue出队,把队首元素删除,队首元素的next节点成为新的队首,队列元素size减一,返回出队元素的值。
Symbol.iterator为每一个对象定义了默认的迭代器,使队列实例可以使用...展开或者for...of循环
总结
- 学习了
yocto-queue源码 - 复习了
队列链表 - 动态数组
同时也越发意识到了一个问题,咦!我咋想不到呢!😠气愤不已!!!
哦还要特别感谢我的小男朋友,我的数据结构不咋地,辛苦男朋友帮我补习,还从底层给我讲了下数组的数据操作(虽然是 Java的)勉强的给他点个赞吧🤔