JS 数据结构 —— 双向链表 😏

487 阅读7分钟

「这是我参与2022首次更文挑战的第3天,活动详情查看:2022首次更文挑战」。

之前分享了关于单向链表的知识,今天则继续介绍双向链表这种数据结构。有不足之处或是任何意见建议,欢迎各位大佬不吝斧正~

定义

单向链表由于每个结点只保存了指向下一个结点的引用,这就导致了一个问题——如果想要获取当前结点的上一个结点,我们只能从头开始遍历。而双向链表,每个结点都会有两个指针,一个(pre)指向前驱结点,另一个(next)指向后继结点。这样,链表就可以同时实现从头到尾和从尾到头的遍历了。当然缺点就是在插入或删除某个结点时,处理的引用为 4 个(插入或删除的位置在头尾需考虑 head、tail、pre、next,中间则考虑该结点前后结点的 pre 和 next 与该结点的关系),是单向结点的 2 倍,更复杂一些,对内存的占用也更大一些。图示如下:

yuque_diagram.jpg 与单向链表一样,双向链表也有个 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. 链表中原本没有任何结点。

只需让 headtail 指向新结点 node 即可,至于结点本身的 prenext 不需要特意赋值,因为默认就为 null,图示如下:

yuque_diagram.jpg

b. 链表中原本存在结点。

此时,重点是需要建立新结点和原本第一个结点(其实就是 head 指向的结点)的关系,即:

  • 新结点 nodenext 需要指向原本的头结点;
  • 原本的头结点的 pre 需要指向新结点 node
  • 最后将 head 指向新结点。

yuque_diagram.jpg

2. 添加的结点放在链表的最后一位

index 等于 length 。此时,this.tail 即为原本的最后一个结点,我们需要让它的 next 指向新结点 node,让 nodepre 指向它,最后再让 tail 重新赋值为新结点 node 。图示如下:

yuque_diagram (1).jpg

3. 添加的结点放在非头尾的其它位置

也就是在链表的中间添加结点。一开始,i = 0current 指向 head,也就是首结点。此时开始执行 for 循环,i 小于 index,进入循环,current = current.nextcurrent 指向了第 2 个结点,然后 i++。于是 i = 1,不满足循环条件,循环结束。此时 current 指向的就是链表中原本位于要插入新结点的位置的结点。图示如下:

yuque_diagram.jpg
我们需要做的就是处理 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 个结点了

这就很好办了,只需要将 headtail 都赋值为 null 即可;

b. 原本链表不止 1 个结点

先画个示意图帮助分析:

yuque_diagram (1).jpg

读图可知,删除第一个结点后,新的首结点就是原本的 this.head.next,我们将 head 指向它,使它成为新的的 head,然后将新 headpre 赋值为 null即可。

2. 删除的是最后一个结点

此时不需要像上面那样考虑 2 种情况了,因为如果链表只剩 1 个结点,那么 index 必然为 0,条件判断时走第 1 种情况的 a 可能。如果原来链表多于 1 个结点,那么我们需要将 tail 指向 this.tail.pre,然后将新的尾结点的 next 指向 null 即可。

3. 删除的是非头尾结点

写到现在其实这种情况也是比较简单的了,准备一个 current 变量,开始时指向 this.head,也就是指向了第一个结点。然后进行 for 循环,找到要删除的结点,此时 current.pre 即为删除结点的前一个结点,current.next 即为删除结点的后一个结点,再让它们的 nextpre 分别指向对方即可。图示如下:

yuque_diagram.jpg

代码

// 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() 方法是将得到的 currentdata 修改为新值而已。

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

感谢.gif 点赞.png