前端应该了解的数据结构 | 链表

715 阅读11分钟

前言

小伙伴,你好!我是 嘟老板。不久前,我发布了一篇关于 线性表 的文章《前端应该了解的数据结构 | 线性表》,深入探讨了 线性表 的核心概念以及两种主要的存储方式 - 顺序结构存储链式存储结构。其中 链式存储结构 重点介绍了 单链表 的概念和特性。今天,我将继续深入探索 链式存储结构 的世界,揭示它所衍生出的其他有趣和实用的结构。

单链表

回顾

首先,让我们快速回顾一下 单链表 的基本构成。单链表是一种线性数据结构,由一系列节点组成,每个节点包含数据域和指向下一个节点的指针域。

感兴趣的小伙伴不妨点击《前端应该了解的数据结构 | 线性表》,这篇文章详尽地介绍了 单链表 的基础知识,以及如何进行 查找插入删除 等基本操作。现在,让我们在前文的基础上继续深入,探讨单链表的 整表创建 过程。

整表创建

为什么要讨论整表创建?在《前端应该了解的数据结构 | 线性表》中,我们了解到 顺序存储结构 的初始化过程相对简单,只需声明一个数组并直接赋值即可。然而,单链表 作为一种动态的、分散存储的数据结构,其初始化过程则需要不同的方法。

单链表 整表创建流程大致如下:

  1. 初始化空表:开始时,创建一个空的链表,通常设置一个头节点作为链表的起始点。
  2. 节点逐个插入:然后,根据需要存储的数据,逐一创建节点并将其插入链表。

根据创建过程中,节点插入位置的不同,主要有两种实现方式:头插入尾插入

头插法

所谓 头插法,就是循环将新节点插入到链表的 第一个位置

以下是 头插法 的详细步骤:

  1. 初始化一个空链表 slList。
  2. 遍历 n 次,循环执行以下步骤:
    1. 创建数据为随机数的节点 p。
    2. 将节点 p 的 next 指针指向 slList 的第一个节点 slList.head.next。
    3. 将 slList 的头节点 slList.head.next 设为节点 p。
    4. slList 长度加 1。
  3. 遍历结束,返回 slList。

具体实现如下:

/**
 * 头插法:单链表整表创建,包含 n 个随机数节点
 * @param {number} n 创建的单链表的节点个数
 */
function createListHead(n) {
  const slList = new SLinkList()

  if (isNaN(n) || n < 0) {
    throw new Error('参数不合法')
  }
  if (n === 0) return slList
  for (let i = 0; i < n; i++) {
    const p = new Node(Math.random() * 100)
    p.next = slList.head.next
    slList.head.next = p
    slList.length++
  }

  return slList
}

尾插法

通常情况,我们比较习惯将新节点插入到单链表的 尾部,这种插入方法,称作 尾插法

以下是 尾插法 的详细步骤:

  1. 初始化一个空链表 slList,声明尾结点 r,默认为 null。
  2. 遍历 n 次,循环执行以下步骤:
    1. 创建数据为随机数的节点 p。
    2. 若 i === 0,则直接将 p 作为第一个节点,即 slList.head.next = p。
    3. 否则,将尾结点 r 的 next 指向新节点 p。
    4. 将 p 作为新的尾结点。
    5. slList 长度加 1。
  3. 遍历结束,返回 slList。

具体实现如下:

/**
 * 尾插法:单链表整表创建,包含 n 个随机数节点
 * @param {number} n 创建的单链表的节点个数
 */
function createListTail(n) {
  // 空链表
  const slList = new SLinkList()
  // 尾结点
  let r = null

  if (isNaN(n) || n < 0) {
    throw new Error('参数不合法')
  }
  if (n === 0) return slList
  for (let i = 0; i < n; i++) {
    const p = new Node(Math.random() * 100)
    if (i === 0) {
      slList.head.next = p
      r = p
    } else {
      r.next = p
      r = p
    }
    slList.length++
  }
  return slList
}

循环链表

