一文带你拿下前端必备数据结构 -- 链表 !!
Posted 小丞同学
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文带你拿下前端必备数据结构 -- 链表 !!相关的知识,希望对你有一定的参考价值。
嘟嘟嘟~~发车啦,快来和博主一起飙车啦!😊文末附上力扣相关题目
不止图文解释,注释更加清晰噢!👍
1. 什么是链表
链表是一组由节点组成的集合,每个节点都有一个指针指向它的下一个节点。举个栗子来说,就像上图的小火车一样,每一节车厢之间都通过绳索相连接,每节车厢都是一个节点,车厢间的连接就是指针❤️
那了解了什么是链表之后,很多小伙伴就会想,这和数组有什么区别呢?数组操作不是更方便吗?
数组的大小是固定的,从数组的起点或中间插入或移除项的操作成本很高,因为需要移动元素(尽管我们已经学过很多的API
,但背后的情况同样是这样)
1.1 链表的优点
相对于传统的数组,链表的一个好处在于,添加或移除元素的时候不需要移动其他元素。这样添加、移除操作的时间复杂度就为O(1)
。下图就是一个单向链表插入节点的示意图,我们只需要改变前一个节点的next
指针并且修改新节点的next
指针是链表连接起来即可完成
1.2 链表的缺点
相对于数组而言,数组在访问一个元素的时候,可以直接通过索引来直接访问,而对于链表而言访问其中的一个元素,需要从起点开始迭代整个链表直到找到所需的元素。因此访问的时间复杂度落在O(1)-O(n)
之间
1.3 单向链表与数组各个操作时间复杂度对比
链表操作 | 最大时间复杂度 | 数组操作 | 最大时间复杂度 |
---|---|---|---|
search(访问) | O(n) | search(访问) | O(1) |
insert(插入) | O(1) | insert(插入) | O(n) |
remove(删除) | O(1) | remove(删除) | O(n) |
append (添加) | O(1) | append (添加) | O(1) |
1.4 链表的分类
有三种:单向链表、双向链表、循环链表
1.4.1 单向链表
1.4.2 双向链表
1.4.3 循环链表
2. 使用JS实现链表
了解了什么是链表,接下来我们使用js来实现链表的相关操作
2.1 单向链表
2.1.1 创建一个单向链表
以下是一个LinkedList
的基本骨架
class LinkedList {
constructor() {
// 链表的长度size
this.size = 0;
this.head = null;
}
//添加链表项
append(element) { }
//插入链表项
appendAt(position, element) { }
// 删除指定列表项
remove(element) { }
//删除指定位置链表项
removeAt(position) { }
// 查找指定元素索引
indexOf(element) { }
// 翻转链表
reserve() { }
// 获取节点
getNode(index) {}
}
//测试
let l1 = new LinkedList();
链表数据结构还需要一个Node
辅助类。Node
类表示要加入列表的项。它包含一个element
属性,即要添加到列表的值,以及一个next
属性,即指向列表中下一个节点
项的指针。
// node类
class Node {
constructor(element) {
this.element = element;
// 添加一个指针
this.next = null
}
}
另一个重要的点是,我们还需要存储第一个节点的引用。为此,可以把这个引用存储在一个称为head
的变量当中,接下来我们就要来实现LinkedList
类中为填写的方法。
append(element)
:向链表尾部添加一个新的项appendAt(position, element)
: 向链表的特定位置插入一个新的项remove(element)
:从列表中移除一项removeAt(position)
:从列表的特定位置移除一项getNode(index)
:获取某个位置的节点reserve()
:反转链表
2.1.2 获取链表中的节点
先写这个是因为后面的很多方法中都有使用到这个函数!其实也可以不用单独封装成一个函数,存粹个人习惯
传入一个需要查找的节点位置,通过for
循环,不断地让current
指向下一位,直至到达index
的位置,跳出for
循环,返回当前current
节点🍊
getNode(index) {
// 边界判断 传入的参数是否在链表的长度范围之内
if (index < 0 || index >= this.size) {
throw new Error('out')
}
let current = this.head;
// 不断的让current移到下一位,直到到达index - 1位置
// 也就是循环遍历的过程
for (let i = 0; i < index; i++) {
current = current.next;
}
// 最后返回节点
return current
}
2.1.3 向链表尾部追加元素
有两种场景:
- 列表为空,添加的是第一个元素
- 列表不为空,向其追加元素
下面是我们实现的append
方法,通过上一部分的getNode
方法,获取到链表的最后一个节点,让最后一个节点的next
指针指向新创建的节点node
,使得链表串联起来,最后让链表长度size
自加,即可实现 ☘️
append(element) {
// 通过实例化添加属性
let node = new Node(element);
// 添加操作
if (this.head === null) {
this.head = node;
} else {
let current = this.getNode(this.size - 1);
// 最后一个节点的next指向新创建的节点
current.next = node
}
//添加链表项后,size自增
this.size++;
}
注意:这里的节点创建是通过new
操作符实现的,构造出来的node
是一个对象,带有了自身的值element
和next
指针,新创建的节点next
指针默认指向null
2.1.4 向链表的任意位置插入一个元素
因为是通过位置来插入元素,所以首先要判断该位置是否越界。如果越界了,可以直接抛出错误。同样的有2种场景:
第一种场景:需要在列表的起点添加一个元素,也就是第一个位置,让新创建的节点next
指针指向头节点,也就是this.head
,再将新创建的节点设为头节点🎉
第二种场景:非起点插入。首先需要找到插入位置的前一个节点pre
,让新创建的节点指向pre
的下一个节点pre.next
,再让pre
节点的next
指针指向新创建的节点🍎
//插入链表项
appendAt(position, element) {
// 边界判断
if (position < 0 || position > this.size) {
throw new Error('position out range')
}
// 实例化创建节点
let node = new Node(element);
//起点插入
if (position === 0) {
node.next = this.head;
this.head = node;
} else {
// 找到需要插入位置的前一个节点
let pre = this.getNode(position - 1);
// 让新创建的节点指向当前前一个节点的下一个节点
node.next = pre.next;
// 让前一个节点指向新创建的节点
pre.next = node;
}
}
2.1.5 从链表中移除元素(根据特定位置移除)
移除元素也有两种场景:第一种是移除第一个元素,第二种是移除第一个以外的任一元素。同样的我们需要先进行边界判断,在链表长度外的抛出错误即可。🍌
第一种场景非常简单,由于移除的是第一个节点,只需要让head指向列表的第二个元素
现在,假设我们要移除列表的最后一项或者中间某一项。首先我们需要获取到被删除节点的前一个节点,让该节点的next
指针指向被删除节点的下一个节点。这样,被遗弃的元素就会被丢弃在计算机内存中,等着被垃圾回收器清除。🍐
//删除指定位置链表项
removeAt(position) {
// 边界判断,超出链表长度,抛出错误
if (position < 0 || position >= this.size) {
throw new Error('position out range')
}
let current = this.head
if (position === 0) {
// 如果删除的是第一个节点,则让head指向下一位即可
this.head = current.next;
} else {
// 获取删除位置的前一个节点
let pre = this.getNode(position - 1);
// current为被删除节点
current = pre.next;
// 让前一个节点指向删除节点的下一个节点
pre.next = current.next;
}
// 长度自减
this.size--;
}
2.1.6 查找元素在链表的位置
我们需要一个变量来帮助我们循环访问列表,也就是代码中的current
,它的初始值是head
。通过for
循环来遍历链表,判断当前位置的值是否等于查找值,如果是,就返回它的位置;如果不是,就继续向下访问。如果for
循环结束还未弹出,说明到达了链表的尾部,也就是说链表中不存在该元素,返回-1
。💟
// 查找指定元素索引
indexOf(element) {
// 当前链表头节点
let current = this.head;
// 遍历整个链表
for (let i = 0; i < this.size; i++) {
if (current.element === element) {
return i
}
// 依次向下移动
current = current.next;
}
return -1;
}
2.1.7 根据元素值移除元素
现在有了indexOf
方法,我们可以传入元素的值,找到它的位置,然后调用removeAt
方法并传入找到的位置,就能实现移除元素🌳
remove(element) {
// 获取需要删除元素在链表的位置
let index = this.indexOf(element)
if(index!= -1 ) {
// 通过下面的removeAt方法删除指定位置节点
this.removeAt(index)
}else {
// 删除不存在的元素,抛出错误
throw new Error('element no found')
}
}
2.1.8 反转链表
反转链表在
leetcode
中经常会遇到,在各个面试题中也都会发现它的身影。所以,这里就顺带写以下
首先定义两个指针:prev
和 current
,current
在前 prev
在后。每次让 current
的 next
指针指向prev
,实现一次局部反转,局部反转完成之后,prev
和current
同时往前移动一个位置,循环这个过程,直到current
到达链表尾部,实现动画如下
reserve() {
let prev = null;
let current = this.head;
while (current) {
// 保存当前的下一位
let next = current.next;
// 让当前节点指向前面的节点prev
current.next = prev;
// 交换完之后,让prev移到下一位也就是current
prev = current;
current = next;
}
//返回翻转的链表
this.head = prev
}
2.2 双向链表
双向链表和单向链表的区别在于,单向链表一个节点只有链向下一个节点的指针,而在双向链表中,有两个指针,一个指向前一个元素,一个指向下一个元素,示意图如下:
2.2.1 创建一个双向链表
相较于单向链表多了一个指向前一个元素的指针,所以在代码中要进行一些修改
//一个链表节点
class Node {
constructor(element) {
this.element = element
this.next = null
this.prev = null//新增
}
}
//双向链表
class doubleLinkedList {
constructor () {
this.size = 0
this.head = null
this.tail = null//新增
}
// 这里是接下来写的方法
}
双向链表的优点:可以访问一个特定节点的下一个或前一个元素。在单向链表中,如果迭代链表时错过了要查找的元素,就需要回到链表的起点重新开始迭代😓
注意:在doubleLinedList
类中有保存对列表最后一项的引用的tail属性。
2.2.2 获取链表中的节点
根据位置获取链表的方法和单向链表中的是相同,忘记了记得跳回去看看噢!❤️
// 获取某个位置的节点,和单向链表相同
getNode(index) {
if (index < 0 || index >= this.size) {
throw new Error('out')
}
let current = this.head
for(let i = 0;i < index;i++) {
current = current.next
}
return current
}
2.2.3 向链表尾部追加节点
相信看到这里的你,应该知道要先干嘛了吧!分两种情况
第一种情况:链表为空
第二种情况:链表不为空
对于第一种情况而言,我们只需要让head
和tail
指向新创建的节点即可
对于第二种情况,相对于单向链表而言,有一点不一样的地方,我们需要设置前驱指针。
首先我们需要让最后一个节点的next
指针指向新节点,再让新节点的prev
指针指向前一个节点(也就是连接前的最后一个节点),最后记得将tail
移向最后一个节点,同时size
也要自增!下面是示意图,清晰明了👍
append(element) {
// 创建节点
let node = new Node(element)
// 保存当前指向最后一个元素的指针
let tail = this.tail
// 如果链表为空则新节点将作为第一个
if(this.head === null) {
// head和tail都为node
this.head = node
this.tail = node
}else {
// 常规尾插,让最后一个元素的next指针指向node
tail.next = node
// 再让node的前驱指针指向最后一个节点tail
node.prev = tail
// 最后更新tail指向最后一个元素node
this.tail = node
}
this.size++
}
2.2.4 向链表中插入节点
向双向链表中插入一个新节点和单向链表非常相似。区别在于,单向链表只需要控制一个next
指针,而双向链表则要同时控制next
和prev
两个指针
首先来分析第一种场景:在链表的第一个位置插入一个新节点。如果链表为空,只需要把head
和tail
都指向这个新节点。如果不为空,current
变量将保存的是链表中第一个元素的引用,那么只需让新节点的next
指针指向current
,让current
节点的prev
指针指向新节点,最后让head
指向第一个节点即可,演示过程如下:
接下来是第二种场景:在尾部插入,这个和上一个方法有点类似,可以查看上一小节,这里就不重复赘述了
最后一个场景也是相对复杂一点点的:在链表的中间部分插入
通过前面写的getNode
方法,获取到需要插入位置的前一个节点preNode
以及下一个节点current
,我们将在current
和preNode
元素之间插入新元素。首先,node
节点的next
指针指向current
,再让node
的prev
指针指向preNode
节点,再处理剩下两个指针分别指向node
即可,演示图如下:
// 插入节点
insert(position, element) {
// 边界判断,输入的position需要在范围之内
if (position < 0 || position > this.size) {
throw new Error('position out range')
}
// 创建节点
let node = new Node(element)
// 保存第一个节点,为一个变量
let current = this.head
// 第一种情况,在最头部插入
if (position === 0) {
// 链表为空的情况
if (!this.head) {
this.head = node
this.tail = node
} else {
// 链表不为空
// 让node的next指针指向第一个节点
node.next = current
// 第一个节点的前驱指针指向node
current.prev = node
// head指向新的第一个节点
this.head = node
}
this.size++
} else if (position === this.size) {
// 在最后插入
// current变量保存最后一个节点
current = this.tail
// 最后一个节点的next指针指向新节点
current.next = node
// 新节点的前驱指针指向前一个节点
node.prev = current
// tail移至node
this.tail = node
this.size++
} else {
// 在中间插入
// 获取插入位置的前一个节点
let preNode = this.getNode(position - 1)
// current变量保存preNode节点的下一个节点
current = preNode.next
// 1. 让node节点的next指针指向后一个节点current
node.next = current
// 2. 让node节点的prev指针指向前一个节点preNode
node.prev = preNode
// 3. 让preNode节点的next指针指向新节点
preNode.next = node
// 4. 让current节点的prev指针指向新节点
current.prev = node
this.size++
}
}
注意:在我们封装的getNode
方法中,无论如何都是从头开始遍历的,实际上我们可以优化这个过程,当我们要找的position
大于size
的一半时,我们可以从尾部开始遍历,这样可以提高性能。
2.2.5 从链表中的特定位置删除元素
双向链表的操作其实都和单向链表相似,只是多了一个前驱指针,要多操作一个指针而已,对于这个删除特定位置元素的方法,我们需要知道最重要的一点就是将被删除的节点从链表中移出,再将链表连接完好即可
同样的我们需要分成3种情况
第一种情况:删除第一个节点
用current
变量保存链表中第一个节点的引用,也就是我们想要移除的第一个节点。首先要改变的是 head
的引用,将其从current
改为current.next
。但我们还需要更新current.next
指向上一个元素的指针,因此需要判断链表的长度是否为1,如果为1说明删除第一个节点之后链表就为空了,这时候就需要将tail
设为null
,如果不为1,则把head.prev
的引用改为null
即可。演示图如下:
第二种情况:删除最后一个节点
因为有了最后一个节点的引用tail
,我们不需要通过getNode
来获取最后一个节点,这样我们也就可以把tail
的引用赋给current
变量。接下来,需要把tail
的引用更新为列表中倒数第二个元素,同时将next
指针指向null
,这个过程可以展示为下图:
第三种情况:删除中间的节点
首先我们需要通过getNode
方法找到要删除的节点,用current
变量保存,再用preNode
表示要删除节点的前一个节点,。那么要移除它,我们可以通过更新prev.next
和current.next.prev
的引用,在链表中跳过它,因此,将prev
的next
指针指向current.next
,而current.next
的prev
指针指向prev
,如下图演示:
removeAt(position) {
// 边界判断,输入的position需要在范围之内
if (position < 0 || position > this.size) {
throw new Error('position out range')
}
let current = this.head
// 如果删除的是第一个节点
if (position === 0) {
// 让head指向下一位
this.head = current.next
// 如果链表的长度为1,删除后末尾应当为null
if(this.size === 1) {
this.tail = null
}else {
// 不为1 删除节点后第一个节点(current.next)指向null
this.head.prev = null
}
this.size--
}else if (position === this.size -1) {
// 删除最后一个节点
// 先让current保存我们要删除的节点
current = this.tail
// 让tail移向删除节点的前一位
this.tail = current.prev
// 再让tail的next指针指向null,这样最后一个节点就被丢弃了
this.tail.next = null
this.size--
}else {
// 被删除的节点
let current = this.getNode(position)
// 删除节点的前一个节点
let preNode = current.prev
// 删除节点的前一个节点next指向它的后一个节点
preNode.next = current.next
// 后一个节点的prev指向preNode
current.next.prev = preNode
this.size--
}
}
2.2.6 查找元素在链表中的位置
和单向链表的处理方式相同,没有做什么改动🍔
indexOf(以上是关于一文带你拿下前端必备数据结构 -- 链表 !!的主要内容,如果未能解决你的问题,请参考以下文章