力扣hot100-142.环形链表II-哈希集合与快慢指针详解

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}

节点 -4next 指向节点 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)

fastslow 多走的距离,全都是在环里多绕出的完整圈数。因此:

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。

pslow 每次都走一步:

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 同步走,第二次相遇就是入口。
相关推荐
-dzk-4 小时前
【链表】LC 160.相交链表
数据结构·链表
Hi李耶4 小时前
【LeetCode】6-Z字形变换
算法·leetcode·职场和发展
AKA__Zas4 小时前
芝士算法(前缀和2.0)
java·数据结构·算法·leetcode·哈希算法·学习方法
Tisfy6 小时前
LeetCode 0486.预测赢家:深度优先搜索(DFS)
算法·leetcode·深度优先·dfs·博弈
退休倒计时7 小时前
【每日一题】LeetCode 45. 跳跃游戏 II TypeScript
算法·leetcode·typescript
圣保罗的大教堂8 小时前
leetcode 3016. 输入单词需要的最少按键次数 II 中等
leetcode
m0_728033139 小时前
LeetCode HOT100(技巧)
算法·leetcode·职场和发展
白白白小纯19 小时前
算法篇—反转链表
c语言·数据结构·算法·leetcode
圣保罗的大教堂1 天前
leetcode 3517. 最小回文排列 I 中等
leetcode