☰
环形链表判环:Floyd快慢指针原理与环入口定位解析
2026/9/26 5:33:59 网站建设 项目流程

1. 题目解析:环的定义、边界形态与应用场景

作为链表类问题里最经典的入门题之一,"环形链表I"考查的并不仅仅是你会不会写一个 while 循环,而是你有没有理解指针移动背后那条"追及问题"的逻辑链条。题目说起来特别简单:给定一个链表的头节点 head,判断链表是否存在环。可就是这道题,在面试中能延伸出环入口定位、环长计算、算法正确性证明等一系列问题。这篇文章就把这条主线完整梳理一遍。

先把这个题目拆到最干净。判环的定义是:链表中某个节点的 next 指针,指向了链表中前面已经出现过的节点,这就形成一个环。换句话说,只要从某个节点出发,顺着 next 一直走,能走回到自己头上,那就有环。这个定义里有个容易被忽略的细节:环不一定是"从头节点的某个位置才开始"的。它可能很小,比如只有两个节点互相指;也可能很大,比如一万个节点的链表,最后一个节点的 next 指回第五百个节点;甚至极端到只有一个节点,它自己指向自己。输入链表也可能直接是空链表。这些边界情况都会在判定时给你找麻烦。

从实际应用的角度看,判环为什么值得专门考一道题?因为现实系统里有大量"链表形态"的结构。举个最典型的例子:一个内存池里的空闲块如果因为指针被错误改写而形成了环路,分配器就可能在同一批块里无限打转;再比如某些状态机或任务调度链,一旦某个回调注册成了环,程序就表现为"看起来在运行,实际上永远跑不完"。判环的思想在死锁检测、路由环路防御、编译器控制流分析里都有直接或间接的对应。所以面试官问这道题,说是考算法,其实也在考你有没有"在一个可能有问题的链式结构里快速定位异常"的工程直觉。

这个系列在在线评测平台上分 I 和 II。I 只要求你回答"有没有环",能输出布尔值即可;II 则进一步要求你把环的入口节点找出来。很多人的误区是把 I 当作 II 的简化版,只背一个解法就完事。但事实上 I 才是核心,II 的所有推导都建立在 I 的相遇结论之上。只有把 I 的每一步推演吃透,后面遇到入口定位、环长计算、正确性证明才不会心虚。

1.1 三种输入形态:无环、有环、自环

为了方便后续讨论,先把链表能出现的形态归一下类:

  • 无环链表:每个节点最多被访问一次,遍历到 None 结束,这是最普通的情况。
  • 有环链表:某个节点的 next 指回之前出现过的节点,遍历永远不会自然停止。入口节点可能离头节点很远,环也可能很长。
  • 自环:单个节点的 next 指向自身,这是环的最简形态,也是面试写代码时最容易漏判的一种。

判定时真正要处理的无非三类:空链表、单节点无环、单节点自环。空链表和单节点无环都直接返回 false,单节点自环必须返回 true。这个看起来很基础的边界,恰恰是哈希表解法和快慢指针解法都必须单独照顾的地方。很多人写完代码只测了普通用例,觉得能跑通就提交了,结果在自环上栽了跟头。

2. 暴力解法的价值:哈希表思路、实现与它的天花板

先别急着上快慢指针。如果你第一次见到这道题,最自然的想法其实是哈希表:沿着链表走,每经过一个节点就把它记下来,如果走到某个节点时发现它已经在记录里,说明有环;如果走到尾部的 None,说明没环。这个思路完全正确,也几乎不可能写错,所以它天然适合作为第一版答案。

2.1 哈希表解法的代码与复杂度

Python 写出来是这样:

def has_cycle(head): seen = set() cur = head while cur: if cur in seen: return True seen.add(cur) cur = cur.next return False

Java 版本大同小异,用 HashSet:

public boolean hasCycle(ListNode head) { Set<ListNode> seen = new HashSet<>(); for (ListNode cur = head; cur != null; cur = cur.next) { if (!seen.add(cur)) { return true; } } return false; }

