深入yocto-queue源码,60余行代码实现一个链表队列🎉🎉

2,378 阅读12分钟

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

hey🖐! 我是pino😊😊。一枚小透明,期待关注➕ 点赞,共同成长~

在开始本篇之前,其实需要一定的前置知识,比如,链表是什么?队列是什么? 相信对于很多经常刷leetcode,或者接触学习过算法的人来说,这两种数据结构并不陌生,但是还是有必要让我们花几分钟的时间来简短的聊一下这两种数据结构的基础前置知识。

链表

链表是一种数据结构,链表中的每个节点至少包含两个部分:数据域指针域。其中数据域用来存储数据,而指针域用来存储指向下一个数据的地址,如下图所示:

image-20220703101444-zvl2cc7.png

  1. 链表中的每个节点至少包含两个部分:数据域和指针域
  2. 链表中的每个节点,通过指针域的值,形成一个线性结构
  3. 查找节点O(n),插入节点O(1),删除节点O(1)
  4. 不适合快速的定位数据,通过动态的插入会让删除数据的场景

模拟实现一个简单的链表:

  1. 首先定义一个Node
 class Node{
     constructor(val) {
         // 数据域
         this.val = val
         // 指针域
         this.next = null
     }
 }
  1. 接下来实现添加和打印功能:
 class LinkNodeList{
     constructor() {
         this.head = null
         this.length = 0
     }
     // 添加节点
     append(val) {
         // 使用数据创建一个节点node
         let node = new Node(val)
         let p = this.head
         if(this.head) {
             // 找到链表的最后一个节点,把这个节点的.next属性赋值为node
             while(p.next) {
                 p = p.next
             }
             p.next = node
         } else {
             // 如果没有head节点,则代表链表为空,直接将node设置为头节点
             this.head = node
         }
         this.length++
     }
     // 打印链表
     print() {
         if(this.head) {
             let p = this.head
             let ret = ''
 ​
             do{
                 ret += `${p.val} --> `
                 p = p.next
             }while(p.next)
             ret += p.val
         } else {
             console.log('empty')
         }
     }
 }
 ​
 let linkList = new LinkNodeList()
 ​
 linkList.append(1)
 linkList.append(2)
 linkList.append(3)
 linkList.append(4)
 
 linkList.print() // 1 --> 2 --> 3 --> 4
 
 console.log(linkList.length) // 4

队列

队列是一种先进先出的数据结构。

image-20220703104955-7rw17ov.png

也就是只允许从队尾插入元素,队头弹出元素,如图:

出队操作

image-20220703105116-wtud9pn.png

入队操作

image-20220703105029-0dbjqva.png

其实对应到js中数组的操作是这样的:

只涉及数组中的两种操作push(后增),shift(前删)

 // 伪代码
 class Queue {
    constructor() {
   this.queue = []
    },
    push(val) {
   // 使用push进行入队操作
     this.queue.push(val)
    },
    shift() {
   // 使用shift进行出队操作
         this.queue.shift()
    }
 }

yocto-queue

yocto-queue其实是实现了一个微小的队列结构,它的目的实际上是为了让用户在操作数据量较大的数组时提高程序执行的效率。

那么可能就有人会问了,我用数组不香吗?你这又是链表又是队列的,我折腾这个干啥?

那么一切都要从数组这种结构说起。

数组

数组大家可能已经不陌生了,在日常的工作当中,几乎每时每刻都在和数组打交道,数组在内存中是一种按顺序进行存储的数据结构

例如我们有一个数组ary:

 let ary = ['吃饭', '睡觉', '吃黄瓜']

它在可用内存中是这样存储的:

Untitled Diagram.drawio 1-20220703110927-a9po6nb.png

可以看到数组中的每一项都是按照在内存中的顺序进行存储的,这样的数据结构可以很便捷的访问数组中的每一项,可以直接通过下标进行访问:

 // 取数组中的第二项
 ary[1]  // 睡觉
 ​
 // 因为是顺序存储,所以下标的位置就是数组中值的所在的位置

但是这种数据结构也由此产生了一个非常麻烦的问题,如果我们删除数据的第一项(数组中的shift操作)或者在任意一个位置插入元素,那么其他后面所有的位置全部都要向前移动一位!

因为数组是顺序存储的,如果不进行移动,那么我们在使用ary[0]访问第一个元素的时候,打印的会是undefined

 // 伪代码
 // 假设数组不向前移动
 ary.shift() // [undefined, '睡觉', '吃黄瓜']
 ary[0] // undefined

Untitled Diagram.drawio 3-20220703112117-0rno5pj.png

