快慢指针精讲:链表成环检测与 Floyd 判圈算法
摘要
快慢指针是链表问题中最优雅的算法工具之一。本文以 Linked List Cycle 为切入点,完整证明 Floyd 判圈算法的三个核心结论:快慢指针必然相遇、环起点的定位数学推导、环长的计算方法。然后将快慢指针推广到数组(Happy Number),展示同一算法框架在不同数据结构上的统一性。
第 1 章 为什么需要 Floyd 判圈算法
1.1 链表环检测的朴素想法
给定一个链表,判断它是否有环。最直觉的做法:遍历链表,把访问过的节点记录在 HashSet 中。每访问一个新节点,检查它是否已经在 HashSet 里——如果是,说明链表有环。
// 哈希表解法
public boolean hasCycle(ListNode head) {
Set<ListNode> visited = new HashSet<>();
ListNode cur = head;
while (cur != null) {
if (visited.contains(cur)) return true;
visited.add(cur);
cur = cur.next;
}
return false;
}这个解法完全正确,时间复杂度 ,但空间复杂度也是 (需要存储最多 个节点的引用)。
面试题的常见追问:能用 空间解决吗?
这就是 Floyd 判圈算法(又称”龟兔赛跑算法”)的出发点。
1.2 Floyd 算法的直觉来源
想象一条操场跑道,有一段直线入口,然后是一个环形跑道。一只乌龟(慢指针)和一只兔子(快指针)同时从起点出发:乌龟每步走 1 格,兔子每步走 2 格。
- 无环:兔子会先到达终点,乌龟还在后面慢慢走——兔子先到
null。 - 有环:兔子进入环后开始绕圈,乌龟最终也进入环。此后,由于兔子速度更快,在环内相对于乌龟以速度 1 追赶,最终两者相遇。
这个直觉解释了”为什么快慢指针能检测环”,但面试中光靠直觉不够,需要能说清楚”为什么一定相遇”以及”相遇后如何找环的入口”。
第 2 章 LeetCode 141:链表是否有环
2.1 题目
LeetCode 141 - Linked List Cycle(简单)
给你一个链表的头节点 head,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递。仅仅是为了标识链表的实际情况。
如果链表中存在环,则返回 true;否则,返回 false。
2.2 Floyd 算法解法
public boolean hasCycle(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next; // 慢指针走一步
fast = fast.next.next; // 快指针走两步
if (slow == fast) return true; // 相遇,有环
}
return false; // fast 到达末尾,无环
}2.3 快指针判空顺序的细节
while (fast != null && fast.next != null) 中,必须先判 fast != null,再判 fast.next != null。
- 先判
fast != null:确保fast不是空指针,才能安全访问fast.next - 再判
fast.next != null:确保fast.next不是空指针,才能安全访问fast.next.next
如果顺序反过来(先判 fast.next != null),当 fast == null 时访问 fast.next 直接抛出 NPE。
生产避坑:链表指针的判空顺序
任何链表操作,访问
node.next之前必须先确认node != null;访问node.next.next之前必须先确认node != null && node.next != null。这是链表题最常见的 NPE 来源。
2.4 为什么快慢指针一定相遇(数学证明)
定理:若链表有环,快慢指针(快指针每步走 2,慢指针每步走 1)必然在有限步数内相遇。
证明:
设链表非环部分(从头节点到环入口)长度为 ,环长为 。
第一阶段:慢指针进入环(步数 )。
当慢指针走了 步到达环入口时,快指针已经走了 步,在环内的位置为 (从环入口算起,走了 步进入环后,在环内继续走 步)。
不妨设此时快指针在环内位置为 (从慢指针位置算,快指针领先 步,也可以理解为快指针在慢指针”前面” 步,或者说慢指针在快指针”前面” 步)。
注意:这里”前面”指的是在环内相对于同一方向的距离。如果 ,两指针恰好在环入口相遇,无需追赶。
第二阶段:快指针在环内追赶慢指针。
每过一步,快指针比慢指针多走 1 步,即追赶距离减少 1。初始追赶距离为 (慢指针在快指针”前面”的距离),经过 步后,快指针追上慢指针,两者相遇。
总步数: 步(从慢指针进入环的那一刻算,这是一个有限数),因此必然相遇。
核心概念:相遇点不一定是环入口
快慢指针相遇的位置,一般不是环的入口节点,而是环内某个位置。找环入口需要额外步骤(见下一题 LeetCode 142)。
第 3 章 LeetCode 142:找环的入口节点
3.1 题目
LeetCode 142 - Linked List Cycle II(中等)
给定一个链表的头节点 head,返回链表开始入环的第一个节点。如果链表无环,则返回 null。
3.2 数学推导:相遇点到环入口的距离
设快慢指针在环内某点相遇。此时:
- 慢指针共走了 步
- 快指针共走了 步(速度是慢指针的 2 倍)
慢指针的路径: 步到达环入口,在环内走了 步(在环内转了若干圈)。 快指针的路径: 步到达环入口,在环内走了 步。
由于在环内走任意圈数后回到同一位置(环长为 ),相遇条件:快指针在环内的位置 = 慢指针在环内的位置,即:
即 是 的倍数,设 ( 为正整数)。
慢指针从头走了 步到达相遇点,其中 步是非环部分, 步是在环内走的。
关键推导:此时慢指针在环内的位置(从环入口算)是 。
换个角度:从相遇点继续走 步,在环内走了 步,位置变为 ,恰好回到环入口!
结论:相遇点到环入口的距离 = (链表头到环入口的距离)。
因此,找环入口的算法:相遇后,将一个指针重置到链表头,两个指针以相同速度(每步 1)移动,再次相遇的地方就是环入口。
3.3 完整解法
public ListNode detectCycle(ListNode head) {
ListNode slow = head, fast = head;
boolean hasCycle = false;
// 阶段一:快慢指针找相遇点
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
hasCycle = true;
break;
}
}
if (!hasCycle) return null; // 无环
// 阶段二:将 fast 重置到头节点,两指针以速度 1 移动,再次相遇即环入口
fast = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow; // 此时 slow == fast == 环入口节点
}为什么阶段二将 fast 重置到 head,而不是 slow 重置到 head?
两者等价,重置哪个指针到头节点都正确(再次相遇的地方都是环入口)。习惯上重置 fast 到 head,因为 fast 在阶段一结束时在相遇点,slow 也在相遇点,任意重置一个到 head 即可。
3.4 特殊情况:相遇点恰好是环入口
当 (即 是 的整数倍)时,慢指针进入环时,快指针也在环入口(绕了整数圈后回到入口),两指针立刻相遇。此时相遇点就是环入口,阶段二中 fast = head,slow 在环入口,两指针走 步后均到达环入口,正确。
第 4 章 LeetCode 876:链表的中间节点
4.1 题目
LeetCode 876 - Middle of the Linked List(简单)
给你单链表的头节点 head,请你找出并返回链表的中间节点。如果有两个中间节点,则返回第二个中间节点。
输入:head = [1,2,3,4,5]
输出:[3,4,5]
解释:链表只有一个中间节点,值为 3。
输入:head = [1,2,3,4,5,6]
输出:[4,5,6]
解释:链表有两个中间节点,值分别为 3 和 4,返回第二个。
4.2 解法
public ListNode middleNode(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}奇数长度(如 [1,2,3,4,5]):fast 到达节点 5 时,fast.next == null,退出循环,slow 在节点 3(中点)。
偶数长度(如 [1,2,3,4,5,6]):fast 到达节点 6 时,fast.next == null,退出循环,slow 在节点 4(第二个中点)。
找中点的快慢指针模板与环检测完全相同,区别只在于:找中点时不需要检查 slow == fast。
第 5 章 LeetCode 202:快乐数
5.1 题目
LeetCode 202 - Happy Number(简单)
编写一个算法来判断一个数 n 是不是快乐数。
「快乐数」 定义为:
- 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和
- 然后重复这个过程,直到这个数变为 1,也可能是无限循环但始终变不到 1
- 如果这个过程的结果为 1,则这个数是快乐数;如果最终进入无限循环,则不是快乐数
输入:n = 19
输出:true
解释:
1² + 9² = 82
8² + 2² = 68
6² + 8² = 100
1² + 0² + 0² = 1
5.2 为什么可以用快慢指针?
这道题表面上是数论题,与链表毫无关系。但仔细分析:“每次将数 n 替换为各位数字平方和”,这是一个函数:。
如果不是快乐数,则 会进入一个无限循环,即这个数列构成了一个带环的序列(就像有环链表!)。
因此,判断 n 是否是快乐数,等价于:判断从 n 出发、不断应用 的数列是否会到达 1(无环,最终停止),还是进入循环(有环,永远循环)。
这正是 Floyd 判圈算法的适用场景!
5.3 解法
public boolean isHappy(int n) {
int slow = n;
int fast = getNext(n); // fast 比 slow 快一步
while (fast != 1 && slow != fast) {
slow = getNext(slow); // 慢指针走一步
fast = getNext(getNext(fast)); // 快指针走两步
}
return fast == 1; // 如果 fast 到达 1,是快乐数;否则有环,不是
}
// 计算各位数字的平方和
private int getNext(int n) {
int sum = 0;
while (n > 0) {
int digit = n % 10;
sum += digit * digit;
n /= 10;
}
return sum;
}终止条件:
fast == 1:快指针到达 1,是快乐数slow == fast:快慢指针相遇且不在 1,有环,不是快乐数
为什么不是快乐数的数列一定有环?
对于 3 位及以上的数 n(),(各位平方和必然比原数小,因为 ,这个估算对所有实际输入成立)。所以 n 最终会被压缩到两位数范围内()。
两位数的数列是有限的,只有 99 个可能的值, 必然会产生重复——即循环。因此不是快乐数的数一定会进入有限循环,Floyd 算法一定能在有限步内检测到。
第 6 章 快慢指针的共性与适用边界
6.1 快慢指针的共性模式
所有快慢指针问题都具备以下结构:
- 存在一个”下一步”函数:链表中是
node.next,数组中可能是某个映射函数 - 问题转化为环检测或距离计算:有环/无环、走到末尾的时间差
- 节奏差产生信息:快指针走 2 步,慢指针走 1 步,节奏差最终带来两者相遇或差距恒定
6.2 快慢指针不适用的场景
- 双向链表:有
prev指针,可以往回走,不需要快慢指针(可以直接找到中点) - 数组(一般情况):数组可以 随机访问,不需要快慢指针找中点(直接用
n/2索引) - 环检测问题但步幅不为 1 和 2:可能错过相遇点(如快 3 慢 1,在某些环长下可能不相遇)
生产避坑:快指针步幅选择
经典 Floyd 算法中快指针步幅为 2、慢指针步幅为 1。如果快指针步幅 ,相遇不再有保证(例如步幅为 3 的快指针和步幅为 1 的慢指针,在长度为 2 的环中永远不相遇)。面试中除非特别说明,快慢指针的步幅固定为 2:1。
第 7 章 面试中的 Floyd 算法讲解方法
7.1 分阶段清晰表达
面试中讲解 Floyd 算法,推荐分三段:
第一段(环检测):“快指针每步走 2,慢指针每步走 1。无环时快指针先到 null;有环时快指针在环内绕圈,慢指针进入环后两者以相对速度 1 追赶,最终相遇。”
第二段(相遇点性质):“设非环部分长 ,环长 ,慢指针走了 步相遇,其中 是正整数。从相遇点继续走 步,恰好回到环入口。”
第三段(找入口算法):“相遇后将一个指针重置到 head,两指针以速度 1 一起走,再次相遇的地方就是环入口,因为两者各走了 步。“
7.2 常被追问的细节
“为什么慢指针走了 s = kC 步?”
慢指针从 head 走了 步到达相遇点。相遇点是快指针追上慢指针的地方,快指针走了 步。两者路径的差 必须是环长 的整数倍(因为它们在环内位置相同,只是绕了不同的圈数),所以 。
“为什么相遇点不一定是环入口?”
除非 ,否则相遇点在环入口沿环方向走了 步的位置,不是环入口。
小结
本文完整覆盖了快慢指针在环检测场景下的三个核心结论:
- 有环时必然相遇:快指针以相对速度 1 追赶慢指针, 步后相遇
- 相遇点到环入口距离 = 头节点到环入口距离:推导自
- 找环入口的实现:相遇后一个指针重置到头,同速移动,再次相遇即入口
同时将快慢指针推广到非链表场景(Happy Number),揭示了”序列中的函数映射形成隐式链表”这一通用思想。
下一篇继续链表双指针的实战题:删除倒数第 N 个节点、判断链表是否为回文、相交链表等,重点介绍”距离双指针”(先走 K 步,再同速移动)这一变体。