在计算机科学中,链表 (Linked List) 是一种基本且重要的数据结构,它与数组 (Array) 共同构成了存储有序数据集合的基石。与数组在内存中连续存储元素不同,链表的元素(称为节点)分散在内存的各个位置,并通过指针(或引用)相互连接,形成一个逻辑上的序列。

理解链表的内部工作机制,掌握其操作方法,对于深入学习算法、优化数据存储以及构建复杂系统至关重要。它的动态大小特性和在特定场景下高效的插入/删除操作,使其在许多应用中成为比数组更优的选择。

本教程将全面深入地探讨 JavaScript 中链表的实现和操作。我们将从链表的核心概念(节点、头、尾)入手,详细剖析其工作原理。随后,我们将通过清晰的 ES6 类 (Class) 语法,一步步构建一个功能完善的单向链表 (Singly Linked List),并实现其所有基本操作:插入、删除、查找、遍历等。我们还将简要介绍双向链表 (Doubly Linked List) 的概念,并讨论链表相对于数组的优势与劣势,以及它们各自的适用场景。最后,我们将总结链表的最佳实践和常见陷阱,帮助您彻底掌握这一底层数据结构,为更高级的算法学习和系统设计奠定坚实基础。


1. 链表的核心概念

链表由一系列称为节点 (Node) 的独立单元组成。每个节点通常包含两部分信息:

  1. 数据 (Data): 存储该节点实际的值。
  2. 下一个节点的引用/指针 (Next): 指向链表中的下一个节点。如果当前节点是链表的最后一个节点,则 next 指针通常为 null

链表的整体结构由以下几个关键部分组成:

  • 头节点 (Head): 指向链表中的第一个节点。通过头节点,我们可以遍历整个链表。如果链表为空,head 通常为 null
  • 尾节点 (Tail): 指向链表中的最后一个节点。拥有尾节点可以使在链表末尾添加元素的操作更加高效(O(1))。如果链表为空,tail 通常为 null
  • 大小 (Size): 记录链表中节点的数量,方便查询链表长度。

图示概念 (单向链表):

null <-- Head --> [Data1 | next] --> [Data2 | next] --> [Data3 | next] --> null
                                                                       ^
                                                                       |
                                                                      Tail

2. 单向链表的 JavaScript 实现

我们将使用 ES6 的类语法来构建我们的链表。首先,定义 Node 类,然后是 LinkedList 类。

2.1 Node

Node 类是链表的基本构建块。

/**
 * @class Node
 * @description 代表链表中的一个节点
 */
class Node {
  /**
   * @constructor
   * @param {*} data - 存储在节点中的数据
   */
  constructor(data) {
    this.data = data;     // 节点的数据
    this.next = null;     // 指向下一个节点的引用,默认为 null
  }
}
2.2 LinkedList

LinkedList 类将管理链表的 headtailsize,并提供各种操作方法。

/**
 * @class LinkedList
 * @description 单向链表的实现
 */
class LinkedList {
  /**
   * @constructor
   * 初始化链表,头尾节点为null,大小为0
   */
  constructor() {
    this.head = null; // 链表的头节点
    this.tail = null; // 链表的尾节点 (为了 O(1) 的 append 操作)
    this.size = 0;    // 链表中节点的数量
  }

  /**
   * @method isEmpty
   * @description 检查链表是否为空
   * @returns {boolean} 如果链表为空则返回 true,否则返回 false
   */
  isEmpty() {
    return this.size === 0;
  }

  /**
   * @method getSize
   * @description 返回链表中节点的数量
   * @returns {number} 链表的当前大小
   */
  getSize() {
    return this.size;
  }

  /**
   * @method printList
   * @description 遍历链表并打印所有节点的数据
   */
  printList() {
    if (this.isEmpty()) {
      console.log("List is empty.");
      return;
    }

    let current = this.head;
    let listValues = [];
    while (current) {
      listValues.push(current.data);
      current = current.next;
    }
    console.log(listValues.join(" -> "));
  }

  // --- 基本插入操作 ---

