数组和链表的性能差异来自内存布局而非 API,看懂地址计算和 V8 元素类型,才知道什么时候该换掉手里的数组。
虚拟列表按下标取第 10000 条数据,LRU 缓存从链表中间摘掉一个节点,两件事都叫"取列表元素",底层代价却差了几个数量级。前端天天写 arr.map,真正决定快慢的是元素在内存里怎么排、V8 把它当成什么。
| 维度 | 数组 | 链表 |
|---|---|---|
| 内存分布 | 一整块连续区域 | 节点散落在堆上,靠指针相连 |
| 定位方式 | base + i * size 直接算出 | 从头逐个 next 走 |
| 随机访问 | O(1) | O(n) |
| 头部插入 | O(n),整体搬移 | O(1),改两个指针 |
| 尾部插入 | 均摊 O(1) | O(1)(有尾指针时) |
| 缓存友好度 | 高,预取器能整块加载 | 低,每跳一次都可能 cache miss |
| 额外开销 | 无 | 每个节点多一个指针字段 |
数组的 O(1) 不是玄学,是一次乘加:
// 起始地址 + 下标 × 元素宽度 = 目标地址,与数组长度无关
function addrOf(base, index, size) {
return base + index * size;
}
console.log(addrOf(1000, 3, 4)); // 1012,第 3 个 4 字节元素
链表的节点地址由分配器决定,第 n 个节点必须走 n 次指针才能到,所以随机访问是 O(n)。反过来,删除一个已知节点只需改指针,不用搬动其它元素。
typeof [] === 'object',下标在规范里是字符串键 "0"、"1",Array 只是带 length 和一组方法的特殊对象。V8 为了不让它慢成字典,对"连续整数索引"做了专门的快路径,也就是 elements kinds。
| 元素类型 | 触发条件 | 存储形态 |
|---|---|---|
PACKED_SMI_ELEMENTS | 全是小整数(Smi) | 连续存放的整数 |
PACKED_DOUBLE_ELEMENTS | 数字里含小数 | 连续存放的 double |
PACKED_ELEMENTS | 混入对象、字符串 | 连续存放的指针 |
HOLEY_SMI_ELEMENTS | 小整数但有空洞 | 带 hole 标记 |
HOLEY_DOUBLE_ELEMENTS | double 但有空洞 | 带 hole 标记 |
HOLEY_ELEMENTS | 对象或字符串且有空洞 | 带 hole 标记 |
类型转换是单向的,只会往"更通用"退化:SMI → DOUBLE → ELEMENTS,PACKED → HOLEY,退过去就回不来。
const a = [1, 2, 3]; // PACKED_SMI_ELEMENTS
a.push(4.5); // 退化成 PACKED_DOUBLE_ELEMENTS
a.push('x'); // 再退化成 PACKED_ELEMENTS
const b = [1, , 3]; // 一个逗号就是 hole,直接 HOLEY_SMI_ELEMENTS
[1, , 3] 比 [1, 2, 3] 慢,原因是读每个下标都要先判断是不是 hole,是 hole 就得沿原型链去找,V8 无法生成"按偏移直接取"的机器码。map、forEach 会跳过 hole,for 循环却读到 undefined。混存类型退化的代价在热循环里最明显:变成 PACKED_ELEMENTS 后每次读都要解引用指针、做类型判断,没法整块预取。
JS 没有指针,链表就是"对象里存另一个对象的引用":
class ListNode {
constructor(val, next = null) {
this.val = val;
this.next = next;
}
}
// 手工串一条 1 -> 2 -> 3
const head = new ListNode(1, new ListNode(2, new ListNode(3)));
业务代码几乎不用链表:小数据量下 arr.splice 更快。真正需要链表的是"遍历过程中要频繁摘除节点,且已经持有节点引用"的场景。
React Fiber 是典型例子:每个 fiber 用 child、sibling、return 三根指针串成树,遍历可以在任意节点暂停、恢复。
const fiber = { child: null, sibling: null, return: null };
fiber.child = { type: 'span' }; // 第一个子节点
fiber.sibling = { type: 'p' }; // 下一个兄弟节点
fiber.return = { type: 'div' }; // 父节点,命名避开了关键字
LRU 缓存同理:哈希表存 key → 节点,双向链表维护访问顺序,命中后把节点摘下来挂到头部,全程 O(1)。
| 需求 | 首选 | 理由 |
|---|---|---|
| 按下标随机读、批量遍历 | Array | 连续内存,遍历命中缓存 |
| 已知节点的频繁增删 | 双向链表 | 改指针即可,O(1) |
| 去重 + 成员判断 | Set | 哈希,O(1) 查找 |
| 键值映射且需要顺序 | Map | 哈希 + 插入顺序 |
| 按下标访问又要频繁头部插入 | 数组 + 头指针 | 躲开 unshift 的整体搬移 |
很多人以为"链表插入是 O(1),所以一定比数组快"。 实际上 O(1) 的前提是已经拿到那个节点,找位置本身要 O(n);节点散落在堆上,每跳一次都可能 cache miss,几千条以内 splice 常常反超。
很多人以为 JS 数组就是 C 的动态数组。 实际上它是对象,长度不固定、可以稀疏、可以挂任意属性(arr.foo = 1)。连续存储只是 V8 对密集整数索引的优化,出现空洞或混存就退回慢路径。
思路:三指针原地翻转,prev 记住前驱,cur 往前走之前先把 next 存下来,否则链会断。
function reverseList(head) {
let prev = null;
let cur = head;
while (cur) {
const next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
return prev;
}
时间 O(n),空间 O(1)。易错点:返回 prev 而不是 head,循环结束后 head 已经是尾节点。
思路:哨兵节点省掉"谁是头"的判断,双指针每次取较小的那个接在尾部。
function mergeTwoLists(l1, l2) {
const dummy = { val: 0, next: null };
let tail = dummy;
while (l1 && l2) {
if (l1.val <= l2.val) {
tail.next = l1;
l1 = l1.next;
} else {
tail.next = l2;
l2 = l2.next;
}
tail = tail.next;
}
tail.next = l1 || l2; // 剩下的整段直接接上
return dummy.next;
}
时间 O(m + n),空间 O(1)。易错点:忘记把剩余部分接上;比较要和 <= 才能保持稳定。
思路:快指针先走 n + 1 步,再和慢指针一起走,快指针到头时慢指针正好停在待删节点的前驱。
function removeNthFromEnd(head, n) {
const dummy = { val: 0, next: head };
let fast = dummy;
let slow = dummy;
for (let i = 0; i <= n; i++) fast = fast.next;
while (fast) {
fast = fast.next;
slow = slow.next;
}
slow.next = slow.next.next;
return dummy.next;
}
时间 O(n),空间 O(1)。易错点:不加 dummy 时删头节点要单独判断;快指针只走 n 步,慢指针会停在待删节点本身。