JavaScript 数据结构与算法 — 单向链表

链表(Linked List)是一种基本的数据结构,用于表示一组按顺序排列的元素。链表中的每个元素都与下一个元素连接,元素在内存中并不是连续的,而是通过指针来链接在一起。每个元素都包含两部分:自己的数据和指向下一个元素的指针。

单向链表

我们常说的链表指的是单向链表,第一个元素的指针指向第二个元素,第二个元素的指针指向第三个元素......最后一个元素的指针指向 null,以表示链表的结束。

实现链表节点类

在实现一个链表之前,我们需要先实现一个链表节点 Node 类,它表示我们想要添加到链表中的元素(节点)。它应该包含两个属性:链表元素的值 value 及指向链表中下一个元素的指针 next。它的代码展示如下:

/**
 * Node.js
 * @description: 创建链表节点的类
 */
export default class Node {
  constructor(val) {
    this.value = val // 当前节点的值
    this.next = null / 指向下一个节点的指针
  }
}

实现链表类

接着开始实现链表类 LinkedList。链表无法像数组一样通过 [] 语法直接获取某个元素,需要从头部(表头)开始迭代链表直到找到目标元素。那么链表类应该含有属性 size 用来表示链表的元素数量(遍历链表节点使用),还要有一个表头属性 head(通常表示遍历链表的起点)。链表类的基本代码如下:

/**
 * LinkedList.js
 * @description: 创建链表的类,实现链表的基本操作:添加节点、删除节点、查找节点、插入节点、打印链表、链表大小、是否为空等
 */
export default class LinkedList {
  constructor() {
    this.head = null // 头节点
    this.size = 0 // 链表大小
  }
}

实现链表方法

数组拥有很多方法,我们可以参考着数组来逐步完善链表的方法。

1. push方法:向链表尾部添加元素

数组中最常用的方法就是 push 方法了,我们也可以给链表实现该方法,添加成功后要返回当前链表的元素数量。向 LinkedList 对象尾部添加一个元素时,可能有两种情况:链表为空,那么添加的就是第一个元素节点;链表不为空,则向其追加元素节点。为了展示核心方法的代码,其他代码便不展示,全量代码后面会提供。

// 引入节点类,创建节点使用
import Node from './Node.js'

// 链表的push方法
push(val) {
    // 没有传参的话不做处理
    if (arguments.length === 0) return this.size
    
    // 创建一个链表节点,新创建的Node实例节点,它的next指针总是指向null
    const node = new Node(val)
    // 如果链表为空
    if (this.head === null) {
      this.head = node
    } else {
      let current = this.head
      // 链表元素的next指针指向不为空,说明不是最后一个元素,要找到最后一个元素
      while (current.next) {
        current = current.next
      }
      current.next = node
    }
    // 链表元素添加后,链表对应的大小也要改变
    this.size++
    return this.size
}

push 方法添加完成后我们就可以向链表添加元素了:

const linkedList = new LinkedList()
linkedList.push(1)
linkedList.push(2)
linkedList.push(3)
linkedList.push(4)
linkedList.push(5)
console.log(linkedList) // LinkedList {head: Node, size: 5}

2. 删除节点

删除链表元素有两种方法实现:第一种是指定特定位置删除元素(removeAt),第二种是根据元素的值删除元素(remove)。我们先来实现第一种方法。

2.1. removeAt方法:根据位置删除元素

链表是没有类似于数组的下标的,我们可以将头节点 head 的位置作为 0,后面的元素依次增加。删除指定位置 n 的元素,遍历节点到位置 n 处,获取上一个元素节点 preNode 及下一个元素节点 nextNode,将元素节点 preNode 的 next 指针指向元素节点 nextNode,将 n 处的节点绕过去即可。被绕过的节点会被丢弃在计算机内存中,等着垃圾回收器清除。
删除节点时还需要考虑一个特殊位置即第一个节点,也就是头结点 head,因为它没有上一个节点 preNode。
删除节点成功后,要返回被删除元素的值。同时还要注意链表大小也要同步调整。

// 删除某个位置的节点
  removeAt(index) {
    if (arguments.length === 0) return undefined
    // 需要验证删除点是否有效,不能越界删除
    if (index < 0 || index >= this.size) return undefined

    // 定义一个当前节点变量,记录每次遍历时的当前节点。初始时将头结点赋给它
    let current = this.head
    // 删除第一个节点
    if (index === 0) {
      this.head = current.next
    } else {
      let previous = null
      for (let i = 0; i < index; i++) {
        // 每遍历一次,当前节点会变成前一个节点,下一个节点会变成当前节点
        previous = current
        current = current.next
      }
      // 被删除节点的前一个节点的next的指针指向删除节点的下一个节点
      previous.next = current.next
    }
    this.size--
    return current.value
  }
2.2. remove方法: 根据值删除元素

通过元素的值来删除元素,需要一个方法来判断元素节点的值与传入的值是否相等。给链表 LinkedList 类的构造函数传递一个配置项,配置项中可以包含一个判等的方法。在工具集文件 utils.js 中增加一个判等方法 isEqual

/**
 * utils.js
 * @description: 工具集
 */
export const isEqual = (a, b) => {
  if (typeof a === 'object' && typeof b === 'object') {
    if (a === null && b === null) {
      return true
    } else {
      return JSON.stringify(a) === JSON.stringify(b)
    }
  } else {
    return a === b
  }
}

