JavaScript 没有内置链表,但链表的思想贯穿了前端框架的底层设计。这篇文章带你从节点、指针到底层代价,全面掌握这个"被忽视"的线性结构。
一、什么是链表?
链表和数组同属线性数据结构------每个元素有且仅有一个前驱、有且仅有一个后继。但二者的底层实现截然不同:
css
数组:[1][2][3][4][5] ← 连续内存,依赖索引访问
链表:[1|→] → [2|→] → [3|→] → [4|→] → [5|/] ← 离散内存,依赖指针串联
JS 没有内置链表,需要手动构建:
js
// 方式一:对象字面量(最直观)
const list = {
val: 1,
next: {
val: 2,
next: {
val: 3,
next: null
}
}
};
// 方式二:构造函数(更工程化)
function ListNode(val) {
this.val = val;
this.next = null;
}
const node1 = new ListNode(1);
node1.next = new ListNode(2);
node1.next.next = new ListNode(3);
二、节点的本质
kotlin
┌──────────┐
│ val │ ← 数据域:存数据本身
│ next │ ← 指针域:存"下一个节点在哪"的引用
└──────────┘
每个节点 = 一块独立内存。自己的 val 和 next 紧挨着,但节点与节点之间可以相隔千里。
JS 中的"指针" = 对象引用
JS 没有 C 语言那种真实的内存地址,但对象引用(reference)的效果等同于指针:
js
const node1 = { val: 1, next: null };
const node2 = { val: 2, next: null };
node1.next = node2; // node1.next 存的是 node2 的引用
// 效果上等同于"指向 node2"的指针
运行时效果:
yaml
栈(变量名) 堆(对象实际存储)
────────── ──────────────────
node1 ────→ { val: 1, next: ────→ { val: 2, next: null }
node2 ────────────────────────────────────→ ────
node1.next 不持有 node2 的副本,它只存了一个"箭头"(引用),指向 node2 所在的位置。
三、"离散"的真正代价
3.1 无法用公式定位
scss
✅ 数组 --- 连续内存:
[1][2][3][4][5]
↑ ↑
0x1000 0x1020
arr[3] = 基地址 + 3 × 元素大小 → 直接算出 → O(1)
❌ 链表 --- 离散内存:
[1|→] ──→ [2|→] ──→ [3|→] ──→ [4|→] ──→ [5|/]
↑ ↑ ↑
0x1000 0x3F00 0xA800 ← 地址毫无关联
想找第 3 个?只能从第一个开始,跟着箭头一个一个跳 → O(n)
3.2 CPU 缓存不友好
CPU 在读取内存时会把"附近的内存"一起加载到缓存行(通常 64 字节)。由于数组元素连续,一次加载就覆盖了多个元素。链表节点分散在内存各处,每次跳转大概率触发 cache miss------代价约 100~300 个 CPU 周期。
3.3 每个节点的空间开销
js
// 一个节点存 1 个整数(4 字节)
{ val: 4, next: <ref> }
// 实际占用:
// val (4 字节) + next 引用 (8 字节,64 位) + V8 对象头 (~16 字节)
// ≈ 28 字节 ------ 而实际数据只有 4 字节!
数组存 100 个整数 ≈ 400 字节。链表存 100 个整数 ≈ 2800 字节。 这就是"离散"的代价------每个节点必须随身携带一个指针用来指路。
3.4 离散也有好处
链表不需要一整块连续内存,可以利用内存中零散的"碎片小块"。而数组扩容时需要找到一块能装下全部元素的新空间。
四、链表变体
4.1 双向链表(Doubly Linked List)
css
head ⇄ [A|prev|next] ⇄ [B|prev|next] ⇄ [C|prev|next] ⇄ tail
每个节点同时持有 prev 和 next 指针。
核心优势:已知目标节点时,增删不用再从头遍历找前驱节点,真正 O(1)。
js
function DoublyListNode(val) {
this.val = val;
this.prev = null;
this.next = null;
}
4.2 环形链表(Circular Linked List)
尾节点的 next 指向头节点,形成闭环。
应用场景:轮询调度(Round Robin)、击鼓传花游戏、环形缓冲区。
4.3 哨兵节点(Dummy Head)⭐
这是链表编程中最重要的工程技巧。
js
// ❌ 没有哨兵 --- 头节点和空链表都需要特殊处理
function insert(head, val) {
const node = { val, next: null };
if (!head) return node; // 空链表,特殊处理
if (val < head.val) { // 插在头部,特殊处理
node.next = head;
return node;
}
// ... 正常插入逻辑
}
// ✅ 有哨兵 --- 统一逻辑,消除"空链表"和"头节点操作"两个边界条件
const dummy = { val: 0, next: head }; // 哨兵节点,不计入数据
let cur = dummy;
while (cur.next && cur.next.val < val) cur = cur.next;
node.next = cur.next;
cur.next = node;
return dummy.next; // 真正的头节点
几乎所有链表题目都可以从 dummy head 开始写,这是实战中最实用的技巧。
4.4 跳表(Skip List)
链表 + 多层索引,实现 O(log n) 的查找:
ini
Level 2: head ────────────→ [30] ────────────→ null
Level 1: head ──→ [10] ──→ [30] ──→ [50] ──→ null
Level 0: head → [5] → [10] → [20] → [30] → [40] → [50] → null
这是 Redis ZSet(有序集合)的底层实现------既有链表 O(1) 的增删,又有类似二分查找的 O(log n) 访问。可以理解为数组和链表的"杂交体"。
五、链表四大核心操作模式
模式一:反转链表(三指针)
js
function reverse(head) {
let prev = null;
let cur = head;
while (cur) {
const next = cur.next; // 暂存下一个
cur.next = prev; // 翻转指针方向
prev = cur; // prev 前进
cur = next; // cur 前进
}
return prev; // 新头节点
}
三个指针(prev / cur / next)迭代翻转。这是链表最基础的必会操作。
模式二:快慢指针
js
// 判断环形链表
function hasCycle(head) {
let slow = head, fast = head;
while (fast && fast.next) {
slow = slow.next; // 慢指针走 1 步
fast = fast.next.next; // 快指针走 2 步
if (slow === fast) return true; // 相遇 = 有环
}
return false;
}
快慢指针还能用来找链表中点------fast 走到尾时,slow 刚好在中间。
模式三:多指针同步
两个指针保持 k 步间隔同步前进。经典应用:删除链表的倒数第 k 个节点。
模式四:Dummy Head
如 4.3 节所述,用哨兵节点统一头节点和中间节点的处理逻辑。
六、链表的增删操作
添加元素:对 next 指针重新赋值
js
// 在节点 A 后面插入新节点 N
// 前:A → B
// 后:A → N → B
N.next = A.next; // N 指向 B
A.next = N; // A 指向 N
删除元素:跳过目标节点
js
// 删除节点 A 后面的节点 B
// 前:A → B → C
// 后:A → C
A.next = A.next.next; // A 直接指向 C,B 被丢弃
时间复杂度:已知要操作的位置 → O(1)(只改指针,不移动任何元素)。但找位置本身需要 O(n)。
七、链表 vs 数组总览
| 维度 | 数组 | 链表 |
|---|---|---|
| 内存分布 | 连续 | 离散 |
| 随机访问 | O(1) ⚡ | O(n) 🐢 |
| 头部增删 | O(n) | O(1) ⚡ |
| 尾部增删 | O(1) | O(1)(有尾指针) |
| 中间增删(已知位置) | O(n) | O(1) ⚡ |
| 每元素额外开销 | 0 | val + next 引用 + 对象头 |
| CPU 缓存 | ✅ 友好 | ❌ 不友好 |
| JS 内置支持 | ✅ | ❌ 需手动构建 |
八、链表思想在框架中的应用
虽然在 JS 日常开发中很少直接手写链表,但链表思想贯穿了主流框架的底层:
| 框架/场景 | 应用 |
|---|---|
| React Fiber | 虚拟 DOM 树通过 child/sibling/return 三个指针串联,实现可中断的 diff 算法 |
| Vue 响应式系统 | effect 之间通过链表连接,方便批量清理和依赖追踪 |
| Node.js 事件循环 | 回调队列、微任务队列本质上都是链表结构 |
| LRU 缓存 | Map + 双向链表,O(1) 实现 get/put |
写在最后
链表 = 离散的节点 + 指针串联。它用"存储指针的额外空间"换来了"无需移动元素的增删灵活性"。
如果你刚接触链表,记得先掌握三个基本功:
- 手写一个节点构造函数
- 用 Dummy Head 完成插入操作
- 用三指针完成链表反转
这三板斧熟练了,链表的其他操作都是它们的组合和变形。