news 2026/10/3 3:20:04

LeetCode 141 环形链表:快慢指针与Floyd判圈算法详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode 141 环形链表:快慢指针与Floyd判圈算法详解

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 False

Python 里特别要注意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 一直保留到写生产代码,救过我好几次。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 3:19:30

基于Spring Boot的竞赛管理系统开发全解:从数据库建模到权限控制

1. 项目核心需求拆解与整体设计思路1.1 竞赛管理究竟在解决什么问题做了这么多年开发&#xff0c;我见过太多高校里的竞赛组织方式还停留在“Excel表格 微信群 人工统计”的阶段。报名信息靠接龙&#xff0c;作品提交靠邮箱&#xff0c;评审打分靠纸质表格汇总&#xff0c;成…

作者头像 李华
网站建设 2026/10/3 3:19:10

UE5 C++根组件与TObjectPtr底层解析及Pawn实战

做 UE5 C 开发绕不过去的一个点&#xff0c;就是根组件。你在关卡里拖一个 Actor 到场景&#xff0c;看到它身上那一堆位置、旋转、缩放&#xff0c;底层其实全部落在AActor::RootComponent这一个成员变量上。UE5 源码里它的声明是TObjectPtr<USceneComponent> RootCompo…

作者头像 李华
网站建设 2026/10/3 3:19:03

异步消息队列实战:从聊天室理解解耦与削峰

一个看似简单的聊天室项目&#xff0c;背后其实藏着一整套关于并发、解耦和可靠性的学问。很多人一开始都会用最直接的方式去写&#xff1a;客户端发消息&#xff0c;服务器收到后直接转发给所有在线连接。这种做法在几十人、几百人的测试环境里跑起来没任何问题&#xff0c;可…

作者头像 李华
网站建设 2026/10/3 3:18:56

河道水质检测系统源码解析:基于SVM与遗传算法的机器视觉实现

简介&#xff1a;一份基于检测算法的河道水质检测系统毕业设计Python源码包&#xff0c;面向软件工程、计算机及环境监测等专业学生&#xff0c;覆盖水质数据采集、指标分析、阈值判断、异常识别与可视化展示&#xff0c;并体现需求分析、系统设计、编码测试等软件工程完整实践…

作者头像 李华
网站建设 2026/10/3 3:18:56

MCGS触摸屏Modbus RTU通讯调试全攻略:从接线到故障排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 3:18:53

自研小程序容器:从架构设计到上线排障的实战指南

最近一年我收到最多的私信类型&#xff0c;不是“某个组件怎么用”&#xff0c;而是“我们App里的小程序越来越多&#xff0c;要不要自研一套容器”。问的人多了&#xff0c;我开始意识到一个趋势&#xff1a;当团队业务发展到一定规模&#xff0c;市面上现成的跨端方案已经填不…

作者头像 李华