十个链表题背到吐?一行 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 缓存友好) | 慢(节点跳来跳去) |
🔑 选型口诀:需要随机访问 → 数组。频繁头部增删 → 链表。
避坑五条(条条都有人踩过)
| # | 坑 | 正确做法 |
|---|---|---|
| 1 | while (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 head | head 可能已被删掉或移到后面了。永远 return dummy.next |
| 5 | 没判空链表 | 入口加 if (!head) return null,否则 head.next 直接 TypeError |
结尾:下次遇到链表题,别背操作,推导它
开篇问:为什么四个操作背到吐?
因为你把它们当独立技巧在记,而不是当"改 next 指针"的变体在理解。
用一句话记住:链表的本质就是改 next——遍历是改"目光",删除是跳过,插入是先连后断,反转是逐个翻方向。所有操作归根到底只有一行代码:某个节点的 next = 某个值。
下次刷链表题,就做这一件事
- 画三个节点的图,标清楚每个节点的
next指向谁 - 问自己:改哪个节点的
next,改成指向谁? - 写出那一行
xxx.next = yyy,再补上前后移动指针的步骤
画图 + 改 next。这个两步法能推导出所有链表操作。
开放问题:你在刷链表题时,有哪个操作理解了很久才恍然大悟?是反转时三指针的轮换顺序,还是插入时"先连后断"?评论区分享一下你的顿悟时刻。