有个实现细节值得一说:Java 里直接用!seen.add(cur)作为判断,利用的是 Set.add 返回值的语义——元素已存在时返回 false。这样既完成了去重判断又完成了插入,省了一行代码。但如果你觉得这样可读性差,拆成 contains 加 add 两步也完全没问题。我个人在面试时倾向写得更直白,因为面试官更在意你的思路,而不是这种小聪明。

时间复杂度和空间复杂度都是 O(n)。哈希表方案在数据量小时没有任何问题,但它有两个硬伤:一是空间开销在链表很长时会成为瓶颈;二是在面试中这道题的标准追问就是"能不能用 O(1) 空间?"如果你只答出哈希表,后面基本是被牵着走。所以哈希表更像是热身,它最大的价值是让你确认自己对题意的理解没有偏差,同时给了你后续比较的基准。

2.2 哈希表方案在工程场景里的隐性成本

更进一步说,哈希表判环在真实系统里还有个容易忽略的成本:它需要一套区分节点的手段。如果是链表节点这种引用类型,Java 里默认的 hashCode 基于对象地址,通常没问题;但如果节点是自定义结构且没有正确实现 hashCode 和 equals,就可能引入新的 bug。而快慢指针方案不依赖任何哈希、不依赖节点是否支持相等判断,只需要能沿着 next 移动,适用范围更广。这也是为什么底层编译器分析、网络报文环检测这类场景更倾向用指针类的 Floyd 算法,而不是哈希集合。理解这一点,你在面试时说"选择快慢指针的原因"就会比单纯说"空间更优"更有说服力。

3. Floyd判圈算法:快慢指针为什么必然相遇

Floyd 判圈算法是这道题的标准解,也叫"龟兔赛跑"算法。思路极其简单:两个指针,slow 每次走一步,fast 每次走两步,同时从头节点出发。如果链表无环,fast 会先撞到 None,直接结束;如果有环,slow 和 fast 最终一定会在环内相遇。

这个算法最反直觉的地方在于:fast 每次走两步,它难道不会"跳过"slow 吗?比如两个指针在环上相邻,fast 两步跨过去,不正好和 slow 错开?这是绝大多数人第一次接触时都会有的疑问。答案是不会,因为环是一个闭合的圆形轨道,所谓"跳过"在圆形轨道上只意味着两者的相对位置发生了变化,而不是真的擦肩而过。关键在于相对速度:每过一个单位时间,slow 前进 1,fast 前进 2,所以 fast 相对 slow 前进了 1。在环形轨道上,一个以速度 1 逼近的追及者,无论初始距离是多少,最终都必然追上——除非距离无限大,但环的长度是有限的。

3.1 用追及模型证明:为什么相遇是必然事件

严谨一点,设头节点到环入口的距离为 X,环长为 L,相遇点距离环入口(沿前进方向)为 Y。slow 从入口出发,走到相遇点一共走了 Y 步。这里有个关键前提:相遇发生在 slow 进入环后的第一圈之内。为什么?因为 fast 相对 slow 的速度是 1,当 slow 刚到环入口时,fast 已经在环内某个位置,两者之间的弧长差距最大也只有 L-1,所以追上所需的时间最多 L-1 步。在这段时间里 slow 只前进了 L-1 步,不可能完成整整一圈。这个结论一定要记牢,后面推导环入口时会反复用到。

于是相遇时,slow 的总路程是 X + Y,fast 的总路程是 X + Y + m*L(m 是 fast 在环内多跑的整圈数,m ≥ 1)。由于 fast 速度是 slow 的两倍,总路程也是两倍:

2(X + Y) = X + Y + m*L => X + Y = m*L => X = m*L - Y

这个式子的含义是:头节点到环入口的距离 X,等于 m 圈环长减去入口到相遇点的距离 Y。也就是说,从相遇点再往前走 Y 步就会回到入口,而从 head 出发走 X 步也会到达入口,两者之间的差正好是环长的整数倍。

我最早学这个证明的时候,总觉得"m*L - Y"这个形式不太直观,后来换了个角度就通了:把相遇点想象成环形跑道上的一个标记,把链表从相遇点"剪开",你会得到一条长度为 X + Y 的直线段,而它正好等于若干整圈。只要两者相距整数圈,你从任何一个位置同时出发、同速前进,就必然在同一位置碰上。这个类比让我再也不容易忘记入口推导的结论。

