【算法】链表(二):链表上的双指针——变速、异链与定距,和一份路程账本

【算法】链表(二):链表上的双指针------变速、异链与定距,和一份路程账本

摘要

链表系列第二篇。数组转场链表时我说"快慢指针做过"------做到 LC19 才发现链表上的双指针是另一个世界:数组指针是下标(可以回头看),链表指针是节点地址(改了就丢) ,于是双指针在链表上长出了三种新形态。本篇四题:LC141 环形链表 (变速双指针------快 2 慢 1 的恒定节拍;"快走 3 步行不行"的奇偶性论证;以及 Val 比较误报的教训------有环的判定是同一个节点被再次到达,认点不认值 );LC142 环形链表 II (四版弧线------先判后动的初始同点、两段判定顺序必须相反、a ≡ c (mod 环长) 的路程账本、调试器实证三个变量同地址的误诊事件、以及一个无意中造出的 a=0 杀局);LC160 相交链表 (异链双指针------跳链补路程、nil 也会师 、以及"指针一轮一动一格"纪律的两件新马甲:瞬移冻结 );LC19 删倒数第 N (定距双指针------"倒数第 n ⟺ 与终点距离 n"的转化、两套等价账的对照)。收尾把三题的数学合账:a≡c 和 a+c+b = b+c+a 是同一族------都是在时间轴上凑出相等的路程

前置阅读:链表(一):世界观与指针操作------攥住再改、重绑与改对象、哨兵节点。配套代码仓库(按题号分目录):https://github.com/a18792721831/studyleetCode

