链表题的本质就一行代码——`cur.next = ...`,剩下的全是变体

121 阅读8分钟

十个链表题背到吐?一行 cur.next = cur.next.next 全秒了


开篇:四个操作,你背了四次

你刷链表题的时候是不是这样:

  • 遍历用 while (cur) { cur = cur.next } ——背的
  • 删除用 cur.next = cur.next.next ——背的
  • 插入用 newNode.next = cur.next; cur.next = newNode ——背的
  • 反转用 [pre, cur, temp] 三指针轮换 ——背的

每种操作你都当成独立知识点去记,刷到后面脑子一团乱。 做 LeetCode 203(移除链表元素)的时候忘了删完要不要前进;做 206(反转链表)的时候搞混 pre 和 cur 的移动顺序。

这篇文章换一个角度——不把链表操作当四个独立技巧,而是当"改 next 指针"的四种变体。 理解了这个,你遇到没见过的链表题也能自己推导出来。


核心概念:链表 = 一群节点,每个节点指下一个

链表就是一根"绳子上的珠子",每个珠子(节点)里写着一个地址,告诉你下一个珠子在哪儿。所有操作的本质,就是修改某个珠子里的地址——也就是改 next。

用寻宝游戏来理解:

你拿到纸条A,上面写着"去图书馆"
到了图书馆,找到纸条B,上面写着"去食堂"
到了食堂,找到纸条C,上面写着"宝藏在这里"

纸条A          纸条B           纸条C
[去图书馆]  →  [去食堂]  →  [宝藏在这]  →  null
  next          next           next
现实操作链表操作本质
顺着纸条一路找遍历顺着 next 往前走
把纸条A改成"去食堂"(跳过B)删除让前一个节点的 next 跳过目标
在A和B之间塞一张新纸条插入新节点指向后节点,前节点指向新节点
把所有纸条的地址倒过来写反转每个节点的 next 改成指向前一个

一张图看懂所有操作:

graph LR
    A["节点A"] -->|"next"| B["节点B"]
    B -->|"next"| C["节点C"]
    C -->|"next"| D["null"]

    A2["节点A"] -.->|"删除:跳过B"| C2["节点C"]
    B2["新节点X"] -.->|"插入:塞在A和B之间"| B3["节点B"]

上图:实线箭头是 next 指针。删除 = 把 A 的 next 从 "指B" 改成 "指C"。插入 = 新节点 X 的 next 指 B,A 的 next 改成指 X。


四大操作:一行 cur.next = ... 全搞定

JavaScript 中链表节点长这样:

function ListNode(val, next) {
    this.val = (val === undefined ? 0 : val);
    this.next = (next === undefined ? null : next);
}

// 创建链表 1 → 2 → 3
let head = new ListNode(1, new ListNode(2, new ListNode(3)));

操作一:遍历 — 最简单的改 next

遍历本质上就是"把当前指针移到下一个节点":

let cur = head;
while (cur) {              // cur 变成 null 就是到末尾了
    console.log(cur.val);
    cur = cur.next;        // 🔑 "改"的是 cur 这个临时变量,不是链表本身
}

cur = cur.next 的意思是"我把目光从当前节点移到下一个"。这和删除/插入不同——遍历不改变链表结构,只改变你"看哪儿"。


操作二:删除节点 — 跳过它

LeetCode 203. 移除链表元素

要删掉值为 val 的节点,核心就是让前驱节点的 next 直接跳过目标节点:

删之前:  cur → 待删节点 → 后面的节点
删之后:  cur → 后面的节点(待删节点没人引用,被垃圾回收了)
function removeElements(head, val) {
    // 🔑 dummy 虚拟头节点:让 head 也有"前驱",统一处理逻辑
    let dummy = new ListNode(0, head);
    let cur = dummy;

    while (cur.next) {
        if (cur.next.val === val) {
            cur.next = cur.next.next;  // 🔑 唯一的关键行:跳过待删节点
            // ⚠️ 这时候 cur 不要前进!因为 cur.next 已经指向新节点了,
            //    下一轮 while 刚好检查这个新节点
        } else {
            cur = cur.next;            // 不删:正常前进
        }
    }
    return dummy.next;  // 🔑 返回 dummy.next 而不是 head——head 可能已被删了
}