在单链表的结构中,最后一个节点的指针通常指向 null,这意味着查找特定节点的操作总是需要从链表的头部开始。为了提高查找效率,我们可以探索一种改进的链表结构 — 循环链表

定义

循环链表 是一种特殊的链表结构,它允许从任意节点开始向后遍历,直到找到目标节点。这种结构通过将单链表的最后一个节点的指针重新指向链表的 头结点 来实现,形成一个闭合的环。

以下是循环链表的结构示意图:

image.png

为什么需要循环链表呢?

回顾单链表,借助头结点的帮助,访问第一个节点的时间复杂度是 O(1),但是访问最后一个节点的时间复杂度是 O(n),也就是需要将整个链表全部遍历一遍。

那么有没有一种链表,使得访问链表最后一个节点也是 O(1) 的复杂度呢?循环链表可以。

通过适当的结构改造,借助循环链表的循环特性,完全可以实现访问链表第一个节点和最后一个节点的时间复杂度均是 O(1)

实现循环链表

在循环链表介绍部分,我们绘制了一个循环链表的结构示意图。可以发现,现有结构除了实现了循环,好像并没有什么实质性的作用,对于元素访问的效率并没有提升。

这就需要对循环链表的结构进行适当的改造:将头指针改为尾指针

什么是尾指针?即指向链表 最后一个节点 的指针。

以下是改造后结构示意图:

image.png

以下是具体实现:

class CLinkList {
  // 尾节点,链表为空时,指向 null
  tail = null
  // 链表长度
  length = 0
  constructor() {
    this.length = 0
  }
}

改造后,获取最后一个节点只需 this.tail;获取第一个节点只需 this.tail?.next; 时间复杂度均为 O(1)

另外,借助尾指针,执行两个链表合并操作时非常简单,只需要处理头尾节点的指针即可。例如:

/**
 * 将链表 l2 的节点合并到 链表 l1 中
 * @param {CLinkList} l1 合并的目标链表
 * @param {CLinkList} l2 被合并的链表
 * @returns 
 */
function combine(l1, l2) {
  // l1 第一个节点
  const p = l1.tail.next
  // 将 l1 尾结点指针指向l2 第一个节点
  l1.tail.next = l2.tail.next
  // 将 l2 的最后一个节点的指针指向 l1 的第一个节点
  l2.tail.next = p
  return l1
}

双向链表

在单链表中,每个节点都包含一个指针域,指向其后继节点,使得访问后继节点的时间复杂度为 O(1)。但访问前驱节点就比较费劲了,需要遍历链表才行。那么有没有一种链表,可以更高效的访问前驱节点呢?接下来我们探索另一种改进的链表结构 - 双向链表

定义

双向链表 在单链表的基础上,为每个节点额外增加一个指向其 前驱节点 的指针域。

以下是存在头指针的双向循环链表的结构示意图:

image.png

由图可见,双向链表单链表 的不同之处在于,多了一个前驱结点的指针域。对于 循环链表,头结点的前驱指针指向最后一个节点。

为什么需要双向链表呢?

双向链表和单链表的很多操作都基本一致,比如 查找节点获取节点位置 等,这种只需要一个指针便可完成的操作,并不需要前驱指针的参与。

那么为什么还要设计双向链表结构呢,正如前面所述,我们有时候需要获取节点的前驱。

比较典型的场景,React 18 引入的 Fiber 树,其核心存储结构正是 双向链表。为什么要这样设计呢?

原因在于,React18 支持的异步更新特性,也称为并发模式,可能需要在更新过程中暂停和恢复更新。而在旧版的 React 中,,递归的虚拟 DOM 结构,无法支持这种中断和恢复机制,然而 双向链表 可以很好的应对。

React Fiber 节点包括以下三个指针:

  • return: 指向 父级 Fiber,允许从子节点回溯到父节点。
  • child:指向 子 Fiber,用于访问子节点。
  • sibling:指向 兄弟 Fibler,用于遍历同级节点。

