用 JavaScript 跳一支链表之舞:从串珠子到快慢指针

46 阅读5分钟

🌱 从“串珠子”到“指针舞蹈”:用 JavaScript 彻底吃透链表核心操作

你不需要懂 C 语言或内存地址,只要会用 JavaScript 的对象,就能彻底掌握链表

在学习 JavaScript 的过程中,很多人都会在某一刻被“链表”卡住。
它不像数组那样直观,也没有对象那样自由,却频繁出现在面试和算法题中。

其实,链表并不难,只是我们还没找到合适的理解方式。

本文将通过几个最经典的链表操作——删除节点、反转链表、判断是否有环、删除倒数第 N 个节点,一步步带你建立起对链表的“感觉”。

一、链表到底是什么?先换个角度看问题

如果把数组想象成一排连续的房子

[ A ][ B ][ C ][ D ]

那链表更像是一串用绳子串起来的珠子

A -> B -> C -> D -> null

每一颗珠子(节点)只关心两件事:

{
  val: 当前节点存的数据,
  next: 指向下一个节点的引用
}

📌 链表与数组的关键区别

对比数组链表
内存连续不连续
访问下标直接访问只能一个个找
插入/删除麻烦(要挪)方便(改指针)

链表的所有“魔法”,本质上就一句话:

不断地改 next 指向


二、删除链表中的节点:为什么头节点最麻烦?

1️⃣ 基础版:直接遍历删除

先来看一个最直观的实现:

function remove(head, val) {
    if (head && head.val === val) {
        return head.next
    }

    let cur = head
    while (cur.next) {
        if (cur.next.val === val) {
            cur.next = cur.next.next
            break
        }
        cur = cur.next
    }
    return head
}

🔍 这个版本在做什么?

  • 如果要删的是 头节点
    👉 直接返回 head.next

  • 否则:

    • 从头开始遍历
    • 找到目标节点的前一个
    • 让它的 next 指向下下个节点

📌 生活比喻

像一列火车:

车头 -> 车厢1 -> 车厢2 -> 车厢3

你不能直接“拽走中间的车厢”,
只能让 前一节车厢改挂钩方向


❗问题来了:为什么头节点要特殊处理?

因为:

  • 头节点没有前驱
  • 而删除操作,偏偏需要“前一个节点”

于是我们就写了一个 if (head.val === val) 的特判。

特判一多,代码就开始变得别扭。


三、dummy 节点:链表世界的“垫脚石”

为了解决“头节点没有前驱”的问题,引入了一个非常经典的设计:

dummy 节点(哨兵节点)

1️⃣ dummy 是什么?

  • 一个假的节点
  • 不存真实数据
  • 永远站在真正头节点的前面
dummy -> head -> ...

就像:

在队伍最前面安排一个“空气人”,
让每个人前面都有人


2️⃣ 使用 dummy 重写删除逻辑

function remove(head, val) {
    const dummy = new ListNode(0)
    dummy.next = head

    let cur = dummy
    while (cur.next) {
        if (cur.next.val === val) {
            cur.next = cur.next.next
            break
        }
        cur = cur.next
    }
    return dummy.next
}

✨ 优点一眼可见

  • 不用再区分是不是头节点
  • 所有删除逻辑 完全统一
  • 思维成本明显降低

📌 一句话总结

dummy 节点不是为了存数据,
而是为了消灭边界条件


四、链表反转:头插法其实一点都不神秘

链表反转是很多人的“心理阴影”,
但一旦理解了,它甚至比数组反转更优雅。


1️⃣ 思路先行:别急着看代码

反转前:

1 -> 2 -> 3 -> null

反转后:

3 -> 2 -> 1 -> null

关键问题只有一个:

当前节点,应该指向谁?

答案是:

指向「已经反转好的那一部分的头」


2️⃣ dummy + 头插法实现反转

function reverseList(head) {
    const dummy = new ListNode(0)
    let cur = head

    while (cur) {
        const next = cur.next

        cur.next = dummy.next
        dummy.next = cur

        cur = next
    }
    return dummy.next
}

3️⃣ 拆解成“三步舞蹈”

每一轮循环,都只做三件事:

1️⃣ 保存下一个节点

const next = cur.next

👉 不保存就会“断链”


2️⃣ 当前节点插到反转区最前面

cur.next = dummy.next
dummy.next = cur

👉 这一步就是“头插法”


3️⃣ 指针前进

cur = next

五、快慢指针:两个人走路,为什么能发现环?

1️⃣ 什么是链表中的“环”?

1 -> 2 -> 3 -> 4
          ↑     ↓
          ← ← ← ←

节点指回前面的节点,就形成了环。


2️⃣ 快慢指针的直觉理解

  • 慢指针:一步一步走
  • 快指针:一次走两步

如果:

  • 没有环 👉 快指针先到 null
  • 有环 👉 快指针一定会追上慢指针

3️⃣ 判断链表是否有环

function hasCycle(head) {
    let slow = head
    let fast = head

    while (fast && fast.next) {
        slow = slow.next
        fast = fast.next.next

        if (slow === fast) {
            return true
        }
    }
    return false
}

📌 为什么一定能追上?

就像操场跑步:

  • 一个人快
  • 一个人慢
  • 在一个封闭跑道上
    👉 迟早会相遇

六、删除倒数第 N 个节点:快慢指针的进阶用法

来自19. 删除链表的倒数第 N 个结点 - 力扣(LeetCode)

1️⃣ 为什么“倒数”很难?

因为链表:

  • 不能反向遍历
  • 不知道长度,就没法直接定位

2️⃣ 一个非常巧妙的解法

让快指针先走 N 步

当快指针到终点时:

  • 慢指针正好停在
    👉 倒数第 N 个节点的前一个

3️⃣ 完整实现

const removeNthFromEnd = function(head, n) {
  const dummy = new ListNode(0)
  dummy.next = head

  let fast = dummy
  let slow = dummy

  for (let i = 0; i < n; i++) {
    fast = fast.next
  }

  while (fast.next) {
    fast = fast.next
    slow = slow.next
  }

  slow.next = slow.next.next
  return dummy.next
}

🧠 核心理解

  • 快慢指针之间始终保持 N 步距离
  • 快指针到头,慢指针刚好对位

📌 像两个人拿着尺子一起走路


七、链表的三大“万能套路”

学完这些例子,其实可以总结出链表题的固定解题模型


✅ 1️⃣ dummy 节点

  • 删除
  • 反转
  • 倒数节点

👉 统一头节点逻辑


✅ 2️⃣ 快慢指针

  • 是否有环
  • 倒数第 N 个
  • 中间节点

👉 用时间换空间


✅ 3️⃣ 指针操作的本质

链表题 ≠ 写代码
链表题 = 想清楚 next 指向谁


八、结语:链表并不复杂,只是需要“耐心感”

如果说数组像记事本
那链表更像是一串钥匙扣

你不能跳着找,
但你可以随时加、随时拆。

当你真正理解了:

  • dummy 的意义
  • 快慢指针的节奏
  • 每一次 cur.next = ... 背后的含义

链表,就会从“噩梦”变成“模板题”。

🌟 真正的进步,不是记住代码,而是看懂变化。