⚠️ 最常见 bug:删完节点后 cur = cur.next,结果跳过了新 cur.next(也就是原来的下下个)没检查。删了就不前进——这是 203 题最高频的错误。


操作三:插入节点 — 先连后断

在 cur 后面插入新节点。顺序是铁律:必须先连新的,再断旧的。

① newNode.next = cur.next    先让新节点指向 cur 的下一个
② cur.next = newNode         再让 cur 指向新节点
function insertAfter(cur, val) {
    let newNode = new ListNode(val);
    newNode.next = cur.next;   // 1. 新节点先接上后面的链
    cur.next = newNode;        // 2. cur 再接上新节点
    // ⚠️ 顺序反过来会丢数据!如果先做 ②,cur 和后面节点的联系就断了,
    //    后面所有节点找不到,全被垃圾回收
}
sequenceDiagram
    participant C as cur
    participant N as newNode
    participant R as rest

    Note over C,R: 插入前:cur → rest

    N->>R: ① newNode.next = cur.next
    Note over N,R: newNode → rest(先接上)

    C->>N: ② cur.next = newNode
    Note over C,N: cur → newNode → rest(完成!)

为什么先连后断?和搬家一样——先把新家的钥匙配好,再注销旧地址。先注销旧地址你就回不去拿东西了。


操作四:反转链表 — 逐个翻方向

LeetCode 206. 反转链表

反转的本质:遍历每个节点,把它的 next 指向前一个节点。 需要三个变量:pre(前一个)、cur(当前)、temp(暂存下一个)。

原链表:  1 → 2 → 3 → null

第1步:  null ← 1    2 → 3 → null     (1 指向 null,记得 2 的地址)
              ↑      ↑
             pre    cur

第2步:  null ← 1 ← 2    3 → null     (2 指向 1,记得 3 的地址)
                   ↑      ↑
                  pre    cur

第3步:  null ← 1 ← 2 ← 3               (3 指向 2,cur 到 null,结束)
                        ↑
                       pre → 这就是新头!
function reverseList(head) {
    let pre = null;
    let cur = head;

    while (cur) {
        let temp = cur.next;    // 1. 先存好下一个(不然断链就找不到了)
        cur.next = pre;         // 2. 🔑 反转:当前节点指向前一个
        pre = cur;              // 3. pre 前进
        cur = temp;             // 4. cur 前进(用之前存的 temp)
    }
    return pre;                 // pre 就是反转后的新头
}

🧠 记忆口诀:存下一个 → 指回前一个 → 双双前移。链表反转是面试最高频题之一,把这三步练成肌肉记忆。


万能技巧:虚拟头节点(dummy)

dummy 节点是链表题的"作弊器"——在真正的 head 前面加一个假节点,让所有节点(包括 head)都有一个前驱。

原来:  head → 2 → 3 → null    (head 没有前驱,处理它要写特殊分支)
加后:  dummy → head → 2 → 3 → null  (每个真实节点都有前驱了!)
let dummy = new ListNode(0, head);  // dummy 指向 head
let cur = dummy;                     // 从 dummy 开始操作
// ... 各种操作,cur.next 就是"下一个要处理的节点"
return dummy.next;                   // ⚠️ 返回 dummy.next!head 可能已被删了

什么时候必须用 dummy?

场景为什么必须用
可能要删除头节点head 没有前驱,要删它只能借助 dummy
统一处理逻辑不想为"第一个节点"写特殊分支
返回一个全新的链表用 dummy 串联新节点,最后 return dummy.next

技巧:遇到链表题,第一反应就是"要不要加 dummy"。 90% 的链表题加 dummy 后逻辑都统一了。


进阶套路:快慢指针+双指针

链表题的"难"基本集中在四类问题,口诀先收好:

环 → 快慢追;倒数 K → 快先走 K;相交 → 互换路;合并 → 比大小接。

1. 检测环形链表(LeetCode 141)— 快慢指针相遇

