news 2026/10/18 3:25:45

环形链表 II 解法详解:快慢指针数学推导与哈希表方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
环形链表 II 解法详解:快慢指针数学推导与哈希表方案

刷题软件上关于“环形链表 II”的题解一搜一大把,但每次面试聊到这道题,能写对代码的人不少,能把“两个阶段为什么成立”讲清楚的人寥寥无几。我自己刷题和面试别人的过程中,对这道题感触很深:它其实不是一道纯粹的链表题,而是一道“链表遍历 + 数学推导 + 复杂度意识”的组合题,尤其是题目里那句“不要使用额外空间”,直接把难度拉高了一个台阶。

这篇文章会用最通俗的方式拆解环形链表 II 的多种实现思路,重点讲清楚两种主流解法:哈希表方案和快慢指针方案,并把快慢指针背后的数学推导掰开揉碎讲明白。无论你是刚开始刷 LeetCode 的新手,还是准备校招/社招需要系统复盘链表题的选手,这篇文章都能帮你在面试时把这道题讲得比题解更透。

1. 先把 142 拆开:题目其实考了三件事

很多人第一次看这道题,以为它只是“判断链表有没有环”的升级版,多返回一个节点而已。实际上,这道题把三个独立的考察点叠在了一起。

1.1 一个 O(1) 空间限制把题目分成两个阶段

原题描述很简单:给定一个链表的头节点 head,返回链表开始入环的第一个节点。如果链表无环,则返回 null。注意题目中一般会标注“进阶:尽量不要使用额外空间”。

这句话就是分水岭。

如果你只求“能做出来”,那哈希表方案三分钟就能写完:遍历链表,把每个节点放进 set,遇到重复就返回。整个思路非常直觉,代码也不容易出错。但如果你把进阶要求当成必须满足的条件,就必须进入第二阶段思考:如何在常数空间内完成“找入口”这个任务。这正是环问题里最经典的数学部分。

所以在我看来,环形链表 II 的正确打开方式是分成两步走:第一步判断链表是否有环,第二步在已知有环的情况下定位入口节点。两步缺一不可。判断有环可以用快慢指针完成,定位入口则需要额外的推导。

1.2 哈希表能过,但面试官为什么不满意

很多候选人写完哈希表解法后都很自信,因为代码能在 O(n) 时间内通过所有测试用例,逻辑也没问题。但一旦面试官追问“能不能不用额外空间”,场面就开始尴尬了。

面试官不是不喜欢哈希表,而是这道题的核心考察点在于“你是否知道更优解”。链表题在面试中出现,本身就意味着考察空间复杂度的控制能力。链表不像数组那样可以随机访问,很多操作天然依赖 O(n) 的遍历,如果动不动就开一个哈希表来辅助,那数据结构的空间优势就谈不上。所以,能用 O(1) 空间解决环检测问题,才是这道题真正的考点。

1.3 这道题在算法面试里的真实定位

从面试角度来看,142 是一道非常典型的“中档偏上”题。它不像动态规划那样需要大量刷题积累,也不像并查集那样冷门,它的知识链路非常清晰:

  • 数据结构基础:链表的遍历、节点引用
  • 经典算法:Floyd 判圈算法
  • 数学能力:通过路程关系推导入口位置
  • 工程素养:处理空指针、处理边界条件

一个候选人如果能把这道题完整写出来,并且把“为什么第二次相遇在入口”讲清楚,基本上可以说明他具备扎实的链表基本功和一定的逻辑表达能力。这也就是为什么很多大厂面试喜欢把它当作二面算法题的原因。

2. 哈希表方案:五分钟写完,但别急着提交

哈希表方案是环形链表 II 最容易理解的解法。它的思路可以用一句话概括:路过的每个节点都记下来,如果发现有节点是第二次经过,那它就是环的入口。

2.1 哈希表的完整实现(Python)

先直接看代码:

def detectCycle(head): visited = set() cur = head while cur: # 当前节点已经在集合中,说明第二次到达该节点 if cur in visited: return cur visited.add(cur) cur = cur.next # 遍历到 null,说明链表无环 return None

这里有个细节值得注意:我们是先判断cur in visited,再把节点加入集合。如果把顺序写反,比如先add再判断,那第一次访问的节点也会被当成“重复节点”,整个逻辑就错了。这种顺序问题是新手最常犯的低级错误。

