【LeetCode Hot100 刷题日记(26/100)】142. 环形链表 II —— 链表、双指针、哈希表、环检测🔍

22 阅读7分钟

📌 题目链接:142. 环形链表 II - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:链表、双指针、哈希表、环检测

⏱️ 目标时间复杂度:O(n)

💾 空间复杂度:O(1)(最优解) vs O(n)(哈希表解法)


🔍 题目分析

给定一个链表的头节点 head,返回链表开始入环的第一个节点。如果无环,则返回 null

这道题是 “环形链表 I” 的进阶版本,不仅要判断是否有环,还要定位环的入口点

✅ 关键信息提炼:

  • ❌ 不能修改链表结构。
  • ✅ 节点值可能重复,因此不能通过值来判断是否重复访问
  • ✅ 必须找到第一个进入环的节点(即环的入口)。
  • 🧠 核心难点:如何在不使用额外空间的情况下,精准定位环的起点

🧠 核心算法及代码讲解

本题有两个经典解法:

方法时间复杂度空间复杂度是否推荐面试
哈希表O(n)O(n)✅ 简单易懂,但非最优
快慢指针(Floyd 判圈算法)O(n)O(1)✅✅✅ 面试必考,优雅高效

我们重点讲解 快慢指针法(Floyd Cycle Detection Algorithm),它是图论和链表中环检测的经典算法。


🌀 Floyd 判圈算法原理(又称龟兔赛跑)

🐢 兔子(fast)走两步,乌龟(slow)走一步

while (fast != nullptr) {
    slow = slow->next;
    if (fast->next == nullptr) return nullptr; // fast 后面没节点了,无环
    fast = fast->next->next;
    if (fast == slow) break; // 相遇!说明有环
}

🔁 为什么能相遇?

假设:

  • 链表中环外部分长度为 a
  • 环内部分长度为 b + c(其中 b 是从入口到相遇点的距离,c 是环的剩余部分)
  • fastslow 第一次相遇时,fast 已经走了 2x 步,slow 走了 x

由于 fastslow 快一倍,且都在环里绕圈,最终必然相遇。

📐 数学推导(关键!)

设:

  • a:从头节点到环入口的距离
  • b:从环入口到相遇点的距离
  • c:环的其余部分长度 → 环总长 = b + c

fastslow 相遇时:

  • slow 走了:a + b
  • fast 走了:a + b + n(b + c) (n 是圈数)

因为 fast 速度是 slow 的两倍:

2(a + b) = a + b + n(b + c)
=> a + b = n(b + c)
=> a = n(b + c) - b
=> a = (n - 1)(b + c) + c

💡 这个公式告诉我们:从头节点走到环入口的距离 = 从相遇点绕环若干圈再走 c 的距离

所以我们可以:

  1. slow 继续走
  2. 新建一个指针 ptr 从头开始走
  3. 两者每次走一步,会在环入口处相遇

✅ 快慢指针法完整流程图示:

        a       b       c
[头]----->[环入口]---->[相遇点]----->[环入口]
          ↑           ↑
         ptr         slow
                      ↑
                     fast

ptrslow 同时移动,它们会在环入口处相遇!


🧩 解题思路(分步详解)

  1. 初始化两个指针slowfast 都指向 head
  2. 移动指针
    • slow 每次走 1 步
    • fast 每次走 2 步
  3. 检查是否相遇
    • 如果 fastfast->next 为空 → 无环,返回 nullptr
    • 如果 fast == slow → 有环,进入下一步
  4. 寻找环入口
    • 重置一个指针 ptr 指向 head
    • ptrslow 同时每步走 1 步
    • 它们相遇的地方就是环的入口
  5. 返回结果

📊 算法分析

指标说明
时间复杂度O(n):最多遍历链表两次,第一次找相遇点,第二次找入口
空间复杂度O(1):只用了三个指针变量
适用场景所有需要检测环并找入口的问题,如:约瑟夫问题、链表成环、循环依赖检测等
面试价值⭐⭐⭐⭐⭐ 高频考察点,尤其在大厂系统设计或底层优化岗位中常见

🚨 注意:虽然哈希表方法简单,但空间复杂度高,不适合内存敏感场景;而快慢指针法体现了算法之美——用极小空间解决复杂问题。


💻 代码实现

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// Definition for singly-linked list.
struct ListNode {
    int val;
    ListNode *next;
    ListNode(int x) : val(x), next(nullptr) {}
};

