从删除节点到快慢指针:一篇写给初学者的链表操作指南

从删除节点到快慢指针:一篇写给初学者的链表操作指南

前言

链表,这个数据结构对很多前端同学来说就像一道坎。明明 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:尾节点的隐患 虽然这个例子没体现,但如果要删除尾节点,我们的代码也有问题。尾节点的 nextnull,但我们的删除逻辑依然适用,只是要小心别出现 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
}

哨兵节点做了什么?

  1. 给头节点找了个"前驱":现在所有节点都有前驱节点了
  2. 统一了删除逻辑 :删除任何节点都是 cur.next = cur.next.next
  3. 简化了返回值 :永远返回 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                   // 循环结束

为什么这种方法好?

  1. 原地反转:不需要额外创建新的节点
  2. 思路清晰:每一轮都是在做同一件事 - 把当前节点"插"到最前面
  3. 哨兵节点锚定: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个节点 - 快慢指针 + 哨兵节点

现在我们有了两个武器:

  1. 哨兵节点 - 处理边界情况
  2. 快慢指针 - 一次遍历定位

让我们把它们结合起来,解决一个经典问题。

问题分析

删除倒数第n个节点,最直观的思路是:

  1. 先遍历一遍,拿到链表长度 L
  2. 那么倒数第n个节点就是正数第 L-n+1 个节点
  3. 再遍历一遍,找到它的前驱节点,删除它

但我们可以做得更好:一次遍历搞定!

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。哨兵节点让快慢指针更安全,快慢指针让哨兵节点发挥更大作用。

写在最后

链表操作看似花样繁多,但核心技巧就那么几个。当你理解了:

  • 为什么需要哨兵节点(边界处理)
  • 为什么用头插法(原地反转)
  • 为什么快慢指针能相遇(数学原理)

你就掌握了链表的"内功心法"。剩下的就是多看、多写、多思考。

如果你读到这里,相信链表已经不再是你的痛点。但如果还有困惑,不妨收藏这篇文章,动手敲一遍代码。编程是动手的艺术,只有自己亲手写过,才能真正理解。


本文代码已通过基础测试,但若你发现任何问题或有更好的建议,欢迎在评论区指出。让我们一起写出更好的代码!

相关推荐
青及笄8 小时前
node_moudle无权限
node.js·node
VisuperviReborn13 小时前
我理解的Agent(智能体)开发
前端·人工智能·node.js
一条咸鱼_SaltyFish14 小时前
从零构建个人AI Agent:Node.js + LangChain + 上下文压缩全流程
网络·人工智能·架构·langchain·node.js·个人开发·ai编程
九章-16 小时前
MongoDB驱动直连金仓:现有Node.js/Python应用“零代码”迁移指南
数据库·python·mongodb·node.js
VXbishe16 小时前
基于Spring Boot的老年社区资源分享平台设计与实现-计算机毕设 附源码 25337
javascript·vue.js·spring boot·python·node.js·php·html5
aPurpleBerry20 小时前
webpack: overview, config ( plugin loader alias..
前端·webpack·node.js
该用户已不存在2 天前
我是如何把 API 响应时间从 200ms 压到了 10ms
前端·后端·node.js
画扇落汗2 天前
OpenClaw 安装之(三)DeepSeek模型接入配置和详细配置参数
ai·node.js·github
_果果然2 天前
为什么删除 node_modules 这么慢?原因和解决方案一次讲清
前端·node.js