【算法】链表(二):链表上的双指针------变速、异链与定距,和一份路程账本

  • 【算法】链表(二):链表上的双指针------变速、异链与定距,和一份路程账本
    • 摘要
    • [0. 链表双指针的四形态总览](#0. 链表双指针的四形态总览)
    • [1. 第一课 LC141:变速双指针与奇偶性](#1. 第一课 LC141:变速双指针与奇偶性)
    • [2. 第二课 LC142:四版弧线与 a≡c 账本](#2. 第二课 LC142:四版弧线与 a≡c 账本)
    • [3. 第三课 LC160:异链双指针与 nil 会师](#3. 第三课 LC160:异链双指针与 nil 会师)
    • [4. 第四课 LC19:定距双指针](#4. 第四课 LC19:定距双指针)
    • [5. 三题合账:路程数学的一族](#5. 三题合账:路程数学的一族)
    • [6. 速查表](#6. 速查表)
    • 总结
    • 参考资料

0. 链表双指针的四形态总览

形态 指针关系 代表题 核心机制
同向 prev/curr 一前一后 LC206 反转 攥住再改、单向推进
变速 快 2 慢 1 同起点 LC141/142 找环 速度差=1、路程账本
异链 pA/pB 各走一条、到头互跳 LC160 相交 跳链补路程、nil 会师
定距 fast/slow 固定间距 n LC19 删倒数 间距保持、终点代打

数组的双指针比的是"下标怎么动",链表的双指针比的是"路程怎么凑 "------因为链表没有绝对位置,一切判定都落在"谁比谁多走了多少"上。这篇的主线就是一本路程账

1. 第一课 LC141:变速双指针与奇偶性

判断链表是否有环,O(1) 空间。

第一反应是哈希表记走过的节点("不记就无法感知重复")------错了,"必须记录"是可以攻破的断言 。操场跑圈的直觉:你不需要记住跑过哪些位置,只需要一个比你快的人------他迟早从背后撞上你。感知"在转圈",不需要记忆,只需要速度差。

go 复制代码
slow, fast := head, head
for fast != nil && fast.Next != nil {   // 快指针跳两步的两级守卫
    slow = slow.Next                    // 慢:每轮 1 步
    fast = fast.Next.Next               // 快:每轮 2 步
    if slow == fast {                   // 指针比较(不是 Val!)
        return true
    }
}
return false

三份判决书,一份比一份深:

判决书一(一定相遇) :两指针都进环后,设环上距离 d,每轮慢 +1、快 +2 ⟹ d 恰减 1------整数逐级递减必到 0。

判决书二(不会跳过):距离从 2→1→0 逐级归零,不会从 2 直接越过 0。

判决书三(为什么是快 2 慢 1,不能快 3 慢 1) :速度差 2 ⟹ 距离每轮 -2 ⟹ 奇偶性不变 ------初始距离是奇数就永远差 1 错开,永不相遇。速度差 1 是唯一保证距离逐级归零的配比。这是面试追问的常客,也是"随手配置"和"理解配置"的分界线。

两个工程教训同样值钱:Val 比较是本质错误[1,1] 无环但两节点同值,误报------有环的判定是"同一个节点被再次到达",认点不认值,节点地址唯一);守卫必须两级 (快指针一次跳两步,跳之前必须确认两步都踩得到地------fast != nil && fast.Next != nil,这是"改指针前先攥住"的空指针版)。还有命名纪律:我交的版本 f 走一步、s 走两步------名字和速度倒置,功能全对但半年后重读必先理解反。变量名不换含义。

2. 第二课 LC142:四版弧线与 a≡c 账本

找到入环的第一个节点。判定部分同 141,灵魂在"相遇之后"。

算法:相遇后一个指针回到 head、两指针同速齐走(每次一步),再次相遇的位置就是入环点。凭什么是它?------本篇账本的核心:

复制代码
a = 头 → 入环点(A 独有段)
b = 入环点 → 相遇点
c = 相遇点 → 绕回入环点(环长 = b + c)

slow 走了 a + b
fast 走了 a + b + n(b+c)        ← 套了 n 圈
"fast = 2 × slow":
    a + b + n(b+c) = 2(a+b)
    ⟹  a = (n-1)(b+c) + c      ⟹  a ≡ c (mod 环长)

解读:从 head 走 a 步到入环点;从【相遇点】走 c 步(+绕 n-1 圈)也到入环点
     ⟹ 同速齐走必然【同时】踏进入环点------会师于入环点

[3,2,0,-4] pos=1 代数字最直观:a=1、b=2、c=1(环长 3)。第一段相遇在 -4(slow 走 3 步、fast 走 6 步=2×3);第二段 head=3 走 1 步到 2、slow=-4 走 1 步也到 2------同时到达入环点

四版弧线各有一课:

v1:先判后动。 if fast == slow 写在移动之前------初始两指针天然同点(都从 head 出发),第一轮就 break。出发时的同点不是相遇------相遇的定义是"各自走了若干步之后再次同点"。判定必须在移动之后。

v2:两段判定顺序必须相反。 第一段先动后判 (防"起点同点"被误判为相遇);第二段先判后动 (防"答案即起点"被迈过)------a=0 时头就是入环点,先动一步就把脚下的答案迈过去了。同一段代码两段判定顺序相反,各有各的判决书。每个判定位置都要能说出为什么在这儿。

v3:调试器实证与一场误诊。 我在断点里看到 fast/slow/head 三个变量同地址 ,第一反应是"代码有 bug"(教练还误诊成"你删了某行")------真相是我 main 里把入环点当头 传了进去(a=0 case):slow 走一整圈、fast 走两圈,回到起点相遇------相遇点=起点,三者同地址是完全合法的状态 。教训两条:测试用例传错参数会制造"看起来像 bug 的合法状态";怀疑假设就断点看地址,但看之前先把输入是什么搞对

v4:无环短路。 第一段自然退出(fast 撞 nil)后必须判无环再进第二段------否则 slow 停在链中间、齐走时 slow 先到 nil,slow = slow.Next 解引用空指针。我用的 if fast != slow { return nil } 比标准的 fast == nil || fast.Next == nil 更紧凑,代价是隐含依赖多个守卫的合力(空链、单节点靠第二段守卫兜底)------能用,但标准写法每行独立成立,重读时不依赖"合力推理"。工程取舍,知道即可。

3. 第三课 LC160:异链双指针与 nil 会师

找两条链的交点(同一个节点,不是值相等)。O(n) 时间 O(1) 空间。

浪漫解法:pA 从 headA 出发、走到 nil 跳到 headB;pB 对称。两指针在交点会师。判决书:

复制代码
pA 总路程 = a + c + b     (走完 A,跳过去把 B 的独有段补上)
pB 总路程 = b + c + a
两式恒等 ⟹ 同速前进必然同时到达交点

跳链的本质:只走自己的链,pA 差 b、pB 差 a------跳链就是把对方的独有段补进自己的路程 ,强行拉平。这和 LC142 的账本是亲缘:那边靠"快指针多绕圈"补路程差,这边靠"跳到对方链上"补------本质都是在时间轴上凑出相等的总路程

两个杀手边界:

nil 也会师 :不相交(c=0)时,pA 走 a+b 步、pB 走 b+a 步------同时到达 nil ,而 nil == nil 是合法的指针比较:for pA != pB 在那一刻自然变假,返回 nil。"相交返回交点、不相交返回 nil"塞进同一个循环,零特判。

a=0 或 b=0:对方链的头就是交点------它是一个货真价实的候选节点,瞬移越过它=漏判。

这题我写了三版,每版都是"指针一轮一动一格"纪律的新马甲:

复制代码
v1 瞬移:跳链之后又走了 .Next ------ 从 nil 瞬移到 headB.Next,
    b=0 时跳过交点(跳到 headB 是【抵达】,不是【途经】)
v2 冻结:else if 链让 pA==nil 时 pB 被冻结(本该前进)------
    A 空时 pA 跳到 headB 撞上被冻结的 pB,假会师
v3 独立窗口:两个独立的 if-else,每个指针自己决定跳还是走

v2 的死因值得展开:else if 是"排队的窗口"(前面的命中后面的就不判),但 pA 和 pB 的节奏语义上互不依赖 ------pA 是 nil 不该影响 pB 前进。语义独立的判定,结构上也必须独立。 非空用例全活、只有空链翻车,是因为两指针到 nil 的时刻通常错开、冻结的副作用被掩盖------边界用例是唯一能暴露时序问题的探针

4. 第四课 LC19:定距双指针

删除链表倒数第 n 个节点,一遍扫描。

我最初把快慢指针锁死在 141 的"变速追赶"模型上(想成环上相遇),卡住------LC19 是第四形态:定距。钥匙是一句话转化:

倒数第 n 个 ⟺ 与终点的距离恰好是 n。

不需要知道链长 L(那要两遍扫描)------派 fast 替你踩终点,slow 与它保持 n 的间距

go 复制代码
dummy := &ListNode{}           // 删头节点没有前驱------dummy 挡在前面
dummy.Next = head
fast, slow := dummy, dummy
for i := 0; i < n; i++ {       // fast 先走,拉开间距
    fast = fast.Next
}
for fast.Next != nil {         // 同速齐走,fast 停在尾节点
    fast = fast.Next
    slow = slow.Next
}
slow.Next = slow.Next.Next     // slow 停在被删节点的【前驱】
return dummy.Next

两个细节各值一行注释:

fast 停在尾节点(fast.Next != nil 为界)还是 nil? 两套账等价:

先走 停止条件 slow 终点
标准版 n+1 步 fast == nil 倒数第 n+1(前驱)
我的版 n 步 fast.Next == nil(停在尾) 倒数第 n+1(前驱)

我的 fast 两头各省一格,净效果 slow 停在同一位置------殊途同归,但注释必须配自己的实现(我注释里写"slow 到倒数第 n 个"就是记了标准版的结论配自己的算法,差的那格恰好被实现细节补掉------账面和实现脱节,重读必困惑)。

为什么 slow 要停在前驱而不是被删节点上? 单向链表删节点是 slow.Next = slow.Next.Next------没有 prev 指针,删除永远需要前驱。这是链表公理二(没有回头看)的直接推论,也是为什么 slow 的设计目标是"倒数第 n+1"而不是"倒数第 n"。

5. 三题合账:路程数学的一族

等式 补差手段
LC142 a ≡ c (mod 环长):fast 走 a+b+n(b+c) = 2×(a+b) 快指针多绕圈
LC160 a+c+b = b+c+a 走完自己跳对方链
LC19 fast 路程 − slow 路程 ≡ n(恒定) fast 先走 n 步

三道题的判决书是同一个模板:两个指针各自的路程列个等式,等式保证"同速必会师/间距必保持" 。链表双指针的深度不在指针操作(那是第一篇的事),在这本账------写代码前先把等式列出来,代码就是等式的翻译。

6. 速查表

问题 判据/口诀 出处
感知环 不需要记忆,需要速度差;快 2 慢 1(速度差 1 是唯一逐级归零的配比,差 2 奇偶性锁死) LC141
环判定比较什么 指针(同一个节点),不是 Val(值可重复) LC141
快指针守卫 一次跳两步,先判两级非 nil LC141
判定位置 先动后判 or 先判后动------取决于防"起点同点"还是防"答案即起点" LC142/LC160
入环点 a ≡ c (mod 环长):相遇后一头一回 head,同速齐走会师于入环点 LC142
异链找交点 走完自己跳对方:a+c+b = b+c+a;nil 也会师(不相交自动返回 nil) LC160
跳链语义 跳到对方头是抵达不是途经(对方头可能是交点);跳链轮不前进 LC160
语义独立的判定 结构上也必须独立(两个 if-else,不是一条 else if 链) LC160 v2
倒数第 n ⟺ 与终点距离 n:fast 先拉开 n、同速齐走、slow 停前驱 LC19
删除节点 永远需要前驱(无 prev 指针)------dummy 补上"头的前驱" LC19
注释纪律 结论必须配自己的实现(两套等价账,别记混) LC19
调试器 怀疑指针假设 → 断点看地址;但先确认输入传对了 LC142

总结

四题十二版(141 两版、142 四版、160 三版、19 一版加一轮卡壳),链表双指针的四种形态全部过手。三个感想:

链表双指针的本质是路程账,不是指针戏法。 四形态(同向/变速/异链/定距)的判定书全部落在"两个指针的路程列等式"上------a≡c、a+c+b=b+c+a、路程差恒为 n。写代码前先列等式,等式对了代码就是翻译,等式没列就动笔,四版起步(142 和 160 的弧线都是证据)。

"指针一轮一动一格"是我最难戒的病。 双指针系列四件马甲(平行 if、递归多路),链表又添两件(瞬移 :跳链后又 .Next,越过候选节点;冻结 :else if 让无辜的指针停摆)。六件马甲一个病根:想让一轮做超过一格的事。纪律倒是一句话:每轮,每个指针,恰好一个动作------要么前进,要么跳链抵达,没有第三种。

边界用例是时序问题的唯一探针。 a=0(头即入环点/交点)、空链、单节点------这些输入逼迫两个指针在同一轮发生罕见的事件组合(同时到 nil、起点即答案),把节奏错误当场引爆。非空、a>0 的常规用例会把冻结、瞬移这些 bug 掩盖到天荒地老------测试用例的优先级,永远是"上次死过的形状"优先

链表系列两篇收官。下一站按路线图:堆与单调栈(接 LC862 的前缀和+单调队列伏笔),或按需调整。

参考资料


版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

相关推荐
2601_958352902 小时前
还要写 AEC 算法?0 代码 + 6 个引脚,F-18 让通话清晰度提升 300%
人工智能·算法·降噪消回音
happyprince2 小时前
03-regmix-深刻观-哲学与升华
算法
zcmodeltech2 小时前
火电厂模型能演示冷态启动和故障处理吗?——基于STM32与Modbus RTU的集控实训控制系统设计
数据结构·stm32·单片机·嵌入式硬件·能源
一路向阳~负责的男人2 小时前
运动控制算法收录
算法
czt_java2 小时前
5个核心位运算公式
算法
Doubbbbbbble云3 小时前
区间合并问题的常见算法模式与优化思路4
java·数据结构·算法
David猪大卫3 小时前
【C++修炼】哈希表的实现
数据结构·c++·笔记·学习·算法·哈希算法·散列表
hahaha60164 小时前
HLS高层次综合设计技巧--数组
开发语言·嵌入式硬件·算法·fpga开发
不会就选b4 小时前
算法日常・每日刷题--<贪心>10
算法