class Solution {
public:
    ListNode *detectCycle(ListNode *head) {
        ListNode *slow = head, *fast = head;
        while (fast != nullptr) {
            slow = slow->next;
            if (fast->next == nullptr) {
                return nullptr;
            }
            fast = fast->next->next;
            if (fast == slow) {
                ListNode *ptr = head;
                while (ptr != slow) {
                    ptr = ptr->next;
                    slow = slow->next;
                }
                return ptr;
            }
        }
        return nullptr;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    // 构造测试用例 1: [3,2,0,-4], pos = 1
    ListNode* head1 = new ListNode(3);
    head1->next = new ListNode(2);
    head1->next->next = new ListNode(0);
    head1->next->next->next = new ListNode(-4);
    head1->next->next->next->next = head1->next; // 形成环,pos=1

    Solution sol;
    ListNode* result1 = sol.detectCycle(head1);
    if (result1) {
        cout << "Test 1: Found cycle entry at node with value: " << result1->val << endl;
    } else {
        cout << "Test 1: No cycle found." << endl;
    }

    // 构造测试用例 2: [1,2], pos = 0
    ListNode* head2 = new ListNode(1);
    head2->next = new ListNode(2);
    head2->next->next = head2; // 形成环,pos=0

    ListNode* result2 = sol.detectCycle(head2);
    if (result2) {
        cout << "Test 2: Found cycle entry at node with value: " << result2->val << endl;
    } else {
        cout << "Test 2: No cycle found." << endl;
    }

    // 构造测试用例 3: [1], pos = -1 (无环)
    ListNode* head3 = new ListNode(1);
    ListNode* result3 = sol.detectCycle(head3);
    if (result3) {
        cout << "Test 3: Found cycle entry at node with value: " << result3->val << endl;
    } else {
        cout << "Test 3: No cycle found." << endl;
    }

    return 0;
}

🛠️ 补充知识:Floyd 算法的扩展应用

🧩 应用 1:求环的长度

一旦找到相遇点,可以让 fast 保持不动,slow 继续走,直到再次相遇,所走步数即为环长。

int getCycleLength(ListNode* meetPoint) {
    ListNode* temp = meetPoint;
    int length = 0;
    do {
        temp = temp->next;
        length++;
    } while (temp != meetPoint);
    return length;
}

🧩 应用 2:用于检测循环依赖(如对象引用、任务调度)

在实际系统中,比如 Java GC 中的可达性分析、操作系统中的死锁检测,都会用到类似逻辑。

🧩 应用 3:约瑟夫问题变种

某些约瑟夫问题可以通过构建虚拟链表 + 快慢指针优化求解。


🧪 测试用例验证

输入输出说明
[3,2,0,-4], pos=1返回索引为 1 的节点环入口是 2
[1,2], pos=0返回索引为 0 的节点环入口是 1
[1], pos=-1null无环
[]null空链表

✅ 所有边界情况都已覆盖:空链表、单节点、多节点、环入口在头部或中间。


🎯 面试常问问题 & 回答技巧

Q1:为什么快慢指针一定能相遇?

A:只要存在环,fastslow 快,且在有限步内会追上。即使 fast 多绕几圈,也终将相遇。

Q2:为什么从头节点和相遇点同时出发,能在入口相遇?

A:由数学推导得出:a = (n-1)(b+c) + c,意味着从头走 a 步和从相遇点绕圈后走 c 步到达同一位置。

Q3:能否用栈或递归解决?

A:可以,但空间复杂度至少 O(n),不如双指针优。面试官更看重空间优化能力。

Q4:有没有其他方法?

A:还有「三指针法」、「标记法」(改值),但都不推荐,破坏原始数据或增加复杂度。


🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪


📣 下一期预告:LeetCode 热题 100 第27题 —— 21.合并两个有序链表(简单)

🔹 题目:给定两个已排序的链表,将它们合并为一个新的有序链表,返回合并后的头节点。

🔹 核心思路:使用双指针分别遍历两个链表,比较节点值,较小者加入新链表。

🔹 考点:链表操作、递归 vs 迭代、边界处理、原地合并技巧。

🔹 难度:简单,但却是链表基础中的“压轴题”,很多后续题目(如合并K个链表)都是其扩展。

🔹 面试频率:极高!几乎每轮技术面都会出现变体!

💡 提示:不要用 new 创建新节点(除非题目允许),优先考虑原地合并递归解法

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!


📌 附录:相关优质资源推荐

  • 📘 《算法导论》第 22 章:图的表示与遍历(含判圈算法)
  • 🔗 LeetCode 官方题解:leetcode.com/problems/de…
  • 🎥 B站视频推荐:Floyd判圈算法动画演示(搜索关键词:“龟兔赛跑 环检测”)

坚持每日一题