LeetCode 142:环形链表 II |HashSet 保底解 + Floyd 快慢指针找环入口

一、题目

给定一个链表的头节点 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:
一快一慢第一次相遇
→ 一个回头
→ 同速再走
→ 第二次相遇就是入口

这就足够支撑后续面试复习。

相关推荐
Fairy要carry1 小时前
初创实习-IMU模型
人工智能·算法·机器学习
a187927218311 小时前
【算法】回溯算法(一):从一道 IP 题到万能模板
算法·leetcode·go·回溯·leetcode93·ip复原·算法讲解
鹿角片ljp1 小时前
LeetCode 19:删除链表的倒数第 N 个节点复盘| dummy + 快慢指针
算法·leetcode·链表
玖玥拾1 小时前
LeetCode 290 单词规律
算法·leetcode·哈希算法·散列表
liliangcsdn1 小时前
多空价差收益序列汇总统计的分析和代码示例
人工智能·算法·机器学习
啥都想学点的研究生2 小时前
一篇文章讲清楚:Adaboost算法与GBDT
人工智能·算法
旖旎夜光2 小时前
LeetCode 525:连续数组(前缀和) —— 题解
数据结构·c++·算法·leetcode·前缀和
zander2582 小时前
LeetCode 32. 最长有效括号
算法
lvwangshu2 小时前
AC 自动机
算法·字符串