深入理解 JavaScript 链表 (Linked List):数据结构中的灵活序列
在计算机科学中,链表 (Linked List) 是一种基本且重要的数据结构,它与数组 (Array) 共同构成了存储有序数据集合的基石。与数组在内存中连续存储元素不同,链表的元素(称为节点)分散在内存的各个位置,并通过指针(或引用)相互连接,形成一个逻辑上的序列。
理解链表的内部工作机制,掌握其操作方法,对于深入学习算法、优化数据存储以及构建复杂系统至关重要。它的动态大小特性和在特定场景下高效的插入/删除操作,使其在许多应用中成为比数组更优的选择。
本教程将全面深入地探讨 JavaScript 中链表的实现和操作。我们将从链表的核心概念(节点、头、尾)入手,详细剖析其工作原理。随后,我们将通过清晰的 ES6 类 (Class) 语法,一步步构建一个功能完善的单向链表 (Singly Linked List),并实现其所有基本操作:插入、删除、查找、遍历等。我们还将简要介绍双向链表 (Doubly Linked List) 的概念,并讨论链表相对于数组的优势与劣势,以及它们各自的适用场景。最后,我们将总结链表的最佳实践和常见陷阱,帮助您彻底掌握这一底层数据结构,为更高级的算法学习和系统设计奠定坚实基础。
1. 链表的核心概念
链表由一系列称为节点 (Node) 的独立单元组成。每个节点通常包含两部分信息:
- 数据 (Data): 存储该节点实际的值。
- 下一个节点的引用/指针 (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 类将管理链表的 head、tail 和 size,并提供各种操作方法。
/**
* @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)。
在双向链表中,每个节点除了 data 和 next 指针外,还包含一个 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指针,占用更多内存。 - 更复杂的实现: 插入和删除操作需要维护
prev和next两个指针,逻辑相对复杂一些。
4. 链表的优势与劣势
4.1 优势
- 动态大小: 链表可以根据需要动态地增长或缩小,无需预先指定大小,也不会浪费未使用的内存空间。
- 高效的插入和删除 (O(1)):
- 在头部或尾部插入/删除(如果维护了
tail指针)是 O(1)。 - 如果已知要插入/删除的节点位置(或其前一个节点),插入/删除也是 O(1)。这与数组在中间插入/删除元素时需要移动大量元素(O(N))形成鲜明对比。
- 在头部或尾部插入/删除(如果维护了
- 内存利用率高: 元素可以分散存储在内存中,更灵活地利用可用空间。
4.2 劣势
- 随机访问效率低 (O(N)): 要访问链表中的第
k个元素,必须从头节点开始遍历k次。这使得链表不适合需要频繁随机访问的场景,例如arr[i]这种操作。 - 额外内存开销: 每个节点都需要额外的内存来存储
next(和prev) 指针,这对于存储大量小型数据时可能是一个负担。 - 缓存性能: 由于链表元素在内存中不连续,处理器缓存的命中率可能低于数组,从而影响性能。
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时,始终要检查current或current.next是否为null,以避免TypeError。- 更新
tail指针: 在append和removeTail操作中,确保正确更新tail指针。特别是removeTail,在单向链表中需要特殊处理。 - 指针重定向: 插入和删除操作的核心是正确地重定向节点的
next(和prev) 指针。画图有助于理解这些操作。 - 循环引用: 在某些复杂操作或垃圾回收机制中,需要注意避免不必要的循环引用,尽管在纯粹的链表结构中较少见。
8. 总结与展望
链表作为一种基础数据结构,其独特的非连续存储和通过指针连接的特性,使其在某些场景下比数组更具优势。
- 核心: 由数据和指向下一个节点引用的
Node组成,通过head节点访问。 - 类型: 单向链表最基础,双向链表通过增加
prev指针提供了双向遍历和更高效的尾部/已知节点删除。 - 优势: 动态大小,以及在头部、尾部或已知位置高效的插入和删除操作。
- 劣势: 随机访问效率低,额外内存开销。
- 应用: 栈、队列、LRU 缓存、需要频繁修改序列的应用。
掌握链表的实现和操作,不仅能够让您更好地理解数据结构的基础原理,也为今后学习更高级的算法和解决实际编程问题提供了有力的工具。
更多推荐


所有评论(0)