操场赛跑:两个人在跑道上,快的每步 2 格、慢的每步 1 格。有环 → 快的总能套圈追上慢的;没环 → 快的先冲到终点(null)。

function hasCycle(head) {
    let slow = head, fast = head;
    while (fast && fast.next) {
        slow = slow.next;           // 慢:一步
        fast = fast.next.next;      // 快:两步
        if (slow === fast) return true;  // 追上 = 有环
    }
    return false;                   // fast 到 null = 没环
}

2. 找环的入口(LeetCode 142)— Floyd 判圈

相遇后,把其中一个指针放回头节点,两个指针同速各走一步,再次相遇处就是环入口。

function detectCycle(head) {
    let slow = head, fast = head;
    while (fast && fast.next) {
        slow = slow.next;
        fast = fast.next.next;
        if (slow === fast) {           // 有环
            let p = head;
            while (p !== slow) {       // 同速走,相遇即入口
                p = p.next;
                slow = slow.next;
            }
            return p;
        }
    }
    return null;
}

3. 倒数第 K 个节点(LeetCode 19)

快针先走 K 步,然后快慢同速前进。快针到尾时,慢针正好在倒数第 K 个。

4. 相交链表(LeetCode 160)

两个指针分别从两个链表头出发,走到末尾后换到对方链表继续走。两人总路程相等,必定在交点相遇,或同时到 null。

5. 合并两个有序链表(LeetCode 21)

dummy + 双指针。两个指针分别指向两个链表,比大小,把小的接到结果链后面,移动对应指针。谁空了,直接接上另一串。

function mergeTwoLists(l1, l2) {
    let dummy = new ListNode(0);
    let cur = dummy;
    while (l1 && l2) {
        if (l1.val <= l2.val) {
            cur.next = l1;    // 谁小接谁
            l1 = l1.next;     // 接了谁,谁前移
        } else {
            cur.next = l2;
            l2 = l2.next;
        }
        cur = cur.next;       // cur 始终指向链尾
    }
    cur.next = l1 || l2;      // 剩下的直接挂上
    return dummy.next;
}

链表 vs 数组:什么时候该选谁

数组(Array)链表(Linked List)
访问第 i 个O(1) — 直接下标O(n) — 从头一个个找
头部增删O(n) — 整体搬家O(1) — 改个箭头
内存连续一块,需要整块空闲散在各处,用指针连(灵活)
遍历速度快(CPU 缓存友好)慢(节点跳来跳去)

🔑 选型口诀:需要随机访问 → 数组。频繁头部增删 → 链表。


避坑五条(条条都有人踩过)

#坑正确做法
1while (cur) 写成了 while (cur.next)遍历到最后一个节点时 cur.next 为 null 没问题,但用 while(cur) 更安全——你只想"cur 不是 null 就继续"
2删除后 cur 继续前进删完 cur.next 已经指向新节点了,别动 cur,下一轮刚好检查它
3插入时先断后连永远是"先连新的 → 再断旧的":newNode.next = cur.next 先,cur.next = newNode 后
4用了 dummy 但 return headhead 可能已被删掉或移到后面了。永远 return dummy.next
5没判空链表入口加 if (!head) return null,否则 head.next 直接 TypeError

结尾:下次遇到链表题,别背操作,推导它

开篇问:为什么四个操作背到吐?

因为你把它们当独立技巧在记,而不是当"改 next 指针"的变体在理解。

用一句话记住:链表的本质就是改 next——遍历是改"目光",删除是跳过,插入是先连后断,反转是逐个翻方向。所有操作归根到底只有一行代码:某个节点的 next = 某个值。

下次刷链表题,就做这一件事

  1. 画三个节点的图,标清楚每个节点的 next 指向谁
  2. 问自己:改哪个节点的 next,改成指向谁?
  3. 写出那一行 xxx.next = yyy,再补上前后移动指针的步骤

画图 + 改 next。这个两步法能推导出所有链表操作。

开放问题:你在刷链表题时,有哪个操作理解了很久才恍然大悟?是反转时三指针的轮换顺序,还是插入时"先连后断"?评论区分享一下你的顿悟时刻。