2.2 为什么是“节点”而不是“值”

有些刚入门的朋友会疑惑:为什么要把节点放进 set,而不是把节点的值放进去?答案是链表中可能出现值相同但地址不同的节点。

比如一个链表包含两个值为 3 的节点,它们属于不同的对象。如果只存值,第二次遇到另一个值为 3 的新节点时会误判成环。因此必须保存节点引用本身,用对象身份来区分是否是同一个节点。

在 Python 里,默认情况下对象是可哈希的,set 会按照id(obj)来去重,所以直接存节点没问题。在 Java 中,HashSet 同样基于对象引用去重,也可以直接存节点对象,不需要重写 hashCode 和 equals 方法。

2.3 哈希表方案的加分用法

说实话,哈希表方案的代码量比快慢指针小得多,也容易解释。在面试中直接给出快慢指针当然最好,但如果你一时紧张忘了推导,先写出哈希表解法,再主动提“这个方案空间复杂度是 O(n),我可以优化到 O(1)”,面试官通常不会直接让你挂。

关键在于:别把哈希表解法当成终点,要把它当成一种“兜底方案”。我在实际面试别人时,更看重候选人是否能在给出第一版后主动分析复杂度,并尝试提出改进思路。

3. 快慢指针阶段一:在环里追上才算证明有环

快慢指针方案,正式名称是 Floyd 判圈算法。它的第一步是判断链表是否有环,这一步完全不需要数学推导,代码也容易理解。

3.1 Floyd 判圈算法过程

整个算法分两个阶段:

  • 阶段一:慢指针 slow 每次移动一步,快指针 fast 每次移动两步,两者都从 head 出发。如果 fast 先走到 null,说明链表无环;如果 slow 和 fast 在某个节点相遇,说明链表有环。
  • 阶段二:让 slow 回到 head,fast 留在相遇点不动。然后两个指针都每次移动一步,当它们再次相遇时,相遇节点就是环的入口。

阶段二就是整道题的精髓。先看阶段一的 Python 代码:

def hasCycle(head): slow = head fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow == fast: return True return False

这里while fast and fast.next的条件非常关键。因为 fast 一次走两步,如果链表的最后一个节点指向 null,那么fast.next可能为 null,下一步就无法继续移动了。把条件写错,最容易出现空指针异常。

3.2 为什么 fast 一定会追上 slow

很多人对“一定会相遇”这个结论将信将疑,直觉上觉得 fast 可能绕了一圈又一圈就是碰不到 slow。实际上,只要链表有环,相遇是必然的。

可以这样理解:当 slow 也进入环之后,环就相当于一个圆形跑道。以慢指针为参照物,fast 相对于 slow 的速度是“每回合 1 步”。也就是说,fast 在逐回合地逼近 slow。由于跑道是封闭的,无论初始距离是多少,只要相对速度不为 0,且相对位移方向不变,最终一定能追上。

这个推理在数学上是严格的:设 slow 刚进入环时,fast 在它前方 x 步(在环内,x 小于环长 L)。每回合 fast 与 slow 的距离减少 1 步,最多 x 回合后必然相遇。正因为 x 是有限的,相遇一定发生。

3.3 fast 每次走 2 步,真的没有更好的选择吗

面试中经常有候选人问:快指针能不能走 3 步、4 步,走快点不是效率更高吗?

这个问题值得认真想一下。fast 走 3 步的话,相对速度为 2 步每次,确实也能追上 slow,理论上没错。但问题在于,一旦 fast 走得太快,它可能在某一回合“越过” slow 所在的位置,造成一种视觉上“明明同一条线却没碰头”的错觉。虽然从相对运动的角度来说,多走几个回合还是能追上,但数学分析会复杂不少。

更重要的是,走 2 步是在工程复杂度与数学简洁性之间最好的平衡点。2 步让相对速度为 1,推导入口位置时只需考虑最简单的整圈关系。所以刷题时别去追求“更快的快指针”,老老实实走 2 步是标准答案。

4. 快慢指针阶段二:入口是怎么被数学算出来的

如果说阶段一是“背代码”,那阶段二就是“考推导”。这部分的数学并不难,难的是你愿不愿意静下心来推一遍。

