1. 题目定位与考点拆解:为什么这道题值得反复刷
LeetCode Hot100 里,141. 环形链表几乎是面试官最爱的“开场题”之一。你第一次见到它,可能会觉得不过是一个“链表有没有环”的判断题:给定一个链表的头节点 head,判断链表中是否有环,如果有环返回 true,否则返回 false。但就是这个看似简单的题目,背后藏着不少值得说的东西——它考验的不是你会不会背代码,而是你懂不懂链表的本质、懂不懂空间换时间、懂不懂快慢指针为什么“一定能相遇”。
先说考点:这题涉及“Floyd判圈算法”(也就是龟兔赛跑算法),核心是快慢指针的运用,进阶解法是哈希表记录访问过的节点。快慢指针解法空间复杂度能压到 O(1),哈希表解法虽然直观,但空间复杂度是 O(n)。在工程实践中,这种“能否用常量空间解决问题”的思维,才是面试官真正想听的。
从刷题策略上讲,141 是“环形链表”系列的基础题。刷完它,紧跟着的 142. 环形链表 II(找环入口)、287. 寻找重复数、202. 快乐数,甚至链表相交问题,都会用到同一套思路。所以把 141 吃透,性价比极高。
这篇文章我会从最朴素的暴力解法讲起,逐步推导到最优解,给出完整可运行的代码,把边界情况和常见报错都过一遍。适合所有正在刷 Hot100 的读者,不管你是刚接触链表,还是已经刷了几十题想回头夯实基础,都应该能从里面挖到点东西。
注意:本文所有代码都默认链表节点定义为
val + next的经典结构,不依赖任何外部库,可直接复制运行。
2. 三种解法思路拆解:从暴力到最优的完整推导
2.1 暴力解法:遍历 + “走不到头”
你第一次拿到这题,最直接的念头是:既然链表有环就永远走不到 null,那我直接 while 循环走到 null 不就行了?如果循环正常结束,说明没环;如果永远走不到 null,说明有环。
这个思路框架是对的,但有一个致命问题——如果链表真的带环,循环会永远跑下去,程序直接超时或死循环,你根本没法判断“走了多久才算有环”。所以单纯靠“走不到 null”判环,在计算机里是行不通的,你总要有个“停止条件”。
于是你会想:那我加个计数器,比如走 10000 步还没到 null,就认为有环?这也不行,你得先知道链表长度,可链表长度恰恰是未知的。就算你初始化一个足够大的阈值,比如 10 万,碰上超长链表(比如 20 万节点)又会被误判成有环。所以暴力解必须要“记录”什么,要么记录步数上限,要么记录访问过的节点。
2.2 哈希表解法:空间换时间的标准示范
最常见的“正经”解法是哈希表:每走一步,把当前节点存进哈希集合,如果发现某个节点已经存在集合里,说明又回到了之前访问过的节点——那一定存在环。
这个解法的逻辑非常朴素,也不需要数学证明:链表的节点是唯一的对象引用(在 Java、Python 里就是对象的 id / 地址),一旦重复出现,必定是绕了一圈回来了。
时间复杂度 O(n),每个节点最多访问一次;空间复杂度 O(n),你需要存储所有访问过的节点。
在工程上,哈希表解法其实够用了,而且特别好写、好解释。那为什么还要学快慢指针?因为 O(n) 的空间在数据规模大的时候是真实痛点:一个百万节点的链表,哈希表就要存百万个引用,内存随时告急。而面试官问这题,往往就是想知道“你能不能省下这块空间”。
2.3 快慢指针(Floyd判圈算法):常量的空间,数学的优雅
快慢指针的思路你肯定听过:一个指针每次走一步,另一个指针每次走两步。如果链表有环,快指针最终会追上慢指针;如果没有环,快指针先走到 null。
为什么一定能追上?这里值得展开说一下。
假设链表无环,快指针每次跑两步,跑得比慢指针快,它要么先到达链表末尾的 null,要么在某个瞬间就越过了慢指针所在位置——但因为没有环,它不会绕回来,所以循环正常终止。
假设链表有环,情况就变了。想象两个人在圆形跑道上跑步,一个速度是另一个的两倍。只要跑道上没有终点(环上永远不会遇到 null),快的人迟早会从后面追上慢的人。具体来说,慢指针进入环后,快指针已经在环里了,两者的相对速度是“每次走一步”,所以从慢入环的时刻算起,最多走环的长度 L 步,两人必然相遇。
这背后的数学核心是:无论快指针先跑了多少圈,它在环内与慢指针的距离差一定是有限的,而相对速度恒定为一,所以距离差会被逐步抹平。
当然,题解里经常有人问:为什么快指针步长要是 2,不能是 3、4 吗?其实步长只要是大于 1 的正整数都能追上,但步长 2 最容易理解和论证,而且不会出现“快指针直接跳过慢指针”这种需要额外讨论的情况——虽然即使跳过了,在环内转几圈还是会碰上。从工程上讲,步长设为 2 是最稳定的选择。
3. 完整题解实现:三语言核心代码与逐行解析
3.1 C++ 实现(快慢指针标准写法)
class Solution { public: bool hasCycle(ListNode *head) { if (!head || !head->next) return false; ListNode *slow = head; ListNode *fast = head->next; while (slow != fast) { if (!fast || !fast->next) return false; slow = slow->next; fast = fast->next->next; } return true; } };这是我个人最推荐的一种写法,原因在于:它把“快指针先走一步”的事放在初始化阶段,循环里只要判断slow != fast即可。while里先检查fast和fast->next是否存在,避免空指针解引用。
另一种常见写法是先让fast = head,然后while (fast && fast->next)作为循环条件,循环里移动指针再判断相遇。这种写法更直观,更适合初学者理解。
class Solution { public: bool hasCycle(ListNode *head) { ListNode *slow = head; ListNode *fast = head; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; if (slow == fast) return true; } return false; } };3.2 Java 实现(哈希表解法对照)
public class Solution { public boolean hasCycle(ListNode head) { Set<ListNode> seen = new HashSet<>(); ListNode cur = head; while (cur != null) { if (seen.contains(cur)) return true; seen.add(cur); cur = cur.next; } return false; } }注意 Java 里 HashSet 判断的是对象的equals方法,而链表节点默认的equals就是对象引用比较,所以“重复访问”的判定完全正确。如果你在 LeetCode 里自定义了节点类,且没有重写equals方法,引用比较就是想要的语义。
3.3 Python 实现(简洁直观快慢指针)
class Solution: def hasCycle(self, head: Optional[ListNode]) -> bool: slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow is fast: return True return FalsePython 里特别要注意is而不是==。is判断对象是否为同一个引用,链表节点如果带环,慢指针和快指针指向同一个节点时,这两个变量引用的是同一个对象。而==在自定义ListNode类未实现__eq__的前提下,默认退化为引用比较,两者等价——但为了语义清晰,我强烈建议用is,因为我们要判断的是“同一个节点”,不是“值相等”。
3.4 关键代码细节解释
为什么快指针不能先移动再判断:如果先让
fast = fast.next.next再判断fast是否为空,那么当你已经走到链表末尾时,fast可能已经越界到null,调用fast.next会直接抛异常。所以必须在每一轮开始前检查fast和fast.next是否为空。为什么有的解法从
fast = head->next开始:这样可以让slow和fast初始不同,直接进入while循环判断,避免单独处理“只有一个节点”带来的边界问题。两种初始化方式都正确,但从代码可读性看,while (fast && fast->next)的结构更容易套用到其他题目(比如 142 找环入口)。
4. 边界条件与测试用例:不一定能一眼看出的坑
环形链表这题最大的坑,几乎全在边界条件上。LeetCode 给出的输入虽然不是直接传数组,而是把数组转换成链表节点对象,内部的pos参数表示环的入口位置(-1表示无环),但你自己测试的时候,很多细节都会被忽略。
4.1 空链表
head为null,也就是没有任何节点时,直接返回false。任何一种实现都会先处理“链表为空”的情况。如果快慢指针初始都指向head,那么循环条件while (fast && fast->next)直接不成立,返回false,逻辑上没问题。
4.2 单节点无环
只有一个节点且next指向 null,显然没有环,返回false。如果这个单节点的next指向它自己呢?那就是一个“自环”,快慢指针都能检测出来——fast = head,进入循环后快指针走两步(head->next->next),实际上就是原地head->next = head,也就是head->next->next仍然是head,而慢指针走一步到达head,循环判断slow == fast成立,返回true。
4.3 双节点自环
两个节点 A 和 B,A 指向 B,B 指向 A。快慢指针同步走,慢指针到 B,快指针先到 B 再回到 A,不会立刻相遇,但第二次循环必定碰上。这个案例用来验证步长为 2 的判定逻辑非常有效。
4.4 尾节点指向头节点
这种“整体成环”的情况,在 LeetCode 测试用例里标准形态就是pos = 0。快慢指针最终会在环内相遇,但相遇位置不是头节点,这一点很多人容易混淆:141 题只要判断有没有环,不需要知道环的入口在哪,但如果你用slow == fast的判断逻辑,一定会在某个节点相遇,而不是在某个“特定位置”。
4.5 长链进入环后快慢指针的相遇过程
假设链表前 100 个节点无环,第 101 个节点开始进入一个长度为 100 的环。慢指针需要走 100 步才能到环入口,此时快指针已经走了 200 步,也就是已经进入环内跑了 100 步(正好绕环一圈,在环入口附近)。之后两者相对速度为一个节点每步,最多 100 步内必追上。如果你自己写测试函数,建议打印出相遇时慢指针走的步数,可以直观地验证这个数学结论。
5. 常见报错与排查技巧:我从实际运行中踩过的坑
5.1 无限循环的罪魁祸首:没有检查 fast->next
新手最容易犯的错误是写成下面这样:
while (fast && fast->next) { slow = slow->next; fast = fast->next->next; if (slow == fast) return true; }这段代码看起来没问题,但如果你不小心把while条件写成了while (fast->next),那就直接完蛋了——当fast指向末尾节点时,fast->next为null,循环提前退出,检测结果可能漏判。而如果写成了while (fast),在无环链表上fast走到 null 后循环仍不退出,下一步访问fast->next直接空指针异常。这是我自己刷题时踩过最多次的坑,几乎所有报错都来自这里。
5.2 循环条件 vs 相遇判断的先后顺序
你在循环内先判断相遇还是先移动指针,结果完全一样,但如果你把相遇判断放在移动之前,初始状态slow == fast(都指向 head)时会直接返回true,也就是空链表无环却误判成有环。所以标准做法是:要么在循环内先判断再移动,要么初始化时让fast = head->next错开。第一种写法(先判断再移动)从逻辑上更自然,第二种写法(初始化错开)从流程上更高效,两者我都使用过,都工作正常,只是别搞混。
5.3 负数节点值的干扰
如果链表节点的值存在负数,并且你在哈希表解法中错误地使用节点的值作为判断依据(而不是节点引用),就会出问题。比如节点值全是-1,但你用“当前节点的值是否在集合中”来判断,那么所有节点都会被判定为重复节点,整个链表都会被误判成有环。正确的做法是用节点对象本身作为键,而不是节点值。
5.4 内存泄漏问题(C++ 环境)
用 C++ 刷题时,LeetCode 的环境会自动回收,问题不大。但如果在自己本地用裸指针写链表测试,环形链表会导致delete节点时无法按传统方式从头遍历到尾部。你自己造测试样例时,如果写了析构函数去delete每个节点,碰到环就会死循环。建议在测试代码里不要写析构函数,或者手动记录头节点地址后逐个delete,但要特别小心环的存在。
6. 进阶拓展:会了 141,后面这几道题你会学得更快
6.1 142. 环形链表 II:找环的入口
这是 141 的直接升级版。解题思路是在 141 的基础上,先让快慢指针相遇,然后把其中一个指针重新指向头节点,两者同时以相同速度前进,再次相遇的位置就是环的入口。这个结论有严格的数学推导支撑,在这里分享一个简洁的理解方式:设链表头到环入口距离为 a,环入口到相遇点距离为 b,相遇点继续走到环入口的距离为 c,那么快指针走的路程是慢指针的两倍,列出等式后可推出 a 等于若干倍环长减去 b,再结合指针同步移动,最终会在环入口相遇。
6.2 287. 寻找重复数
这道题可以转换成环形链表来解决:数组长度为 n+1,数值范围在 1 到 n 之间,把数值当作索引去访问下一个位置,就构建了一个链表式的结构,重复的数就是环的入口。这类“根号指针”或“龟兔赛跑”的转化思维,是 Hot100 里非常高阶的考点。
6.3 链表中点问题
快慢指针还有一个常见用途:找链表的中点。快指针速度是慢指针两倍,快指针到达末尾时慢指针刚好在中间。这类问题在 876. 链表的中间结点 出现过,也是同一个算法的变体。掌握了 141 的快慢指针后,遇到这类题你甚至不用思考——直接套模板。
6.4 判断两个链表是否相交
相交链表的经典解法是把两个链表头尾相连,然后转换成长度差问题,或者用双指针同步遍历。它与环形链表的关系在于:如果两个链表有相交,把其中一个链表的末尾连接到另一个链表的头部,就能形成一个环,从而用 141 的思路去检测。
7. 不断优化的心法:从解法到工程思维
你在刷 141 时如果真的深入思考了,会发现它背后其实是一个“如何用有限资源检测无限循环”的工程问题。判断链表有没有环,本质上是判断“某个操作是否进入了重复状态”——这和操作系统中检测死循环、数据库里检测循环外键、甚至网络拓扑中检测环路,都有相似的思想。
我在做服务端开发时,就遇到过类似的问题:一个配置系统里因为配置 A 指向 B、B 指向 C、C 又指回 A,结果在递归解析时直接栈溢出。当时我用的解决方案就是快慢指针的思想——维护一个“是否访问过”的集合,但被老板嫌空间占用太大,最后换成了“循环计数 + 最大深度”的方式。可见 O(1) 空间的判环方案不只是算法题里的标准答案,它在真实工程里也有落地价值。
从刷题的角度,我建议你做三件事:
- 第一,把哈希表解法和快慢指针解法都自己手写一遍,对比空间复杂度的差异,体会两种思路在不同约束下的取舍;
- 第二,把边界条件(空链表、单节点、双节点、长链大环)都写成测试用例,自己跑一遍;
- 第三,把快慢指针模板记牢,然后直接去做 142 和 287,你会发现原来复杂问题不过就是基础模板加一步推导。
最后再分享一个小技巧:写链表题的时候,我习惯在代码里先画一张节点示意图,哪怕只画三五个节点,也能避开大量空指针错误。尤其是快慢指针题,把 slow 和 fast 的位置画出来,每一步移动都对照着图检查,基本不会出错。这个习惯我从刷 LeetCode 一直保留到写生产代码,救过我好几次。