387 lines
13 KiB
Markdown
387 lines
13 KiB
Markdown
---
|
||
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 判圈算法却是图论中一个经典而优雅的技巧——仅凭两个指针的速度差异就能判断整个图的拓扑结构。掌握这个模式,不仅能秒杀环形链表系列题目,也为后续学习图论中的环路检测打下基础。
|