刷LeetCode的朋友应该都见过这道题,环形链表在Hot 100系列里排第二十五,题号是141。题目本身很短:给定一个链表的头节点,判断链表中是否存在环。但就是这么一道“简单题”,我见过不少人在面试时翻车,也见过有人只用一句“快慢指针”带过、结果被追问到怀疑人生。
先说一个很现实的场景:很多初学链表的朋友第一次遇到这题会想,遍历链表然后看能不能走到空指针不就行了?这个想法本身没错,但问题是,如果链表真的有环,遍历就会陷入死循环,你根本走不到头。所以这道题表面在问“有没有环”,实际上考的是两件事:第一,能不能跳出顺序遍历的思维惯性;第二,知不知道双指针在这种场景下的妙用。
这篇文章我把这道题彻底拆开讲。从最直观的哈希表解法开始,再到经典的快慢指针,把原理、推导、代码实现、边界情况都过一遍,最后围绕环形链表这个场景延伸出几个高频变体——找环的入口、计算环的长度、以及快慢指针思想在其他题目里的应用。不管你是刚刷题想弄懂原理,还是准备面试想把这题答出深度,这篇都能给你一点启发。
1. 题目拆解:环形链表到底在考什么
1.1 题面与示例
先回到题目本身。LeetCode 141的题面大概是这样:
给定一个链表的头节点 head,判断链表中是否有环。如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达该节点,则链表中存在环。如果链表中存在环,返回 true,否则返回 false。
注意题目里有个不太显眼的点:pos 表示链表尾部连接的节点下标(从0开始),但在函数签名里,你拿不到这个 pos。也就是说,你只能拿到头节点,环境会帮你构造好带环或不带环的链表,你需要在不知道环在哪里的情况下判断是否存在环。这恰恰是实际工程中更真实的情况——你不知道数据什么时候会出问题,只能靠算法去探测。
举两个简单的例子:
- 输入:
head = [3,2,0,-4],尾部next指向下标1的节点(值为2),输出应为true。 - 输入:
head = [1,2],尾部next指向null,输出应为false。
还有一个约束注意一下:链表中的节点数范围是 [0, 10^4],也就是说 head 可能是空链表,这是最容易漏掉的边界条件。
1.2 为什么这道题值得单独写一篇
环检测是个很经典的图论问题,但在单链表场景下,它的难度被“压”得很低,低到很多人随手就写出来了。可越是这种题,越容易暴露基本功。我见过几个典型的错误示范:
- 直接用
while (head != null) { head = head.next; }遍历,遇到环直接超时死循环; - 用
HashSet存节点,代码没问题,但被问到“能不能不用额外空间”时卡壳; - 快慢指针方向写反,或者
while条件没判断fast.next,直接空指针异常。
所以别看这是个 easy 题,它覆盖的知识点一点都不少:链表引用关系、边界条件处理、时间复杂度与空间复杂度的取舍、双指针思想。面试官特别喜欢拿这种题当“引子”,先用它考察基础,然后顺势追问环形链表 II 的入口问题,或者环的长度问题,一条线问到底。
1.3 先区分两个概念:自环与环
链表中“有环”不只是最后一个节点指回头节点这一种情况。严格来说,只要某个节点的 next 指向了它之前的任意一个节点,就会形成环。比如 1 -> 2 -> 3 -> 2,节点3的 next 指回节点2,这就是环,而且环的入口在节点2。还有一种极端情况:1 -> 1,节点1的 next 指向自己,叫自环。
这些不同的环形态会影响我们判断入口和长度时的推导,但 判断是否存在环 这个基本问题上,所有算法都不关心。快慢指针法无论环的入口在哪、环多长,都能在有限步内判断出来,这个特性在后面的数学推导中会体现出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心解法:快慢指针的原理与证明
2.1 一句话说清快慢指针
快慢指针,也叫 Floyd 判圈算法(Floyd's Cycle Detection),核心思路特别简单:定义两个指针,慢指针 slow 每次走一步,快指针 fast 每次走两步。如果链表没有环,快指针会先一步到达链表末尾的空指针;如果链表有环,快慢指针最终必然在环内相遇。
很多文章把这个算法用“操场跑圈”来比喻:两个人绕操场跑步,一个快一个慢,只要跑的时间足够长,快的那个一定能追上慢的那个。但在链表里这个比喻有一个细节点需要想清楚:链表不是无限长的操场,节点是有限的,快指针会不会绕了好多圈都追不上?答案是不会。因为快指针比慢指针快一步的“相对速度”,每次循环,两个指针之间的距离就会缩短1,距离小于等于环长时一定会相遇。
这一点重要到值得单独强调:设环长为 L,快慢指针都在环内运动时,它们的相对速度是1(每轮快指针多走一步),那么最坏情况下,快指针需要追 L - 1 步就能追上慢指针。所以时间复杂度是 O(n) 的,不会出现死循环。
2.2 数学推导:为什么一定能相遇
上面的解释还是偏直觉,我们严谨推一遍。假设:
- 链表头到环入口的距离为
a; - 环入口到相遇点的距离为
b; - 环的周长为
L。
慢指针 slow 入环后走了 b 步,与快指针相遇。那么此时:
- 慢指针总路程:
a + b; - 快指针总路程:
a + b + k * L,其中k是快指针已经在环内绕的圈数,k >= 1。
因为快指针速度是慢指针的2倍,相同时间内路程也是2倍,所以:
2 * (a + b) = a + b + k * L
化简得到:
a + b = k * L,也就是a = k * L - b
这个式子的意思是:从链表头走到环入口的距离 a,等于快指针绕环 k 圈减去从入口到相遇点的距离 b。也就是说,如果一个指针从链表头出发,另一个指针从相遇点出发,每次都走一步,它们最终会在环入口相遇。这也是 环形链表 II(LeetCode 142) 解法的基础推导。
这里有个非常关键的前提:慢指针进入环时,快指针已经在环里了。因为快指针先入环,它会在环内绕圈,直到慢指针入环。两个指针都进入环之后,它们之间形成一个“追及问题”,相对速度刚好是1,所以不管环多长,一定能追上,不会出现永远差一步的情况。
2.3 为什么不建议把步长调成3或其他值
如果理解了上面追及问题的原理,你可能会想:既然快指针走两步能追上,走三步不是更快吗?理论上确实也能追上,但有两个问题。
第一,快指针步长越大,链表中无环时的边界判断越麻烦。走两步只需要判断 fast != null && fast.next != null,走三步就得判断 fast.next.next 是否为空,代码可读性下降,还容易漏判断导致空指针。
第二,快指针步长的增加并不带来更优的时间复杂度。判圈算法的时间复杂度本来就是 O(n),步长从2变到3,只是常数变化,而且还要处理“快指针越过慢指针”的尴尬情况。步长差为1时,快指针不会“跳过”慢指针;步长差为大于1的整数时,快指针可能直接“越过”慢指针而不相遇,需要额外的判断逻辑。所以面试中老老实实走两步,反而最优。
3. 完整代码实现与复杂度分析
3.1 Java 实现
java复制/**
* Definition for singly-linked list.
* class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public boolean hasCycle(ListNode head) {
// 边界条件:空链表或只有一个节点,都不可能成环
if (head == null || head.next == null) {
return false;
}
ListNode slow = head;
ListNode fast = head;
// 快指针每次走两步,所以需要确保 fast 和 fast.next 都不为 null
while (fast != null && fast.next != null) {
slow = slow.next; // 慢指针走一步
fast = fast.next.next; // 快指针走两步
if (slow == fast) {
return true;
}
}
return false;
}
}
这个版本我默认了快慢指针都从头节点出发。还有一种写法是让 slow = head,fast = head.next,然后在循环里判断相遇,这样在无环链表上会少走一次循环,但两种写法本质上没有区别。我习惯从头节点出发,因为后面找环入口时,这个版本的推导更自然。
3.2 Python 实现
python复制class Solution:
def hasCycle(self, head: ListNode) -> bool:
if not head or not head.next:
return False
slow, fast = head, head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
Python 版本和 Java 结构完全一致,唯一要注意的是判断节点相等用 is 而不是 ==。== 会比较节点里的值,万一两个不同节点的值相同,判断就会出错;is 比较的是内存地址,正好符合“是否是同一个节点”的语义。这个细节在 Python 刷题时值得养成习惯。
3.3 复杂度与空间占用
| 解法 | 时间复杂度 | 空间复杂度 | 是否修改原链表 |
|---|---|---|---|
| 哈希表(HashSet) | O(n) | O(n) | 否 |
| 快慢指针 | O(n) | O(1) | 否 |
| 修改链表值/标记法 | O(n) | O(1) | 是(不推荐) |
快慢指针最明显的优势是空间复杂度 O(1)。在数据量大的场景里,哈希表存节点引用虽然也能判断,但在每一个节点的内存开销都很宝贵,所以能省空间就省空间。这也是为什么快慢指针是这道题的标准答案。
3.4 修改链表值的野路子为什么不推荐
网上有人提出一种“巧解”:遍历链表,每经过一个节点就把它的值改成一个特定值,如果后面遇到值已经等于特定值的节点,就说明有环。这个思路能过测试用例,但严肃地说,它破坏了原始数据,在工程里绝对不能用——你只是来判断环的,凭什么把人家的数据改掉?而且如果链表节点本身的值就是“特定值”,判断逻辑直接失效。所以在面试里提到这个解法可以作为“反面教材”说明自己知道数据不可破坏的原则,但要明确表示不会采用。
4. 进阶变体:找入口、算环长、环形链表 II
4.1 找环的入口:从相遇点到入口的步数推导
如果只是判断有没有环,到上一节代码就结束了。但面试官大概率会追加一问:如果链表有环,怎么找到环的入口节点?这就是 LeetCode 142 环形链表 II 的题目。
之所以把这个问题放在这篇里讲,是因为它的解法几乎是从前面的数学推导直接引出来的。回到 2.2 节的结论:
a = k * L - b
这个式子可以改写为:
a = (k - 1) * L + (L - b)
用人话说就是:从链表头走到环入口的距离 a,等于从相遇点继续走 L - b 步到达入口,然后再绕 k-1 圈回到入口。所以解法呼之欲出:当快慢指针第一次相遇后,把慢指针(或者快指针,这里看你保留哪个)重新指向头节点,两个指针都变成“每次走一步”,继续前进。它们相遇的位置,就是环的入口。
这个结论我建议你背下来,但更重要的是自己动手推导一遍。面试时如果被问到,你能边画图边推,比直接背代码有说服力得多。
找入口的 Java 代码:
java复制public class Solution {
public ListNode detectCycle(ListNode head) {
if (head == null || head.next == null) {
return null;
}
ListNode slow = head;
ListNode 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;
}
// 重新出发,找入口
slow = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow;
}
}
代码很短,但每一步背后都是数学推导,不建议死记硬背。
4.2 计算环的长度
既然能找入口,环的长度也就很容易算了。一个办法是在找到环入口后,从入口节点出发,用一个指针绕一圈回到入口,步数就是环长。另一个更巧妙的办法是在快慢指针第一次相遇后,让慢指针继续走,同时计数,直到再次遇到快指针(快指针原地不动),这样走的步数就是环的长度。
我一般推荐第一种:先找入口,再数环长。逻辑更直观,也方便在面试时用语言描述。伪代码大概是:
java复制ListNode entry = detectCycle(head);
if (entry == null) {
return 0;
}
ListNode cur = entry.next;
int length = 1;
while (cur != entry) {
cur = cur.next;
length++;
}
return length;
环的长度有个很重要的应用场景:在某些系统设计题中,检测到循环依赖或者死循环之后,还需要知道循环涉及的资源有多少个,这时候环长度就派上用场了。
4.3 快慢指针思想的其他应用场景
快慢指针不只用来判链表环,它在数组场景下也有变体。最典型的是 LeetCode 287 寻找重复数。给定一个包含 n+1 个整数的数组,每个整数在 [1, n] 范围内,假设只有一个重复的整数,找出这个重复数。这题有一个非常“绕”的解法:把数组下标和值的关系看成链表,值作为下一个下标,重复数就是环的入口。因为数组长度是 n+1,但值范围只有 1 到 n,所以必然存在一个“跳到已访问位置”的环。
我第一次看到这个解法时觉得挺惊艳的,但其实它就是快慢指针的迁移应用。所以在面试中,能够说出“快慢指针不仅适用于链表,还能处理某些数组问题”是明显的加分项,说明你对算法思想有迁移能力,而不是只背了题。
5. 常见问题与调试心得
5.1 死循环还是空指针?三大典型报错
我在带新人刷题时,发现环形链表这道题最容易出三种错误,列成表给大家参考:
| 错误类型 | 错误示例 | 原因 | 正确做法 |
|---|---|---|---|
| 死循环 | 只用 while (node != null) 遍历 |
有环时永远到不了 null | 用快慢指针,而非单指针遍历 |
| 空指针 | fast.next.next 时 fast.next 为 null |
没有判断 fast.next 是否为空 |
条件写成 fast != null && fast.next != null |
判断相等用 == 处理对象值 |
Python 里用 == 判断节点相等 |
值相同不代表是同一节点 | Python 用 is,Java 直接用 == 比较引用 |
其中空指针问题是最阴间的。比如链表是 [1,2,3,4],没有环,快指针到节点4时,fast.next 是 null,此时如果循环体里执行 fast.next.next,就会报空指针。所以 while 条件的顺序不能写反:一定是先判断 fast != null,再判断 fast.next != null,利用短路逻辑避免空指针。
5.2 如何快速构造测试用例
刷题时不能只靠 LeetCode 的测试用例,自己也得会构造链表。一个简单的构造带环链表的方法:
java复制// 构造一个 1 -> 2 -> 3 -> 4 -> 2 的带环链表
ListNode node1 = new ListNode(1);
ListNode node2 = new ListNode(2);
ListNode node3 = new ListNode(3);
ListNode node4 = new ListNode(4);
node1.next = node2;
node2.next = node3;
node3.next = node4;
node4.next = node2; // 尾部指向 node2,形成环
测试无环链表就直接让最后一个节点的 next = null。这两种基础用例足够覆盖绝大部分情况。
调试时还可以加一行打印,比如在循环里打印 slow.val 和 fast.val,观察它们什么时刻相遇,能帮你直观理解快慢指针的追及过程。不过提交前记得把打印去掉,因为在线判题系统对输出很敏感。
5.3 快慢指针明明更快,为什么还有人用哈希表
我见过不少正式解法用 HashSet 来解这道题:遍历链表,把每个节点放进集合,如果某个节点已经在集合中,说明有环。这个解法在数据量小的时候没问题,而且思路非常直观,也容易写对。但它有一个很现实的短板——空间复杂度是 O(n),如果链表有上万个节点,就要额外存上万个引用。
面试时间充裕的话,我会先讲哈希表方案作为“最直观的思路”,然后主动说“但这个方案要 O(n) 空间”,再引出快慢指针的 O(1) 空间方案。这种“先给可行解,再优化到最优解”的表达方式,比直接甩出最优解更能体现你的思考过程。
5.4 一个小坑:快慢指针的初始位置
回到 3.1 的代码,快慢指针都从头节点出发,然后在循环里先移动再判断。有的人会写成先判断再移动,像这样:
java复制while (fast != null && fast.next != null) {
if (slow == fast) {
return true;
}
slow = slow.next;
fast = fast.next.next;
}
这个写法在最初阶段会直接判断 slow == fast,因为都指向头节点,必然相等,直接返回 true,结果就是所有测试用例都返回有环。所以顺序很重要:先移动,再判断;或者把初始快指针设置为 head.next 作为变通。这种细节极其容易踩,写的时候留意一下。
6. 面试实战:把这道简单题答出亮点
6.1 从题目到方案:面试中的标准表达流程
如果面试官抛出这道题,我建议按下面的节奏来回答,既不会太啰嗦,又能展示完整的思考链路。
第一,先确认边界条件。问清楚链表是否可能为空,节点的值是否有重复。这一步是为了后面判断相等性做准备。
第二,给出最直观的解法。用哈希表存节点,遇到重复节点说明有环,时间 O(n),空间 O(n)。
第三,主动提出优化思路。追问“能不能不用额外空间”时,自然引出快慢指针。说明它的原理:慢指针每次走一步,快指针每次走两步,如果有环,快指针最终会追上慢指针。
第四,说出复杂度。时间 O(n),空间 O(1)。这里的 O(n) 需要解释一下:即使快指针要在环里绕圈,整个过程最多遍历所有节点常数次,不会超过 O(n)。
第五,展示延伸思考。顺着题目继续讲:如果要求返回环的入口,可以在第一次相遇后,把一个指针移回头节点,再同步移动,再次相遇的位置就是入口。这能表明你不是只背了模板,而是真正理解了推导过程。
6.2 面试官在追问什么
面试官对这道题的追问通常围绕三个方向。
第一个方向:证明快慢指针一定会相遇。这考察数学基础和逻辑表达,重点是“相对速度”和“环长度有限,距离每次减1”这两个关键点。
第二个方向:如果链表很长,快指针会不会很慢?这个问题考察复杂度分析能力。答案是仍然 O(n),因为快指针的步长是2,最坏情况下也就是慢指针走完链表的一倍长度。
第三个方向:实际工程中这个算法会用在哪里?比如检测单向链表的循环引用,或者检测某些状态机中的环。如果你的项目里恰好有类似场景,可以提一下,会很加分。
6.3 真实工程里我不会用快慢指针
说点题外话,刷题归刷题,工程实现里我很少手写快慢指针。大多数语言的标准库已经提供了相关工具,比如 Java 的 LinkedHashSet 可以帮你做痕迹记录,或者直接用一个 Set 断重。但理解这个算法的价值不在于“手搓”,而在于它背后“双指针按不同速度前进”的思考方式。这种思想在链表找中间节点、找倒数第K个节点、判断回文链表等题目里都会用到。
所以我给刷题朋友的建议是:不要只背这道题的答案,而是把“快慢指针”当作一类工具掌握。环形链表是你学会这个工具的最佳入口,因为它的代码最短、推导最清晰、变体最多。
最后分享一个小技巧:我刷这题的时候,会顺手把“找入口”和“算环长”都实现一遍。一道题吃透三个功能,比单纯背题效率高很多。而且面试时一旦被追问到 142 题,你会发现自己已经提前准备好了,那种笃定的感觉,比临时硬想要有底气得多。
