我第一次做这道题的时候,提交了三次才通过。不是思路不对,是边界条件没想清楚——第一次忘了处理空链表,第二次把快慢指针相遇的判断放在了移动之后导致死循环。后来我把这道题反复做了几遍,又把相关的变体题刷了一遍,才真正理解了环形链表这类题目的套路。这篇博文把我个人对141题的完整理解写出来,包括暴力解法的思维路径、快慢指针的数学原理、边界情况的测试方法,以及和它配套的进阶题思路。
无论你是刚开始刷题的新手,还是准备面试想巩固基础,这篇内容应该都能给你一些参考价值。
1. 先把题目读懂:链表中"环"到底是什么意思
1.1 链表基础与环形成的本质
单链表是每个节点只存一个next指针的数据结构。正常情况下列表遍历到最后一个节点时,它的next是None/null,遍历自然结束。环形链表则是某个节点的next指回了链表中之前出现过的某个节点,这样一来,因为每一个节点的next唯一,遍历时沿着指针走就会绕进一个循环区间,永远走不到头。
理解环的关键在于:环不是一个独立存在的物理结构,而是指针引用产生的逻辑循环。你无法通过观察节点的值来判断是否成环,因为重复的值不代表重复的节点;你也无法通过限制遍历次数来判断,因为一个足够长的链表和一个带环的链表都可能让你走很多步。判断标准只有一个:是否存在两个不同位置的节点,在指针链上构成了循环依赖。
1.2 这道题的输入条件与输出要求
141题给出的head参数是链表的头节点,返回值是布尔值。题目要求判断链表中是否有环,但有一个容易被忽略的前提:不要修改链表结构。这意味着你不能去标记节点、不能断开指针、不能改变next指向。原因很实际,在工程中,链表可能被其他模块共用,你为了判断是否有环就破坏它的结构,后果是灾难性的。
还有一个细节值得注意:题目中的pos参数是内部评测用的,表示尾节点连接到链表的第几个节点。在本地写代码时,需要自己构造带环的测试数据,而评测系统会按照pos去生成输入。理解pos的作用能帮助你构造出有效的测试用例,不至于手动建链时把环建错了自己还发现不了。
1.3 为什么面试官喜欢这道题
141题在面试中出现的频率很高。它看似简单,但考察点非常集中:候选人是否理解链表的物理结构,是否知道哈希表可以暴力判断,是否能够想到O(1)空间的做法,以及代码能否优雅地处理空指针。一个题目覆盖了数据结构理解、算法优化、防御式编程三个维度。很多候选人能写出哈希表解法,但被追问"不用额外空间怎么判断"时卡住,说明对算法的空间复杂度没有真正敏感。把141题的边界条件和证明过程吃透,比盲目刷十道简单题有价值得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 常规思路与哈希表解法:为什么它不够优雅
2.1 最简单的暴力思路
每个被遍历到的节点最多出现两次。所以,从head开始沿着next走,记录自己经过的所有节点;如果某一步遇到的节点已经出现在记录里,说明链表有环;如果走到null,说明链表无环。这个方法依赖哈希表或集合来存储节点引用,典型的空间换时间。
为什么这里必须用节点本身而不是节点的值作为记录的键?因为节点的值是可以重复的。两个值相同但地址不同的节点是合法存在的,如果按值判断会误判;而节点对象在Python中默认可按哈希计算(默认hash就是id),用id作为唯一标识就能准确区分每个节点。
2.2 哈希表解法代码参考
用Python写,代码非常简洁:
python复制class Solution:
def hasCycle(self, head: ListNode) -> bool:
seen = set()
cur = head
while cur:
if cur in seen:
return True
seen.add(cur)
cur = cur.next
return False
注意,判断cur in seen要放在add之前,顺序不能反。如果把节点先加入集合再判断,下一步遇到重复节点时虽然也能判断出来,但当链表只有一个节点且构成自环时,会出现逻辑混乱。严格写法就是在遍历到每个节点时先检查"我见过你吗",再登记"我现在见到你了"。
2.3 哈希表解法的两个致命问题
第一是空间复杂度O(n),n是链表节点个数。如果链表很长,哈希表占用的内存会线性增长。在面试场景下,面试官接下来一定会追问:"能不能不用额外空间?"
第二是工程层面的问题。哈希表依赖语言的哈希机制,极端情况下哈希碰撞会导致性能退化。更关键的是,链表这种结构在嵌入式、底层系统里往往不允许分配额外内存,你带着O(n)空间的解法去处理上百万节点的链表,程序可能直接被系统杀掉。
所以哈希表解法可以作为第一反应的答案,但绝不是最优答案。你需要一个只看指针、不占额外空间的方案。
3. 快慢指针解法的完整拆解:龟兔赛跑为什么能追上
3.1 Floyd判圈算法的直觉类比
想象一条环形跑道上有一只乌龟和一只兔子同时出发。兔子速度是乌龟的两倍。如果跑道是直的,兔子永远跑在乌龟前面,两者不会再相遇;但如果跑道是环形的,兔子速度比乌龟快,它一定会从后面追上乌龟——因为环形赛道上"前面"就是"后面",快的一方绕一圈总能套圈。
这个直觉放到链表里完全成立。慢指针每次走一步,快指针每次走两步。如果链表没有环,快指针会先到达null,循环结束;如果链表有环,两者必然会在某个节点相遇。
为什么?当慢指针进入环的口时,快指针已经在环里了。此时快慢指针之间存在一段距离(可能是0)。之后快指针每次比慢指针多走一步,相当于快指针以每次1步的相对速度逼近慢指针。环的长度是有限的,所以追赶步数最多等于环长减1,必定追上。
3.2 数学证明:为什么一定能够相遇
设链表头节点到环入口的距离为a,环的长度为b,快指针速度为慢指针的2倍。慢指针走到环入口时走了a步;进入环后继续走,假设走了x步时与快指针相遇,此时慢指针的总步数是a+x。
快指针此时的总步数是多少?它从头部出发,速度是慢的2倍,所以走了2(a+x)步。这个步数可以理解为:从头到环入口a步,然后在环内绕了若干圈后又走了x步到达相遇点。设快指针在环内完整绕了k圈(k≥1),则可列出等式:
2(a+x) = a + x + k·b
化简得到:
a + x = k·b
也就是说,a+x恰好是环长的整数倍。这个等式说明,只要存在环,就存在某个非负整数k使得等式成立;而快慢指针的运动过程本质上就是在不断调整x的取值,直到满足上述关系。这就保证了相遇一定发生。
证明过程中还有一个隐含条件值得说明:快指针速度取2,是用"相对速度"和"步数奇偶性"双重考量后的结果。取2时相对速度为1,追及过程最朴素,不需要考虑跨过慢指针的情况;取3甚至更大时,相对速度变快,但可能出现快指针直接越过慢指针而错过相遇的情况(需要做模运算才能判断),不便于实现。所以教科书和标准答案都采用速度2。
3.3 代码实现与逐行讲解
python复制class Solution:
def hasCycle(self, head: ListNode) -> bool:
slow = head
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
逐行拆解:
- slow和fast都从head出发,这是标准的"同步启动"写法。
- while fast and fast.next的含义是:只要快指针当前节点不为空且快指针的下一个节点不为空,循环就继续。为什么要同时检查fast和fast.next?因为循环体内执行fast = fast.next.next,如果fast.next本身为null,取fast.next.next就会抛出空指针异常。对于节点数为偶数的无环链表,最后一次循环时fast正好跳到null,此时fast为假,循环退出;对于节点数为奇数的无环链表,fast会停在最后一个节点,fast.next为null,循环同样退出。这两个条件合在一起,正好覆盖了无环链表的所有退出路径。
- slow = slow.next和fast = fast.next.next分别让两个指针前进。
- if slow is fast判断是否相遇。注意这里用的是is而不是==。在链表节点中,相等应该是地址级别的一致,is比较的就是地址。如果用==,在Python中ListNode对象默认没有实现eq方法,所以==默认也是比较地址,碰巧也能用;但语义上is更准确,也更符合其他语言的指针比较习惯。
3.4 一个常见的错误写法
有个很容易踩的坑,就是判断相遇的条件写在了移动之前:
python复制# 错误示范
while fast and fast.next:
if slow is fast:
return True
slow = slow.next
fast = fast.next.next
这段代码的问题在于:初始时slow和fast都指向head,两者本来就相等,导致循环第一次进入就返回True,完全没有执行移动。所以先判断is fast就误判为有环。正确写法必须是先移动、再判断,或者初始化时把fast设置为head.next。这个错误我在不同的人代码里见过至少三次,都属于顺序错误导致的逻辑bug,调试时又不容易发现,因为编译器不报错,只有运行结果不对。
顺带给出Java版本,方便对比:
java复制public class Solution {
public boolean hasCycle(ListNode head) {
if (head == null) return false;
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) return true;
}
return false;
}
}
Java中head为null时需要提前判空,因为fast.next在null上调用会抛NullPointerException。Python里则不需要这个判断,while循环直接跳过,返回False。
3.5 空间与时间复杂度
时间复杂度是O(n),为什么不是O(n²)?因为慢指针最多走完整个环再加一段直线距离,步数上限大约是a + b,不会超过总节点数的两倍;快指针步数是慢指针的两倍,也在线性范围。空间复杂度O(1),只用了两个指针。与哈希表相比,交换掉了额外的内存,而且没有哈希碰撞的风险,面对超长链表表现稳定。这是很多标准答案选择它的根本原因。
4. 边界情况与测试陷阱:那些让你红温的隐藏扣分点
4.1 不同类型的测试用例长什么样
空链表:head为null,直接返回False。这个最简单,但很多人忘了while循环里fast本身为假会自然退出,以为自己漏了判空,其实只要while条件写对,空链表天然被覆盖。
单节点链表,且指向null:节点只有一个,没有环。循环因为fast.next为null退出,返回False。这个用例能验证代码处理奇数长度链表的正确性。
单节点链表,且指向自己:这是一个自环。slow和fast都指向唯一的节点,第一次循环中slow先走一步到该节点,fast走两步也是该节点,is判断为True,正确返回True。注意fast.next.next在这里不报错,因为fast.next就是节点自身,不是null。
两个节点的环:head→node2→head。这是最基础的环结构。慢指针到node2时,快指针从head两步跳到head,两者不相遇;慢指针下一步回到head,快指针两步到node2;慢指针下一步到node2,快指针两步回到head。这个过程中两者可能在某个中间链节上错过吗?不会,因为相对速度为1,每一次相对位移都是1,不会跳过去。最终在某一步,两者会指向同一个节点。
更长的环上加一段"引线":比如5个节点,1→2→3→4→5→3。需要验证的是,快指针比慢指针早很多进入环,但它会持续绕圈,直到慢指针抵达环,然后在若干步内追上。这类用例是调试时最有效的,因为它同时考验起点距离、环长度的匹配。
4.2 尝试用脑图推演一次环内追及
我用一个具体例子来推演:链表长度为3的环,头部到环入口距离为2(即节点1→节点2→节点3→节点4→节点2)。
- 初始:slow = 节点1,fast = 节点1。
- 第1步:slow到节点2,fast到节点3。两者不等。
- 第2步:slow到节点3,fast到节点2。两者不等。
- 第3步:slow到节点4,fast到节点3。两者不等。
- 第4步:slow到节点2,fast到节点4。两者不等。
- 第5步:slow到节点3,fast到节点2。两者不等。
- 第6步:slow到节点4,fast到节点3。两者不等。
- 第7步:slow到节点2,fast到节点4。两者不等。
- 第8步:slow到节点3,fast到节点2。两者不等。
看起来似乎永远在错开?其实不是。我上面的推演漏了一个关键:相遇判断在每一步移动后立刻进行,而且快指针每次走两步在实现中是"两步作为一个原子操作"的,所以快指针不会在途中与慢指针对齐到同一节点。但按照上面的推演,似乎快指针总是落在慢指针的"上一步"位置,永远追不上?
这说明两点:第一,a和b的具体取值会影响"首次相遇"发生在哪一步,但最终一定会相遇(证明在前文);第二,我上面的推演在第5步开始进入了周期性错位,可能存在一个数学上的巧合。重新仔细推演,应该从第6步重新计算:
- 第4步结束时:slow = 节点2,fast = 节点4。
- 第5步:slow到节点3,fast到节点2。此时两者不等。
- 第6步:slow到节点4,fast到节点3。此时两者不等。
- 第7步:slow到节点2,fast到节点4。此时两者不等。
- 第8步:slow到节点3,fast到节点2。此时两者不等。
好吧,这个特殊例子似乎陷入了永恒错位,是我构造的数据有问题吗?重新用代数验证:a=2, b=3,慢指针到达环入口(节点2)时用了2步,快指针此时在节点3?不对——快指针是同步移动的,我们从第0步开始推:
- 第0步:slow=1, fast=1。
- 第1步:slow=2, fast=3。
- 第2步:slow=3, fast=2(节点2是环入口,快指针已在环内绕了一圈?不,第1步快指针在节点3,第2步走两步是3→4→2,确实回到了环入口)。
- 第3步:slow=4, fast=3(3→4→2,等等快指针从2开始走两步是3,不对,2的next是3:2→3→4,所以fast=4)。
- 第4步:slow=2(4的next是2),fast从4走两步是2→3→4?4的next是2,2的next是3,所以fast=3。slow=2, fast=3不等。
- 第5步:slow=3, fast从3走两步是4→2,fast=2。slow=3, fast=2不等。
- 第6步:slow=4, fast从2走两步是3→4,fast=4。slow=4, fast=4,相遇!
我之前的第4步推算出错:把fast在第4步算成了节点3,实际上fast在第4步应该从4走两步:4→2→3,也是节点3?让我重新算。第3步结束:slow=4,fast=4。哦,在第2步结束时fast=2,第3步fast从2走两步:2→3→4,所以第3步结束fast=4,同时slow从3走一步到4。第3步结束两者都是4!在第4步移动前判断已经相遇了,所以应该在第3步返回True。我前面那么长的推演全错,根子在于第2步的fast位置算错了。
这个插曲本身就是很好的教训:手动推演环形链表时,只要一步算错,后面全乱。这也是为什么我后来在本地写了一个可视化的小脚本,把每一步slow和fast的节点序号打印出来对比,比自己心算可靠得多。
4.3 如何用代码构造测试用例
用Python手写构造带环链表,需要定义节点类,然后手动把尾节点的next指向某个中间节点:
python复制class ListNode:
def __init__(self, x):
self.val = x
self.next = None
def build_list(arr, pos):
if not arr:
return None
nodes = [ListNode(x) for x in arr]
head = nodes[0]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
if pos >= 0:
nodes[-1].next = nodes[pos] # 尾节点指向第pos个节点,从0计数
return head
pos = -1表示无环,pos >= 0表示有环。这个构造函数的逻辑和评测系统保持一致,用同一份代码方便本地验证。
我习惯准备一组固定用例清单:
| 用例 | 输入 | 期望输出 | 目的 |
|---|---|---|---|
| 空链表 | head = None | False | 验证空指针 |
| 单节点无环 | [1], pos=-1 | False | 验证奇数链表退出 |
| 单节点自环 | [1], pos=0 | True | 最小环 |
| 两个节点成环 | [1,2], pos=0 | True | 基础环 |
| 多个节点有环 | [1,2,3,4], pos=1 | True | 引线段+环 |
| 长链表无环 | [1..10000], pos=-1 | False | 验证退出条件 |
把这组用例跑一遍,代码的健壮性就有底气了。光看逻辑对不算对,跑用例才能发现那些藏在角落里的bug。
5. 从141到142、287:环形链表题型的进阶路径
5.1 142环形链表II:不止判断,还要找到入口
141题的姐妹题是142环形链表II,要求返回环的入口节点,而不只是有没有环。解法在快慢指针相遇后,让一个指针从head重新出发,另一个指针保持在相遇点,两者都每次走一步,再次相遇时就是环的入口。
这个结论可以用代数证明:相遇时慢指针走了a+x步(a是头到环入口的距离,x是入环后到相遇点的距离),且a+x = k·b。当k=1时,a = b - x,意味着从头到环入口的距离等于从相遇点继续走(b-x)步到环入口的距离。所以让两个指针同速前进,它们会在环入口重逢。这背后的数学非常漂亮,推荐把142题做完后再回味一遍141题,会有"原来如此"的顿悟感。
5.2 287寻找重复数:思想迁移到数组
快慢指针的经典应用场景不止链表,在数组中同样适用。287题给定一个包含n+1个整数的数组,数字都在[1, n]范围内,至少存在一个重复数字,要求找出它。把数组索引和值的映射关系看成一种"链表"结构:从索引i跳到nums[i],因为值的范围与索引范围错位,必然存在环,环的入口就是重复值。这个题目能解出来的人不多,不是因为它难,而是因为大多数人没建立"数组可以抽象成链表"的思维模型。
思维模型的好处就是这样,一道题背后是同一个抽象结构,换了一层皮而已。141题是直接给你链表指针,287题需要你自己构建指针关系,本质都是在追踪"谁指向谁"的依赖网络。
5.3 快慢指针思想在其他数据结构的影子
环形缓冲区、循环队列、文件系统中的循环引用检测、分布式系统中的心跳检测,背后都有类似快慢指针的思想。比如循环队列判断空和满,两个指针前后追赶;父子进程的资源回收检测,多个引用彼此成环时需要标记清除法遍历判断环形引用。这类场景的共同点都是:在循环依赖的网络里,用两个运动速度不同的探针去探测结构是否存在回环。工程里"检测环形依赖"的需求比你想的常见得多,掌握这个思想的价值远超刷题本身。
6. 刷这道题时我总结的三条经验
分享几条我自己刷完141题之后的感触。
第一,先写哈希表再优化,这个顺序非常科学。不要一上来就逼自己想最优解,那样容易卡住。先把暴力解写出来跑通,再考虑空间优化,你会发现快慢指针的思路通常是在画图过程中自然浮现的——画出环、标出指针位置、模拟几步,答案就出来了。刷题的瓶颈往往不是知识储备,而是动手模拟的能力。
第二,边界条件的处理其实是核心考点。很多人误以为边界条件是"细心"就能搞定的,实际上它考察的是对代码执行流程的理解。while fast and fast.next这行条件,看起来多写了一判断,其实是防御式编程的缩影。养成每次写循环前问自己"这个循环什么时候退出、退出时指针状态是什么"的习惯,比记住某道题更有价值。
第三,本地测试用例一定要自己写。评测系统只能告诉你对错,不能告诉你怎么错。我用print打印每一步两个指针的位置,用几行脚本跑遍上面的用例清单,任何隐藏问题都会原形毕露。工具简陋没关系,关键是要有验证的思路。
总的来说,141环形链表是一个很容易"以为自己会了但实际没会"的题目。推荐刷完141之后,马上去做142和287,三题连做,把快慢指针的思维模型彻底固化下来。过两周回来再手写一遍,如果能一次通过,那才算真的掌握了。