3.2 代码实现与复杂度分析

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

要点有两个。第一,while 条件必须写成fast and fast.next,顺序不能换。因为 fast 要移动两步,必须先确认当前节点非空,再确认下一个节点非空,否则在访问fast.next.next时可能抛空指针异常。第二,相遇判断放在每次快指针移动之后,不要放在最前面,否则初始状态下 slow 和 fast 都指向 head,一上来就会误判成环。

时间复杂度的最坏情况怎么估?教科书上常写 O(n),但很多初学者会疑惑:fast 可能在环里绕很多圈,为什么整体还是 O(n)?关键在这里:无环部分最多走 X 步,一旦进入环内,fast 追上 slow 至多需要 L-1 步。所以总步数是 X + O(L),而 X + L 正是链表的规模,于是整体是 O(n)。空间复杂度自然是 O(1),这也是它相比哈希表方案最大的优势。

4. 从"有没有环"到"环的入口在哪":一步之遥的进阶推导

141 只问有没有环,但几乎每个面试官都会在你讲完 Floyd 解法后追问一句:"那你能找到环的入口节点吗?"这就是环形链表II。好消息是,答案不需要新算法,只需要在相遇后多做一个小操作。

结论先放在这里:在第一次相遇点,把其中一个指针重新移回 head,另一个保持不动,然后两个指针都改成每次走一步;当它们再次相遇时,相遇点就是环的入口。

4.1 推导过程:为什么同速走一段就能锁定入口

沿用上一节的符号:X 是 head 到入口的距离,Y 是入口沿前进方向到相遇点的距离,L 是环长。我们已经得到 X + Y = mL,所以 X = mL - Y。设想现在有一辆"新车"从 head 出发,每步走一个节点;同时让原来在相遇点的指针也每步走一个节点。新车走了 X 步后到达入口;旧指针从相遇点前进 X 步后,它相对于入口的位置变化是 Y + X。而 Y + X = m*L 正好是环长的整数倍,也就是说旧指针转了整数圈之后也恰好落在入口。两"车"在入口汇合,入口被锁定。

很多资料会用"相遇点到入口的距离,恰好等于 head 到入口的距离"来记忆,更准确的说法其实是:两者之间的差距是环长的整数倍,而同速前进时,这个整数圈差对"在何处相遇"没有任何影响。面试时我通常这样给面试官讲:先确认相遇点,然后派一个指针从头开始、一个指针从相遇点开始,同速跑;因为两个起点之间的弧长差是整圈,所以它们必然在入口碰头。

4.2 入口定位的完整代码

def detect_cycle(head): slow = head fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow is fast: # 相遇了,说明存在环 slow = head while slow is not fast: slow = slow.next fast = fast.next return slow return None

注意这个写法把"相遇判断"和"入口寻找"合并到了一个循环里:第一段循环条件不变,一旦快慢指针相遇就跳出,进入第二段同速追赶。如果链表无环,第一段循环自然结束,返回 None。代码很短,但它集成了两个阶段,理解每一步在做什么比背下来更重要。

顺带一提,入口定位有个很实用的自测价值:你可以用它来验证 has_cycle 是否正确。我在本地测试时通常会构造一个入口在中间、环长比较大的链表,先跑 detect_cycle 确认返回的节点确实在链表里,再手动检查"从这个节点继续走,能否走回它自己"。这种"用 II 验证 I"的做法,比单独测布尔结果可靠得多。

4.3 顺手算出环长

既然已经能定位入口,环长就是顺水推舟的事。最省事的办法是在找到入口后,从入口开始重新绕一圈,数回到入口一共经过多少节点。还有种更取巧的方式:利用第一次相遇时快慢指针的步数关系反推环长,但这样做容易混淆边界,我一般只在讲原理时提一下,工程实现里直接绕环计数最稳妥。

def cycle_length(head): entry = detect_cycle(head) if entry is None: return 0 length = 1 cur = entry.next while cur is not entry: cur = cur.next length += 1 return length

