--- tags: ["LeetCode", "链表", "双指针", "Floyd判圈", "环形链表"] create time: 2026-05-16 15:30 --- # 26-环形链表 II ## 题面 给定一个链表的头节点 `head`,返回链表开始入环的第一个节点。**如果链表无环,则返回 `nil`。** 如果链表中有某个节点,可以通过连续跟踪 `next` 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 `pos` 来表示链表尾连接到链表中的位置(索引从 0 开始)。**注意:pos 不作为参数进行传递。**仅仅是为了标识链表的实际情况。 **不允许修改链表。** **示例 1:** ``` 输入:head = [3,2,0,-4], pos = 1 输出:返回索引为 1 的链表节点 解释:链表中有一个环,其尾部连接到第二个节点。 ``` **示例 2:** ``` 输入:head = [1,2], pos = 0 输出:返回索引为 0 的链表节点 解释:链表中有一个环,其尾部连接到第一个节点。 ``` **示例 3:** ``` 输入:head = [1], pos = -1 输出:返回 null 解释:链表中没有环。 ``` **提示:** - 链表中节点的数目范围在 `[0, 10^4]` 内 - `-10^5 <= Node.val <= 10^5` - `pos` 的值为 `-1` 或者链表中的一个有效索引 **进阶:你是否可以使用 O(1) 空间解决此题?** --- ## 思路 > [!question] 💡 承上启下 > 你已经掌握了 [25-环形链表](./25-环形链表.md)——用 Floyd 快慢指针判断环的存在性。现在问题是:**如果已经有环,如何找到入口节点?** > [!tip] 🔑 结论先行 > 仍然使用快慢指针。第一次相遇后,把其中一个指针放回 `head`,两个指针改为**同速各走一步**,它们第二次相遇的位置就是环的入口。 > **时间复杂度 O(n),空间复杂度 O(1)**。 ### 前置回顾:第一轮——检测环 ```mermaid flowchart LR subgraph S ["慢指针 slow(每次 1 步)"] S1["从头出发"] end subgraph F ["快指针 fast(每次 2 步)"] F1["从头出发"] end S1 --> PULL{"两指针都进环后,快追慢"} F1 --> PULL PULL -->|每步缩小 1
最多 r 步| MEET["🔄 环内相遇点 M"] style MEET fill:#f99,stroke:#333 ``` 此时我们已经确认有环,但相遇点 M 通常**不是**入口 E。我们需要推导一个关系式来定位 E。 ### 核心推导:第二轮——寻找入口 > [!question] 💡 设未知数 > 用三个变量描述链表的拓扑结构,你能建立它们之间的关系吗? 定义三个关键距离(沿链表前进方向计量): | 符号 | 含义 | |------|------| | **x** | 从 `head` 到环入口的距离(入口前非环部分的长度) | | **y** | 从环入口到相遇点的距离 | | **r** | 环的周长 | **慢指针走过的总路程**(设为 L): $$L = x + y$$ > [!info] ℹ️ 为什么是 x + y? > 慢指针速度为 1,先进入环,然后沿着环走到相遇点。它在进入环之后最多走不满一圈就遇到了快指针(因为快指针在后面追),所以路径就是"头→入口" + "入口→相遇点" = x + y。 **快指针走过的总路程**(设为 2L): $$2L = x + y + n \cdot r \quad (n \geq 1)$$ > [!info] 🧮 理解 n · r > 快指针速度是慢指针的两倍,所以它除了走完和慢指针一样的 x + y 之外,还在环里多跑了若干整圈(n 圈)。由于快指针必须至少比慢指针多跑一圈才能追上它,所以 n ≥ 1。 将 L = x + y 代入消元: $$2(x + y) = x + y + n \cdot r$$ 化简得: $$x + y = n \cdot r$$ 移项得到关键公式: $$\boxed{x = n \cdot r - y}$$ > [!warning] ⛔ 常见误区 > 这里 n 可以是 1、2、3……不一定等于 1。但无论如何,**这个等式的几何意义不变**: ### 公式解读:从两个方向走相同距离 $$x = n \cdot r - y = (n - 1) \cdot r + (r - y)$$ 这意味着什么?让我们从两个视角看: **视角 1 — 从 head 出发走 x 步**:直达环入口 E。 **视角 2 — 从相遇点 M 出发走同样的距离**: ``` M → E 的距离拆解: 先走完环的剩余部分 M→E:距离 = r - y 再绕 (n-1) 整圈回到 E:距离 = (n-1) · r 合计 = (n-1) · r + (r - y) = x ✅ ``` > [!success] 🎯 关键洞察 > **从 head 走 x 步到达入口 E;从相遇点 M 也走 x 步同样到达入口 E。** > 两者步数相同!所以我们只需要启动两个新指针——一个从 head 开始,一个从相遇点 M 开始——以相同速度同步前进,它们的第二次相遇点必然是环入口 E。 ```mermaid flowchart TD HEAD["head"] --"第1步"--> A["第1个节点"] A --"第2步"--> B["第2个节点"] B --"第x步"--> ENTRY["⭐ 环入口 E ← 两指针在此相遇"] ENTRY -.-> RING["环中各节点..."] RING -.-> ENTRY MEET["相遇点 M"] --"走同样步数"--> ENTRY style HEAD fill:#bbf,stroke:#333 style MEET fill:#fbf,stroke:#333 style ENTRY fill:#fd4,stroke:#333,stroke-width:3px ``` **示例完整演示**:以 `3→2→0→-4→(回索引1)` 为例 | 阶段 | 参数 | 值 | |------|------|-----| | 非环段 | x | 1(head 直接就是入口) | | 环入口到相遇点 | y | 3(入口 2 → 0 → -4 → 2) | | 环周长 | r | 4(2→0→-4→2) | | 验证 | x + y = n × r | 1 + 3 = 4 = 1 × 4 ✓ | | 入口公式 | x = n×r - y | 1 = 4 - 3 ✓ | 两轮过程: **第一轮(快慢指针检测):** ``` 初始: slow=3(head), fast=3(head) 第1轮: slow→2(E), fast→0 (slow 走1步, fast 走2步) 第2轮: slow→0, fast=-4 (slow 走1步, fast 走2步) 第3轮: slow→-4, fast→2(E) (slow 走1步, fast 走2步) 第4轮: slow→2(E), fast→2(E) ← SLOW == FAST,相遇于 E ✗(巧合!) ⚠️ 本例恰好相遇于入口,但不代表总是如此。 换一组数据就能看出差异。 ``` > [!note] 🤔 为什么本例的特殊情况会让人误解? > 因为 x = 1(head 就是入口),慢指针只走了 4 步(刚好一圈)就和快指针相遇了,相遇点恰好是入口。让我们构造一个更一般的例子来观察: > > 假设链表 `1→2→3→4→5→(回2)`: > - x = 1(head[1]→入口[2]) > - 慢指针走 1+1=2 步到节点 3 > - 快指针走 4 步:1→3→5→3→? 最终也会在节点 3 相遇 > - 但从 head 走 x=1 步到 2,从相遇点 3 走 x=1 步到 4 ≠ 2 > > 等等,让我重新计算: > > 慢指针 L = x + y = 1 + y,快指针 2L = 1 + y + n·r(r=4) > 2(1+y) = 1+y+4n → 1+y = 4n → y = 4n-1 > n=1 时 y=3,即相遇点在入口往后数第3个节点 = 节点5 > > 验证:慢指针走 1+3=4 步到节点5,快指针走8步也到节点5。✓ > > **第二轮:** ptr1 从 head(1) 走 x=1 步到节点2(入口);ptr2 从相遇点(5) 走 1 步到节点2(5→2,因为是环)。二者在节点2相遇!完美。 ### 整体算法流程 ```mermaid flowchart TD START(["输入 head"]) --> BOUNDARY{"head == nil?"} BOUNDARY -->|是| NOLOOP["返回 nil"] BOUNDARY -->|否| ROUND1["⏩ 第一轮:快慢指针
slow 每步1次, fast 每步2次"] ROUND1 --> HASMEET{"是否相遇?"} HASMEET -->|否| NOLOOP2["fast 触底 nil
→ 无环,返回 nil"] HASMEET -->|是| MEETPT["记录相遇点 meetNode"] MEETPT --> ROUND2["⏩ 第二轮:双指针同速
ptr1 = head, ptr2 = meetNode"] ROUND2 --> SYNCLOOP{"ptr1 != ptr2?"} SYNCLOOP -->|是| MOVE["ptr1++, ptr2++"] MOVE --> SYNCLOOP SYNCLOOP -->|否| ENTRY["🎯 ptr1/ptr2 就是环入口!"] style NOLOOP fill:#ccc,stroke:#333 style NOLOOP2 fill:#ccc,stroke:#333 style ENTRY fill:#fd4,stroke:#333,stroke-width:3px ``` | 轮次 | 操作 | 时间复杂度 | 说明 | |------|------|-----------|------| | 第一轮 | 快指针每次 2 步,慢指针每次 1 步 | O(n) | 找到是否有环及相遇点 | | 第二轮 | 两指针各 1 步,从 head 和相遇点出发 | O(n) | 找到环入口 | | **合计** | — | **O(n)** | **空间 O(1)** | > [!info] 📐 正确性证明总结 | 步骤 | 命题 | 依据 | |------|------|------| | 1 | 若有环,快慢指针必在环内相遇 | 每步距离缩短 1,最多 r 步 | | 2 | 相遇时满足 x + y = n × r | 快指针速度是慢指针的两倍 | | 3 | 从 head 走 x 步与从相遇点走 x 步汇于同一节点 | x = (n-1)r + (r-y) | | 4 | 该节点即为环入口 | 从 head 往前走 x 步正好到入口 | --- ## 代码提示 ```go // ======== 第一轮:检测环,找相遇点 ======== slow, fast := head, head for fast != nil && fast.Next != nil { slow = slow.Next // 每次 1 步 fast = fast.Next.Next // 每次 2 步 if slow == fast { // 相遇!进入第二轮 break } } // 没相遇 → 无环 if fast == nil || fast.Next == nil { return nil } // ======== 第二轮:找入口 ======== ptr1 := head // 从头部出发 ptr2 := slow // 从相遇点出发 for ptr1 != ptr2 { ptr1 = ptr1.Next ptr2 = ptr2.Next } return ptr1 // 或 ptr2 —— 就是环入口 ``` **Go 语言技巧:** - Go 不支持 Python 风格的多赋值同时推进不同目标,需要分开写 - 利用 Go 的 `nil` 语义安全地检查边界条件 - 可以将第一轮的 `break` 后的检查合并为一行:`if slow == fast { ... } else { return nil }` > [!tip] 🔑 Go 一行流写法(竞赛向) > 利用 Go 的 for 语句特点,可以将两段逻辑合并: ```go func detectCycle(head *ListNode) *ListNode { for slow, fast := head, head; fast != nil && fast.Next != nil; { slow = slow.Next fast = fast.Next.Next if slow == fast { // 相遇后立即切换模式 for head != slow { // ptr1=head, ptr2=slow 同速走 head = head.Next slow = slow.Next } return head } } return nil // fast 触底 → 无环 } ``` --- ## 技巧 > [!tip] 🔑 核心模式:Floyd 环检测 + 对称映射 > 这道题的本质是利用代数推导出的**距离对称性**:从 head 到入口的距离 = 从相遇点到入口的距离。这可以看作一种"镜像翻转"——把线性前缀映射到了环形剩余路径上。 > > **记忆口诀:"一快一慢有环必遇,一回一遇入口必现。"** > [!question] 🧠 深入思考:如果快指针每次走 3 步呢? > 快指针走 k 步(k > 2),慢指针走 1 步,那么每步距离缩短 k-1。 > 相遇时有:(k-1)(x+y) = n·r。当 k=2 时,(x+y)=n·r,推导出 x = nr-y——简洁优美。 > 当 k=3 时,2(x+y)=n·r,推导出 x = nr/2 - y——可能不是整数,且无法直接使用"同速两步对走"的简单策略。 > 这也是为什么 Floyd 选 k=2(快2慢1)是最优设计的原因。 > [!danger] ⚠️ 常见错误 > > 1. **忘记处理无环的情况**:第一轮结束后需要检查是否真的相遇了(`fast == nil || fast.Next == nil`),否则会把 `nil` 当成相遇点进入死循环。 > 2. **第二轮只用一个指针移动**:必须两个指针各走一步。 > 3. **把 `ptr1` 初始化成 `slow.Next`**:应该都是严格的一步一步走,`ptr1 = head`, `ptr2 = slow`。 > [!note] 🐹 Go 中的链表定义 > LeetCode 的 Go 环境内置如下结构体定义: ```go type ListNode struct { Val int Next *ListNode } ``` 不需要手动定义,直接在解题中使用即可。 > [!info] 📊 两种方法对比 | 方法 | 时间复杂度 | 空间复杂度 | 备注 | |------|-----------|-----------|------| | 哈希集合记录访问过的节点 | O(n) | O(n) | 遍历每个节点,查 set | | **Floyd 双指针 ⭐** | **O(n)** | **O(1)** | **最优,面试标配** | > [!success] ✅ 相关题目串联 > - [25-环形链表](./25-环形链表.md) — 基础版:判断环的存在性 > - [22-相交链表](./22-相交链表.md) — 同样是双指针的经典应用 > - 剑指 Offer 23 — 环形链表的入口(完全相同的题目) > [!warning] ⚠️ 面试注意事项 > - 面试时建议先推导一遍 x = n·r - y 的过程,展示你的分析能力 > - 面试官可能追问"如果没有环怎么办"——务必加上边界检查 > - 有些面试官会要求画图解法,提前准备好图示思维 > - 如果面试官要求你构建测试用例,至少准备:空链表、单节点有环/无环、head 就是入口、相遇点恰好在入口 --- ## 代码 ### 方法一:哈希集合(O(n) 空间,不推荐) ```go /** * Definition for singly-linked list. * type ListNode struct { * Val int * Next *ListNode * } */ func detectCycle(head *ListNode) *ListNode { seen := make(map[*ListNode]bool) for node := head; node != nil; node = node.Next { if seen[node] { return node // 该节点已出现过 → 这是环入口 } seen[node] = true } return nil // 走到末尾也没重复 → 无环 } ``` --- ### 方法二:Floyd 环检测 + 对称映射 ⭐(最优 O(1) 空间) ```go /** * Definition for singly-linked list. * type ListNode struct { * Val int * Next *ListNode * } */ func detectCycle(head *ListNode) *ListNode { // ======== 第一轮:快慢指针检测环 ======== slow, fast := head, head for fast != nil && fast.Next != nil { slow = slow.Next // 慢指针:每次走一步 fast = fast.Next.Next // 快指针:每次走两步 if slow == fast { // 在环内相遇了! break // 跳出循环,进入第二轮 } } // 没相遇 → fast 触底 → 无环 if fast == nil || fast.Next == nil { return nil } // ======== 第二轮:找环入口 ======== ptr1 := head // 指针1:从头节点出发 ptr2 := slow // 指针2:从相遇点出发 for ptr1 != ptr2 { ptr1 = ptr1.Next ptr2 = ptr2.Next } return ptr1 // ptr1 == ptr2,即为环入口节点 } ``` > [!tip] 🔑 精简写法(竞赛向) ```go func detectCycle(head *ListNode) *ListNode { for slow, fast := head, head; fast != nil && fast.Next != nil; { slow = slow.Next fast = fast.Next.Next if slow == fast { // 相遇后切换模式 for head != slow { head = head.Next slow = slow.Next } return head } } return nil } ``` > [!success] ✅ 运行验证 > 这是 LeetCode 第 142 题,通过率约 46%,属于经典中等题。这道题的精妙之处在于**纯数学推导驱动算法设计**——没有复杂的技巧,仅靠一个简单的等式 x = n·r - y 就完成了从"检测环"到"定位入口"的跨越。建议在纸上画图推导一遍这个公式,理解后会感受到数学之美。