4.1 假设与变量定义

为了方便推导,先定义三个变量:

  • a:从链表头节点到环入口节点的距离
  • b:从环入口节点到 slow 和 fast 第一次相遇节点的距离
  • L:环的长度
  • c:从相遇节点继续向前走到环入口节点的距离,即 c = L - b

需要注意,这里的环长 L、距离 b 等都是以“节点个数”为单位计算的。举例:如果环长 4,入口到相遇点是 1 个节点,那相遇点到入口的下一次到达就是走 3 次 next,所以 c = 3。

4.2 从路程关系推出 a 和 c 的关系

第一次相遇时,假设 slow 走了 s 步,fast 走了 2s 步。

slow 从 head 出发,走到相遇点一共走了 a + b 步。因为 slow 刚进环不久,在第一圈内就被 fast 追上了,所以 b < L,这个前提很重要,后面会解释为什么一定成立。

fast 从 head 出发,沿环至少跑了一圈后来追上 slow。假设 fast 在环内比 slow 多跑了 k 圈(k >= 1),那 fast 的总路程是 a + b + kL。

于是有:

2(a + b) = a + b + kL

两边同时减去 a + b,得到:

a + b = kL

移项:

a = kL - b = (k - 1)L + (L - b) = (k - 1)L + c

这个等式非常漂亮。它说明:从 head 到环入口的距离 a,等于从相遇点继续走到环入口的距离 c,再加上整数倍的环长。

因此,如果让一个指针从 head 出发,另一个指针从相遇点出发,两者每次都走一步,那么走 a 步后,第一个指针正好到入口;第二个指针走 a 步,等价于绕了 (k - 1) 圈后再走 c 步,也正好到入口。所以两者第二次相遇的位置就是环入口。

4.3 为什么第二次“碰头”不会提前发生

细心的读者可能马上会追问:第二个指针在环里走着,第一个指针还在从 head 到入口的路上。如果第二个指针走 (k-1)L + c 步的过程中,第一个指针可能还没到入口,两个指针会不会在环入口之前就相遇?

答案是:不会。

原因很简单:在第一个指针到达入口之前,它还在链表的直线部分,根本没进入环。而第二个指针始终在环内。一个在环外,一个在环内,它们不可能提前相遇。只有当第一个指针刚进入环的那一瞬间,两者才有机会重合。而根据上面的路程关系,第二个指针在这个瞬间也恰好到达入口。

所以“第二次相遇点就是入口”这个结论,在逻辑上是闭环的。

顺带回答前面留下的问题:为什么第一次相遇时 slow 一定还没走满一圈?设 slow 刚进入环时,fast 在环内距离它最多 L - 1 步。由于 fast 相对 slow 每回合逼近 1 步,那么慢指针最多走 L - 1 步就会被追上,小于环长 L。所以 b < L 成立。

5. 多语言代码实现与本地验证

理论讲清楚了,代码就得跟上。这一节给出 Python 和 Java 的完整参考实现,以及本地构造测试用例的方法。

5.1 Python 完整解法(带注释)

from typing import Optional class ListNode: def __init__(self, x): self.val = x self.next = None class Solution: def detectCycle(self, head: Optional[ListNode]) -> Optional[ListNode]: # 空链表或只有一个节点时肯定无环 if not head or not head.next: return None slow = head fast = head # 阶段一:找第一次相遇点 while fast and fast.next: slow = slow.next fast = fast.next.next if slow == fast: break # 如果 fast 走到了空,说明链表无环 if not fast or not fast.next: return None # 阶段二:一针从头走,一针从相遇点走 slow = head while slow != fast: slow = slow.next fast = fast.next return slow

这个解法的关键是不要忘记if not fast or not fast.next这个判断。很多人写完阶段一后直接进入阶段二,结果遇到无环链表时 fast 已经是 null,一进入阶段二就直接空指针报错。

5.2 Java 解法与对象比较陷阱

Java 版本的思路完全一致,但有一个小坑需要提醒:链表节点比较一定要用引用比较,也就是slow == fast,不要用slow.val == fast.val。因为链表里可能存在值相同的节点,值相等并不代表是同一个节点。

