142. 环形链表 II:哈希集合与快慢指针详解
题目链接:142. 环形链表 II
算法思路
text
链表 / 访问记录 / HashSet / 快慢指针 / 环入口
141 题只需要判断:
text
链表是否有环?
本题还要继续找到:
text
如果有环,链表第一次进入环时到达的是哪个节点?
这个节点叫作环入口。
例如:
text
3 -> 2 -> 0 -> -4
^ |
|______________|
从头节点 3 出发,第一次进入环时到达节点 2,所以节点 2 是环入口。
题目中的 pos 只是 LeetCode 用来构造测试数据的说明,不是方法参数。方法只接收 head,并且要返回入口的 ListNode 节点对象。
1. 先从 141 题的本质继续思考
有环链表沿着 next 访问时,会不断重复访问节点:
text
3 -> 2 -> 0 -> -4 -> 2 -> 0 -> -4 -> ...
141 题中,只要发现"当前节点以前访问过",就可以判断有环。
142 题多了一步:
text
第一次重复访问到的节点,为什么就是环入口?
从 head 出发到环入口之前,是一段不重复的链:
text
head -> ... -> 环入口
进入环后,会沿着环继续走一整圈:
text
环入口 -> ... -> 环入口
走完一圈后,最先重新到达的节点一定是环入口本身。
因此:
text
从 head 出发遍历时,第一个已经访问过的节点,就是环入口。
这个结论直接得到最容易理解的 HashSet 解法。
2. HashSet 解法的完整过程
假设链表是:
text
3 -> 2 -> 0 -> -4
^ |
|______________|
初始状态:
text
cur = 3
visited = {}
依次访问:
text
访问节点 3:不在 visited,加入集合。
访问节点 2:不在 visited,加入集合。
访问节点 0:不在 visited,加入集合。
访问节点 -4:不在 visited,加入集合。
此时:
text
visited = {节点3, 节点2, 节点0, 节点-4}
节点 -4 的 next 指向节点 2,所以继续访问会回到节点 2:
text
访问节点 2:已经在 visited 中。
节点 2 是从 head 出发第一个被重复访问的节点,因此它就是入口,直接返回当前节点:
java
return cur;
3. 保存的是节点对象,不是节点值
应该写:
java
Set<ListNode> visited = new HashSet<>();
不能写:
java
Set<Integer> visited = new HashSet<>();
因为题目要找的是:
text
同一个节点对象是否再次出现。
不是:
text
某个 val 是否重复出现。
例如下面这条无环链表:
text
1 -> 2 -> 2 -> null
两个值为 2 的节点是两个不同节点对象。
text
节点 A(val = 2) != 节点 B(val = 2)
若存 val,第二次遇到 2 会错误地认为存在环;存 ListNode 本身才符合题意。
同样,返回入口时也应该返回节点:
java
return cur;
而不是返回节点值:
java
return cur.val;
4. Java 代码完整注释:HashSet 主解法
java
import java.util.HashSet;
import java.util.Set;
class Solution {
public ListNode detectCycle(ListNode head) {
// visited 保存已经访问过的节点对象。
// 保存 ListNode 本身,而不是节点值 val。
Set<ListNode> visited = new HashSet<>();
// cur 从头节点开始,沿 next 向后遍历。
ListNode cur = head;
while (cur != null) {
// 当前节点已经出现过。
// 从 head 出发第一个重复访问到的节点,就是环入口。
if (visited.contains(cur)) {
return cur;
}
// 第一次访问当前节点,记录它。
visited.add(cur);
// 继续访问下一个节点。
cur = cur.next;
}
// 能走到 null,说明链表无环,因此不存在入口。
return null;
}
}
5. HashSet 解法的复杂度
假设链表中有 n 个不同节点。
时间复杂度:
text
O(n)
每个不同节点最多访问一次;下一次进入已访问节点时立刻返回。
额外空间复杂度:
text
O(n)
最坏情况下,HashSet 需要保存所有节点。
它的优点是推理直接:
text
记录访问过的节点;第一次重复访问的节点就是入口。
6. 快慢指针:如何不用 HashSet 找入口
题目的进阶要求是使用 O(1) 额外空间,因此不能记录所有节点。
第一阶段使用 141 题的快慢指针:
java
ListNode slow = head;
ListNode fast = head;
text
slow 每轮走 1 步。
fast 每轮走 2 步。
如果无环,fast 会到达 null。
如果有环,fast 会在环内从后面追上 slow,两者相遇。
但要注意:
text
第一次相遇点不一定是环入口。
例如:
text
3 -> 2 -> 0 -> -4
^ |
|______________|
入口是 2,但快慢指针可能在 -4 相遇。
所以第一阶段的目标仅仅是:
text
确认有环,并拿到环内的一个相遇节点。
第二阶段:
text
让一个新指针 p 回到 head。
让 p 和 slow 每轮都走 1 步。
它们下一次相遇的位置,就是环入口。
7. 为什么第二次相遇是入口
先定义:
text
a:head 到环入口的距离
b:环入口到第一次相遇点的距离
L:环的长度
示意图:
text
head
|
| a
v
入口 ---- b ----> 相遇点
^ |
|----- L - b ------|
快慢指针第一次相遇时:
text
slow 走过的距离:a + b
fast 走过的距离:2(a + b)
fast 比 slow 多走的距离,全都是在环里多绕出的完整圈数。因此:
text
fast 距离 - slow 距离 = k * L
其中 k 表示多绕的整圈数。
代入上面的距离:
text
2(a + b) - (a + b) = k * L
a + b = k * L
a = k * L - b
k * L - b 的含义是:从相遇点向前走到入口,先走完剩余的 L - b,必要时再多绕若干整圈,走的总步数与 a 相同。
因此:
text
p 从 head 出发走 a 步,会到入口。
slow 从相遇点出发走 a 步,也会到入口。
两者每次走一步,就会在入口相遇。
这里最重要的推理链是:
text
快指针走的是慢指针的两倍
-> 两者的路程差是整圈长度的倍数
-> head 到入口的距离,等于相遇点绕环到入口的距离(模 L 意义下)
-> 一个从 head、一个从相遇点同步走,会在入口相遇。
8. 快慢指针完整推演
仍然使用:
text
3 -> 2 -> 0 -> -4
^ |
|______________|
第一阶段:在环内相遇
初始:
text
slow = 3
fast = 3
每轮:
text
slow 走 1 步。
fast 走 2 步。
经过若干轮,二者会在环内同一个节点相遇。这里可能在节点 -4 相遇:
text
3 -> 2 -> 0 -> -4
^ |
|______________|
^
slow、fast
此时只能说明有环;相遇点 -4 不是入口。
第二阶段:同步寻找入口
新建指针:
java
ListNode p = head;
此时:
text
p 在节点 3。
slow 在相遇点 -4。
让 p 和 slow 每次都走一步:
text
第 1 轮:p 到 2,slow 到 2。
它们在节点 2 相遇:
text
3 -> 2 -> 0 -> -4
^ |
|______________|
^
p、slow
所以返回节点 2。
9. Java 代码完整注释:快慢指针优化
java
class Solution {
public ListNode detectCycle(ListNode head) {
// slow 每轮走一步。
ListNode slow = head;
// fast 每轮走两步。
ListNode fast = head;
// 第一阶段:判断是否有环,并寻找环内相遇点。
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
// slow 和 fast 指向同一个节点对象,说明链表有环。
if (slow == fast) {
// 第二阶段:p 从 head 出发,slow 从相遇点出发。
// 两个指针都每轮走一步,再次相遇处就是环入口。
ListNode p = head;
while (p != slow) {
p = p.next;
slow = slow.next;
}
return p;
}
}
// fast 走到 null,说明链表无环。
return null;
}
}
快慢指针解法的复杂度:
text
时间复杂度:O(n)
额外空间复杂度:O(1)
10. 两种方法如何选择
| 方法 | 核心想法 | 时间复杂度 | 额外空间 | 学习阶段 |
|---|---|---|---|---|
HashSet |
第一个重复访问的节点就是入口 | O(n) |
O(n) |
先掌握,最直观 |
| 快慢指针 | 先在环内相遇,再同步走到入口 | O(n) |
O(1) |
进阶优化,重点理解距离关系 |
两种方法的共同本质是:
text
必须发现"链表沿 next 回到了旧节点"。
HashSet 直接记录旧节点;快慢指针不保存节点,而是通过不同速度让循环最终表现为"相遇"。
一句话记忆:
text
HashSet 中第一次重复访问的节点就是入口;快慢指针先相遇,再让一个指针回 head 同步走,第二次相遇就是入口。