这里从入口的下一个节点开始计数,是为了避免一开始就把入口统计进去导致多算一个。这个细节同样容易出错,建议自己动手跑一遍。

5. 边界条件、常见错误与面试追问:把细节扣到肌肉记忆

代码能跑通简单用例,不代表面试能过。环形链表这道题真正的区分度,在于边界条件的处理和对算法正确性的解释是否自信。下面这些点,是我在当面试官和被面试时都反复见过的。

5.1 最容易翻车的三个边界

首先,空链表和单节点无环链表。head 为 null,或 head.next 为 null,此时直接返回 false。Floyd 算法的 while 条件fast and fast.next天然处理了这两种情况,所以很少需要额外写 if。真正容易翻车的是只有一个节点且 next 指向自身的自环:此时 fast 和 slow 都指向这个节点,第一轮循环里 slow 走一步、fast 走两步,实际上都是从自身出发回到自身,然后slow is fast成立,返回 true。建议你在写完代码后主动提示面试官测这个用例,很多候选人都会在这里犹豫。

其次,环的入口正好在头节点,也就是整个链表首尾相连。此时 X=0,相遇点一定在环内某个位置,入口回推的第二步循环会让一个指针留在相遇点、另一个回到 head,由于 head 就是入口,slow 这一轮几乎没怎么移动就满足了条件,直接返回 head。逻辑依然成立,但如果你对推导不够熟,面试时遇到这种输入容易怀疑自己写错了。

第三,环特别大、入口特别靠后。比如一万个节点,入口在第 9997 个节点,环长三千。这种用例能暴露 while 条件里fast and fast.next的判空顺序问题。只要 fast 在进入环前的最后一跳时 next 不为空,就不会有空指针;但如果你先判 fast.next 再判 fast,当 fast 恰好停在倒数第二个节点时就会异常。所以顺序不要写反,这也算是这道题里唯一一个跟"语法习惯"强相关的坑。

5.2 面试官可能追问的几个问题

"为什么快指针走两步,不能走三步吗?"这是最常见的追问。标准回答是:走三步在"有环"的前提下未必会出错,但正确性证明不如两步来得干净。走两步时,fast 相对 slow 的速度是 1,追及过程没有跳变;走三步时相对速度是 2,当环长为偶数、两者初始距离为奇数时,可能出现一层层相邻错过的情况,分析复杂度时徒增麻烦。所以面试时你只需要说"两步保证相对速度为 1,追及一定发生,整体线性"即可,不必主动展开太深。

"如果链表很长,fast 会不会提前走到 None?"这恰好说明无环,直接返回 false。无环情况下链表是有限直线,fast 每次跳跃两格,必然更早到达终点。这是快慢指针方案里最直观的部分。

"哈希表和快慢指针,你会选哪个?"我的标准答案是:如果空间不是瓶颈、代码可维护性优先,哈希表更不容易写错;如果链表规模大、内存敏感,或者节点本身不支持哈希,快慢指针就是不二之选。面试时主动给出这种权衡对比,比只报一个答案要加分。

5.3 从面试题到工程直觉:这个算法的用处不止于刷题

最后说点题外话。这个算法在工程里的变体不少:有些系统用类似的思路检测并发数据结构里的环路,有些运行时工具用它分析对象引用是否存在循环,还有配置解析器处理依赖关系时用它避免无限引用。工程版本通常不会写成链表 next 指针这么直白,而是抽象成"从任意状态按某种转移规则,能否访问到已访问过的状态"。

我自己在实际项目里就遇到过类似的事:一个配置文件解析器读取依赖关系列表,依赖项之间允许互相引用。起初没做环检测,结果某些异常的配置会让解析器陷入死循环,表现为进程 CPU 占用打满。后来我用快慢指针的思路,在依赖关系"游走"时快速判定环的存在,几百个节点的配置规模下跑得飞快,比维护一张全局访问哈希表省了不少内存。这大概就是刷题最实在的回报——你背下来的不是一道题的答案,而是一种可以在完全不同的数据结构上迁移的思维模式。环形链表 I 值得好好吃透,它真的不只是一道题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询