改造链表类的构造函数:

import Node from './Node.js'
import { isEqual } from './utils.js'

export default class LinkedList {
  constructor(options = { isEqual: isEqual }) {
    this.head = null // 头节点
    this.size = 0 // 链表大小
    this.options = options
  }
}

根据节点的值进行删除的方法:

remove(val) {
    if (arguments.length === 0) return this.size
    
    let current = this.head // (1)
    let index = -1 // (2)
    const { isEqual } = this.options // (3)
    for (let i = 0; i < this.size && current; i++) { // (4)
      if (isEqual(current.value, val)) { // (5)
        index = i // (6)
        break // (7)
      } // (8)
      current = current.next // (9)
    } // (10)

    // 链表中找不到要删除的值的元素
    if (index === -1) return this.size
    return this.removeAt(index)
}

3. indexOf 方法:返回一个元素的位置

remove 代码中(1)-(10)行代码是为了获取链表中第一个符合条件的元素的位置,类似于数组中的 indexOf 方法,我们可以把这部分代码提取出来实现一个indexOf 方法。

// 返回节点位置
  indexOf(val) {
    if (arguments.length === 0) return -1
    let current = this.head
    const { isEqual } = this.options
    for (let i = 0; i < this.size && current; i++) {
      if (isEqual(current.value, val)) {
        return i
      }
      current = current.next
    }
    return -1
  }

indexOf 方法实现后,我们可以改造 remove 方法了。

remove(val) {
    if (arguments.length === 0) return this.size
    let index = this.indexOf(val)
    if (index === -1) return this.size
    return this.removeAt(index)
}

4. getNodeAt方法:根据位置获取节点元素

indexOf 方法是返回一个元素的位置,有时候我们需要知道某个位置处的元素,那么根据位置获取节点元素的方法 getNodeAt 可以这样写:

// 根据位置获取节点
getNodeAt(index) {
    if (arguments.length === 0) return undefined
    if (index < 0 || index > this.size) return undefined
    let current = this.head
    for (let i = 0; i < index; i++) {
      current = current.next
    }
    return current
}

getNodeAt 方法实现后,我们可以对 removeAt 方法进行重构:

removeAt(index) {
    if (arguments.length === 0) return undefined
    if (index < 0 || index >= this.size) return undefined
    let current = this.head
    // 删除第一个节点
    if (index === 0) {
      this.head = current.next
    } else {
      // let previous = null
      // for (let i = 0; i < index; i++) {
      //   previous = current
      //   current = current.next
      // }
      // previous.next = current.next

      // 重构
      const previous = this.getNodeAt(index - 1)
      current = previous.next
      previous.next = current.next
    }
    this.size--
    return current.value
  }

5. insert方法:任意位置插入元素

链表中另一种常使用的方法就是往链表插入数据,插入链表时要提供插入链表的位置及插入的元素,我们使用 insert 方法来实现:

// 插入节点
insert(index, val) {
    if (arguments.length === 0) return this.size
    if (arguments[1] === undefined) return this.size

    if (index < 0 || index > this.size) return this.size
    const node = new Node(val)
    // 插入头部
    if (index === 0) {
      const current = this.head
      node.next = current
      this.head = node
    } else {
      // 获取插入位置前一个元素
      const previous = this.getNodeAt(index - 1)
      const current = pre.next
      node.next = current
      previous.next = node
    }
    this.size++
    return this.size
}

前面的几个方法中,我们使用变量 current、previous 等作为引用来控制节点,这非常重要,这样处理循环时不会丢失节点之间的链接。

6. isEmpty方法:判断链表是否为空

我们可以通过头节点是否为空或者链表长度大小来判断链表是否为空。

isEmpty() {
   return this.size === 0
}

7. getSize方法:获取链表长度(大小)

目前链表实例中已经有一个 size 属性了,想要再增加一个方法的话,把 size 返回即可。

getSize() {
  return this.size
}

8. getHead方法:获取链表第一个元素

我们可以根据 getNodeAt(0) 来获取来链表第一个元素,但是我们不想传递一些位置信息,那就简单的封装下吧。

// 获取头节点
getHead() {
  return this.head
}

9. getTail方法:获取链表最后一个元素

// 获取尾节点
getTail() {
  if (this.isEmpty()) return undefined
  return this.getNodeAt(this.size - 1)
}

10. toString方法:打印链表数据结构

有时我们想知道链表的数据结构,直接打印链表实例的话,我们需要在控制台一层一层往下展开,查看很不方便,如下图所示:
链表数据结构
我们只想知道链表中每个节点中的值以及指向下一个的值,我们完全可以使用 a => b => ...这样的形式展示一个字符串即可。那么,我们就使用 toString 方法来实现链表数据结构的输出:

  // 输出链表数据结构
  toString() {
    if (this.isEmpty()) return ''
    let current = this.head
    let str = ''
    while (current) {
      str += current.value + (current.next ? ' -> ' : ' -> null')
      current = current.next
    }
    return str
  }

其他方法:

像数组中的头部添加 unshift、头部删除 shift、翻转 reverse、尾部删除 pop等也可以在链表中实现,聪明如你,自己动手实现下吧。

思考:面试笔试中常见的一个算法题,互换链表中任意两点位置,如何实现?

代码具体实现请查看 gitee仓库 或者 gitHub仓库

posted @ 2025-03-29 18:52  老甄Home  阅读(60)  评论(0)    收藏  举报