Files
leetcode-go/链表/26-环形链表 II.md

441 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<br/>最多 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["⏩ 第一轮:快慢指针<br/>slow 每步1次, fast 每步2次"]
ROUND1 --> HASMEET{"是否相遇?"}
HASMEET -->|否| NOLOOP2["fast 触底 nil<br/>→ 无环,返回 nil"]
HASMEET -->|是| MEETPT["记录相遇点 meetNode"]
MEETPT --> ROUND2["⏩ 第二轮:双指针同速<br/>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 就完成了从"检测环"到"定位入口"的跨越。建议在纸上画图推导一遍这个公式,理解后会感受到数学之美。