  /**
   * @method prepend
   * @description 在链表头部添加新节点 (O(1))
   * @param {*} data - 要添加到头部的数据
   */
  prepend(data) {
    const newNode = new Node(data);
    if (this.isEmpty()) {
      // 如果链表为空,新节点既是头节点也是尾节点
      this.head = newNode;
      this.tail = newNode;
    } else {
      // 否则,新节点的 next 指向当前头节点,然后更新头节点为新节点
      newNode.next = this.head;
      this.head = newNode;
    }
    this.size++;
  }

  /**
   * @method append
   * @description 在链表尾部添加新节点 (O(1))
   * @param {*} data - 要添加到尾部的数据
   */
  append(data) {
    const newNode = new Node(data);
    if (this.isEmpty()) {
      // 如果链表为空,新节点既是头节点也是尾节点
      this.head = newNode;
      this.tail = newNode;
    } else {
      // 否则,当前尾节点的 next 指向新节点,然后更新尾节点为新节点
      this.tail.next = newNode;
      this.tail = newNode;
    }
    this.size++;
  }

  /**
   * @method insertAt
   * @description 在指定索引位置插入新节点 (O(N))
   * @param {*} data - 要插入的数据
   * @param {number} index - 要插入的索引位置
   * @returns {boolean} 如果插入成功则返回 true,否则返回 false
   */
  insertAt(data, index) {
    if (index < 0 || index > this.size) {
      console.error("Invalid index for insertion.");
      return false;
    }
    if (index === 0) {
      this.prepend(data);
      return true;
    }
    if (index === this.size) {
      this.append(data);
      return true;
    }

    const newNode = new Node(data);
    let current = this.head;
    // 遍历到要插入位置的前一个节点
    for (let i = 0; i < index - 1; i++) {
      current = current.next;
    }
    // 将新节点插入到 current 和 current.next 之间
    newNode.next = current.next;
    current.next = newNode;
    this.size++;
    return true;
  }

  // --- 基本删除操作 ---

  /**
   * @method removeHead
   * @description 移除并返回链表头部节点的数据 (O(1))
   * @returns {*} 被移除节点的数据,如果链表为空则返回 null
   */
  removeHead() {
    if (this.isEmpty()) {
      return null;
    }
    const removedData = this.head.data;
    this.head = this.head.next; // 头节点指向下一个节点
    if (this.head === null) {
      // 如果移除了最后一个节点,则链表变空,尾节点也设为 null
      this.tail = null;
    }
    this.size--;
    return removedData;
  }

  /**
   * @method removeTail
   * @description 移除并返回链表尾部节点的数据 (O(N) for Singly Linked List)
   * @returns {*} 被移除节点的数据,如果链表为空则返回 null
   */
  removeTail() {
    if (this.isEmpty()) {
      return null;
    }
    if (this.size === 1) { // 如果只有一个节点
      const removedData = this.head.data;
      this.head = null;
      this.tail = null;
      this.size--;
      return removedData;
    }

    let current = this.head;
    // 遍历到倒数第二个节点
    while (current.next !== this.tail) {
      current = current.next;
    }
    const removedData = this.tail.data;
    current.next = null; // 倒数第二个节点的 next 设为 null
    this.tail = current; // 更新尾节点
    this.size--;
    return removedData;
  }

  /**
   * @method removeAt
   * @description 移除指定索引位置的节点 (O(N))
   * @param {number} index - 要移除节点的索引位置
   * @returns {*} 被移除节点的数据,如果索引无效或链表为空则返回 null
   */
  removeAt(index) {
    if (index < 0 || index >= this.size) {
      console.error("Invalid index for removal.");
      return null;
    }
    if (index === 0) {
      return this.removeHead();
    }
    if (index === this.size - 1) {
      return this.removeTail();
    }

    let current = this.head;
    // 遍历到要删除位置的前一个节点
    for (let i = 0; i < index - 1; i++) {
      current = current.next;
    }
    const removedNode = current.next;
    current.next = removedNode.next; // 跳过被删除节点
    this.size--;
    return removedNode.data;
  }

  /**
   * @method removeValue
   * @description 移除链表中第一个匹配指定数据的节点 (O(N))
   * @param {*} data - 要移除的数据
   * @returns {*} 被移除节点的数据,如果未找到则返回 null
   */
  removeValue(data) {
    if (this.isEmpty()) {
      return null;
    }

    if (this.head.data === data) { // 如果头节点匹配
      return this.removeHead();
    }

    let current = this.head;
    while (current.next && current.next.data !== data) {
      current = current.next;
    }

    if (current.next) { // 找到了匹配的节点
      const removedNode = current.next;
      current.next = removedNode.next;
      if (removedNode === this.tail) { // 如果移除的是尾节点
        this.tail = current;
      }
      this.size--;
      return removedNode.data;
    }
    return null; // 未找到匹配的节点
  }