上图为数组移位操作

还有一个问题是,如上面的存储空间的示意图所示:如果我们的数组后面的空间被占用了,但是我们还想要再次增加一位,这时就需要将数组全部转移到内存的其他地方。

所以对数组的有些操作对性能的损耗将是非常大的。

链表

例如我们有一个链表queue:

 let queue = node('吃饭') -> node('睡觉') -> node('吃黄瓜')

由于链表的数据结构中存在指针域的存在,用于存储下一个元素的位置的坐标,所以数组中所产生的两个问题就不会存在了,链表中的任意一个值可以存储在内存中的任意一个地方,如下图:

Untitled Diagram.drawio 2-20220703112644-m32sohz.png

所以我们进行链表的删除或者插入操作时,只需要将上一个节点的next指针指向新插入元素,将新插入的元素的next指针指向下一个元素即可,没有被next指针所连接的元素会自动被回收,而我们上文中提到删除第一个元素就更简单了,只需要我们将head头节点变更为第二个元素即可。

但是链表这种结构也并不是完美无缺的,因为链表是随机存储的,每个值的位置都不是固定的,所以我们就不可能通过下标的方式进行访问链表的元素了,只能通过一个一个遍历的方式进行查找:

 // 伪代码
 ​
 while(this.head) {
  this.head = this.head.next
 }

yocto-queue实现了队列的几种基本操作:enqueue(入队)、dequeue(出队)、clear(清除)、size(获取长度)、[Symbol.iterator](处理for...of遍历)

接下来我们就从yocto-queue的测试文件入手,来一个一个的实现每个功能的逻辑:

node节点类

由于链表中每一个元素都是一个节点,所以,我们先创建一个节点类:

 class Node {
   // 定义节点值->value (用于保存节点真正的值)
   value;
   // 定义指针域->next (用于保存指向下一个节点的指针地址)
   next;
   constructor(value) {
     // 初始化节点值
     this.value = value
   }
 }

这个地方有一个需要注意的点,可以看到我们的初始化的值是定义在constructor外面的,也就是说,定义变量初始值有两种写法:

例如:

 // 第一种写法
 class Person {
   constructor() {
     this._count = 0;
   }
 }
 class Person {
   _count = 0;
   constructor() {
   }
 }

上面代码中,第二种写法的实例属性_count,放在了constructor上面,与constructor处于同一个层级。这时,不需要在实例属性前面加上this

注意,第二种写法定义的属性是实例对象自身的属性,而不是定义在实例对象的原型上面。

yocto-queue中定义Node类的初始化值的写法正是采用了第二种写法。

Queue 类

 class Queue {
     // 长度
     #size;
     // 头节点
     #head;
     // 尾节点
     #tail;
     constructor() {
       // 初始化类时,手动初始化一次各个属性值
       this.clear()
     }
 }

Queue类中定义了三个属性,this.#size用于保存队列的长度,this.#head用于保存头节点,返回整个队列只需要返回头节点即可,因为各个节点都是通过next属性串联起来的,换言之,获得了头节点,就可以获得整个通过链表生成的整个队列所有的值。

那么为什么还要实现 this.#tail 用于保存尾节点呢?

其实在yocto-queue源码中已经做了回答:

How it works: this.#head is an instance of Node which keeps track of its current value and nests another instance of Node that keeps the value that comes after it. When a value is provided to .enqueue(), the code needs to iterate through this.#head, going deeper and deeper to find the last value. However, iterating through every single item is slow. This problem is solved by saving a reference to the last value as this.#tail so that it can reference it to add a new value.

它是如何工作的: this.#head是一个Node的实例,它记录了它当前的值,并嵌套了另一个Node的实例,它记录了它后面的值。 当一个值被提供给.enqueue()时,代码需要遍历this.#head,不断深入以找到最后的值。然而,遍历每一个单项是很慢的。这个问题的解决方法是将最后一个值的引用保存为this.#tail,这样它就可以引用它来添加新的值。

所以这就可以解释this.#tail的作用:用于保存尾节点便于更快的添加新节点,当我们添加新节点时,只需要将尾节点(也就是this.#tail)的next属性定义为新节点即可。

enqueue(入队)

入队操作

我们先来分析一下enqueue方法的单测文件,看一下都实现了那些功能:

test('.enqueue()', t => {
    // 创建一个queue结构
    const queue = new Queue();
    // 入队一个彩虹小马
    queue.enqueue('🦄');
    // dequeue为出队操作(下文会讲解),出队后是否等于彩虹小马
    t.is(queue.dequeue(), '🦄');
  
    // 连续入队彩虹和黑桃心
    queue.enqueue('🌈');
    queue.enqueue('❤️');
    // 此时链表中为🌈 ❤️
    t.is(queue.dequeue(), '🌈');  
    // 是否为头部出队
    t.is(queue.dequeue(), '❤️');
});