public class Solution { public ListNode detectCycle(ListNode head) { if (head == null || head.next == null) { return null; } ListNode slow = head; ListNode fast = head; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; if (slow == fast) { break; } } if (fast == null || fast.next == null) { return null; } slow = head; while (slow != fast) { slow = slow.next; fast = fast.next; } return slow; } }

在 Java 里还要注意,ListNode默认的==比较的是引用地址,这正是我们想要的。别用equals(),因为如果没有重写,equals()在大多数时候也等价于引用比较,但一旦误写成比较 val,就会踩坑。

5.3 如何构造带环链表做本地测试

刷题时经常遇到一个尴尬:IDE 里没法像 LeetCode 那样直接传一个“带环链表”,不知道如何自测。这里分享一个简单的构造方法。

# 构造一个无环链表 a = ListNode(3) b = ListNode(2) c = ListNode(0) d = ListNode(-4) a.next = b b.next = c c.next = d # 为了让第 2 个节点成为环入口,让 d 指向 b d.next = b solution = Solution() result = solution.detectCycle(a) print(result.val) # 输出 2,符合预期

这种构造方式就能模拟“环入口在第二个节点”的用例。测试时记得多切换几种形态:环入口在头节点、链表只有一个节点且自环、长链加长环,把这些边界都跑一遍,代码才算稳。

6. 高频变体与进阶追问:环长度、141、标记法

面试官不会只满足于你写出 142 本身。他大概率会顺着这道题往下追问几个变体,提前准备一下会非常加分。

6.1 环的长度怎么算

这是一个很自然的追问。当已经通过快慢指针找到第一次相遇点后,求环长度就非常简单:

def cycleLength(meetNode): cur = meetNode length = 1 while cur.next != meetNode: cur = cur.next length += 1 return length

具体做法是:固定 slow 在相遇点,让另一个指针从相遇点出发,每次走一步,直到重新回到相遇点,走过的步数就是环长。

这里有个需要注意的边界:如果环中只有一个节点,也就是某个节点的 next 指向自己,循环应该立即结束,所以 length 的初始值设为 1 是必要的。也可以从cur.next != meetNode这个条件里看出来,这个写法天然覆盖了自环的情况。

6.2 只判环的 141 与 142 的代码差异

LeetCode 第 141 题问的是“是否存在环”,只需要返回 true 或 false。它其实就是 142 的截断版本,只需要阶段一,不需要阶段二。

def hasCycle(head): slow = head fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow == fast: return True return False

不少候选人做 142 时,会把 141 的代码直接搬过来,再强行挤进阶段二,结果越写越乱。我的建议是:先把 141 的空指针处理写好,再单独加阶段二,不要两题混在一起练。分开练能帮你把两个知识点的边界理清楚。

6.3 面试追问:能否修改链表结构

还有一种不太常规但偶有面试官会提的思路:遍历链表时,把每个访问过的节点的 next 指向自己或者指向某个固定哨兵节点。如果后续还能再遇到一个“已被标记”的节点,说明回到了起点,该节点就是环入口。

这种方案的空间复杂度是 O(1),但会破坏链表结构,不满足“不要修改链表”的约束。面试官抛出这个问题时,通常是想考察你是否具备“通过变更数据换取空间复杂度”的意识。你可以先指出它能做到 O(1) 空间,再说它的致命缺点:破坏输入数据,工程上一般不接受,而且对只读链表不可行。这样回答既展示了思路广度,也体现了工程判断力。

7. 常见错误与排查经验速查

最后整理一份我在实际调试和面试点评中遇到频率最高的错误清单,都是实打实的踩坑经验。

7.1 空指针与死循环

以下两个错误是刷题新手最常犯的:

  • while fast.next:而不是while fast and fast.next:。如果 fast 本身已经是 null,再访问fast.next会直接抛异常。
  • 阶段二忘记重置 slow 到 head。直接从相遇点继续走,两个指针永远一起移动,永远相等,返回的节点完全错误。

我的建议是,代码写完后,先手动走一遍最小的用例:一个节点的链表,或两个节点成环的链表。这些极端用例能快速暴露空指针问题。

7.2 用值比较代替对象比较