  // --- 查找操作 ---

  /**
   * @method get
   * @description 获取指定索引位置节点的数据 (O(N))
   * @param {number} index - 要获取节点的索引位置
   * @returns {*} 指定索引节点的数据,如果索引无效则返回 null
   */
  get(index) {
    if (index < 0 || index >= this.size) {
      console.error("Invalid index.");
      return null;
    }
    let current = this.head;
    for (let i = 0; i < index; i++) {
      current = current.next;
    }
    return current.data;
  }

  /**
   * @method search
   * @description 查找链表中是否包含指定数据,并返回其索引 (O(N))
   * @param {*} data - 要查找的数据
   * @returns {number} 数据所在的索引,如果未找到则返回 -1
   */
  search(data) {
    if (this.isEmpty()) {
      return -1;
    }
    let current = this.head;
    let index = 0;
    while (current) {
      if (current.data === data) {
        return index;
      }
      current = current.next;
      index++;
    }
    return -1; // 未找到
  }

  /**
   * @method clear
   * @description 清空链表 (O(1))
   */
  clear() {
    this.head = null;
    this.tail = null;
    this.size = 0;
    console.log("List cleared.");
  }
}
2.3 使用示例
const myList = new LinkedList();
console.log("Is list empty?", myList.isEmpty()); // true
myList.printList(); // List is empty.

myList.append(10);
myList.append(20);
myList.prepend(5); // 5 -> 10 -> 20
myList.printList(); // 5 -> 10 -> 20
console.log("Size:", myList.getSize()); // 3

myList.insertAt(15, 2); // 5 -> 10 -> 15 -> 20
myList.printList(); // 5 -> 10 -> 15 -> 20

myList.insertAt(0, 0); // 0 -> 5 -> 10 -> 15 -> 20
myList.printList(); // 0 -> 5 -> 10 -> 15 -> 20
console.log("Size:", myList.getSize()); // 5

console.log("Element at index 3:", myList.get(3)); // 15
console.log("Index of 10:", myList.search(10)); // 2
console.log("Index of 99:", myList.search(99)); // -1

console.log("Removed head:", myList.removeHead()); // 0
myList.printList(); // 5 -> 10 -> 15 -> 20

console.log("Removed tail:", myList.removeTail()); // 20
myList.printList(); // 5 -> 10 -> 15

console.log("Removed at index 1:", myList.removeAt(1)); // 10
myList.printList(); // 5 -> 15

myList.append(25); // 5 -> 15 -> 25
myList.printList();

console.log("Removed value 15:", myList.removeValue(15)); // 15
myList.printList(); // 5 -> 25

console.log("Removed non-existent value 100:", myList.removeValue(100)); // null
myList.printList(); // 5 -> 25

myList.clear();
myList.printList(); // List is empty.

3. 双向链表 (Doubly Linked List) 简介

单向链表的缺点是,如果需要向前遍历(比如从尾部移除元素),则必须从头节点开始。为了解决这个问题,可以引入双向链表 (Doubly Linked List)

在双向链表中,每个节点除了 datanext 指针外,还包含一个 prev (previous) 指针,指向前一个节点。

图示概念 (双向链表):

null <== Head <==> [Data1 | prev | next] <==> [Data2 | prev | next] <==> [Data3 | prev | next] <==> Tail ==> null
DoublyNode 类示例
class DoublyNode {
  constructor(data) {
    this.data = data;
    this.next = null; // 指向下一个节点
    this.prev = null; // 指向前一个节点
  }
}

双向链表的优势:

  • 双向遍历: 可以从头部或尾部开始遍历链表。
  • 高效的 removeTail() 移除尾节点变成 O(1) 操作,因为 tail.prev 直接指向倒数第二个节点。
  • 高效的给定节点删除: 如果已知要删除的节点,可以在 O(1) 时间内删除它,因为可以直接访问到其前一个和后一个节点。

