刷 LeetCode 的链表专题时,142. 环形链表 II 是一道绕不开的题。题目本身只有一句话:给定一个链表的头节点 head,返回链表开始入环的第一个节点;如果链表无环,则返回 null。但就是这么一道题,很多人会卡在“为什么快慢指针相遇后,把一个指针放回 head,再走一次就能找到入口”这个点上。今天我不打算只贴答案,而是把判断有环、找入口、数学证明、边界条件和面试变形一次讲完,适合刚接触链表的读者,也适合准备面试时想快速理清思路的人。
1. 拿到题目后先拆需求,避免被“环入口”三个字绕晕
1.1 题目到底在说什么:从“判断有环”到“找到入口”
“环形链表”这四个字,其实包含两个层次的问题。第一层是判断链表里有没有环,这是 LeetCode 141 题;第二层是如果有环,找到环的入口节点,也就是 142 题。
很多人一开始会把“判断有环”和“找到入口”混在一起,觉得只要遍历时发现某个节点重复出现,那它就是入口。这个想法本身没错,哈希表就是这么做的。但面试官经常要求的 O(1) 空间解法则需要另一种思路:先用快慢指针确定有环,再用一次数学上可证明的“第二次相遇”来定位入口。
我建议拿到题目后先在纸上画链表。比如一个链表 1 -> 2 -> 3 -> 4 -> 2,尾节点 4 的 next 指向 2,那么环入口就是 2。如果只是判断“有没有环”,快慢指针相遇就够了;但要返回节点 2,直接返回相遇点是错的,因为第一次相遇点很可能不在入口。把这两个目标分开,思路会清晰很多。
1.2 哈希表与快慢指针,两种解法的直觉对比
哈希表法非常符合人的直觉:从 head 开始,一个一个把节点放进集合,如果遇到某个节点已经在集合里,说明它就是入口。它的好处是代码短、不容易出错,坏处是需要 O(n) 的额外空间。在算法题里,空间限制经常成为考察点,所以哈希表解法一般是作为“最自然但不够优”的答案出现。
快慢指针法则把空间复杂度压到了 O(1),但理解成本更高。首次相遇时,慢指针走一步,快指针走两步;如果没有环,快指针会先走到空节点;如果有环,快指针最终会从后面追上慢指针。相遇之后,把一个指针放回 head,另一个留在相遇点,两个指针都改为每次走一步,它们再次相遇的节点就是环入口。
这个“第二次相遇就是入口”的结论,很多人背下来了但不知道原因。我后面会做完整推导。你只需要先记住:快慢指针不是“快的人先到”,而是“在环里追上慢的人”,所以相遇点一定在环内,但不一定是入口。
1.3 为什么快慢指针是经典解法?先记住两个关键结论
快慢指针之所以经典,原因有两个。
第一,它只用两个指针,空间复杂度是 O(1)。第二,它的时间复杂度是 O(n),线性时间内就能完成检测和定位。面试中谈到链表环问题,快慢指针几乎是标准答案,所以你必须掌握。
实现前先记住两个关键结论:一,慢指针入环后,在走完一圈之前一定会被快指针追上。二,第一次相遇后,把其中一个指针放回 head,两者同时以相同速度走,第二次相遇点就是环入口。第一个结论保证了算法能终止,第二个结论提供了找入口的方法。这两个结论听上去有点“神奇”,但它们的背后是一个简单而优美的数学关系。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心实现:Python 和 Java 各一份,代码逐行讲解
2.1 先用快慢指针完成有环检测
第一步是判断链表有没有环。先说判断有环的代码,因为它也是 142 题的前半段:
python复制def has_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
这里有两个细节值得注意。第一,循环条件为什么是 while fast and fast.next?因为 fast 每次要往后跳两步,如果 fast 为 None,那么 fast.next 会直接报错;如果 fast.next 为 None,那么 fast.next.next 也会报错。所以必须同时确认 fast 和 fast.next 都存在。
第二,slow is fast 表示两个指针指向同一个节点对象。在 Python 里,链表节点是对象,用 is 比较引用更准确。在 Java 里就直接用 ==。当两个指针相遇,说明链表存在环。
如果链表无环,比如一个普通的三节点链表,fast 会先走到 None,循环退出,返回 False。这个过程很直观:一条路走到头都没追上,自然就没有环。
2.2 第二次遍历如何找到环入口
当 2.1 的循环因为 slow is fast 而 break 时,说明有环。此时 slow 或 fast 都停在第一次相遇点。下一步:
python复制slow = head
while slow is not fast:
slow = slow.next
fast = fast.next
return slow
短短几行,背后的逻辑却非常关键。把 slow 放回 head,fast 留在相遇点,然后两个指针每次都走一步。它们最终会在环入口相遇,然后返回这个入口节点。
这段代码为什么能成立?我在第 3 节会做完整推导。现在先强调一点:第二次循环里,两个指针的速度是相同的,都是每次一步。如果你还是让 fast 一次走两步,那第二次相遇点就不一定是入口。这一点很多人会写错,我面试时见过好几次。
2.3 两个语言版本的完整代码与注释
完整 Python 版本如下:
python复制class ListNode:
def __init__(self, x):
self.val = x
self.next = None
class Solution:
def detectCycle(self, head: ListNode) -> ListNode:
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
# 第一次相遇,说明有环
slow = head
while slow is not fast:
slow = slow.next
fast = fast.next
return slow # 第二次相遇点就是入口
return None
Java 版本:
java复制public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
slow = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow;
}
}
return null;
}
}
这段代码放在 LeetCode 上可以直接通过。你可能会觉得它太短了,短到不像是一道中等题。但正是这种“代码很短、证明很长”的题目,才能考察出你是真懂还是背了答案。所以接下来我花大篇幅讲推导。
3. 数学推导与高频坑位:弄懂证明才能不被追问难倒
3.1 一个简单等式,证明“重置后相遇点就是入口”
把链表分成两部分看:head 到环入口的距离记为 a;从环入口沿着环走到第一次相遇点的距离记为 b;环的总长度记为 L,那么从相遇点继续走回到环入口的距离是 L - b,记为 c。
假设两个指针从 head 同时出发,慢指针速度为 1,快指针速度为 2。当它们第一次相遇时,慢指针总共走了 s 步,快指针走了 f 步。由于快指针速度是慢指针的两倍,有:
f = 2s
又因为快指针比慢指针多跑了 n 整圈,n 是一个正整数,所以有:
f = s + nL
两个式子联立:
s = nL
慢指针走过的路程,等于 head 到入口的距离 a,加上入口到相遇点的距离 b,所以:
a + b = nL
把 L 写成 b + c,得到:
a + b = n(b + c) = (n - 1)(b + c) + (b + c)
两边同时消去 b:
a = (n - 1)L + c
这个等式是关键。它说的是:从 head 走到入口的距离 a,等于从相遇点走到入口的距离 c,再加上整数圈 (n - 1)L。也就是说,如果一个指针从 head 出发,另一个指针从相遇点出发,两者速度相同,它们一定会在入口处同时到达。因为走整圈不改变相对位置,真正起作用的是那 c 步。
这也就解释了为什么第二次循环里两个指针必须同速:只有同速,才能在 a 和 (n-1)L + c 相等的时候相遇。如果速度不同,相遇点就不是入口了。
3.2 坑位一:快指针不要一次走三步
面试时经常有人问:快指针为什么不能一次走三步?不是更快吗?
快指针一次走两步时,相对慢指针的速度是 1 步/次。无论两个指针在环内距离多远,每走一次,距离就会缩小 1,所以迟早会相遇。如果快指针一次走三步,相对速度是 2 步/次,那么当两个指针之间的距离为奇数时,每轮会把距离减少 2,永远不能变成 0,也就可能永远交错下去。
所以,“快慢指针”的“快”不是越快越好,而是要保证相对速度为 1。这是这道题的一个隐藏考点,也是面试官爱挖的细节。以后你再看到有人用三倍速快指针,可以提醒他:“环内距离的奇偶性会让你一直追不上。”这个结论在数学上可以严格证明,这里就不展开了,但你要有这个意识。
3.3 坑位二:循环条件怎么写才不容易空指针
第二个高频坑是空指针。我第一次写这道题时,循环条件写成了 while fast.next and fast.next.next,结果 head 为 None 时直接报错。后来我习惯写成:
python复制while fast and fast.next:
这样能同时覆盖空链表、单节点无环、单节点自环等边界情况。如果 fast 是 None,循环不进入;如果 fast.next 是 None,循环也不进入。只有存在至少两个节点时才会继续走。
类似的坑出现在 Java 里:if (fast != null && fast.next != null) 和 while (fast != null && fast.next != null) 都要把 fast 本身放在前面判断。顺序写反,同样会在 fast 为 None 时访问 fast.next。很多新手在本地测试时没有构造空链表,就漏掉了这个边界,等到线上跑才发现。
3.4 四组边界测试用例,覆盖所有情况
我自己刷题时会准备四组测试用例,确保代码不会被边界打趴:
- 空链表:
head = None,应该返回 None。这时while fast and fast.next条件直接不成立,返回 None。 - 单个节点且无环:
[1],同样返回 None。 - 单个节点且自成环:
1.next = 1,应该返回节点 1。快慢指针在第一次循环中就会相遇,然后第二次遍历也能正常找到入口。 - 正常带环链表:比如
1 -> 2 -> 3 -> 4 -> 2,入口是 2,返回值应该是节点 2。
如果你在 LeetCode 上测试,第三组用例需要自己构造;在本地调试时,可以写一个工具函数:
python复制def make_cycle(arr, pos):
head = ListNode(arr[0]) if arr else None
cur = head
nodes = []
for i, val in enumerate(arr):
node = ListNode(val)
if head is None:
head = node
else:
cur.next = node
cur = node
nodes.append(node)
if pos >= 0:
cur.next = nodes[pos]
return head
有了这个工具,后面调试任何链表环相关题目都会方便很多。
4. 面试追问与变形题:把一道题变成一串题
4.1 什么时候可以偷懒用哈希表
虽然快慢指针是标准答案,但哈希表解法在实际业务场景中有它的价值。如果链表来自某个不可控的外部数据源,你只是想快速确认有没有环,或者需要记录所有访问过的节点以便后续处理,那哈希表往往更直接。
python复制def detectCycleWithSet(head):
seen = set()
cur = head
while cur:
if cur in seen:
return cur
seen.add(cur)
cur = cur.next
return None
这段代码的时间复杂度同样是 O(n),但空间复杂度是 O(n)。面试时如果先给出哈希表解法,一定要立刻补一句“但这不是最优解,我还可以用快慢指针做到 O(1) 空间”。这样既展示了你思路的广度,也避免了被面试官认为只会暴力解。
需要注意,Python 的 set 要求节点可哈希。如果你自定义的 ListNode 重写了 __eq__ 却没有重写 __hash__,放进 set 会报错。我见过不止一个候选人在这个细节上翻车。
4.2 链表的进阶问题:环长度、删除环、找前驱节点
面试官经常在 142 题基础上追加几个问题。
第一个是“如何求环的长度”。做法很简单:第一次相遇后,让一个指针停在相遇点,另一个指针继续每次走一步,再次回到相遇点时,走过的步数就是环的长度。环的长度不一定等于第一次相遇时快指针比慢指针多走的圈数,所以需要单独测。
第二个是“如何删除环,让链表恢复为普通链表”。先找到环入口,再找到入口节点的前驱,把前驱的 next 置为 None。找前驱不能直接用快慢指针得到,需要在第二次遍历时用一个 prev 变量记录 fast 的上一个节点。当 slow == fast 时,prev 就是入口前驱。
第三个是“如果链表入口恰好是头节点怎么办”。比如 1 -> 2 -> 3 -> 1,入口就是 head。这种情况下第二次遍历中 slow 从 head 出发,fast 绕一圈后也会回到 head,两者立刻相遇,返回 head 即可。没有特殊处理,代码自然支持。
4.3 链表环检测在实际系统里的应用场景
很多人觉得链表判环只是面试题,但在实际工程里,环形结构并不罕见。
比如操作系统中的空闲内存块链表,如果某个节点的 next 指针因为内存越界被写坏,链表可能变成环。此时遍历分配器就会陷入死循环,系统卡死。检测环的存在,能帮助我们快速判断数据结构是否被破坏。
再比如序列化一个对象图,如果对象之间有循环引用,直接用递归遍历会栈溢出。这时候需要记录已经访问过的节点,实际上和哈希表判环的思路一样。很多 JSON 序列化库在处理循环引用时,都会用一个 visited 集合来避免无限递归。
所以,142 题训练的不仅是“链表上快慢指针”的小技巧,更是一种在指针结构中安全导航的底层思维。想通这一点,刷题就不会觉得只是在应付面试。
5. 调试方法与刷题建议:从 AC 到真正掌握
5.1 用日志打印指针轨迹,直观感受相遇过程
如果你对快慢指针的运行过程没有感觉,我强烈建议在本地写一个带日志的版本。每次循环打印两个指针当前的值和步数:
python复制def debug_detect(head):
slow = fast = head
step = 0
while fast and fast.next:
slow = slow.next
fast = fast.next.next
step += 1
print(f"step={step}, slow={slow.val}, fast={fast.val}")
if slow is fast:
print("meet at", slow.val)
break
然后构造一个 1 -> 2 -> 3 -> 4 -> 5 -> 3 的链表,运行后你会看到 slow 进入环后,fast 一圈圈地从后面追上来,最后在某一点相遇。这个动态过程比任何静态解释都更容易让人记住。
我在给朋友讲这道题时,总要他们至少跑一次日志。跑完之后,很多人对“相遇点一定在环内”有了直观理解,再回头看数学推导就不觉得抽象了。
5.2 我踩过的三个低级错误,你不必再踩
第一个错误:第二次遍历时忘了把指针重置回 head。曾经我以为第一次相遇的 fast 就是入口,结果返回的是相遇点,自然不对。要记住,入口不是相遇点,必须经过第二次遍历。
第二个错误:第二次遍历时让 fast 每次走两步。相遇条件完全乱掉,入口也找不对。第二次遍历两个指针必须同速,不能用原来的快指针速度。
第三个错误:在第一个 while 里先判断 slow is fast,再更新指针。如果你在更新之前判断,两个指针都还在 head,会误判为有环。正确顺序是先更新,再判断是否相遇。
这些错误虽然低级,但很真实,写代码的时候一不小心就踩。建议每次提交前检查一遍三个关键点:更新顺序、重置指针、同速前进。
5.3 时间复杂度与空间复杂度的严谨分析
时间复杂度上,无环时快指针最多遍历整个链表,O(n)。有环时,慢指针从 head 到入环口需要走 a 步,入环后到第一次相遇最多走不到一圈,即最多 L-1 步,所以第一阶段是 O(a + L) = O(n)。第二阶段从 head 和相遇点同时出发,最多走 a 步就到入口,也是 O(n)。两个阶段相加,总体 O(n)。
空间复杂度就简单了,全程只有两个指针,所以是 O(1)。
面试时如果被追问“为什么慢指针不会在环里走超过一圈”,可以用一个简单的论证:假设慢指针刚进入环时,快指针在环内距离它有 d 步(0 < d < L),快指针相对慢指针的速度是 1,所以最多 d 步后就能追上,而 d 最大是 L-1,所以慢指针在相遇前走的路程不超过 L-1,也就是不到一圈。这一点能答上来,面试官通常会觉得你理解很到位。
5.4 刷完 142 之后,建议按这个顺序继续练
如果你刚 AC 了 142,下一步不应该急着刷别的,而是按这三个方向巩固。
第一,重做 141 环形链表,要求自己不用哈希表,只用快慢指针写出来。第二,做 287 寻找重复数,这道题可以将数组下标和值映射成链表,再用快慢指针找环入口,解法极妙。第三,做 160 相交链表,它和找环入口有相似之处,但解法完全不同,能帮你区分“同一指针”和“同一节点值”的概念。
另外,建议自己写一个带环链表构造工具,就像我在 3.4 节给出的 make_cycle。以后遇到任何需要构造环的题目,都能直接复用。我至今在本地环境里还留着这个工具函数,调试其他链表问题时也经常用到。
写到这里,我已经把环形链表 II 从代码到证明、从边界到扩展都过了一遍。这道题最值得学习的地方,不是那 12 行代码,而是“看似魔法的操作背后一定有数学原理”这样的思维方式。画图、推导、打印日志,把每一步都理解透,再遇到类似的环形结构问题,你就能真正举一反三。