有些语言对对象比较比较严格,有些则容易让新手混淆。比如 Java 中Integer的==在 -128 到 127 之间可能有特殊的缓存行为,这会让人误以为“值相等就是对象相等”。但在链表节点这种场景中,一定要坚持引用比较。

推荐在脑海中对齐一个原则:链表里判断“同一个节点”,看的不是val,而是“还是不是当初那个对象”。哪怕两个节点的值都是 5,只要地址不同,就是两个完全不同的节点。

7.3 复杂度、写法规范与面试表达要点

时间复杂度是 O(n),空间复杂度是 O(1),这是标准结论。但很多候选人在解释复杂度时会含糊其辞,只背结论不解释过程。

面试时可以这样表达:slow 进入环之前,两个指针都在链表的直线部分移动,这一步最多走 O(n) 步;slow 进入环后,fast 最多再走 L 步就能追上 slow,这里的 L 是环长,小于 n;阶段二同样最多走 O(n) 步,所以整体时间复杂度是 O(n),空间上只用了两个指针变量,所以是 O(1)。

写代码时还有一个小规范:不要在一行里把所有变量都声明完,也不要写一堆没用上的临时变量。链表题本来就容易绕,变量越少,思路越清晰。


个人经验是:环形链表 II 这道题,代码量只有二十行出头,但值得你花两小时把第 4 节那段推导完整写一遍。我面试过不少候选人,能默写出快慢指针代码的人很多,能顺手画出a = (k-1)L + c的人很少。而后者往往就是面试官心里给你加分的关键。

最后分享一个小技巧:把环想象成操场跑道,慢指针是散步的人,快指针是跑步的人。第一次碰头只能证明你俩在同一片场地,想找到“入口”,得用路程差算出那个唯一的入场点。这个画面感建立起来之后,这道题基本就长在你脑子里了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 3:25:39

T+转U8工具:财务数据迁移的科目编码与辅助核算实战指南

简介&#xff1a;面向财务信息化实施与维护人员&#xff0c;用于将畅捷通T系统中的基础档案与业务数据平滑迁移至用友U8环境&#xff0c;解决异构系统间数据对接与手工重录难题&#xff0c;可应用于企业财务系统升级、账套合并及实施顾问交付等场景。资源包共13个文件&#xff…

作者头像 李华
网站建设 2026/10/18 3:23:43

两百行MATLAB实现随机森林:从基尼分裂到袋外评估全解析

简介&#xff1a;面向MATLAB初学者的随机森林分类算法入门实现&#xff0c;基于统计与机器学习工具箱编写&#xff0c;直观演示决策树集成、自助抽样、随机特征选择与多数投票等核心机制&#xff0c;可快速用于二分类或多分类建模。压缩包共八个文件&#xff0c;主体为三个M脚本…

作者头像 李华
网站建设 2026/10/18 3:22:02

海康威视C++ SDK工程骨架:登录/取流/云台控制全链路实践

简介&#xff1a;本资源是海康威视HCNetSDK V5.1.1.4&#xff08;2015年4月20日发布的64位Windows中文版&#xff09;完整C开发包&#xff0c;面向安防监控领域初/中级C开发者&#xff0c;解决视频设备接入、实时预览、云台控制、录像回放及报警处理等核心二次开发需求。压缩包…

作者头像 李华
网站建设 2026/10/18 3:19:52

Godot 2D游戏开发:从Demo到可玩原型,用GameManager与信号搭建游戏循环

跟着这个系列一路做到现在&#xff0c;你应该已经在 Godot 里搭出了一个能控制的 2D 角色&#xff1a;它能移动、能跳跃、能和地图碰撞体打交道&#xff0c;摄像机跟在屁股后面跑&#xff0c;甚至可能已经给角色挂上了一套 idle/run 切换的动画。但说句实话&#xff0c;到这个阶…

作者头像 李华
网站建设 2026/10/18 3:18:37

systemd报错control process exited:RabbitMQ启动失败排查指南

1. 这个 systemd 报错到底在说什么1.1 "control process" 指的不是 RabbitMQ 本身先说个容易混淆的概念。systemd 在启动一个服务时&#xff0c;真正执行的是 unit 文件里 ExecStart 指定的命令。对 RabbitMQ 来说&#xff0c;这个命令通常是/usr/sbin/rabbitmq-serv…

作者头像 李华