接下来就可以实现enqueue函数功能了,可以看到核心就是两点:

  • 根据值创建节点
  • 从尾部入队
enqueue(value) {
    // 创建节点
    const node = new Node(value);
    // 首先判断头节点是否存在
    if (this.#head) {
        // 存在的话,直接将尾节点的next属性设置为新节点
	this.#tail.next = node;
        // 将尾节点移动最后一个位置
	this.#tail = node;
    } else {   
        // 如果头节点不存在(整个队列为空,那么直接将头节点设置为新节点)
	this.#head = node;
        // 此时队列中只存在一个节点,头节点和尾节点都指向新元素
	this.#tail = node;
    }
    // 更新长度值
    this.#size++;
}

dequeue(出队)

还是先来看一下单测文件:

test('.dequeue()', t => {
    const queue = new Queue();
     // 如果队列中并没有值,那么返回undefined
    t.is(queue.dequeue(), undefined);
    t.is(queue.dequeue(), undefined);
    queue.enqueue('🦄');
    // 从头部开始弹出
    t.is(queue.dequeue(), '🦄');
    t.is(queue.dequeue(), undefined);
});

其实出队操作也只有两点:

  • 如果队列为空,那么直接返回undefined
  • 从头部开始弹出队列
dequeue() {
    // 保存头节点,防止丢失
    const current = this.#head;
    // 如果头节点没有值,那么直接返回
    if (!current) {
        return;
    }
    // 将头节点移动到第二个元素
    this.#head = this.#head.next;
    // 更新长度值
    this.#size--;
    // 返回已删除头节点
    return current.value;
}

clear(清除)

清除队列的操作

清除队列的操作很简单,只需要将headtail的指针都变为undefined,清空长度值即可

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

size(返回长度)

返回队列长度的操作也很简单,只需要将#size属性返回即可

get size() {
    return this.#size
}

[Symbol.iterator]

Symbol.iterator属性为可遍历数据的遍历器函数,只要是在内部部署了[Symbol.iterator]函数,那么就可以被for...of所遍历,之前的文章已经详细的讲过了这部分的知识,就不再赘述了,有兴趣的话,可以看从一道算法题到Map再到for...of的执行原理这篇文章。

yocto-queue中也支持了for...of调用

// 生成器函数
* [Symbol.iterator]() {
    // 获取头节点
     let current = this.#head;

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

可以看到yocto-queue中遍历器的实现使用了Generator函数,那么什么是Generator函数呢?

形式上, Generator 函数是一个普通函数,但是有两个特征。一是,function关键字与函数名之间有一个星号;二是,函数体内部使用yield表达式,定义不同的内部状态(yield在英语里的意思就是“产出”)。

function* helloWorldGenerator() {
  yield 'hello';
  yield 'world';
  return 'ending';
}

var p1 = helloWorldGenerator();

上面代码定义了一个 Generator 函数helloWorldGenerator,它内部有两个yield表达式(helloworld),即该函数有三个状态:helloworldreturn 语句(结束执行)。

然后, Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是上一章介绍的遍历器对象(Iterator Object)。

下一步,必须调用遍历器对象的next方法,使得指针移向下一个状态。也就是说,每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。换言之, Generator 函数是分段执行的,yield表达式是暂停执行的标记,而next方法可以恢复执行。

p1.next()
// { value: 'hello', done: false }

p1.next()
// { value: 'world', done: false }

p1.next()
// { value: 'ending', done: true }

p1.next()
// { value: undefined, done: true }

上面代码一共调用了四次next方法。

for...of中的实现内部就会自动去调用[Symbol.iterator]函数的next方法,所以for...of在执行的时候不断地执行next进行调用,遇到Generator函数中的yield关键字会暂停,并返回值,然后再调用next方法从中断的位置继续执行...直到遍历完成。

完整代码

/*
How it works:
`this.#head` is an instance of `Node` which keeps track of its current value and nests another instance of `Node` that keeps the value that comes after it. When a value is provided to `.enqueue()`, the code needs to iterate through `this.#head`, going deeper and deeper to find the last value. However, iterating through every single item is slow. This problem is solved by saving a reference to the last value as `this.#tail` so that it can reference it to add a new value.
*/

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

本文参考

Generator

算法图解

写在最后 ⛳

未来可能会更新实现mini-vue3javascript基础知识系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