一、题目
给定一个链表的头节点 head,如果链表中存在环,要求返回:
环开始的第一个节点
如果不存在环,则返回:
null
例如:
1 → 2 → 3 → 4
↑ ↓
← ← ←
这里:
3
就是环入口。
二、我的第一反应:HashSet
这道题我第一反应是:
遍历链表,把访问过的节点放进 HashSet,如果某个节点第二次出现,那么这个节点就是环入口。
这个思路是完全正确的。
代码:
public class Solution {
public ListNode detectCycle(ListNode head) {
Set<ListNode> set = new HashSet<>();
ListNode p = head;
while (p != null) {
if (set.contains(p)) {
return p;
}
set.add(p);
p = p.next;
}
return null;
}
}
三、为什么 HashSet 可以找到环入口?
例如:
1 → 2 → 3 → 4
↑ ↓
← ← ←
实际遍历顺序:
1
2
3
4
3
4
3
...
第一次访问:
1
2
3
4
都会加入:
Set
当第二次回到:
3
时:
set.contains(p)
第一次变成:
true
所以:
return p;
此时返回的正好就是:
环入口 3
因此:
第一次重复访问到的节点,就是环入口。
四、为什么存的是 ListNode,而不是节点值?
一定要写:
Set<ListNode>
而不是:
Set<Integer>
因为链表里完全可能出现相同的值。
例如:
1 → 2 → 1 → 3 → null
两个节点的:
val
都是:
1
但这并不代表链表有环。
我们真正判断的是:
是否再次访问到了同一个节点对象。
所以:
set.contains(p)
判断的是节点对象。
同样,在链表题里:
p1 == p2
也是在判断两个引用是不是指向同一个节点。
五、HashSet 解法复杂度
假设链表一共有:
n
个不同节点。
每个节点最多访问一次,HashSet 的 contains() 和 add() 平均是:
O(1)
因此时间复杂度:
O(n)
但最坏情况下需要把所有节点都存入 HashSet:
O(n)
所以:
时间复杂度:O(n)
空间复杂度:O(n)
六、HashSet 版本还能稍微简化
原代码:
if (set.contains(p)) {
return p;
}
set.add(p);
其实 HashSet 的:
add()
本身有返回值。
如果元素第一次加入:
true
如果元素已经存在:
false
所以可以写:
java
public class Solution {
public ListNode detectCycle(ListNode head) {
Set<ListNode> set = new HashSet<>();
while (head != null) {
if (!set.add(head)) {
return head;
}
head = head.next;
}
return null;
}
}
不过面试时如果我更熟悉:
contains + add
那我宁愿写前一个版本。
因为:
面试优先保证正确、稳定、好解释,而不是强行追求代码最短。
七、面试最优解:Floyd 快慢指针
HashSet 已经可以解决问题。
但是这题还有一个经典的:
Floyd 判圈算法
可以把空间复杂度从:
O(n)
优化为:
O(1)
第一阶段和「141. 环形链表」完全一样:
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
// 有环
}
}
其中:
slow 每次走 1 步
fast 每次走 2 步
如果没有环:
fast 最终走到 null
如果有环:
fast 和 slow 最终一定相遇
八、141 和 142 的区别
这两个题最好一起记。
141 环形链表
只需要判断:
有没有环
所以:
if (slow == fast) {
return true;
}
即可。
142 环形链表 II
还要求:
找到环入口
所以第一次:
slow == fast
以后不能直接返回。
还需要第二阶段:
一个指针回到 head
两个指针同时每次走一步
再次相遇的位置就是环入口
可以记成:
141:
快慢相遇
→ 有环
142:
快慢相遇
→ 一个回 head
→ 两个都一步一步走
→ 再次相遇
→ 环入口
九、Floyd 完整代码
java
public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode slow = head;
ListNode fast = head;
// 第一阶段:判断是否有环
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
// 第二阶段:寻找环入口
ListNode p = head;
while (p != slow) {
p = p.next;
slow = slow.next;
}
return p;
}
}
return null;
}
}
十、为什么第一次相遇只能说明"有环"?
因为:
slow == fast
代表:
快指针在环里追上了慢指针。
但这个位置不一定是:
环入口
例如:
head → ... → 环入口 → A → B → C
↑ ↓
← ← ← ← ←
快慢指针可能在:
B
第一次相遇。
所以第一次相遇只能证明:
存在环
不能直接:
return slow;
十一、为什么一个指针回 head,再一起走,就会在环入口相遇?
这是 Floyd 最经典的一部分。
设:
head 到环入口距离 = x
环入口到第一次相遇点的距离:
y
环长:
C
可以画成:
head -------- 环入口 -------- 相遇点
x y
慢指针第一次相遇时走过:
x + y
因为 fast 速度是 slow 的两倍:
fast 距离 = 2(x + y)
fast 和 slow 最终位于同一个节点,说明 fast 比 slow 多走的是整数圈:
2(x + y) - (x + y) = kC
所以:
x + y = kC
于是:
x = kC - y
也可以写成:
x = (k - 1)C + (C - y)
其中:
C - y
就是:
从第一次相遇点继续走到环入口的距离。
所以:
head 到环入口的距离
和:
第一次相遇点到环入口的距离
在环上是等价的。
因此让:
p 从 head 出发
slow 从第一次相遇点出发
并且:
两个人每次都走一步
最终一定会在:
环入口
相遇。
十二、但面试前不一定需要死背完整数学证明
Floyd 的证明理解一次即可。
如果临近面试,真正需要记的是这个口诀:
第一阶段:
slow 1 步
fast 2 步
相遇
→ 有环
第二阶段:
一个指针回 head
然后:
两个都 1 步
再次相遇
→ 环入口
对应代码骨架:
java
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
ListNode p = head;
while (p != slow) {
p = p.next;
slow = slow.next;
}
return p;
}
}
return null;
十三、如果现在只能记住 HashSet 怎么办?
这是我这次学习中比较实际的一个问题。
答案是:
完全可以先把 HashSet 作为保底解写稳。
因为 HashSet:
不是错误解
不是暴力超时解
不是不可接受的解法
它仍然是:
时间 O(n)
只是用了:
空间 O(n)
面试时如果第一时间只记得 HashSet,可以先写:
java
Set<ListNode> set = new HashSet<>();
ListNode p = head;
while (p != null) {
if (set.contains(p)) {
return p;
}
set.add(p);
p = p.next;
}
return null;
然后说明:
这个方案时间复杂度 O(n),空间复杂度 O(n)。还可以进一步使用 Floyd 快慢指针把空间优化到 O(1)。
至少说明自己知道最优方向。
十四、为什么不建议为了 O(1) 强行背错代码?
算法面试里:
正确
永远比:
看起来高级但写错
更重要。
例如:
HashSet:
逻辑明确
代码稳定
能解释
O(n)
比:
Floyd:
只记住几行
不知道为什么
边界容易写错
要靠谱得多。
所以我目前给自己的策略是:
HashSet = 保底解
Floyd = 最优解 / 加分解
先保证:
HashSet 能闭卷写出
再逐渐熟悉:
Floyd
十五、HashSet 和 Floyd 对比
| 方法 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| HashSet | O(n) | O(n) | 最直观、最稳 |
| Floyd | O(n) | O(1) | 面试经典最优解 |
所以:
只会 HashSet
不代表:
不会这道题
而是:
已经会正确解
但还可以继续优化空间
十六、面试讲法:如果先写 HashSet
可以这样说:
我的第一种思路是使用 HashSet 记录已经访问过的节点。遍历链表时,如果当前节点已经存在于 Set 中,说明这是第一次重复访问到的节点,也就是环入口;否则将节点加入 Set 并继续遍历。
这种方法时间复杂度为 O(n),空间复杂度为 O(n)。
如果要求 O(1) 额外空间,可以进一步使用 Floyd 快慢指针:先通过快慢指针第一次相遇确认存在环,再让一个指针回到 head,两个指针每次都走一步,再次相遇的位置就是环入口。
这个回答已经很完整。
十七、面试讲法:如果直接写 Floyd
可以这样说:
我使用 Floyd 快慢指针。第一阶段 slow 每次走一步,fast 每次走两步,如果存在环,两者一定会在环内相遇。
第一次相遇后,将一个指针重新放到 head,另一个保持在相遇点,然后两个指针都每次走一步。根据第一次相遇时的路程关系,head 到环入口的距离与相遇点继续前进到环入口的距离在环上等价,因此两者第二次相遇的位置就是环入口。
时间复杂度 O(n),额外空间复杂度 O(1)。
十八、最容易写错的地方
1. 比较节点值
错误:
slow.val == fast.val
应该:
slow == fast
因为判断的是:
是否指向同一个节点对象。
2. HashSet 存 Integer
错误:
Set<Integer>
应该:
Set<ListNode>
因为不同节点可以拥有相同 val。
3. fast 边界没判断
必须:
while (fast != null && fast.next != null)
因为:
fast = fast.next.next;
一次要走两步。
4. 第一次相遇就直接返回
错误:
if (slow == fast) {
return slow;
}
第一次相遇:
只是证明有环
不一定是入口。
5. 第二阶段仍然让 fast 走两步
第二阶段必须:
两个指针都走一步
例如:
p = p.next;
slow = slow.next;
而不是继续一快一慢。
十九、和 141 环形链表一起记
这两道题强烈建议绑定复习。
141:环形链表
目标:
有没有环
模板:
java
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;
142:环形链表 II
目标:
环入口在哪里
在 141 基础上继续:
java
if (slow == fast) {
ListNode p = head;
while (p != slow) {
p = p.next;
slow = slow.next;
}
return p;
}
所以可以直接记:
142 = 141 + 第二阶段找入口
二十、面试前 30 秒速记
HashSet 保底版
java
Set<ListNode> set = new HashSet<>();
ListNode p = head;
while (p != null) {
if (set.contains(p)) {
return p;
}
set.add(p);
p = p.next;
}
return null;
逻辑:
第一次重复节点
=
环入口
复杂度:
O(n) 时间
O(n) 空间
Floyd 最优版
slow 1
fast 2
第一次相遇
→ 有环
一个回 head
两个都 1
第二次相遇
→ 环入口
代码:
java
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
ListNode p = head;
while (p != slow) {
p = p.next;
slow = slow.next;
}
return p;
}
}
return null;
二十一、最终总结
这道题我目前分两层掌握。
第一层:
HashSet
这是最直观、最稳定的正确解:
访问节点
↓
已经出现过?
↓
是
↓
当前节点就是环入口
第二层:
Floyd 快慢指针
通过:
第一次相遇
→ 判断有环
第二次相遇
→ 找到入口
把空间复杂度优化为:
O(1)
目前如果面试现场只能想起 HashSet,也可以先写正确解,然后说明还可以用 Floyd 优化空间。
真正需要建立的心态不是:
必须马上背下所有最优解
而是:
先有稳定正确解
再逐渐掌握最优模板
对于这道题,我最终的记忆方式就是:
HashSet:
第一次重复就是入口
Floyd:
一快一慢第一次相遇
→ 一个回头
→ 同速再走
→ 第二次相遇就是入口
这就足够支撑后续面试复习。