深入理解链表:线性数据结构的另一面

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    │  ← 指针域:存"下一个节点在哪"的引用
└──────────┘

每个节点 = 一块独立内存。自己的 valnext 紧挨着,但节点与节点之间可以相隔千里

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

每个节点同时持有 prevnext 指针。

核心优势:已知目标节点时,增删不用再从头遍历找前驱节点,真正 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

写在最后

链表 = 离散的节点 + 指针串联。它用"存储指针的额外空间"换来了"无需移动元素的增删灵活性"。

如果你刚接触链表,记得先掌握三个基本功:

  1. 手写一个节点构造函数
  2. 用 Dummy Head 完成插入操作
  3. 用三指针完成链表反转

这三板斧熟练了,链表的其他操作都是它们的组合和变形。

相关推荐
林希_Rachel_傻希希1 小时前
学React治好了我的焦虑症,1小时速通React 前20分钟。
前端·javascript·面试
小林ixn1 小时前
从 Ajax 到异步编程:JSON 序列化、Event Loop 与 XHR 请求完全解析
javascript
Queenie_Charlie2 小时前
哈夫曼树
数据结构·c++·哈夫曼树
丷丩3 小时前
MapLibre GL JS第47课:添加动画图标
javascript·gis·动画·mapbox·maplibre
快乐的哈士奇3 小时前
【Next.js实战①】Gmail API 按柜号检索邮件:OAuth 双 Cookie 与搜索 Fallback
开发语言·javascript·ecmascript
云水一下3 小时前
Vue.js从零到精通系列(五):全局状态管理——Pinia 核心与实践
前端·javascript·vue.js
kmblack14 小时前
javascript计算年龄
开发语言·javascript·ecmascript
Shan12054 小时前
经典问题——验证栈序列
数据结构·算法
Dick5074 小时前
ROS2 多机器人通用 Driver 层复盘:BaseRobotDriver 到多平台 Mock 切换实现
前端·javascript·机器人