「这是我参与2022首次更文挑战的第3天,活动详情查看:2022首次更文挑战」。
之前分享了关于单向链表的知识,今天则继续介绍双向链表这种数据结构。有不足之处或是任何意见建议,欢迎各位大佬不吝斧正~
定义
单向链表由于每个结点只保存了指向下一个结点的引用,这就导致了一个问题——如果想要获取当前结点的上一个结点,我们只能从头开始遍历。而双向链表,每个结点都会有两个指针,一个(pre)指向前驱结点,另一个(next)指向后继结点。这样,链表就可以同时实现从头到尾和从尾到头的遍历了。当然缺点就是在插入或删除某个结点时,处理的引用为 4 个(插入或删除的位置在头尾需考虑 head、tail、pre、next,中间则考虑该结点前后结点的 pre 和 next 与该结点的关系),是单向结点的 2 倍,更复杂一些,对内存的占用也更大一些。图示如下:
与单向链表一样,双向链表也有个
head 属性,指向第一个结点。另外还有个 tail 属性,用于指向最后一个结点。下面就开始封装一个双向链表。
整体结构
在实现单向链表时采用的是 es5 的构造函数形式,实现双向链表我们就采用 es6 的类。我们用一个名为 DoubleLinkedList 的类来封装双向列表。此外,还需要一个 Node 类用以生成结点。双向链表实例本身的属性相较于单向链表多个了 tail 属性:
// 结点
class Node {
constructor(data) {
this.data = data
this.pre = null
this.next = null
}
}
// 双向链表
class DoubleLinkedList {
constructor() {
// 属性
this.head = null
this.tail = null
this.length = 0
}
// 方法
// ...
}
下面我们来定义双向链表的增删改查方法:
方法
增:insert()
insert() 用于添加结点,传入 2 个参数,需要保存的数据 data,和保存的位置 index。根据添加的位置 index 不同,分为 3 种情况考虑:
1. 添加的结点放在链表的第一位
即 index 为 0 时,此时又分为 2 种情况:
a. 链表中原本没有任何结点。
只需让 head 和 tail 指向新结点 node 即可,至于结点本身的 pre 和 next 不需要特意赋值,因为默认就为 null,图示如下:
b. 链表中原本存在结点。
此时,重点是需要建立新结点和原本第一个结点(其实就是 head 指向的结点)的关系,即:
- 新结点
node的next需要指向原本的头结点; - 原本的头结点的
pre需要指向新结点node; - 最后将 head 指向新结点。
2. 添加的结点放在链表的最后一位
即 index 等于 length 。此时,this.tail 即为原本的最后一个结点,我们需要让它的 next 指向新结点 node,让 node 的 pre 指向它,最后再让 tail 重新赋值为新结点 node 。图示如下:
3. 添加的结点放在非头尾的其它位置
也就是在链表的中间添加结点。一开始,i = 0,current 指向 head,也就是首结点。此时开始执行 for 循环,i 小于 index,进入循环,current = current.next,current 指向了第 2 个结点,然后 i++。于是 i = 1,不满足循环条件,循环结束。此时 current 指向的就是链表中原本位于要插入新结点的位置的结点。图示如下:
我们需要做的就是处理 4 个引用关系:
- 让原本这个位置的前一个结点(也就是
current.pre)的next指向新结点; - 让新结点的
pre指向原本该位置的前一个结点; - 让新结点的
next指向原本该位置的结点; - 让原本该位置的结点的
pre指向新结点。
代码
第 2 个参数 index 默认等于链表的个数,如果不传,则是往链表末尾添加:
// insert,添加结点(增)
insert(data, index = this.length) {
// 越界判断
if (index < 0 || index > this.length) throw Error('输入的 index 无效')
const node = new Node(data)
// 1.添加的位置在链表头
if (index === 0) {
if (this.length === 0) {
// a.链表原本为空
this.head = node
this.tail = node
} else {
// b.链表原本有结点
node.next = this.head
this.head.pre = node
this.head = node
}
} else if (index === this.length) {
// 2.添加的位置在链表尾
this.tail.next = node
node.pre = this.tail
this.tail = node
} else {
// 3.添加的位置在中间
let current = this.head
for (let i = 0; i < index; i++) {
current = current.next
}
current.pre.next = node
node.pre = current.pre
node.next = current
current.pre = node
}
this.length++
}
注意:因为参数有个 index,我们在最开始需要验证一下传入的 index 是否是合法的,做个越界判断。
删:removeAt()
removeAt() 方法需要传入一个 index,然后从双向链表中删除该位置的结点。与增加结点时一样,需要根据 index 的不同考虑 3 种情况:
1. 删除的是第一个结点
即 index === 0 的情况,此时,还需要继续细分 2 种可能:
a. 原本链表只剩 1 个结点了
这就很好办了,只需要将 head 和 tail 都赋值为 null 即可;
b. 原本链表不止 1 个结点
先画个示意图帮助分析:
读图可知,删除第一个结点后,新的首结点就是原本的 this.head.next,我们将 head 指向它,使它成为新的的 head,然后将新 head 的 pre 赋值为 null即可。
2. 删除的是最后一个结点
此时不需要像上面那样考虑 2 种情况了,因为如果链表只剩 1 个结点,那么 index 必然为 0,条件判断时走第 1 种情况的 a 可能。如果原来链表多于 1 个结点,那么我们需要将 tail 指向 this.tail.pre,然后将新的尾结点的 next 指向 null 即可。
3. 删除的是非头尾结点
写到现在其实这种情况也是比较简单的了,准备一个 current 变量,开始时指向 this.head,也就是指向了第一个结点。然后进行 for 循环,找到要删除的结点,此时 current.pre 即为删除结点的前一个结点,current.next 即为删除结点的后一个结点,再让它们的 next 和 pre 分别指向对方即可。图示如下:
代码
// removeAt,删除结点(删)
removeAt(index) {
if (index < 0 || index >= this.length) throw Error('请输入正确的 index')
// 1.删除首结点
let current = this.head
if (index === 0) {
// a.链表原本只有 1 个结点
if (this.length === 1) {
this.head = null
this.tail = null
} else {
// b.链表原本不止 1 个结点
this.head = this.head.next
this.head.pre = null
}
} else if (index === this.length - 1) {
// 2.删除尾结点
current = this.tail
this.tail = this.tail.pre
this.tail.next = null
} else {
// 3.删除中间结点
for (let i = 0; i < index; i++) {
current = current.next
}
current.next.pre = current.pre
current.pre.next = current.next
}
this.length--
return current
}
因为最后需要返回被删除的结点的数据,所以将 current 放到了最前面定义,第 1 种情况的 current 刚好为 this.head,无需处理。第 2 种情况需将 current 赋值为 this.tail。
查:get()
get() 方法用于查询位于传入的 index 位置上的结点的 data 值。与单向链表很像,但也有不同之处。假设链表原本有 100 个结点,我们想查询第 99 个结点。如果是单向链表,则只能从头开始一个个查,但是双向链表可以从 tail 处,由后往前查,效率就高了很多。
// get,查询结点(查)
get(index) {
if (index < 0 || index >= this.length) throw Error('请输入正确的 index')
let current = null
if (index <= this.length / 2) {
// index 小于结点数的一半,从前往后查
current = this.head
for (let i = 0; i < index; i++) {
current = current.next
}
} else {
// index 比较大,由后往前查
current = this.tail
for (let i = this.length - 1; i > index; i--) {
current = current.pre
}
}
return current.data
}
改:updata()
updata() 方法用于修改某一位置的结点。与 get() 方法类似,只不过 get() 是直接返回,而 updata() 方法是将得到的 current 的 data 修改为新值而已。
// update,修改结点(改)
updata(index, data) {
if (index < 0 || index >= this.length) throw Error('请输入正确的 index')
let current = null
let i = null
if (index <= this.length / 2) {
// index 小于结点数的一半,从前往后
current = this.head
i = 0
while (i++ < index) {
current = current.next
}
} else {
// index 比较大,由后往前
current = this.tail
i = this.length - 1
while (i-- > index) {
current = current.pre
}
}
current.data = data
return true
}