Files

387 lines
13 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:00
---
# 25-环形链表
## 题面
给你一个链表的头节点 `head`,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 `next` 指针再次到达,则链表中存在环。
为了表示给定链表中的环,评测系统内部使用整数 `pos` 来表示链表尾连接到链表中的位置(索引从 0 开始)。**注意:pos 不作为参数进行传递。**仅仅是为了标识链表的实际情况。
如果链表中存在环,则返回 `true`。否则,返回 `false`。
**示例 1:**
```
输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。
```
**示例 2:**
```
输入:head = [1,2], pos = 0
输出:true
解释:链表中有一个环,其尾部连接到第一个节点。
```
**示例 3:**
```
输入:head = [1], pos = -1
输出:false
解释:链表中没有环。
```
**提示:**
- 链表中节点的数目范围是 `[0, 10^4]`
- `-10^5 <= Node.val <= 10^5`
- `pos` 为 `-1` 或者链表中的一个有效索引
**进阶:你能用 O(1)(即,常量)内存解决此问题吗?**
---
## 思路
> [!question] 💡 核心直觉
> 想象两个人在同一条跑道上赛跑——一个人跑得快,另一个人跑得慢。如果他们都在同一条**封闭跑道**上,快的迟早会从后面追上慢的。但如果是在**直线跑道**上,快的最终会冲到前面再也不回头。
>
> **把这个想法映射到链表上:** 如果有环,快慢两个指针就会像跑道上的运动员一样"相遇";如果没有环,快的会先走到终点 `nil`。
> [!info] 🧩 两种基本思路
| 方法 | 时间复杂度 | 空间复杂度 | 能否满足进阶要求? |
|------|-----------|-----------|-----------------|
| 哈希集合记录已访问节点 | O(n) | O(n) | ❌ |
| **快慢指针(Floyd 判圈算法)** | **O(n)** | **O(1)** | ✅ |
---
### 方法一:哈希集合(直觉方案)
维护一个已经遍历过的节点的集合。每到一个新节点就检查是否已经在集合中出现过:出现过说明有环;没出现过就加入集合并继续前进;走到 `nil` 说明无环。
```mermaid
flowchart TD
START(["从头节点出发"]) --> CHECK{"节点已在集合中?"}
CHECK -->|是| HASLOOP["🔄 发现环 → 返回 true"]
CHECK -->|否| ADD["加入集合"]
ADD --> NILCHECK{"节点是否为 nil?"}
NILCHECK -->|否| NEXT["移动到下一个节点"]
NEXT --> CHECK
NILCHECK -->|是| NOLOOP["✅ 走到末尾 → 返回 false"]
style HASLOOP fill:#f99,stroke:#333
style NOLOOP fill:#4c4,stroke:#333
```
**步骤拆解:**
| 步骤 | 动作 | 说明 |
|------|------|------|
| 1 | 初始化空哈希集合 | 存储已访问的节点引用(地址) |
| 2 | 遍历链表,对每个节点查集合 | 若存在则有环 |
| 3 | 不存在则加入集合,继续走 | 直到节点为 `nil` |
| 4 | 走到 `nil` 则无环 | 返回 `false` |
**时间复杂度:O(n)** — 每个节点最多访问一次,哈希集合的插入和查找都是 O(1)。
**空间复杂度:O(n)** — 最坏情况下需要存储所有 n 个节点的引用。
> [!note] 🤔 这个方法的问题是什么?
> 完全能正确检测环,但空间复杂度为 O(n),不满足进阶的 O(1) 空间要求。而且它需要额外的数据结构开销。有没有可能只靠指针本身来完成检测?
---
### 方法二:快慢指针 / Floyd 判圈算法 ⭐(最优,O(1) 空间)⭐
> [!warning] ⚠️ 核心类比
> 这是本道题的灵魂思想,建议反复体会:
>
> - **直线跑道(无环):** 快指针每次走两步,慢指针每次走一步 → 快指针先到终点 `nil`
> - **环形跑道(有环):** 快指针进入环后绕圈,慢指针也在环里慢慢走 → 快指针从后面**追上**慢指针
#### 算法原理
设定两个指针:`slow` 每次走一步,`fast` 每次走两步。让它们在链表中同时前进。
**为什么一定能在环内相遇?**
> [!question] 💡 数学直觉
> 当两个指针都进入环之后,它们之间的距离变化是怎样的?
假设某一时刻两指针都在环内,相距 k 步(沿着移动方向计量)。每一步操作中:
- 慢指针向前走 1 步
- 快指针向前走 2 步
- 两者距离缩短 1 步
```
初始状态: S · · · · F (相距 k=4)
第1步后: S · · · F · (相距 k=3)
第2步后: S · · F · · (相距 k=2)
第3步后: S · F · · · (相距 k=1)
第4步后: S F · · · · (相遇!k=0)
```
因此,**只要两指针都在环内,最多经过 k 步(k 为入环后的距离),快指针就能追上慢指针。**
> [!danger] ⛔ 常见误区澄清
> **快指针不会"跳过"慢指针!** 有人担心快指针一步跨过慢指针所在的位置而永远不会相等。但实际上在离散的一步移动中,快指针只能比慢指针多走 1 步,所以它会恰好落在慢指针所在的那个节点上,不可能跳过。
#### 边界情况处理
```mermaid
flowchart LR
subgraph "空链表"
A1["head == nil"] --> R1["直接返回 false ✓"]
end
subgraph "单节点、无环"
A2["head.Next == nil"] --> R2["fast=head.Next=nil\n不进入循环\n返回 false ✓"]
end
subgraph "两节点、有环(连回自身)"
A3["1→2→1→..."] --> R3["一轮相遇!\nslow=2, fast=2 ✓"]
end
subgraph "普通有环"
A4["长链表+环"] --> R4["快追慢 → 环内某处相遇 ✓"]
end
style R1 fill:#4c4,stroke:#333
style R2 fill:#4c4,stroke:#333
style R3 fill:#4c4,stroke:#333
style R4 fill:#4c4,stroke:#333
```
**关键细节:** 循环条件为 `fast != nil && fast.Next != nil`,确保 `fast.Next.Next` 安全。
> [!info] 🧮 为什么 fast 要检查 fast.Next != nil?
> 因为 `fast` 每次要走两步(`fast = fast.Next.Next`)。如果只检查 `fast != nil` 而不检查 `fast.Next != nil`,当 `fast` 指向最后一个节点时,`fast.Next` 为 `nil`,`fast.Next.Next` 就会 panic。
#### 完整流程图解
以 `3→2→0→-4→(回到索引1的节点2)` 为例:
```
初始: slow=3(head), fast=3(head)
第1轮: slow→2, fast→0 (slow 走1步, fast 走2步)
第2轮: slow→0, fast→-4 (slow 走1步, fast 走2步)
第3轮: slow→-4, fast→2 (slow 走1步, fast 走2步, fast 进入环)
第4轮: slow→2, fast→2 (SLOW == FAST → 有环!✓)
```
```mermaid
flowchart TD
START(["head = 3→2→0→-4(连回索引1)"]) --> INIT["slow=3, fast=3"]
INIT --> LOOP{"fast!=nil && fast.Next!=nil"}
LOOP -->|是| STEP1["slow→下一步"]
STEP1 --> STEP2["fast→下两步"]
STEP2 --> MEET{"slow==fast?"}
MEET -->|是| HASLOOP["🔄 有环 → true ✓"]
MEET -->|否| LOOP
LOOP -->|否| NOLOOP["✅ 无环 → false ✗"]
style HASLOOP fill:#f99,stroke:#333
style NOLOOP fill:#4c4,stroke:#333
```
**时间复杂度:O(n)** — 如果存在环,慢指针最多走环外部分 + 一圈环内,快指针最多走环外部分 + 两圈环内。总步数不超过 2n。
**空间复杂度:O(1)** — 只用了 slow、fast 两个指针变量,完美满足进阶要求!
> [!success] 🎯 Floyd 判圈算法的历史
> 这个算法由美国科学家 Robert W. Floyd 于 1967 年提出,是图论中经典的环路检测方法。它的精妙之处在于**不需要任何额外存储空间**,仅通过指针的速度差来探测拓扑结构,被誉为计算机科学中最优雅的算法之一。
---
## 代码提示
### 伪代码模板
```
// ① 边界检查:空链表直接无环
if head == nil {
return false
}
// ② 初始化双指针
slow := head // 每次走一步
fast := head // 每次走两步
// ③ 同步推进 —— 快指针能走两步时才继续
for fast != nil && fast.Next != nil {
slow = slow.Next // 慢指针前进一步
fast = fast.Next.Next // 快指针前进两步
if slow == fast { // 在环内相遇了!
return true
}
}
// ④ 快指针触底,无路可走 → 无环
return false
```
**Go 语言技巧:**
- Go 支持匿名多赋值,可以用一行完成两个指针的移动,但分写更清晰易读
- 利用 Go 的 `&&` 短路特性:`fast != nil && fast.Next != nil` 保证安全
- Go 的指针语义天然适合此算法——比较的是节点引用(地址),而非值
---
## 技巧
> [!tip] 🔑 核心模式:Floyd 判圈算法(Tortoise and Hare)
> 双指针速度差法 —— 慢指针一步、快指针两步。这是检测链表中是否存在环的标准 O(1) 空间解法。
>
> **记忆口诀:"一快一慢,有环必遇;快碰底线,无环无疑。"**
> [!tip] 🔑 延伸变体:如果有环,如何找到入口节点?
> 这是一个非常有趣的扩展(对应 LeetCode 142. 环形链表 II)。证明如下:
>
> 设:
> - x = 从 head 到环入口的距离
> - y = 从环入口到相遇点的距离
> - r = 环的周长
> - L = 相遇时慢指针走过的总步数 = x + y
>
> 此时快指针走了 2L = x + y + n×r(n 为快指针在环内多走的圈数)。
> 化简得:**x + y = n×r**,即 **x = n×r - y**。
>
> **这意味着:从相遇点再走 (n-1) 圈 + 从 head 走 x 步,等于从环入口走 (n-1) 圈再走 y 步。**
>
> 因此只需在第一次相遇后,把其中一个指针重新放回首部,两者各走一步,第二次相遇点就是**环的入口节点**。
>
> ```go
> // 第一次相遇后的入口查找
> ptr1 := head
> ptr2 := slow // 或 fast,两者此刻相等
> for ptr1 != ptr2 {
> ptr1 = ptr1.Next
> ptr2 = ptr2.Next
> }
> return ptr1 // 环的入口
> ```
> [!note] 🐹 Go 中的链表定义
> LeetCode 的 Go 环境内置如下结构体定义:
```go
type ListNode struct {
Val int
Next *ListNode
}
```
不需要手动定义,直接在解题中使用即可。
> [!info] 📊 两种方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|------|-----------|-----------|------|------|
| 哈希集合 | O(n) | O(n) | 思路直观,容易想到 | 占用额外空间 |
| **Floyd 判圈 ⭐** | **O(n)** | **O(1)** | **最优,面试标配** | 逻辑稍有门槛 |
> [!success] ✅ 相关题目串联
> - [[26-环形链表 II]] — 进阶版:找环的入口(同一组快慢指针技术,加一步推导)
> - [22-相交链表](./22-相交链表.md) — 同样是双指针的经典应用
> - [24-回文链表](./24-回文链表.md) — 也用到了快慢指针找中点的技巧
> [!warning] ⚠️ 面试注意事项
> - Go 中不用显式释放资源,垃圾回收会自动处理,**不需要也不应该手动置 nil 断链**
> - 快慢指针初始化都应该从 `head` 开始,不要错误地把 fast 初始化为 `head.Next`(那是回文链表的写法,不适用于本题)
> - 空链表 `head == nil` 的情况需要考虑,虽然 LeetCode 测试用例通常不包含,但良好的习惯应该加上
---
## 代码
### 方法一:哈希集合(O(n) 空间)
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func hasCycle(head *ListNode) bool {
seen := make(map[*ListNode]bool)
for node := head; node != nil; node = node.Next {
if seen[node] {
return true // 该节点已出现过 → 有环
}
seen[node] = true
}
return false // 走到末尾也没重复 → 无环
}
```
---
### 方法二:Floyd 判圈算法 ⭐(最优 O(1) 空间)⭐
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func hasCycle(head *ListNode) bool {
// 空链表不可能有环
if head == nil {
return false
}
slow := head // 慢指针:每次走一步
fast := head // 快指针:每次走两步
// 快指针能连续走两步才继续推进
for fast != nil && fast.Next != nil {
slow = slow.Next // 慢指针前进一步
fast = fast.Next.Next // 快指针前进两步
if slow == fast { // 快慢指针相遇 → 有环
return true
}
}
// 快指针触底 → 无环
return false
}
```
> [!tip] 🔑 精简写法(竞赛向)
> 利用 Go 的 `for` 不带初始化语句的特性,可以让代码更紧凑:
```go
func hasCycle(head *ListNode) bool {
for slow, fast := head, head; fast != nil && fast.Next != nil; {
slow = slow.Next
fast = fast.Next.Next
if slow == fast {
return true
}
}
return false
}
```
> [!success] ✅ 运行验证
> 这是 LeetCode 第 141 题,通过率约 48%。看似简单的一道题,但其背后的 Floyd 判圈算法却是图论中一个经典而优雅的技巧——仅凭两个指针的速度差异就能判断整个图的拓扑结构。掌握这个模式,不仅能秒杀环形链表系列题目,也为后续学习图论中的环路检测打下基础。