链表高频操作精要:Dummy节点、头插法与快慢指针实战指南
链表是数据结构中的基础线性结构,其节点通过指针链接。由于链表没有下标索引、头尾边界特殊,很多操作(如删除、反转、找倒数第 N 个)容易因边界条件出错。为此,我们常借助 哨兵节点(dummy 节点) 和 快慢指针 等技巧简化逻辑、提升代码鲁棒性。
💼 面试提示:链表题在算法面试中极为高频(尤其是大厂),不仅考察实现能力,更关注边界处理、代码简洁性、是否修改原结构等细节。以下每个技巧后都附有典型面试变种。
一、哨兵节点(Dummy Node)
1. 什么是 Dummy 节点?
- 是一个人为添加的假节点,不存储真实数据。
- 通常放在链表最前面(有时也可用于尾部)。
- 作用:统一处理头节点等边界情况,避免特殊判断。
2. 应用场景
- 删除节点(尤其是头节点)
- 反转链表
- 删除倒数第 N 个节点
3. 示例对比:删除指定值节点
❌ 不使用 dummy 的写法(需特判头节点)
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;
}
✅ 使用 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;
}
💼 面试延伸场景:
“只给要删除的节点(非尾节点),如何删除?”
→ 无法访问前驱,只能将下一节点值复制过来并跳过它(伪删除)。node.val = node.next.val; node.next = node.next.next;“删除所有值为 val 的节点(不止一个)”
→ 把break去掉,继续遍历即可。注意仍需 dummy 处理连续头节点被删的情况。
二、链表反转:Dummy + 头插法
核心思想
- 利用 头插法 将原链表的每个节点逐个插入到新链表的头部,从而实现反转。
- 引入
dummy节点作为虚拟头节点,其dummy.next始终指向当前已反转部分的真实头节点。 - 这种方法天然避免了对“原 head 变成尾节点后 next 应为 null”的手动处理,逻辑简洁且边界安全。
实现步骤(三步头插)
function reverseList(head) {
const dummy = new ListNode(0); // 虚拟头,dummy.next 指向反转后的头
let cur = head; // 当前待处理的节点
while (cur) {
const next = cur.next; // 🔑 第1步:先保存下一个节点!
cur.next = dummy.next; // 第2步:将当前节点插入到已反转部分的头部
dummy.next = cur; // 第3步:更新反转链表的新头
cur = next; // 移动到原链表中的下一个节点
}
return dummy.next;
}
⚠️ 为什么必须用 next = cur.next 保存下一个节点?
这是头插法中最关键、也最容易出错的细节,面试官常借此考察你对指针操作的理解深度。
❌ 如果省略 next,直接写:
while (cur) {
cur.next = dummy.next; // 👈 修改了 cur.next!
dummy.next = cur;
cur = cur.next; // 💥 此时 cur.next 已不是原链表的下一个节点!
}
📌 会发生什么?
假设原链表为 1 → 2 → 3 → null:
-
第一次循环:
cur = 1- 执行
cur.next = dummy.next(即1.next = null) - 然后
cur = cur.next⇒cur = null
-
循环提前结束!
- 节点
2和3永远无法访问,逻辑上丢失。 - 最终只反转了第一个节点,返回
[1],而非[3,2,1]。
- 节点
💡 根本原因:
cur.next是通往原链表剩余部分的唯一指针。一旦被覆盖(指向已反转部分),就断开了与原链表的连接,导致遍历中断。
可以把这个过程类比为拆桥重建:
- 你站在当前桥墩
cur上; - 下一座桥墩是
cur.next; - 如果你先把脚下的桥(
cur.next)拆了(指向别处),又没记住下一座在哪,就再也过不去了; - 所以必须先记下下一座桥墩的位置(
next = cur.next) ,再动手拆桥。
💼 面试表达建议
当被问到“为什么要有 const next = cur.next”时,可以清晰回答:
“因为在头插过程中,我们会修改
cur.next指向已反转部分的头(即dummy.next)。如果不提前保存原链表中cur的下一个节点,就会永久丢失对剩余链表的引用,导致只能处理第一个节点。所以必须在修改指针前,先用一个临时变量保存cur.next,确保遍历能继续进行。”
三、快慢指针(Two Pointers)
1. 基本原理
-
两个指针同向移动,速度不同(通常 fast 走 2 步,slow 走 1 步)。
-
适用于:
- 判断环(Floyd 判圈算法)
- 找中点
- 删除倒数第 N 个节点
2. 判断链表是否有环
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;
}
💼 面试延伸场景:
- “如果有环,返回环的入口节点”
→ 快慢指针相遇后,将一个指针重置到 head,两者同速前进,再次相遇即为入口。
(原理:head 到入口距离 = 相遇点绕环若干圈后到入口的距离)- “判断链表是否为回文”
→ 快慢指针找中点 → 反转后半段 → 双指针比较前后两段 → (可选)恢复结构。
⚠️ 面试官可能追问:“能否不修改原链表?” → 此时可用栈存前半段。
四、删除链表的倒数第 N 个节点:Dummy + 快慢指针
思路
- 快指针先走 N 步,慢指针随后同步移动。
- 当快指针到达末尾时,慢指针正好在倒数第 N 个节点的前一个。
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 个节点,但要求一次遍历且不用 dummy”
→ 可行但复杂:需额外判断 n 是否等于链表长度(即删头节点)。强烈建议坚持用 dummy,并向面试官说明:“这样能统一处理边界,提高代码健壮性。”- “找出倒数第 N 个节点的值(不删除)”
→ 同样用快慢指针,最后返回slow.next.val。
五、总结:链表操作三大法宝 + 面试心法
| 技巧 | 作用 | 典型应用 | 面试加分点 |
|---|---|---|---|
| Dummy 节点 | 统一边界处理 | 删除、反转、分组 | 主动说明“避免特判头节点,提升鲁棒性” |
| 头插法 | 构建逆序 | 反转、局部反转 | 能画图解释三步操作,强调“必须先保存 next” |
| 快慢指针 | 定位特定位置 | 判环、找中点、倒数第 N 个 | 能推导环入口原理 |
💡 面试答题黄金流程:
- 澄清问题:是否可修改原链表?是否可能为空?是否只删一个?
- 画图分析:标出关键指针(prev, cur, next, dummy 等)
- 选择技巧:优先 dummy + 快慢指针组合
- 写代码:结构清晰,注释关键步骤
- 验证边界:举例
[1],[1,2],n=1,val=1等
🌟 终极口诀:
“有边界,加 dummy;要反转,用头插;找位置,快慢指针上;遇删除,想前驱;面链表,先画图! ”