数组在 JavaScript 中如此方便,为什么我们还需要链表呢?当 V8 引擎处理数组时,链表又在哪些场景下更有优势?本篇文章将深入探讨数据结构的核心差异。
前言:从一道面试题说起
javascript
// 面试题:如何高效地从大型数据集合中频繁插入和删除元素?
const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// 场景1:在数组开头插入元素(性能如何?)
data.unshift(0); // 需要移动所有元素!
// 场景2:在数组中间删除元素(性能如何?)
data.splice(5, 1); // 需要移动一半的元素!
// 场景3:只需要顺序访问数据
for (let i = 0; i < data.length; i++) {
console.log(data[i]); // 数组很快!
}
那么问题来了:有没有一种数据结构,插入和删除快,顺序访问也快?当然有,答案就是:链表! 数组和链表是编程中最基础的两种数据结构,理解它们的差异能帮助我们在不同场景下做出最优选择。
理解数组与链表的本质差异
内存结构对比
我们先通过一个简图,观察一下数组和链表在内存中的存储方式:
数组的内存结构(连续存储)
text
┌─────┬─────┬─────┬─────┬─────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ ← 索引
├─────┼─────┼─────┼─────┼─────┤
│ 10 │ 20 │ 30 │ 40 │ 50 │ ← 值
└─────┴─────┴─────┴─────┴─────┘
地址: 1000 1004 1008 1012 1016 (假设每个元素占4字节)
数组的存储特点:
- 连续的内存空间
- 通过索引直接计算地址:地址 = 基地址 + 索引 × 元素大小
- 随机访问时间复杂度:O(1)
链表的内存结构(非连续存储)
text
┌─────┐ ┌─────┐ ┌─────┐
头 → │ 10 │ → │ 20 │ → │ 30 │ → null
└─────┘ └─────┘ └─────┘
地址: 2000 3040 4080 (地址不连续)
链表的特点
- 非连续的内存空间
- 每个节点包含数据和指向下一个节点的指针
- 随机访问需要遍历:O(n)
- 插入和删除只需要修改指针:O(1)
时间复杂度对比表
| 数据结构 | 访问 | 插入开头 | 插入结尾 | 插入中间 | 删除开头 | 删除结尾 | 删除中间 | 搜索 |
|---|---|---|---|---|---|---|---|---|
| 数组 | O(1) | O(n) | O(1) | O(n) | O(n) | O(1) | O(n) | O(n) |
| 单向链表 | O(n) | O(1) | O(n) | O(n) | O(1) | O(n) | O(n) | O(n) |
| 双向链表 | O(n) | O(1) | O(1) | O(n) | O(1) | O(1) | O(n) | O(n) |
| 带尾指针的单向链表 | O(n) | O(1) | O(1) | O(n) | O(1) | O(n) | O(n) | O(n) |
关键差异总结
- 随机访问元素:数组完胜(O(1) vs O(n))
- 插入/删除开头:链表完胜(O(1) vs O(n))
- 插入/删除结尾:数组和双向链表都很快
- 插入/删除中间:都不快,但链表稍好
- 内存使用:数组更紧凑,链表有指针开销
实现单向链表
基础节点类
javascript
class ListNode {
constructor(value, next = null) {
this.value = value; // 存储的数据
this.next = next; // 指向下一个节点的指针
}
}
单向链表类
javascript
class LinkedList {
constructor() {
this.head = null; // 链表头节点
this.length = 0; // 链表长度
}
// 获取链表长度
get size() {
return this.length;
}
// 在链表头部添加节点
addFirst(value) {
// 创建新节点,指向原来的头节点
const newNode = new ListNode(value, this.head);
// 更新头节点为新节点
this.head = newNode;
this.length++;
return this;
}
// 在链表尾部添加节点
addLast(value) {
const newNode = new ListNode(value);
// 如果链表为空,新节点就是头节点
if (this.head == null) {
this.head = newNode;
} else {
// 找到最后一个节点
let current = this.head;
while (current.next != null) {
current = current.next;
}
// 将新节点添加到末尾
current.next = newNode;
}
this.length++;
return this;
}
// 删除头节点
removeFirst() {
if (this.head == null) {
return null;
}
const removedValue = this.head.value;
this.head = this.head.next;
this.length--;
return removedValue;
}
// 删除尾节点
removeLast() {
if (this.head == null) {
return null;
}
// 如果只有一个节点
if (this.head.next == null) {
const removedValue = this.head.value;
this.head = null;
this.length--;
return removedValue;
}
// 找到倒数第二个节点
let current = this.head;
while (current.next.next != null) {
current = current.next;
}
const removedValue = current.next.value;
current.next = null;
this.length--;
return removedValue;
}
}
单向链表的实际应用
应用1:浏览器历史记录
javascript
class BrowserHistory {
constructor() {
this.history = new LinkedList();
this.current = null;
}
// 访问新页面
visit(url) {
// 如果当前有页面,添加到历史记录
if (this.current !== null) {
this.history.addLast(this.current);
}
this.current = url;
console.log(`访问: ${url}`);
}
// 后退
back() {
if (this.history.size === 0) {
console.log('无法后退:已经是第一页');
return null;
}
const previous = this.history.removeLast();
const current = this.current;
this.current = previous;
console.log(`后退: ${current} → ${previous}`);
return previous;
}
// 查看历史记录
showHistory() {
console.log('历史记录:');
this.history.forEach((url, index) => {
console.log(` ${index + 1}. ${url}`);
});
console.log(`当前: ${this.current}`);
}
}
应用2:任务队列
javascript
class TaskQueue {
constructor() {
this.queue = new LinkedList();
}
// 添加任务
enqueue(task) {
this.queue.addLast(task);
console.log(`添加任务: ${task.name}`);
}
// 执行下一个任务
dequeue() {
if (this.queue.isEmpty()) {
console.log('任务队列为空');
return null;
}
const task = this.queue.removeFirst();
console.log(`执行任务: ${task.name}`);
// 模拟任务执行
try {
task.execute();
} catch (error) {
console.error(`任务执行失败: ${error.message}`);
}
return task;
}
// 查看下一个任务(不执行)
peek() {
if (this.queue.isEmpty()) {
return null;
}
return this.queue.get(0);
}
// 清空任务队列
clear() {
this.queue = new LinkedList();
console.log('任务队列已清空');
}
// 获取队列长度
get size() {
return this.queue.size;
}
// 打印队列状态
printQueue() {
console.log('当前任务队列:');
this.queue.forEach((task, index) => {
console.log(` ${index + 1}. ${task.name}`);
});
}
}
实现双向链表
基础节点类
javascript
class ListNode {
constructor(value, next = null) {
this.value = value; // 存储的数据
this.next = next; // 指向下一个节点的指针
this.prev = prev; // 指向前一个节点的指针
}
}
双向链表类
javascript
class DoublyLinkedList {
constructor() {
this.head = null; // 链表头节点
this.tail = null; // 链表尾节点
this.length = 0; // 链表长度
}
get size() {
return this.length;
}
// 在头部添加节点
addFirst(value) {
const newNode = new DoubleListNode(value, null, this.head);
if (this.head !== null) {
this.head.prev = newNode;
} else {
// 如果链表为空,新节点也是尾节点
this.tail = newNode;
}
this.head = newNode;
this.length++;
return this;
}
// 在尾部添加节点
addLast(value) {
const newNode = new DoubleListNode(value, this.tail, null);
if (this.tail !== null) {
this.tail.next = newNode;
} else {
// 如果链表为空,新节点也是头节点
this.head = newNode;
}
this.tail = newNode;
this.length++;
return this;
}
// 删除头节点
removeFirst() {
if (this.head === null) {
return null;
}
const removedValue = this.head.value;
if (this.head === this.tail) {
// 只有一个节点
this.head = null;
this.tail = null;
} else {
this.head = this.head.next;
this.head.prev = null;
}
this.length--;
return removedValue;
}
// 删除尾节点
removeLast() {
if (this.tail === null) {
return null;
}
const removedValue = this.tail.value;
if (this.head === this.tail) {
// 只有一个节点
this.head = null;
this.tail = null;
} else {
this.tail = this.tail.prev;
this.tail.next = null;
}
this.length--;
return removedValue;
}
}
双向链表的实际应用
应用1:浏览器历史记录(增强版)
javascript
class EnhancedBrowserHistory {
constructor() {
this.history = new DoublyLinkedList();
this.current = null;
this.currentIndex = -1;
}
// 访问新页面
visit(url) {
console.log(`访问: ${url}`);
// 如果当前位置不在末尾,需要截断后面的历史
if (this.currentIndex < this.history.size - 1) {
// 移除当前位置之后的所有历史
const removeCount = this.history.size - 1 - this.currentIndex;
for (let i = 0; i < removeCount; i++) {
this.history.removeLast();
}
}
// 添加新页面到历史记录
if (this.current !== null) {
this.history.addLast(this.current);
}
this.current = url;
this.currentIndex = this.history.size;
this.printHistory();
}
// 后退
back() {
if (this.currentIndex <= 0) {
console.log('无法后退:已经是第一页');
return null;
}
this.currentIndex--;
this.current = this.history.get(this.currentIndex);
console.log(`后退到: ${this.current}`);
this.printHistory();
return this.current;
}
// 前进
forward() {
if (this.currentIndex >= this.history.size - 1) {
console.log('无法前进:已经是最后一页');
return null;
}
this.currentIndex++;
this.current = this.currentIndex === this.history.size ?
'当前页面' : this.history.get(this.currentIndex);
console.log(`前进到: ${this.current}`);
this.printHistory();
return this.current;
}
// 查看历史记录
printHistory() {
console.log('历史记录:');
this.history.forEach((url, index) => {
const marker = index === this.currentIndex ? '← 当前' : '';
console.log(` ${index + 1}. ${url} ${marker}`);
});
if (this.currentIndex === this.history.size) {
console.log(` 当前: ${this.current}`);
}
}
// 跳转到指定历史
go(index) {
if (index < 0 || index > this.history.size) {
console.log(`无效的历史位置: ${index}`);
return null;
}
this.currentIndex = index;
this.current = index === this.history.size ?
'当前页面' : this.history.get(index);
console.log(`跳转到: ${this.current}`);
this.printHistory();
return this.current;
}
}
核心要点总结
数组 vs 链表的本质差异
- 数组:连续内存,随机访问快(O(1)),插入删除慢(O(n))
- 链表:非连续内存,随机访问慢(O(n)),插入删除快(O(1))
- JavaScript数组:是特殊对象,V8引擎会优化存储方式
单向链表 vs 双向链表
- 单向链表:每个节点只有一个指针(next),内存开销小
- 双向链表:每个节点有两个指针(prev, next),支持双向遍历
- 选择:需要反向操作时用双向链表,否则用单向链表
结语
数据结构的选择没有绝对的对错,只有适合与否。理解数组和链表的差异,能帮助我们在实际开发中做出更明智的选择,写出更高效的代码。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!