这些指针使得 React 在中断更新后,可以通过当前执行的 Fiber 节点,快速找到其 父子兄弟节点,从而构建一个完整的 Fiber 树,并继续执行更新。极大的增强了 React 的性能和灵活性。

当然啦,ReactFiber 设计是一种使用双向链表存储的 树形结构。对于树这种数据结构,后续会专门深入探讨,敬请期待...。

实现双向链表

双向链表 多了一个指针域,节点结构调整如下:

class DNode {
  constructor(data) {
    // 数据域
    this.data = data
    // 前驱指针域
    this.prior = undefined
    // 后继指针域
    this.next = undefined
  }
}

类属性定义可与单链表一致,具体实现如下:

class DLinkList {
  // 头结点
  head
  // 链表长度
  length = 0
  constructor() {
    this.head = new Node(null)
    this.length = 0
  }
}

另外,对于仅需要后继指针便可完成的操作,如 查找,与单链表实现无异。但是 插入删除 确稍麻烦一点,因为要更改两个指针变量。过程不复杂,但是操作顺序比较重要,不要写反了。

插入

假设我们要将一个新 节点 s 插入到双向链表中的 节点 pp.next 之间。

以下是插入操作的详细步骤:

  1. 节点 p 赋值给 节点 s 的前驱。
  2. 节点 p.next 赋值给 节点 s 的后继。
  3. 节点 s 赋值给节点 节点 p.next 的前驱。
  4. 节点 s 赋值给 节点 p 的后继。

以下为插入操作的示意图:

image.png

具体实现如下:

/**
   * 在链表位置 i 插入数据 e
   * @param {object} e 需要插入的节点数据
   * @param {number} i 要插入的链表位置
   */
  insert(e, i) {
    // 获取链表第 i 个节点
    const p = this.getElem(i);
    // 若不存在节点 p,返回 false 或报错
    if (!p) return false
    // 生成数据为 e 的节点 s
    const s = new Node(e)

    // 1.将 **节点 p** 赋值给 **节点 s** 的前驱。
    s.prior = p
    // 2.将 **节点 p.next** 赋值给 **节点 s** 的后继。
    s.next = p.next
    // 3.将 **节点 s** 赋值给节点 **节点 p.next** 的前驱。
    p.next.prior = s
    // 4.将 **节点 s** 赋值给 **节点 p** 的后继。
    p.next = s

    // 链表长度 +1
    this.length++
    return true
  }

删除

假如我们要删除第 i 个 节点 p

以下是删除操作的具体步骤:

  1. 节点 p 的后继赋值给 节点 p.prior 的后继。
  2. 节点 p 的前驱赋值给 节点 p.next 的前驱。

以下为删除操作的示意图:

image.png

具体实现如下:

/**
   * 删除链表中第 i 个节点
   * @param {number} i 要删除的节点位置
   */
  delete(i) {
    // 获取要删除的节点 p
    const p = this.getElem(i)
    // 若不存在节点 p,返回 false 或报错
    if (!p) return false
 
    // 1.把 **节点 p** 的后继赋值给 **节点 p.prior** 的后继。
    p.prior.next = p.next
    // 2.把 **节点 p** 的前驱赋值给 **节点 p.next** 的前驱。
    p.next.prior = p.prior

    return p.data
  }

由上可见,双向链表相对于单链表,由于多了一个 prior 前驱指针,所以在 插入删除 操作时稍复杂一点。而且每个节点都需要记录两个指针,所以在控件上占用略多。不过因为其良好的对称性,使得访问某个节点的前后节点非常方便,有效提升了算法的时间性能,属于 空间换时间 的典型。

结语

本文作为 线性表 的延伸,重点介绍了一个几种常用的链表结构,单链表循环链表,和 双向链表。通过辅助的代码示例,旨在帮助小伙伴快速掌握不同链表结构的特点及差异。

如果您对文章内容有任何疑问或想深入讨论,欢迎评论区留下您的问题和见解。

技术简而不凡,创新生生不息。我是 嘟老板,咱们下期再会。


往期推荐