
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) { SetListNode 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 和 BA 指向 BB 指向 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. 寻找重复数这道题可以转换成环形链表来解决数组长度为 n1数值范围在 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 一直保留到写生产代码救过我好几次。