从删除节点到快慢指针:一篇写给初学者的链表操作指南
前言
链表,这个数据结构对很多前端同学来说就像一道坎。明明 JavaScript 里到处都是对象引用,为什么到了链表这里就理不清了?
今天这篇文章,我会带你从最基础的链表删除开始,一步一步深入到快慢指针。每一行代码我都会解释为什么这么写,每一个变量我都会说清楚它的作用。相信我,看完这篇文章,链表不再是你的痛点。
什么是链表?一个最简单的比喻
想象一下寻宝游戏:每一张纸条上写着一个宝藏的名字,还有下一张纸条的位置。你拿到第一张纸条,看完宝藏,顺着地址找到第二张纸条...这就是链表。
javascript
// 链表中的一个节点
class ListNode {
constructor(val) {
this.val = val // 当前节点的值(宝藏的名字)
this.next = null // 指向下一个节点的引用(下一张纸条的位置)
}
}
第一部分:删除节点 - 为什么要引入哨兵节点?
场景:删除链表中值为 val 的节点
我们先从最简单的需求开始:给定一个链表,删除其中第一个值为 val 的节点。
初版代码:问题在哪里?
javascript
function remove(head, val) {
// 问题1:头节点特殊处理
if (head && head.val === val) {
return head.next // 直接返回第二个节点作为新头节点
}
// 问题2:这里的逻辑和上面不统一
let cur = head
while (cur.next) {
if (cur.next.val === val) {
cur.next = cur.next.next // 跳过目标节点
break
}
cur = cur.next
}
return head
}
这段代码有什么问题?
问题1:头节点需要特殊处理 为什么?因为删除节点的通用逻辑是:找到要删除节点的前一个节点,让它的 next 指向要删除节点的下一个节点。
但头节点没有前一个节点!所以我们必须单独处理。
问题2:逻辑不统一 删除头节点:return head.next 删除其他节点:cur.next = cur.next.next 两种写法,两个思维路径,容易出错。
问题3:尾节点的隐患 虽然这个例子没体现,但如果要删除尾节点,我们的代码也有问题。尾节点的 next 是 null,但我们的删除逻辑依然适用,只是要小心别出现 null.next。
引入哨兵节点:一个革命性的改进
javascript
function remove(head, val) {
// 创建一个哨兵节点,值是多少不重要,0只是占位
const dummy = new ListNode(0)
// 哨兵节点指向头节点
dummy.next = head
// 现在,dummy 成为了头节点的前驱节点
let cur = dummy
// 遍历链表
while (cur.next) {
if (cur.next.val === val) {
// 删除 cur.next 节点
cur.next = cur.next.next
break
}
cur = cur.next
}
// 返回真正的头节点(dummy.next 可能是原来的头,也可能是新头)
return dummy.next
}
哨兵节点做了什么?
- 给头节点找了个"前驱":现在所有节点都有前驱节点了
- 统一了删除逻辑 :删除任何节点都是
cur.next = cur.next.next - 简化了返回值 :永远返回
dummy.next,不需要判断头节点是否被删
为什么叫"哨兵"? 就像军队的哨兵站在营区门口一样,这个节点站在链表的最前面,帮我们处理边界情况。它不存储有效数据,但它让我们的代码更安全。
内存视角:删除节点时发生了什么?
很多初学者会问:cur.next = cur.next.next 之后,被删除的节点去哪里了?
答案是:没有人引用它了,它会被 JavaScript 的垃圾回收机制回收。
vbscript
删除前:
dummy → node1 → node2(node_to_delete) → node3 → null
↑
cur
执行 cur.next = cur.next.next:
dummy → node1 ──────→ node3 → null
↘
node2(没有引用指向它了,等待垃圾回收)
第二部分:反转链表 - 哨兵节点的妙用
如果说删除节点是哨兵节点的"被动防御",那反转链表就是它的"主动进攻"。
理解头插法:像打牌一样反转链表
javascript
function reverseList(head) {
// dummy 节点将作为新链表的头哨兵
const dummy = new ListNode(0)
// cur 指向当前要处理的节点,从原链表头开始
let cur = head
while (cur) {
// 第一步:保存下一个节点
// 为什么要保存?因为一旦改变了 cur.next 的指向,我们就找不到下一个节点了
const nextNode = cur.next
// 第二步:头插法的核心 - 把当前节点插入到 dummy 和 dummy.next 之间
// 这一步让当前节点指向已反转部分的头部
cur.next = dummy.next
// 第三步:更新 dummy.next,让它指向最新的头节点
dummy.next = cur
// 第四步:移动到下一个节点
cur = nextNode
}
return dummy.next
}
详细拆解每一步:
假设链表是:1 → 2 → 3 → null
初始状态:
csharp
dummy → null
cur = 1 → 2 → 3 → null
处理节点1:
ini
保存 next = 2
1.next = dummy.next = null // 1 → null
dummy.next = 1 // dummy → 1 → null
cur = 2 // 移动到下一个节点
处理节点2:
ini
保存 next = 3
2.next = dummy.next = 1 // 2 → 1 → null
dummy.next = 2 // dummy → 2 → 1 → null
cur = 3 // 移动到下一个节点
处理节点3:
ini
保存 next = null
3.next = dummy.next = 2 // 3 → 2 → 1 → null
dummy.next = 3 // dummy → 3 → 2 → 1 → null
cur = null // 循环结束
为什么这种方法好?
- 原地反转:不需要额外创建新的节点
- 思路清晰:每一轮都是在做同一件事 - 把当前节点"插"到最前面
- 哨兵节点锚定:dummy.next 始终指向最新的头节点
第三部分:检测环形链表 - 快慢指针入门
场景:如何判断一个链表里有环?
想象一下操场跑步的场景:
- 如果是直线跑道,跑得快的人永远在前面,先到终点
- 如果是环形跑道,跑得快的人会从后面追上跑得慢的人
这就是快慢指针的核心思想。
javascript
function hasCycle(head) {
// 如果链表为空或只有一个节点,肯定没有环
if (!head || !head.next) return false
// 两个指针起点相同
let slow = head
let fast = head
// 快指针每次走两步,所以必须保证 fast 和 fast.next 都存在
while (fast && fast.next) {
slow = slow.next // 慢指针走1步
fast = fast.next.next // 快指针走2步
// 如果两个指针相遇了,说明有环
// 注意:这里比较的是引用地址,不是值
if (slow === fast) {
return true
}
}
// 快指针到达了终点,说明没有环
return false
}
为什么快指针每次走2步,慢指针走1步?
这是数学上的最优解。你可以理解为:
- 如果快指针走3步,可能会"跳过"慢指针(在环中擦肩而过)
- 如果快指针走1步,那就和慢指针永远在一起了
- 走2步是最稳妥的,只要有环,快指针一定会在某圈追上慢指针
为什么返回 false 的条件是 fast 或 fast.next 为 null?
因为快指针走得快,如果链表没有环,它一定会先到达链表的末尾。而链表末尾的特征就是:
fast === null(链表长度为偶数,fast 直接走到了末尾)fast.next === null(链表长度为奇数,fast 走到了最后一个节点)
一个常见的困惑:
问:如果链表很长,环很小,快指针会不会在环里转很多圈才能追上慢指针?
答:是的,但这不是问题。当慢指针进入环时,快指针已经在环里了。它们的速度差是1步/次,所以最多转一圈就会被追上。时间复杂度仍然是 O(n)。
第四部分:删除倒数第N个节点 - 快慢指针 + 哨兵节点
现在我们有了两个武器:
- 哨兵节点 - 处理边界情况
- 快慢指针 - 一次遍历定位
让我们把它们结合起来,解决一个经典问题。
问题分析
删除倒数第n个节点,最直观的思路是:
- 先遍历一遍,拿到链表长度 L
- 那么倒数第n个节点就是正数第 L-n+1 个节点
- 再遍历一遍,找到它的前驱节点,删除它
但我们可以做得更好:一次遍历搞定!
javascript
function removeNthFromEnd(head, n) {
// 1. 创建哨兵节点,统一处理逻辑
const dummy = new ListNode(0)
dummy.next = head
// 2. 快慢指针都从哨兵节点开始
let fast = dummy
let slow = dummy
// 3. 快指针先走n步
// 这样快指针和慢指针之间就保持了n个节点的距离
for (let i = 0; i < n; i++) {
fast = fast.next
}
// 4. 快慢指针一起走
// 当快指针到达最后一个节点时,慢指针刚好在倒数第n个节点的前一个位置
while (fast.next) {
fast = fast.next
slow = slow.next
}
// 5. 删除倒数第n个节点
// slow.next 就是要删除的节点
slow.next = slow.next.next
// 6. 返回真正的头节点
return dummy.next
}
为什么这个解法是优雅的?
关键理解1:为什么快指针先走n步?
因为我们要删除倒数第n个节点。倒数第n个节点到链表的末尾的距离是n-1(不算尾节点的next)。
当快慢指针一起走时,快指针到达末尾(null的前一个)时,慢指针和快指针的距离保持不变(n步)。此时,慢指针指向的就是倒数第n个节点的前一个节点。
关键理解2:为什么用dummy?
考虑一个极端情况:链表只有一个节点,要删除倒数第1个节点(也就是它自己)。
如果没有dummy:
- slow和fast都指向head
- 快指针先走1步:fast = fast.next = null
- 进入while循环:fast.next 会报错,因为 null.next 不存在
有了dummy:
- slow和fast都指向dummy
- 快指针先走1步:fast = dummy.next = head
- 再走1步:fast = head.next = null
- 进入while循环:fast.next?fast是null,报错?
为什么是 while(fast.next) 而不是 while(fast)?
因为我们想要的是:当fast是最后一个节点时停止。这样slow刚好指向倒数第n个节点的前驱。
如果写成 while(fast),fast会一直走到null,slow就会指向倒数第n个节点本身,而不是它的前驱。
总结:这些技巧的本质是什么?
哨兵节点的本质:用空间换逻辑的简洁性。我们多创建了一个节点,但换来的是:
- 不需要if-else处理特殊情况
- 代码更易读,更易维护
- 边界条件自动化解
快慢指针的本质:用速度差来定位位置。就像两个人跑步,我们通过控制他们的速度差,让慢的人在我们想要的位置停下来。
组合技巧的本质:1+1 > 2。哨兵节点让快慢指针更安全,快慢指针让哨兵节点发挥更大作用。
写在最后
链表操作看似花样繁多,但核心技巧就那么几个。当你理解了:
- 为什么需要哨兵节点(边界处理)
- 为什么用头插法(原地反转)
- 为什么快慢指针能相遇(数学原理)
你就掌握了链表的"内功心法"。剩下的就是多看、多写、多思考。
如果你读到这里,相信链表已经不再是你的痛点。但如果还有困惑,不妨收藏这篇文章,动手敲一遍代码。编程是动手的艺术,只有自己亲手写过,才能真正理解。
本文代码已通过基础测试,但若你发现任何问题或有更好的建议,欢迎在评论区指出。让我们一起写出更好的代码!