一、问题理解
问题描述
给定一个链表的头节点 head,返回链表开始入环的第一个节点。如果链表无环,则返回 null。
为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。
不允许修改链表。
示例
示例 1:
text
输入:head = [3,2,0,-4], pos = 1
输出:返回索引为 1 的链表节点
解释:链表中有一个环,其尾部连接到第二个节点。
(图:节点 -4 的 next 指向节点 2,环入口为节点 2)
示例 2:
text
输入:head = [1,2], pos = 0
输出:返回索引为 0 的链表节点
解释:链表中有一个环,其尾部连接到第一个节点,环入口为节点 1。
示例 3:
text
输入:head = [1], pos = -1
输出:返回 null
解释:链表中没有环。
要求
-
时间复杂度: O(n)
-
空间复杂度: 哈希表解法 O(n),快慢指针解法 O(1)
-
不允许修改链表
二、核心思路:找到环的入口
基本思想
在 LeetCode 141 中,我们学习了如何用快慢指针判断链表是否有环。本题是它的进阶,需要找出环的入口节点。
核心思路:
-
判断是否有环:使用快慢指针,如果相遇则说明有环。
-
找到入口 :当快慢指针相遇后,将其中一个指针(比如
slow)移回链表头,然后两个指针以相同速度(每次一步)前进,再次相遇的位置即为环的入口。
数学证明
为什么这样能找到入口?
假设:
-
链表头到环入口的距离为 a(节点数,从头节点出发,移动 a 步到达入口节点)
-
环入口到快慢指针相遇点的距离为 b
-
相遇点到环入口的距离(剩余环的长度)为 c,即环周长 L = b + c
当快慢指针相遇时:
-
慢指针走过的距离:a + b
-
快指针走过的距离:a + b + n * L,其中 n 是快指针在环内多绕的圈数(n ≥ 1)
由于快指针速度是慢指针的2倍,所以:
text
2(a + b) = a + b + n * L
=> a + b = n * L
=> a = n * L - b = (n-1) * L + (L - b) = (n-1) * L + c
因此,从相遇点继续前进 c 步(即到环入口)相当于从头节点出发走 a 步,两者在入口相遇。
所以,当相遇后,将慢指针放回头节点,然后两个指针以相同速度移动,它们会在入口处相遇。
三、代码逐行解析
方法一:快慢指针法(最优解,O(1) 空间)
Python 解法
python
# Definition for singly-linked list.
class ListNode:
def __init__(self, x):
self.val = x
self.next = None
class Solution:
def detectCycle(self, head: ListNode) -> ListNode:
# 1. 判断是否有环,并找到相遇点
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
# 有环,找入口
slow = head
while slow != fast:
slow = slow.next
fast = fast.next
return slow
# 无环
return None
Java 解法
java
/**
* Definition for singly-linked list.
* class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public ListNode detectCycle(ListNode head) {
// 1. 快慢指针找相遇点
ListNode slow = head;
ListNode 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;
}
}
方法二:哈希表法(使用额外空间)
Python 解法
python
class Solution:
def detectCycle(self, head: ListNode) -> ListNode:
seen = set()
curr = head
while curr:
if curr in seen:
return curr
seen.add(curr)
curr = curr.next
return None
Java 解法
java
public class Solution {
public ListNode detectCycle(ListNode head) {
Set<ListNode> seen = new HashSet<>();
ListNode curr = head;
while (curr != null) {
if (seen.contains(curr)) {
return curr;
}
seen.add(curr);
curr = curr.next;
}
return null;
}
}
四、Java 与 Python 语法对比
| 操作 | Java | Python |
|---|---|---|
| 链表节点定义 | class ListNode { int val; ListNode next; ListNode(int x) { val = x; } } |
class ListNode: def __init__(self, x): self.val = x; self.next = None |
| 空值判断 | node == null |
node is None |
| 集合/哈希表 | Set<ListNode> set = new HashSet<>(); set.add(node); set.contains(node); |
set_ = set() set_.add(node) node in set_ |
| 循环条件 | while (fast != null && fast.next != null) |
while fast and fast.next: |
| 返回节点 | return node; |
return node |
五、实例演示
示例:head = [3,2,0,-4],环入口为节点 2(索引 1)
链表结构:3 → 2 → 0 → -4 → 2
快慢指针法过程
第一步:找相遇点
| 步骤 | slow 位置 | fast 位置 | 说明 |
|---|---|---|---|
| 初始 | 3 | 3 | 同时出发 |
| 1 | 2 | 0 | slow 走一步到 2,fast 走两步到 0 |
| 2 | 0 | 2 | slow 到 0,fast 到 2 |
| 3 | -4 | -4 | slow 到 -4,fast 到 -4,相遇!此时 slow = fast = 节点 -4 |
第二步:找入口
-
将
slow移回head(节点 3) -
fast保持在相遇点(节点 -4) -
两者同步每次走一步:
-
第1步:slow 到 2,fast 到 2(因为 -4 的 next 是 2)
-
此时
slow == fast,返回该节点(节点 2),即为环入口。
-
哈希表法过程
遍历节点并存入集合,直到遇到重复节点:
-
访问 3,加入集合
-
访问 2,加入集合
-
访问 0,加入集合
-
访问 -4,加入集合
-
再次访问 2,发现已在集合中 → 返回当前节点 2
六、关键细节解析
1. 为什么快慢指针相遇后,将慢指针移回头部再同步移动就能找到入口?
数学证明已在第二部分给出。关键在于快慢指针相遇时,慢指针走的距离为 a+b,快指针走了 a+b+nL,且 2(a+b) = a+b+nL → a+b = nL → a = nL - b = (n-1)L + c。从头节点走 a 步与从相遇点走 c 步到达同一位置(环入口)。因此同步移动后会在入口相遇。
2. 快慢指针相遇时,快指针一定在环内多绕了至少一圈吗?
是的,因为快指针速度是慢指针的2倍,当慢指针进入环时,快指针已经在环内,之后快指针追上慢指针时,必然比慢指针多走至少一圈(n≥1)。
3. 如何处理无环情况?
在快慢指针过程中,如果 fast 或 fast.next 为 null,说明链表无环,直接返回 null。
4. 哈希表法中,节点对象作为键是否可靠?
在 Python 和 Java 中,对象默认基于内存地址哈希,因此可以唯一标识节点,可靠。
5. 为什么不允许修改链表?
如果允许修改,可以在遍历时给节点加标记(如将 next 指向自身),但本题明确不允许修改,因此只能使用不破坏原结构的解法。
七、复杂度分析
方法一:快慢指针法
-
时间复杂度: O(n)
-
找相遇点:最多 O(n)
-
找入口:最多 O(n)
-
-
空间复杂度: O(1),只使用了常数个指针。
方法二:哈希表法
-
时间复杂度: O(n),每个节点访问一次,集合查找 O(1)
-
空间复杂度: O(n),存储所有节点。
八、其他解法
解法三:数学计算法(类似快慢指针,但更数学化)
有些解法通过计算环的长度来辅助,但本质上与快慢指针相同。
解法四:递归标记法(破坏链表,不推荐)
递归遍历时标记节点,但会修改链表,不符合要求。
九、常见问题与解答
Q1: 快慢指针相遇后,为什么将 slow 移回头部,而不是 fast 移回头部?
两者都可以,只要一个移到头部,另一个保持在相遇点,然后同步移动。结果是一样的。
Q2: 如果链表很长,快慢指针会溢出吗?
不会,指针只存储地址。
Q3: 环入口证明中,a 和 b 的定义是否包括入口节点?
a 是从头节点到环入口节点所需的步数(移动次数),b 是从入口节点到相遇点所需的步数。这些定义在数学推导中是自洽的,不影响结果。
Q4: 能否用哈希表找到入口后,再释放内存?
不需要,只返回节点即可。
十、相关题目
1. LeetCode 141. 环形链表
判断是否有环,是本题的基础。
2. LeetCode 160. 相交链表
两个无环链表找交点,也可用双指针法,思路有相似之处。
3. LeetCode 287. 寻找重复数
将数组视为链表,可以转化为环形链表 II 问题,用快慢指针找重复数(入口)。
4. LeetCode 202. 快乐数
判断循环,也可用快慢指针。
十一、总结
核心要点
-
问题本质: 找到链表中环的入口节点。
-
最优解法: 快慢指针 + 数学推导,时间复杂度 O(n),空间复杂度 O(1)。
-
关键步骤:
-
用快慢指针找到相遇点。
-
将慢指针移回头部,然后与快指针同步移动,再次相遇即为入口。
-
算法步骤(快慢指针)
-
初始化
slow = fast = head。 -
循环条件:
fast != null且fast.next != null。-
slow走一步,fast走两步。 -
如果
slow == fast,则存在环,跳出循环进入步骤3;否则继续。
-
-
将
slow指向head,fast保持在相遇点。 -
同步移动(均每次一步),直到
slow == fast,该节点即为环入口。 -
如果步骤2循环正常结束,返回
null。
复杂度对比
| 解法 | 时间复杂度 | 空间复杂度 | 是否修改链表 |
|---|---|---|---|
| 快慢指针 | O(n) | O(1) | 否 |
| 哈希表 | O(n) | O(n) | 否 |
扩展思考
快慢指针不仅能判断是否有环,还能精确定位环的入口,体现了数学在算法中的美妙应用。掌握这个推导过程,可以帮助我们解决更多类似问题,如寻找重复数等。