环形链表这个题目,我在面试别人和准备面试的时候都反复遇到过。LeetCode上叫"Linked List Cycle II",也就是找环入口那道经典题,很多人会做但说不出所以然,能手动推一遍公式的更是少之又少。这次借"环形链表——成环节点判断"这个关键词,把这个知识点彻底拆开揉碎,从暴力解法到Floyd判圈,从数学推导到代码实现,一次讲透。
1. 成环节点判断的本质:环的检测和入口定位是两码事
先理清一个概念。很多人把"判断链表有没有环"和"找到成环节点"混为一谈,实际上这是两道题,难度差了不止一个档次。
"判断是否有环"只需要返回true或false,经典做法是Floyd判圈算法(快慢指针),一个走一步一个走两步,只要有环,两者必然相遇。这道题的AC率在LeetCode上超过40%,属于入门级。
"成环节点判断"则要求你返回环的起始节点。举个例子,链表从节点3开始进入环,节点3就是成环节点。这个要求一步到位,不仅要知道有没有环,还得精确指出环的入口在哪里。这道题的AC率在国内外平台统计中通常只有25%到30%左右,为什么低?因为大多数人只背了"相遇后从头再走"这个结论,却不知道这个结论成立的前提条件是什么。
再往深说一层,这道题考察的是人对链表结构空间关系的直觉。链表本身是线性结构,一旦出现环,就变成了非线性结构。面试官让你做这道题,通常不是真的为了让你处理一个内存泄漏排查问题,而是看你能不能从"快慢指针相遇"这个表象中提炼出数学上必然成立的约束条件,然后基于约束条件设计算法。
所以这篇文章围绕两个核心问题展开:
- 快慢指针为什么一定会相遇?相遇时停留的位置有什么特殊性质?
- 从相遇位置出发,凭什么可以肯定"从头节点再走一个指针"和"从相遇点继续走一个指针"最终会在环入口相遇?
很多人卡在第二个问题上。先记住结论:从头节点和相遇点各派一个指针,都是一次走一步,两者相遇的位置就是环的入口。 接下来重点解释这个结论怎么来的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 快慢指针的相遇逻辑:多走一圈的必然性
先讲判断环存在的基础。设置两个指针,slow每次移动一个节点,fast每次移动两个节点。如果链表无环,fast会率先到达链表尾部(fast或fast.next为null),判断结束。如果链表有环,fast会先进入环,slow后进入环,之后两者在环内展开追逐。
想象一个场景:fast已经在环里绕了若干圈,slow刚进入环的入口。此时两者都在环内,fast相对slow的速度差是1步(fast每次走2步,slow每次走1步,差值恰好是1)。这意味着每经过一个时间单位,fast和slow之间的距离就缩短1步。
关键问题来了:环的长度是有限的,假设环的长度为L。fast和slow在环内的初始距离记为d,d一定是一个小于等于L的值。由于fast每次追近1步,最多经过L个时间单位,两者必然相遇。这就是"多走一圈必然追到"的直觉:在一个封闭跑道上,只要后者速度更快,且两者都在跑道上绕圈,后者总能追上前者,只不过可能要多跑好几圈。
这里有一个很容易被忽视的细节:fast和slow相遇时,fast到底比slow多走了多少步?这个量不是固定的,它等于环长度的整数倍。这一点非常重要,后面推导成环节点位置时会用到。
用公式表达。假设链表头到环入口的距离为a,环入口到相遇点的距离为b,环的长度为L。当两者相遇时:
- slow走过的路程:a + b(在环内走了b步,且只走了b步,因为slow入环后第一圈内就会被追上)
- fast走过的路程:a + b + nL(n表示fast在环内比slow多绕的圈数,n至少为1)
由于fast的速度是slow的两倍,所以路程关系是:
2(a + b) = a + b + nL
化简后得到:
a + b = nL
即:
a = nL - b
这个公式是整道题的核心。它说明:从链表头到环入口的距离a,等于fast在环内比slow多跑的圈数乘以环周长,再减去相遇点沿环回到入口的距离b。
等一下,nL - b是什么?结合图形看:从相遇点沿环继续走到环入口的距离是L - b。所以a = nL - b又可以理解为:
a = (n - 1)L + (L - b)
这个式子的含义非常直观:从链表头出发到环入口的距离,等于fast绕了(n-1)整圈,再多走一段"从相遇点回到环入口"的距离。注意,无论n取几,(n-1)L都是整数圈,不影响位置;关键是那一小段L - b,它就是从相遇点继续走到环入口的剩余距离。
于是就有了经典的解法:找到相遇点后,把一个指针重新放到链表头(另一个指针保持在相遇点),两个指针都改为每次走1步。当头部指针走完距离a到达环入口时,相遇点那个指针恰好也走出L - b的距离,到达同一个位置——环入口。两者在此相遇。
这串推导是"成环节点判断"这道题最核心的思维工具。有了它,代码不过十几行;没有它,你写出来的代码不过是背下来的"魔术"。
3. 数学推导的边界条件:n的取值不能被忽略
刚才的推导里有一个n,表示fast比slow多绕的圈数。很多人在纸上推导时习惯直接取n=1,得出a = L - b,然后就开始写代码。这样理解是不完整的,虽然结论一样,但推导过程属于特例,禁不起面试官的追问。
n能不能等于0?不能。因为两者不可能在slow入环前相遇,fast必须比slow多跑至少一整圈才能追上slow。n的最小值是1。
n能不能等于2、3、4......?可以。假设链表很长,环很小,比如环长度L=3,链表头到环入口距离a=100。slow走完100步才入环,fast已经以2倍速度走了200步。入环瞬间,fast已经在环内绕了(200-100)/3 = 33圈多一点。这种情况下,fast追上slow需要的圈数n肯定不是1,而是比33大的某个整数。
所以不要被a + b = L这个特殊情况迷惑。完整公式是a + b = nL,n的取值取决于链表长度和环长度的比例。无论n取几,化简后的操作逻辑都是"从头节点和相遇点各放1个指针,每次走1步,相遇即入口",因为a = (n-1)L + (L-b),而(n-1)L在环内等价于回到原点。
理解这个边界条件的实际价值在于:写代码时你不会困惑"为什么头节点的指针走了那么久还没遇到相遇点的指针"。如果a远大于L,头节点指针需要走很久才能走完a这一段的距离,这是正常的,不要以为程序死循环了。
还有一个边界条件值得单独提:如果链表只有一个节点,且成环(节点自身指向自己),会怎样?slow进入环后走1步,fast走2步又从后面绕上来,两者会在入环处相遇。此时a=0,b=0,L=1,公式成立:a + b = 0 = nL中的n取0?不对,n取1时等式为0+0=1?这里要单独看。
这种情况要回到原始路程公式。slow走的路程是a + b = 0 + 0 = 0,这里说slow走了0步就相遇了?不对。仔细算:头节点就是环入口,slow从入口出发走1步,由于环长度为1,它会停在自己位置上;fast从入口走2步,等于在环上绕两圈,也回到自己位置。两者在第一步之后相遇了。所以实际上slow走了1步,fast走了2步,路程公式是2(1) = 1 + 1×1,也就是2 = 2,n=1成立。我之前说的slow走了a+b = 0+0 = 0是错的,因为此时b的定义是"相遇点在环上的偏移量",由于环入口和相遇点是同一个节点,b的取值应该是1(走了1步回到入口),而不是0。
这个小细节特别容易翻车。因为环的入口你画成一条线上一个点,觉得它的偏移量是0;但实际上这个点既属于直线部分也属于环,从环的视角看,它绕一整圈后回到自己,偏移量是L而不是0。
做题时遇到这种自环情况,直接用代码跑一遍就好了:
- 节点1指向自己
- slow和fast初始都在节点1
- 先移动:slow到节点1(走1步),fast到节点1(走2步,又回到原地)
- 两者相遇,返回节点1
代码逻辑自然处理了这个边界,不需要手动特判。
4. 代码实现的三个版本:从判环到定位入口的完整演进
分析完了原理,接下来是落地。我会给出几种实现方式,从最直接的Floyd推导版本,到哈希表版本,再到递归思路(虽然不推荐),方便不同基础的读者对照学习。
4.1 Floyd判圈标准实现(推荐)
这个版本使用两个指针,第一次循环找相遇点,第二次循环找入口。时间复杂度O(n),空间复杂度O(1)。
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def detectCycle(head: ListNode) -> ListNode:
slow = head
fast = head
# 第一次循环:判断是否有环,找到相遇点
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
# 第二次循环:从头节点和相遇点同步走
ptr1 = head
ptr2 = slow
while ptr1 != ptr2:
ptr1 = ptr1.next
ptr2 = ptr2.next
return ptr1
return None
这段代码的核心就是刚才推导的公式。第一次循环结束后,slow和fast在环内相遇,此时把其中一个指针放回链表头,然后两者每次走1步,第二次相遇的地方就是环入口。
测试几个场景:
- 无环链表[1,2,3,4]:fast率先到达链表末尾,返回None
- 环形链表[1,2,3,4],4指向2:第一次循环在节点3相遇(具体位置取决于链表结构),第二次从头和相遇点出发,最终在节点2相遇,返回节点2
- 自环节点[1],1指向自己:第一次循环在节点1相遇,第二次也立刻在节点1相遇,返回节点1
这段代码的鲁棒性很好,不需要对head为空的情况单独处理,因为while循环的条件fast and fast.next会覆盖空链表和单节点非环链表的情况。
4.2 哈希表版本:简单直观但空间占用高
这个版本思路朴素:遍历链表,用哈希集合记录访问过的节点,如果某个节点已经在集合中,说明该节点就是环的入口。
python复制def detectCycle(head: ListNode) -> ListNode:
visited = set()
cur = head
while cur:
if cur in visited:
return cur
visited.add(cur)
cur = cur.next
return None
这段代码为什么能正确找到环入口?因为链表是单向前进的,不存在"从另一个路径到达同一个非环节点"的可能。一旦某个节点被重复访问,唯一解释就是走到了环上,而且这个节点就是环的起始点。
哈希表版本的优点是理解成本低,代码几乎不会写错;缺点需要O(n)的额外空间。面试时如果面试官没有明确要求O(1)空间,这个版本可以作为"思路一"提出来,再引出Floyd版本作为优化。这样既展示了基础能力,又展示了优化意识。
4.3 完整验证代码:构造环形链表的测试方法
光有算法代码还不够,你得能构造出环形链表来测试。下面是Python中构造环形链表并验证结果的完整代码:
python复制def create_cycle_list(values, pos):
"""
构造环形链表
:param values: 节点值列表
:param pos: 链表尾部连接到第几个节点(从0开始),-1表示无环
:return: 链表头节点
"""
if not values:
return None
head = ListNode(values[0])
cur = head
node_list = [head] # 保存所有节点引用
for val in values[1:]:
node = ListNode(val)
cur.next = node
cur = node
node_list.append(node)
if pos >= 0:
cur.next = node_list[pos] # 成环
return head
# 测试用例1:标准环形链表,链表尾部连接到头节点的第三个节点
# 链表结构:1 -> 2 -> 3 -> 4 -> 5,然后5指回3,所以环入口是节点3
head1 = create_cycle_list([1, 2, 3, 4, 5], 2)
result1 = detectCycle(head1)
print(f"测试用例1入口节点值: {result1.val if result1 else None}") # 期望输出3
# 测试用例2:无环链表
head2 = create_cycle_list([1, 2, 3, 4, 5], -1)
result2 = detectCycle(head2)
print(f"测试用例2入口节点值: {result2.val if result2 else None}") # 期望输出None
# 测试用例3:自环节点,节点1指向自己
head3 = create_cycle_list([1], 0)
result3 = detectCycle(head3)
print(f"测试用例3入口节点值: {result3.val if result3 else None}") # 期望输出1
我实际跑这段代码的输出是:
code复制测试用例1入口节点值: 3
测试用例2入口节点值: None
测试用例3入口节点值: 1
三个用例全部通过。注意测试用例1中,链表尾部5指回节点3,这个"指回"动作就是创建环的关键。pos参数从0开始计数,所以pos=2对应节点3。
5. 成环节点的位置规律:为什么可以"从头再走一个指针"
很多人背下了解法,却从没细想过为什么第二次遍历时两个指针一个从头走、一个从相遇点走,就能精准定位到环入口。这一节把这个问题彻底讲明白。
用生活中的例子类比。想象一个圆形操场,你和朋友在某个直道上跑步。你在操场边上的起点处,朋友已经在操场内的某个位置。现在你们都开始跑,速度相同。你的朋友从当前位置跑向操场入口的距离是L - b(就是相遇点到环入口的距离),而你从起点跑向操场入口的距离是a。
数学推导告诉我们a = nL - b,等价的写法是a = (n-1)L + (L-b)。这意味着你跑到操场入口时,你朋友已经从相遇点出发,跑完了L-b这段距离,到了操场入口,并且在跑这段距离之前还多绕了(n-1)整圈。由于整圈结束会回到原位置,这个(n-1)圈不影响最终位置。
所以两者在操场入口处碰面,操场入口就是链表的成环节点。
这个位置规律非常优雅。它充分利用了环的周期性:任何长度的位移,在环上都可以分解为"整数圈 + 余数",整数圈不影响最终落点,只有余数决定位置。整个算法实际上是在利用这个性质做位置对齐。
在解释这个规律时,我建议画一张图:一条直线加一个圆,直线和圆在一点相切。从直线起点到切点的距离标为a,从切点沿着圆走一段到相遇点标记为b,从相遇点继续沿着圆走到切点标记为L-b。然后你就能直观地看到a和L-b的关系。一旦这个图画在脑子里,面试时即使紧张也能很快推导出答案。
需要特别提醒:环入口是"线段部分"和"环形部分"的交界点,它既属于直线也属于环,但做题时通常把它视作环的一部分。这个点在数学和代码中都是唯一的,不像相遇点可能在环上的任意位置。
6. 常见误区与边界情况:五个高频踩坑点
做题多了,我总结了几个高频错误。如果这道题被判错,大概率是以下问题。
6.1 误用哈希表版本却不注意节点对象比较方式
Python的set存储ListNode对象时,比较的是对象的内存地址(因为没重写__eq__),所以可以直接用。但如果你用的语言中,对象比较的是值而不是地址,就可能出问题。比如两个不同节点的值相同,却被哈希表误判为重复访问,导致返回错误节点。
在Java中,HashSet存储的是对象引用,也是地址比较,所以没问题。C++中如果存的是指针,也无所谓。真正要小心的是某些自定义数据结构或值类型语言。我的建议是做题时优先用Floyd版本,从根上避开这类语言差异。
6.2 判断环时使用fast.next.next导致空指针
经典的错误代码:
java复制while (fast.next != null && fast.next.next != null) {
...
}
这个条件在某些情况下会漏判。正确写法应该是:
java复制while (fast != null && fast.next != null) {
...
}
理解为什么:fast每次走两步,如果链表长度为奇数,fast会落在最后一个节点,此时fast.next为null,但fast本身不为null。如果用fast.next != null作为条件,会把这种情况判断为"链表无环"并退出,逻辑上没有错,但有环链表永远不会走到这步,因为fast会在环里打转。这里真正重要的是进入循环时不访问null引用。
6.3 第二次循环不把指针重置到head
有人写了第一段循环找相遇点后,直接从slow和fast在相遇点继续走,结果发现两个指针永远不相等(因为它们在相遇点同为同一个节点)。
第二次循环前必须把其中一个指针重置到head。这是新手最容易忽略的步骤,也是最关键的步骤。没有重置,第二次循环没有任何意义。
6.4 认为快慢指针在环入口相遇
这是概念性错误。快慢指针第一次相遇的位置不一定是环入口,它可以是环上任意一点,取决于链表头到环入口的距离a和环周长L的关系。大多数情况下,相遇点不是入口。如果有人认为fast总是在入口追上slow,就会对第二次循环的必要性产生疑惑。
我的推导中,第一次相遇地点恰好在入口的情况只在a是L的整数倍时才会发生。日常测试很少碰到这种特殊case。
6.5 对不可能出现环的链表做死循环调整
有的测试用例是空链表、单节点非环链表、长链表无环。这三种情况必须能在有限步内返回None。Floyd版本的while条件已经覆盖了这些情况。但如果你在第二次循环中写的是while True而不是while ptr1 != ptr2,就会在无环链表中死循环。不要这样做,务必确保第二次循环也能在无环情况下正常退出。
下表总结一下这些坑:
| 踩坑点 | 错误示例 | 正确做法 | 后果 |
|---|---|---|---|
| 循环条件 | fast.next.next直接访问 | 先判断fast和fast.next非空 | 空指针异常 |
| 指针重置 | 忘记重置head | 循环前ptr1=head | 死循环或错误结果 |
| 相遇位置误解 | 认为在环入口相遇 | 可能在环上任意位置 | 公式推导出错 |
| 哈希存储方式 | 值比较语言 | 确保引用比较 | 错误判断成环节点 |
| 无环终止条件 | 第二次循环while True | 使用while ptr1 != ptr2 | 死循环 |
7. 复杂度分析与面试答题策略
这道题的时间复杂度是O(n),空间复杂度是O(1)。这个分析不能只背结论,要能说出为什么。
时间复杂度:第一次循环中,slow进入环后最多走一圈就会被fast追上,所以第一次循环的时间复杂度是O(n),n为链表节点数。第二次循环中,指针从head走到环入口,最坏情况下环入口在链表末尾,需要走n步,所以也是O(n)。总体O(n)。
空间复杂度:只用了几个指针变量,没有额外数据结构,O(1)。
面试答题策略上,我建议按以下顺序组织回答:
- 先说暴力解:用哈希表记录访问过的节点,遇到重复就返回,空间O(n);
- 追问"能不能优化空间"后,引入Floyd判圈算法;
- 说清楚相遇条件:fast每次走2步,slow每次走1步,相对速度差为1,有环必然相遇;
- 推导a = nL - b,解释为什么第二次循环能找到入口;
- 给出代码并画图演示过程;
- 最后讲边界情况:无环、空链表、单节点自环。
这套回答的优点是逻辑链条完整,从暴力到优化,从直觉到数学证明,每一步都有理有据。面试官问深了也不慌,因为你真的掌握了原理而不是背代码。
另外再说一个实战技巧:拿到题目先别急着写代码,列出"输入可能是什么形态":
- 空链表
- 单节点非环
- 单节点自环
- 多节点无环
- 多节点有环(入口在开头、中间、结尾分别测试)
把这五种情况的输出预期先写出来,再着手写算法。这种方法表面上多花一分钟,实际上能帮你避免大量低级错误,而且面试官会觉得你的思维非常结构化。
8. 变体问题的拓展:如果题目改成这些条件你会做吗
成环节点判断这个知识点经常会出变体,面试时顺手考一下你的迁移能力。我盘点几个高频变体。
8.1 求环的长度
这个变体最简单。用Floyd找到相遇点后,保持slow不动,fast继续每次走1步,统计再次回到相遇点走的步数。第二次相遇时,fast绕环走了一圈,步数就是环的长度。
python复制def cycle_length(head: ListNode) -> int:
slow = head
fast = head
# 找相遇点
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
# 计算环长度
length = 1
fast = fast.next
while fast != slow:
fast = fast.next
length += 1
return length
return 0
这里有一个需要注意的地方:fast从相遇点每次走1步(不再是2步),否则可能在绕环过程中跳过slow。代码中fast初始为slow的下一个节点,此时两者必不同(除非环长度为1),每走一步length加1,直到fast走完一圈回到slow。
8.2 判断两个链表是否相交,并找出第一个相交节点
这也是经典题LeetCode 160。常见的解法是:把链表B的尾节点接到链表B的头节点,构成一个临时环,然后用本文的方法找环入口。找到的环入口就是两个链表的第一个相交节点。分析完成后把指针恢复原状。
用成环节点的思想解决链表相交问题是很有迁移价值的。相交链表的本质就是两个链表的"尾部共享段"构成了一个环——当你从链表A的头部出发经过相交段再走到链表B的头部时,就走了一个完整的环。
8.3 只判断是否有环,不要求找到入口
这种情况下甚至不需要第二次循环,代码更短:
python复制def has_cycle(head: ListNode) -> bool:
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False
8.4 找到环中节点总数和链表总长
已知环长度L,还知道a = nL - b,理论上可以推算出链表总长a + L。但n的具体值无法从指针移动中获得(因为n取决于a和L的比例),所以无法单纯靠一次Floyd运行精确推出总长。需要先求得环入口,再从head走到环入口统计a的长度,再累加L。这个问题有点深度,面试里能答出来基本就是加分项了。
8.5 为什么fast要走2步,不能走3步或4步
这是面试官最爱追问的问题之一。关键在于fast和slow速度差必须是1。如果fast每次走3步,相对速度差是2,fast可能在某个时间点直接跨越slow,导致"错过"相遇。尤其是在环长度很小的情况下,fast可能会反复跨越slow,却永远没有恰好重合的时刻。
严格数学证明:速度差为2时,fast和slow的距离每单位时间减少2;如果当前距离是奇数,减少2后依然是奇数,永远不可能减少到0。换句话说,只有速度差为1时,才能保证每次追击距离减少1,经过有限步后距离必为0。
那fast每次走2步就是最优解吗?实际上速度差为1即可,fast走3步slow走2步也可以,但那样slow不在环里转圈的时间变长,整体效率差一些。2步和1步的搭配是效率最优且证明最简洁的方案。
我能想到的变体基本就这些了。这几个变体如果全部做一遍,环形链表相关的面试题基本就全通了。
最后分享一个我在实际使用中发现的小技巧:不要在LeetCode的编辑器里直接写正式代码,先在草稿纸上画出链表结构,标注a、b、L三个量,写出a = nL - b这个公式,再开始动手写代码。做了这几步,代码的正确率会显著提升。因为这道题最大的难点不在于语法,而在于你是否真正理解指针移动的数学原理。把这个原理吃透了,以后遇到带环的图结构、带环的数组索引问题,你也会比别人多一层直觉。