双向链表的劣势:

  • 更多内存: 每个节点需要额外的 prev 指针,占用更多内存。
  • 更复杂的实现: 插入和删除操作需要维护 prevnext 两个指针,逻辑相对复杂一些。

4. 链表的优势与劣势

4.1 优势
  1. 动态大小: 链表可以根据需要动态地增长或缩小,无需预先指定大小,也不会浪费未使用的内存空间。
  2. 高效的插入和删除 (O(1)):
    • 在头部或尾部插入/删除(如果维护了 tail 指针)是 O(1)。
    • 如果已知要插入/删除的节点位置(或其前一个节点),插入/删除也是 O(1)。这与数组在中间插入/删除元素时需要移动大量元素(O(N))形成鲜明对比。
  3. 内存利用率高: 元素可以分散存储在内存中,更灵活地利用可用空间。
4.2 劣势
  1. 随机访问效率低 (O(N)): 要访问链表中的第 k 个元素,必须从头节点开始遍历 k 次。这使得链表不适合需要频繁随机访问的场景,例如 arr[i] 这种操作。
  2. 额外内存开销: 每个节点都需要额外的内存来存储 next (和 prev) 指针,这对于存储大量小型数据时可能是一个负担。
  3. 缓存性能: 由于链表元素在内存中不连续,处理器缓存的命中率可能低于数组,从而影响性能。

5. 链表的适用场景

由于其独特的性能特点,链表适用于以下场景:

  • 实现其他数据结构: 栈 (Stack)、队列 (Queue)、哈希表的冲突解决(链式哈希)、图的邻接表等。
  • 需要频繁在中间插入/删除元素的场景: 例如,一个动态的播放列表,用户可以随意添加/删除歌曲,且顺序很重要。
  • 内存使用不确定的场景: 当数据的总量未知或频繁变化时,链表的动态特性很有用。
  • 实现 LRU (Least Recently Used) 缓存: 常用双向链表结合哈希表实现。
  • 历史记录功能: 浏览器前进/后退功能可以用双向链表实现。

6. 链表与数组的对比

特性数组 (Array)链表 (Linked List)
存储方式内存连续分配内存分散分配,通过指针连接
访问元素O(1) (随机访问)O(N) (顺序访问)
头部插入/删除O(N)O(1)
尾部插入/删除O(1) (如果支持动态扩容)O(1) (如果维护 tail 指针)
中间插入/删除O(N)O(1) (如果已知前驱节点)
内存开销每个元素仅存储数据每个元素存储数据 + 指针
动态大小通常是固定的,或需要扩容/缩容开销 (O(N))动态,按需分配/释放
缓存性能较好 (数据局部性)较差

7. 最佳实践与常见陷阱

  • 头节点和尾节点的处理: 在实现链表操作时,特别注意链表为空 (head === null)、链表只有一个节点 (head === tail) 以及操作头尾节点时的边界条件。
  • null 检查: 在遍历链表或访问 current.next 时,始终要检查 currentcurrent.next 是否为 null,以避免 TypeError
  • 更新 tail 指针:appendremoveTail 操作中,确保正确更新 tail 指针。特别是 removeTail,在单向链表中需要特殊处理。
  • 指针重定向: 插入和删除操作的核心是正确地重定向节点的 next (和 prev) 指针。画图有助于理解这些操作。
  • 循环引用: 在某些复杂操作或垃圾回收机制中,需要注意避免不必要的循环引用,尽管在纯粹的链表结构中较少见。

8. 总结与展望

链表作为一种基础数据结构,其独特的非连续存储和通过指针连接的特性,使其在某些场景下比数组更具优势。

  • 核心: 由数据和指向下一个节点引用的 Node 组成,通过 head 节点访问。
  • 类型: 单向链表最基础,双向链表通过增加 prev 指针提供了双向遍历和更高效的尾部/已知节点删除。
  • 优势: 动态大小,以及在头部、尾部或已知位置高效的插入和删除操作。
  • 劣势: 随机访问效率低,额外内存开销。
  • 应用: 栈、队列、LRU 缓存、需要频繁修改序列的应用。

掌握链表的实现和操作,不仅能够让您更好地理解数据结构的基础原理,也为今后学习更高级的算法和解决实际编程问题提供了有力的工具。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