携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第3天,点击查看活动详情 >>
- 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
- 这是源码共读的第32期,链接:juejin.cn/post/709076…
本篇文章我们通过阅读yocto-queue这个开源库,来学习队列、链表、数组这几个数据结构类型,并了解它们一些特点。yocto-queue这个库就是针对数组的一些低效操作比如“插入”、“删除”操作,利用链表的数据结构来进行优化。下面我们先复习下这几个数据结构的基本概念。
基础概念
首先先复习下数组、链表、队列等基础数据结构的相关知识。
数组
什么是数组?数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。这里注意下线性表、连续的内存空间、相同类型这几个关键字。
线性表:就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。除了数组,链表、队列、栈等都是线性表结构。
连续的内存空间和相同类型的数据:因为要保证连续的内存空间,因此也就导致了数组的很多操作变得非常低效,比如要在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工具。
用插入这个操作来举个例子。假设数组的长度为n,如果我们将一个数据插入到数组中第k个位置。为了把第k个位置空出来,给新的数据,我们需要将第k-n这部分的数据都顺序地往后顺移。如果在数组的末尾插入,就不需要移动数据,其数据复杂度就是O(1)。如果是在开头插入数据,所有的元素都需要依次往后移动,其数据复杂度为O(n)。所以算起来的平均时间复杂度为O(n)。
时间复杂度这里不做延伸,可以查看相关的文章。
图例示意:
链表
什么是链表?链表就是通过“指针”将一组零散的内存块串联起来。其中,我们把内存块称为链表的“节点”。为了将所有的节点串起来,每个链表的节点除了存储数据之外,还需要记录链上的下一个节点的地址,我们把这个记录下个节点地址的指针叫做后继指针next。可以用火车来理解链表。
从图中可以看到。其中有两个节点比较特殊,它们分别是第一个节点和最后一个节点。一般我们把第一个节点叫做头节点,最后一个节点叫做尾节点。其中,头节点用来记录链表的基地址,有了它,就可以遍历得到整条链表。而尾节点特殊的地方是:指针不是指向下一个节点,而是指向一个空地址null,表示这是链表上最后一个节点。这种链表叫做单链表。
与数组一样,链表也支持数据的查找、插入和删除。
在链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移节点,因为链表的存储空间本身就是不连续的。所以,在链表中插入和删除一个数据是非常快速的。针对插入和删除操作,只需要考虑相邻节点的指针改变,其对应的时间复杂度是O(1)。
但有利就有弊。链表想随机访问第K个元素,就没有数组的高效了。因为链表的数据并非连续存储,因此无法像数组那样根据首地址和下标,通过寻址公式直接计算出对应的内存地址,而是需要根据指针一个节点一个节点地依次遍历,知道找到相应的节点。
队列
什么是队列?队列是一种先进先出的、操作受限的线性表。队列最基本的操作只有两个:入队(enqueue)放一个数据到队列尾部;出队(dequeue)从队列头部取一个元素。
队列这个概念非常容易理解,可以想象成排队买票,先来的先买,后来的只能站在末尾,不能插队。
yocto-queue产生背景
根据上面数组、链表、队列的复习,对各个数据结构的特点都有了比较清晰的认知。我们知道数组的插入、删除操作的时间复杂度是O(n),而链表的插入、删除操作时间复杂度为O(1)。如果我们对数量级很大的数组进行数据的插入和删除操作,其性能开销是非常大的,因此我们可以使用链表这种线性数据结构来解决大数量级数据的参入和删除操作。
如果在处理先进先出这种具有队列特性的数据时,可以使用yocto-queue这个库来处理。该库就是一个简化版的链式队列,来优化操作性能。
源码学习
节点的实现。根据单链表节点的特性,每一个节点包含当前的数据存储value,以及指向下一个节点地址的指针next。
class Node {
value;
next;
constructor(value) {
this.value = value;
}
}
链式队列的实现。 首先我们先确定下队列都有哪些操作方法。入队enqueue()、出队dequeue()、清空队列clear()、队列长度size()。可以看下整体的结构。
class Queue {
#head; // 链表的头指针,表示链表中的第一个节点,并指向另一个节点信息
#tail; // 指向当前元素的指针
#size; // 队列的长度
constructor() {}
enqueue(value) {}
dequeue() {}
clear() {}
get size() {}
}
这里解释下#tail的作用。
当我们调用
enqueue()时,我们需要从#head这个节点遍历整个队列,找到队尾的数据,而这个遍历过程是非常耗时间的,因此我们通过将对最后一个值的引用保存为#tail来解决这个遍历的问题,直接引用它来添加新的节点。
队列方法的实现
enqueue()
enqueue(value) {
// 生成一个包含传入的入队数据的一个节点实例。
const node = new Node(value);
// 如果队列中已经存在数据,
if(this.#head) {
// 则将#tail(当前指向的尾节点)的next指向入队的数据
this.#tail.next = node;
// 同时将#tail指向入队的这个节点数据
this.#tail = node;
} else {
// 当队列是空时,将#head和#tail都指向生成的节点数据
this.#head = node;
this.#tail = node;
}
// 长度+1
this.#size++;
}
dequeue()
dequeue() {
// 获取当前队首的数据。
const current = this.#head;
// 如果找不到,直接return。
if(!current) {
return;
}
// 将队首节点指向下一个节点
this.#head = this.#head.next;
// 同时长度减1;
this.#size--;
// 返回当前出队的数据
return current.value;
}
clear()
// 将队列进行置空
clear() {
this.#head = undefined;
this.#tail = undefined;
this.#size = 0;
}
size()
// 返回队列的长度
get size() {
return this.#size;
}
迭代器的实现
迭代器相关的知识可以查阅官方文档或其他文章,或者查看前面写的这篇文章 源码学习—— arrify 转数组,其中也有关于可迭代对象的相关知识。
* [Symbol.iterator]() {
let current = this.#head;
while(current) {
yield current.value;
current = current.next;
}
}
为什么要实现Symbol.iterator。因为队列也是一种线性的数据结构,作为线性数据结构,能够被遍历是一项基础的能力,因此,在我们自定义实现的链式队列中,也要具备可迭代能力。
事例:
const queue = new Queue();
queue.enqueue('A');
queue.enqueue('B');
for (const item of queue) {
console.log('=====item', item); // A B
}
Generator函数及yield关键字,可以参考官方文档,来了解学习,这里不做过多的延伸。
收获
- 对数组、链表、队列数据结构的复习,并通过这个库,深入学习了我们所学的数据结构在实际项目中的应用。
- 链式队列的实现。
- 性能优化方面。通过该库了解到在数据量级很大的情况下,为了节省性能开销,利用数据结构方面的知识进行针对性的优化。
- 迭代协议的实际应用。如何让一个我们自定义的类型具备可迭代的能力。
思考
在开发过程中,数据量小的时候,其实我们很少去关注性能的,优先保证业务功能的实现,不建议过度设计,一方面在前期数据量小时候,优化性能的投入产出比是很小的,另一方面不知道业务会发展到什么数量级,可能根本就不会产生性能瓶颈,因此也不必在前期优先考虑性能的优化。
但我们还是要深入了解性能问题,且具备优化性能体验的能力,不断补充我们的武器库。在遇到问题的时候,能够有更多的角度更多的方法去思考去解决。