vault backup: 2026-05-16 16:26:24

This commit is contained in:
2026-05-16 16:26:24 +08:00
parent 4df7229ee4
commit 69653414c5
6 changed files with 1828 additions and 0 deletions
+386
View File
@@ -0,0 +1,386 @@
---
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 判圈算法却是图论中一个经典而优雅的技巧——仅凭两个指针的速度差异就能判断整个图的拓扑结构。掌握这个模式,不仅能秒杀环形链表系列题目,也为后续学习图论中的环路检测打下基础。
+440
View File
@@ -0,0 +1,440 @@
---
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 就完成了从"检测环"到"定位入口"的跨越。建议在纸上画图推导一遍这个公式,理解后会感受到数学之美。
+360
View File
@@ -0,0 +1,360 @@
---
tags: ["LeetCode", "链表", "双指针", "递归", "简单"]
create time: 2026-05-16 14:40
---
# 27-合并两个有序链表
## 题面
将两个 **升序** 链表合并为一个新的 **升序** 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
**示例 1:**
```
输入:l1 = [1,2,4], l2 = [1,3,4]
输出:[1,1,2,3,4,4]
```
**示例 2:**
```
输入:l1 = [], l2 = []
输出:[]
```
**示例 3:**
```
输入:l1 = [], l2 = [0]
输出:[0]
```
**提示:**
- 两个链表的节点数目范围是 `[0, 50]`
- `-100 <= Node.val <= 100`
- `l1` 和 `l2` 均按 **非递减顺序** 排列
---
## 思路
> [!question] 💡 引导思考
> 假设你面前有两张已经排好序的扑克牌,你会如何把它们合并成一张更大的有序牌堆?——每次比较两张牌的**最上面**那一张,取较小的放入结果中。这就是本题的核心思想。
### 方法一:迭代法 — 虚拟头节点 + 尾指针 ⭐(推荐)
维护一个**虚拟头节点** `dummy` 和一个**尾指针** `tail`:
| 变量 | 含义 |
|------|------|
| **dummy** | 虚拟头节点,`dummy.Next` 始终指向结果链表的真正头部 |
| **tail** | 当前已合并部分的最后一个节点,用于在末尾追加 |
**核心操作:** 每次比较 `l1.Val` 和 `l2.Val`,将较小的节点连接到 `tail.Next`,然后该链表的指针前进一步。
```mermaid
flowchart TB
subgraph Inputs["📥 输入:两条升序链表"]
L1["l1\n1→2→4→nil"]
L2["l2\n1→3→4→nil"]
end
subgraph Core["⚙️ 核心操作(循环内每轮重复)"]
CMP["比较 l1.Val vs l2.Val"]
Pick["选较小值节点"]
end
subgraph Output["📤 输出:结果链表(尾部追加)"]
Dummy["dummy (虚拟头节点)"]
Res["结果: 1→1→2→3→4→4→nil"]
end
L1 -->|当前 head| CMP
L2 -->|当前 head| CMP
CMP -->|l1.Val ≤ l2.Val?| Pick
Pick -->|连接到 tail.Next| Res
Res --> Dummy
style Dummy fill:#e0e0ff,stroke:#666,stroke-width:2px
style CMP fill:#fff4e6,stroke:#f90,stroke-width:2px
style Pick fill:#d4edda,stroke:#28a,stroke-width:2px
style Res fill:#e8f5e9,stroke:#4a4,stroke-width:1px
```
**图示执行流程:**
```mermaid
flowchart LR
State0["初始\n---\ndummy → ?\nl1: 1→2→4\nl2: 1→3→4"] -->|"1≤1, 取 l1(1)"| State1["第 1 轮\n---\ndummy → [1]\nl1: 2→4\nl2: 1→3→4"]
State1 -->|"2>1, 取 l2(1)"| State2["第 2 轮\n---\ndummy → [1→1]\nl1: 2→4\nl2: 3→4"]
State2 -->|"2≤3, 取 l1(2)"| State3["第 3 轮\n---\ndummy → [1→1→2]\nl1: 4\nl2: 3→4"]
State3 -->|"4>3, 取 l2(3)"| State4["第 4 轮\n---\ndummy → [1→1→2→3]\nl1: 4\nl2: 4"]
State4 -->|"4≤4, 取 l1(4)"| State5["第 5 轮\n---\ndummy → [1→1→2→3→4]\nl1: nil\nl2: 4"]
State5 -->|"l1 空, 接 l2"| State6["收尾\n---\ndummy → [1→1→2→3→4→4] ✅"]
State6:::final
classDef final fill:#4c4,color:white
```
以示例 1(`l1 = [1,2,4]`, `l2 = [1,3,4]`)为例,逐步展开:
| 轮次 | 比较对象 | 较小值 | tail 连接 | l1 移动后 | l2 移动后 | 结果链表 |
|------|---------|--------|----------|----------|----------|---------|
| 初始 | — | — | dummy | 1→2→4 | 1→3→4 | `dummy → ?` |
| 第 1 轮 | l1.Val(1) vs l2.Val(**1**) | 相等,选 l1 | tail→l1(1) | 2→4 | 1→3→4 | `1_a` |
| 第 2 轮 | l1.Val(**2**) vs l2.Val(1) | 1 | tail→l2(1) | 2→4 | 3→4 | `1_a → 1_b` |
| 第 3 轮 | l1.Val(**2**) vs l2.Val(3) | 2 | tail→l1(2) | 4 | 3→4 | `1_a → 1_b → 2` |
| 第 4 轮 | l1.Val(**4**) vs l2.Val(3) | 3 | tail→l2(3) | 4 | 4 | `1→1→2→3` |
| 第 5 轮 | l1.Val(**4**) vs l2.Val(4) | 相等,选 l1 | tail→l1(4) | nil | 4 | `1→1→2→3→4` |
| l1 耗尽 | — | — | tail→l2(4) | nil | nil | `1→1→2→3→4→4` ✅ |
> [!info] 🧠 关键性质
> - 因为两个输入链表本身就是有序的,所以**每一步贪心选择较小的元素**就能保证全局有序
> - `dummy` 解决了"第一个节点没有前驱可连"的边界问题——统一了所有节点的处理逻辑
> - `tail` 只在末尾追加,永不回头修改,所以不需要反转操作
**当某个链表提前走完时:**直接将 `tail.Next` 指向另一个链表的剩余部分即可——因为剩余部分本身有序,无需额外处理。
**时间复杂度:O(m + n)** — 每个节点恰好被访问一次。
**空间复杂度:O(1)** — 只使用了常数个指针变量。
### 方法二:递归法(更优雅但占用栈空间)
> [!question] 💡 引导思考
> 合并两个有序链表时,**第一步做什么**——一定是把两个头节点中较小的那个作为结果链表的第一个节点。那第二步呢?
第二步是:**用剩下的部分重复同样的过程**。这正是递归的天然结构!
**递归关系:**
$$\text{merge}(l1, l2) = \begin{cases} l1 & \text{if } l2 == \text{nil} \\ l2 & \text{if } l1 == \text{nil} \\ l1.\text{Next} = \text{merge}(l1.\text{Next}, l2); \;\; return\; l1 & \text{if } l1.\text{Val} \leq l2.\text{Val} \\ l2.\text{Next} = \text{merge}(l1, l2.\text{Next}); \;\; return\; l2 & \text{otherwise} \end{cases}$$
**执行流程拆解**(以 `l1=[1,2,4]`, `l2=[1,3,4]` 为例):
### 递:深入调用栈
每一层做出选择后,将较小节点的 `Next` 指向下一层递归的返回值,然后继续深入:
```mermaid
flowchart TD
L1A["Merge(①, ①) → 选 l1(①)"] -->|"Next = Merge(2→4, 1→3→4)"| L1B["Merge(②, ①) → 选 l2(①)"]
L1B -->|"Next = Merge(2→4, 3→4)"| L1C["Merge(②, ③) → 选 l1(②)"]
L1C -->|"Next = Merge(4, 3→4)"| L1D["Merge(④, ③) → 选 l2(③)"]
L1D -->|"Next = Merge(4, ④)"| L1E["Merge(④, ④) → 选 l1(④)"]
L1E -->|"Next = Merge(nil, ④)"| L1F["Merge(nil, ④) ← 基准情况,返回 ④"]
style L1F fill:#f9d,stroke:#333,stroke-width:2px
style L1A fill:#bbf,stroke:#333
```
### 归:逐层拼接结果
在函数返回的过程中,上一层接收到的就是下一层的返回值——直接连到之前选择的节点上:
```mermaid
flowchart LR
R1["Merge(nil, ④)\n返回: 4→nil"] --> R2["Merge(④, ④)\n4→Next=4→nil\n返回: 4→4→nil"]
R2 --> R3["Merge(④, ③)\n3→Next=4→4→nil\n返回: 3→4→4→nil"]
R3 --> R4["Merge(②, ③)\n2→Next=3→4→4→nil\n返回: 2→3→4→4→nil"]
R4 --> R5["Merge(②, ①)\n1→Next=2→3→4→4→nil\n返回: 1→2→3→4→4→nil"]
R5 --> R6["Merge(①, ①)\n1→Next=1→2→3→4→4→nil\n返回: 1→1→2→3→4→4 ✅"]
style R1 fill:#bbf,stroke:#333
style R6 fill:#4c4,color:white,stroke:#333,stroke-width:2px
```
> [!info] 🧠 递归与迭代的本质联系
> 递归版本中的 `l1.Next = mergeTwoLists(l1.Next, l2)` 或 `l2.Next = mergeTwoLists(l1, l2.Next)` 就是在做和迭代版 `tail.Next = smallerNode` 完全相同的操作——把当前最小的节点挂到结果后面。只是递归通过函数调用栈隐式地"记住"了上下文,而迭代版用显式的 `tail` 变量追踪当前位置。
**时间复杂度:O(m + n)** — 每层递归处理一个节点,共 m + n 层。
**空间复杂度:O(m + n)** — 递归调用栈深度等于节点总数。
---
## 代码提示
### 迭代法伪代码
```
dummy = &ListNode{} // 虚拟头节点
tail = dummy // 尾指针紧跟 dummy
for l1 != nil AND l2 != nil {
if l1.Val <= l2.Val {
tail.Next = l1 // 把 l1 接到结果末尾
l1 = l1.Next // l1 往前走一步
} else {
tail.Next = l2 // 把 l2 接到结果末尾
l2 = l2.Next // l2 往前走一步
}
tail = tail.Next // tail 也往前走一步(跟上)
}
// l1 或 l2 中至少有一个为空,接上非空的部分
if l1 != nil {
tail.Next = l1
} else {
tail.Next = l2
}
return dummy.Next // 跳过 dummy,返回真正的头节点
```
### 递归法伪代码
```
func merge(l1, l2):
if l1 == nil: return l2 // 基准情况 1
if l2 == nil: return l1 // 基准情况 2
if l1.Val <= l2.Val {
l1.Next = merge(l1.Next, l2)
return l1
} else {
l2.Next = merge(l1, l2.Next)
return l2
}
```
---
## 技巧
> [!tip] 🔑 虚拟头节点(Dummy Node)万能模板
> 凡是涉及"在链表头部插入/连接"的场景,优先考虑加 dummy。它能避免对首个节点写特殊逻辑,让循环体内保持统一的操作模式:`tail.Next = newnode; tail = tail.Next`。这个模式在「移除链表元素」「分隔链表」「奇偶链表」等题目中反复出现。
> [!note] 🔑 Go 语言细节:切片转链表的辅助函数
> 在本地测试时,你可能需要方便地在切片和链表之间转换:
```go
// sliceToList: 将切片转为链表,方便构造测试用例
func sliceToList(vals []int) *ListNode {
dummy := &ListNode{}
tail := dummy
for _, v := range vals {
tail.Next = &ListNode{Val: v}
tail = tail.Next
}
return dummy.Next
}
// listToSlice: 将链表转为切片,方便打印验证
func listToSlice(head *ListNode) []int {
var result []int
for head != nil {
result = append(result, head.Val)
head = head.Next
}
return result
}
```
> [!warning] ⚠️ 常见错误:忘记推进 tail
> 在连接 `tail.Next = smallerNode` 之后,一定要执行 `tail = tail.Next`。漏掉这步会导致后续所有节点都覆盖到同一个位置,最终结果只剩最后一个接入的节点。
> [!tip] 🔑 相等情况的选择
> 当 `l1.Val == l2.Val` 时,选哪个都可以,结果相同。但从递归视角看——先选 l1 意味着下一轮比较的是 `l1.Next` vs `l2`,先选 l2 则比较 `l1` vs `l2.Next`——虽然不影响最终结果,但在某些变体题(如稳定排序需求)中可能会有细微差异。
> [!note] 🐹 Go 中的链表定义
> LeetCode 的 Go 环境内置如下结构体定义:
```go
type ListNode struct {
Val int
Next *ListNode
}
```
不需要手动定义,直接在解题中使用即可。
> [!info] 📊 两种方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|------|-----------|-----------|------|------|
| **迭代法 ⭐** | **O(m + n)** | **O(1)** | 空间最优、无栈溢出风险 | 需要理解 dummy + tail 的配对使用 |
| 递归法 | O(m + n) | O(m + n) | 代码极短(4 行核心逻辑)、可读性高 | 栈空间消耗大;Go 不会尾递归优化,n 较大时可能栈溢出 |
> [!success] ✅ 相关题目串联
> - [82-删除排序链表中的重复元素 II](./82-删除排序链表中的重复元素-II.md) — 同一组有序链表的变种
> - [21-Merge k Sorted Lists](https://leetcode.com/problems/merge-k-sorted-lists/) — 进阶:合并 K 个有序链表(分治 + 本题解法)
---
## 代码
### 迭代法
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func mergeTwoLists(l1 *ListNode, l2 *ListNode) *ListNode {
// 虚拟头节点:避免对第一个节点做特殊处理
dummy := &ListNode{}
tail := dummy // tail 始终指向结果链表的最后一个节点
// 只要两条链表都不为空,逐个比较、依次接入
for l1 != nil && l2 != nil {
if l1.Val <= l2.Val {
tail.Next = l1 // 将较小的 l1 节点接到结果末尾
l1 = l1.Next // l1 前进一步
} else {
tail.Next = l2 // 将较小的 l2 节点接到结果末尾
l2 = l2.Next // l2 前进一步
}
tail = tail.Next // tail 跟进,保持指向末尾
}
// 有一条链表走完后,直接把 tail 连接到另一条的剩余部分
// Go 的 nil 检查很直观:如果 l1 为空就接 l2,反之接 l1
if l1 != nil {
tail.Next = l1
} else {
tail.Next = l2
}
// 返回 dummy.Next(跳过虚拟头节点),即为合并后的真正头节点
return dummy.Next
}
```
### 递归法
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func mergeTwoLists(l1 *ListNode, l2 *ListNode) *ListNode {
// 基准情况:一条链表为空,直接返回另一条
if l1 == nil {
return l2
}
if l2 == nil {
return l1
}
// 递归:选较小的头节点,它的 Next 指向合并剩余部分的结果
if l1.Val <= l2.Val {
l1.Next = mergeTwoLists(l1.Next, l2) // 递归处理 l1 的剩余部分
return l1
} else {
l2.Next = mergeTwoLists(l1, l2.Next) // 递归处理 l2 的剩余部分
return l2
}
}
```
> [!success] ✅ 运行验证
> 这是 LeetCode 第 21 题,通过率约 60%+。作为链表入门题的代表作,它完美展示了两个经典范式:① **迭代**——dummy + tail 的双指针尾部追加模式;② **递归**——"选出当前最小者 + 递归处理剩余"的分治思路。建议两个版本都手写一遍,体会"显式状态追踪"和"隐式调用栈"两种编程哲学的差异。
+382
View File
@@ -0,0 +1,382 @@
---
tags: ["LeetCode", "链表", "模拟", "简单"]
create time: 2026-05-16 15:00
---
# 28-两数相加
## 题面
给你两个 **非空** 的链表,表示两个非负的整数。它们每位数字都是按照 **逆序** 的方式存储的,并且每个节点只能存储 **一位** 数字。
请你将两个数相加,并以相同形式返回一个表示和的链表。
你可以假设除了数字 0 之外,这两个数都不会以 0 开头。
**示例 1:**
```
输入:l1 = [2,4,3], l2 = [5,6,4]
输出:[7,0,8]
解释:342 + 465 = 807.
```
**示例 2:**
```
输入:l1 = [0], l2 = [0]
输出:[0]
```
**示例 3:**
```
输入:l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9]
输出:[8,9,9,9,0,0,0,1]
```
**提示:**
- 每个链表中的节点数在范围 `[1, 100]` 内
- `0 <= Node.val <= 9`
- 题目数据保证列表表示的数字不含前导零
---
## 思路
> [!question] 💡 引导思考
> 回想小时候学竖式加法——从右往左(个位开始),每一位加起来,如果结果 ≥ 10 就把十位"进"到下一位。链表是逆序存储的,意味着 **头节点就是最低位**。这不就是天然适合从头到尾做竖式加法吗?
### 核心洞察:逐位模拟加法
> [!info] 🧠 关键性质
> 因为链表逆序存储了数字,低位在前、高位在后,所以我们从两个链表的头节点同时出发,就像从个位开始逐位相加,完全对应人类列竖式的习惯。
每次循环需要处理三件事:
| 步骤 | 动作 | 说明 |
|------|------|------|
| ① | **取当前位的值** | 如果某个链表已走到末尾,该位的值视为 `0` |
| ② | **加上进位** | 把上一步产生的进位也加进去 |
| ③ | **计算新位和新的进位** | `sum % 10` 是新位的值,`sum / 10` 是向下一位的进位 |
```mermaid
flowchart TB
subgraph Inputs["📥 输入:两条逆序链表"]
L1["l1\n2→4→3→nil\n(表示 342)"]
L2["l2\n5→6→4→nil\n(表示 465)"]
end
subgraph Loop["⚙️ 每轮循环"]
ADD1["取 l1.Val + l2.Val + carry"]
MOD["新位 = sum % 10"]
DIV["新进位 = sum / 10"]
APPEND["创建新节点,追加到结果尾部"]
end
subgraph Output["📤 输出:结果链表"]
Res["结果\n7→0→8→nil\n(表示 807) ✅"]
end
L1 -->|当前位| ADD1
L2 -->|当前位| ADD1
carry_in[/"💾 carry 入"/] -.进位.-> ADD1
ADD1 --> MOD
ADD1 --> DIV
DIV -->|进位给下一轮| carry_next["carry → ..."]
MOD --> APPEND
APPEND --> Res
style ADD1 fill:#fff4e6,stroke:#f90,stroke-width:2px
style carry_in fill:#ffe0e0,stroke:#c66,stroke-width:1px
style Res fill:#d4edda,stroke:#28a,stroke-width:2px
style Loop fill:#fafafa,stroke:#ccc,stroke-dasharray:5 5
```
### 方法一:迭代法 — 虚拟头节点 + 尾指针 ⭐(推荐)
维护三个变量:
| 变量 | 含义 | 初始值 |
|------|------|--------|
| **dummy** | 虚拟头节点,`dummy.Next` 指向结果的真正头部 | `&ListNode{}` |
| **tail** | 已构建结果链表的最后一个节点 | `dummy` |
| **carry** | 当前的进位值 | `0` |
**逐步展开执行过程**(以示例 3 为例,展示进位传播的极端情况):
`l1 = [9,9,9,9]`, `l2 = [9,9,9,9,9,9,9]`
| 轮次 | l1 取值 | l2 取值 | carry 入 | sum | 新位(sum%10) | 新进位(sum/10) | 结果链表 |
|:----:|:-------:|:-------:|:--------:|:---:|:------------:|:--------------:|---------|
| 初始 | — | — | 0 | — | — | — | `dummy → ?` |
| 第 1 轮 | **9** | **9** | 0 | **18** | **8** | **1** | `8_a` |
| 第 2 轮 | **9** | **9** | 1 | **19** | **9** | **1** | `8→9` |
| 第 3 轮 | **9** | **9** | 1 | **19** | **9** | **1** | `8→9→9` |
| 第 4 轮 | **9** | **9** | 1 | **19** | **9** | **1** | `8→9→9→9` |
| l1 耗尽 | 0 | **9** | 1 | **10** | **0** | **1** | `8→9→9→9→0` |
| 第 6 轮 | 0 | **9** | 1 | **10** | **0** | **1** | `8→9→9→9→0→0` |
| 第 7 轮 | 0 | **9** | 1 | **10** | **0** | **1** | `8→9→9→9→0→0→0` |
| **收尾** | — | — | **1** | **1** | **1** | **0** | `8→9→9→9→0→0→0→1` ✅ |
> [!tip] 🔑 收尾处理两个场景
>
> 当主循环结束时有两种可能:
>
> | 场景 | 条件 | 处理方式 |
> |------|------|----------|
> | A: 无剩余进位 | `l1 == nil && l2 == nil && carry == 0` | 直接返回 `dummy.Next`,无需额外操作 |
> | B: 有剩余进位 | `l1 == nil && l2 == nil && carry == 1` | 追加一个值为 `1` 的新节点 |
**时间复杂度:O(max(m, n))** — 遍历较长的那个链表。
**空间复杂度:O(max(m, n))** — 结果链表本身需要的空间(不计入的空间复杂度为 O(1))。
### 方法二:递归法(同一思想的函数式表达)
> [!question] 💡 为什么递归不太适合?
> 迭代版用 `carry` 这个局部变量自然地在循环中传递进位,而递归版的 `carry` 必须作为参数显式地穿层而过。虽然逻辑等价,但 Go 不支持尾递归优化,长链表可能导致栈溢出。
**递归关系:**
$$\text{add}(p, q, \text{carry}) = \begin{cases} \text{nil} & \text{if } p=\text{nil} \land q=\text{nil} \land \text{carry}=0 \\ \text{newNode}(\text{carry}) & \text{if } p=\text{nil} \land q=\text{nil} \land \text{carry}=1 \\ \text{node}(\text{sum}\bmod 10).\text{Next} = \text{add}(p.\text{Next}, q.\text{Next}, \text{sum}/10) & \text{otherwise} \end{cases}$$
其中 $\text{sum} = v_p + v_q + \text{carry}$,$v_p$ 表示 $p$ 的值(若 $p=\text{nil}$ 则为 0)。
**调用栈展开**(以 `l1=[2,4,3]`, `l2=[5,6,4]` 为例):
```mermaid
flowchart TD
F1["AddTwoNumbers(2,5,0)\nsum=7,carry=0\nNext = F2"] -->|"7→"| F2["AddTwoNumbers(4,6,0)\nsum=10,carry=1\nNext = F3"]
F2 -->|"0→"| F3["AddTwoNumbers(3,4,1)\nsum=8,carry=0\nNext = F4"]
F3 -->|"8→"| F4["AddTwoNumbers(nil,nil,0)\n基准情况,返回 nil"]
F4:::base
classDef base fill:#f9d,stroke:#333,stroke-width:2px
```
**时间复杂度:O(max(m, n))** — 每层处理一个节点。
**空间复杂度:O(max(m, n))** — 递归栈深度等于较长链表的长度。Go 不保证尾递归消除,实际不推荐用于生产场景。
---
## 代码提示
### 迭代法伪代码
```
dummy = &ListNode{} // 虚拟头节点
tail = dummy // 尾指针,指向结果链表末尾
carry = 0 // 进位,初始为 0
for l1 != nil OR l2 != nil OR carry > 0 {
val1 = 0
if l1 != nil { val1 = l1.Val; l1 = l1.Next }
val2 = 0
if l2 != nil { val2 = l2.Val; l2 = l2.Next }
sum = val1 + val2 + carry
tail.Next = &ListNode{Val: sum % 10} // 新位
tail = tail.Next // 尾指针跟进
carry = sum / 10 // 新进位
}
return dummy.Next
```
### 递归法伪代码
```
func addNodes(p, q *Node, carry int) *Node:
// 基准情况:两个链表都走完且无进位
if p == nil and q == nil and carry == 0:
return nil
valP = 0 if p == nil else p.Val
valQ = 0 if q == nil else q.Val
sum = valP + valQ + carry
newNode = Node{ Val: sum % 10 }
// 递推下一层
newNode.Next = addNodes(
p.Next if p != nil else nil,
q.Next if q != nil else nil,
sum / 10
)
return newNode
```
---
## 技巧
> [!tip] 🔑 短写法:利用 Go 的 nil 解引用
>
> 在 Go 中,`*ListNode` 的零值是 `nil`。可以用以下惯用法避免冗长的 if 判断:
```go
val1 := 0
if l1 != nil {
val1 = l1.Val
l1 = l1.Next
}
```
> 也可以写成一行表达式:`val1 := 0; if l1 != nil { val1, l1 = l1.Val, l1.Next }`,但拆成多行可读性更好。
> [!warning] ⚠️ 常见错误:遗漏"最后一位进位"
>
> 很多人写出的循环条件是 `for l1 != nil && l2 != nil`,这会导致当两个链表等长且最高位产生进位时(如 `5+5=10`),遗漏最前面的那个 `1`。**正确做法**是把终止条件写成 `for l1 != nil || l2 != nil || carry > 0`,确保所有情况都被覆盖。
> [!note] 🔑 为什么不需要判空输入?
>
> 题目保证链表是非空的,所以不必检查 `l1 == nil || l2 == nil`。但如果在实际工作中遇到类似场景,记得在最前面加一句保护性检查。
> [!tip] 🔑 与竖式加法的精确映射
| 竖式加法 | 代码对应 |
|---------|---------|
| 从个位开始算 | 从链表的 head(低位)开始遍历 |
| 不够两位补零 | 链表为空时取值为 0 |
| 满十进一 | `carry = sum / 10` |
| 写下当前位 | `newNode{Val: sum % 10}` |
| 最后进位写在最前面 | 循环结束后若 carry > 0,追加新节点 |
> [!note] 🐹 Go 中的链表定义
> LeetCode 的 Go 环境内置如下结构体定义:
```go
type ListNode struct {
Val int
Next *ListNode
}
```
不需要手动定义,直接在解题中使用即可。
> [!info] 📊 两种方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|------|-----------|-----------|------|------|
| **迭代法 ⭐** | **O(max(m,n))** | **O(1)**(除结果外) | 无栈溢出风险、常数小 | 需要理解 dummy + tail 模式 |
| 递归法 | O(max(m,n)) | O(max(m,n)) | 代码简洁、逻辑直观 | Go 无尾递归优化,长链表可能栈溢出 |
> [!success] ✅ 相关题目串联
> - [[2-两数相加 II]] — 进阶版:数字是**正序**存储的(需要从后往前加)
> - [[27-合并两个有序链表]] — 同样是双链表遍历的经典范式
> - [[67-二进制求和]] — LeetCode 第 67 题:原理完全相同,只是逢二进一
---
## 代码
### 迭代法
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func addTwoNumbers(l1 *ListNode, l2 *ListNode) *ListNode {
// 虚拟头节点:避免对第一个节点做特殊处理
dummy := &ListNode{}
tail := dummy // tail 始终指向结果链表的最后一个节点
carry := 0 // 进位值
// 当任意一条链表还有节点,或者还有进位未处理时继续循环
for l1 != nil || l2 != nil || carry > 0 {
// 取出当前位的值(链表为空时视为 0)
val1 := 0
if l1 != nil {
val1 = l1.Val
l1 = l1.Next
}
val2 := 0
if l2 != nil {
val2 = l2.Val
l2 = l2.Next
}
// 逐位相加:当前位的两个数字 + 上一轮的进位
sum := val1 + val2 + carry
// 创建新节点存放结果的当前位(个位数)
tail.Next = &ListNode{Val: sum % 10}
tail = tail.Next // 尾指针跟进
// 更新进位(十位数)
carry = sum / 10
}
// 跳过 dummy,返回真正的头节点
return dummy.Next
}
```
### 递归法
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func addTwoNumbers(l1 *ListNode, l2 *ListNode) *ListNode {
return addNodes(l1, l2, 0)
}
// addNodes 递归处理两个链表对应位置及进位
func addNodes(p, q *ListNode, carry int) *ListNode {
// 基准情况:两个链表都已走完,且没有剩余进位
if p == nil && q == nil && carry == 0 {
return nil
}
// 取当前位的值(节点为空时值为 0)
valP := 0
if p != nil {
valP = p.Val
}
valQ := 0
if q != nil {
valQ = q.Val
}
// 计算当前位的和与新的进位
sum := valP + valQ + carry
// 创建当前位的节点
current := &ListNode{Val: sum % 10}
// 递归处理下一位,并将返回值链接到当前节点的 Next
current.Next = addNodes(
mapNil(p), // 传入下一节点(nil 安全)
mapNil(q),
sum/10, // 进位作为参数传入下一层
)
return current
}
// mapNil 帮助函数:nil-safe 获取 Next
func mapNil(node *ListNode) *ListNode {
if node == nil {
return nil
}
return node.Next
}
```
> [!success] ✅ 运行验证
> 这是 LeetCode 第 2 题,通过率约 45%+。它考察的核心只有一个:**模拟竖式加法并处理进位**。虽然代码很短,但它完美展示了链表遍历的经典范式——dummy 节点 + 尾指针 + 边界循环条件。建议至少手写迭代版,递归版作为拓展理解即可。
@@ -0,0 +1,260 @@
---
tags: ["LeetCode", "链表", "双指针", "快慢指针", "虚拟头节点", "中等"]
create time: 2026-05-16 15:00
---
# 29-删除链表的倒数第 N 个结点
## 题面
给你一个链表,删除链表的倒数第 `n` 个结点,并且返回链表的头结点。
**示例 1:**
```
输入:head = [1,2,3,4,5], n = 2
输出:[1,2,3,5]
```
**示例 2:**
```
输入:head = [1], n = 1
输出:[]
```
**示例 3:**
```
输入:head = [1,2], n = 1
输出:[1]
```
**提示:**
- 链表中结点的数目为 `sz`
- `1 <= sz <= 30`
- `0 <= Node.val <= 100`
- `1 <= n <= sz`
**进阶:** 你能尝试使用一趟扫描实现吗?
---
## 思路
> [!question] 💡 核心洞察
> 要删除倒数第 n 个节点,最直接的念头是——"先数总长,再找正数位置"。但这需要**两遍扫描**。进阶要求我们**一趟搞定**,怎么做到的?
关键在于:**用距离(gap)代替计数**。如果我们让两个指针之间始终保持 n 个节点的间隔,那么当快指针走到末尾时,慢指针自然就在目标位置。
### 关键问题:如何定位?
> [!warning] ⚠️ 最大陷阱
> 删除一个节点需要的是它的**前驱节点**——因为只有前驱才能修改 `Next` 指针把目标摘出去。如果慢指针恰好停在待删节点上,你就无从下手了。
所以我们的目标是:让慢指针最终停在**待删节点的前驱位置**。
### 方法一:快慢指针 + 虚拟头节点 ⭐(最优 — 一趟扫描)
维护两个指针 `fast` 和 `slow`,初始都指向一个**虚拟头节点** `dummy`(`dummy.Next = head`)。
**核心策略:** 先让 `fast` 走 n 步,然后 `fast` 和 `slow` 同时出发,每次各走一步。当 `fast` 到达最后一个节点时(`fast.Next == nil`),`slow` 恰好停在倒数第 n 个节点的前驱。
**为什么这样一定正确?**
> [!question] 💡 引导思考
> 假设链表长度为 sz,快指针领先 n 步。那么当快指针走完剩余路程时,它走了多少步?慢指针呢?它们之间的距离保持了几步?
快指针再走 `sz - n` 步会到达**最后一个节点**(而非越过边界)。循环条件 `fast.Next != nil` 保证在快指针停在尾节点时立即终止。此时慢指针也恰好走了 `sz - n` 步。由于 `dummy` 是第 0 位,`dummy → slow` 的距离就是 `sz - n`——这意味着 slow 指向的是正数第 `sz - n` 个节点,即倒数第 `n + 1` 个节点,正是待删节点的前驱。
关键不变量:**从 fast 先走 n 步之后起,slow 永远比 fast 落后 n 个节点**。这 n 的间隔全程保持不变,直到循环结束。
**图示执行流程:**
```mermaid
flowchart LR
subgraph Init["第一步:fast 先走 n 步(n=2)"]
D["dummy"] -->|"slow"| A["1"]
A --> B["2"]
B --> C["3"]
C --> D2["4"]
D2 --> E["5"]
F["fast"] -.走2步.-> B
end
subgraph Sync["第二步:fast & slow 同步前进(直到 fast == 尾节点)"]
B -->|"slow→1, fast→3"| G["slow: 1 / fast: 3"]
G -->|"slow→2, fast→4"| H["slow: 2 / fast: 4"]
H -->|"slow→3, fast→5"| I["slow: 3 / fast: 5"]
end
subgraph Done["结束:fast==尾节点, slow在倒数第 n+1 个(待删前驱)"]
I -->|"fast.Next==nil, 退出"| K["slow指向3\n删除slow.Next(4)\n1→2→3→5 ✅"]
end
Init --> Sync --> Done
classDef done fill:#4c4,color:white
class K done
```
逐步展开(以 `head = [1,2,3,4,5], n = 2` 为例):
| 阶段 | dummy | slow | fast | 说明 |
|------|--------|------|------|------|
| 初始 | → 1→2→3→4→5 | dummy | dummy | 都从虚拟头节点出发 |
| fast 走 n=2 步后 | → 1→2→3→4→5 | dummy | **2** | fast 跳过 1、2,停在 2 |
| 第 1 轮同步 | → 1→2→3→4→5 | **1** | **3** | 各走一步(fast.Next=3 ≠ nil) |
| 第 2 轮同步 | → 1→2→3→4→5 | **2** | **4** | 各走一步(fast.Next=4 ≠ nil) |
| 第 3 轮同步 | → 1→2→3→4→5 | **3** | **5** | 各走一步(fast.Next=5 ≠ nil) |
| 循环终止 | — | **3** | **5** | fast.Next == nil,退出循环 |
| 删除操作 | — | 指向 3 | — | `slow.Next = slow.Next.Next`,删除节点 4 |
```mermaid
flowchart TD
Before["删除前\n1→2→3→4→5\n ↑slow\n ↑slow.Next\n ↑slow.Next.Next\n \ndelete slow.Next"] --> After["删除后\n1→2→3→5\n ↑slow.Next 指向 5 ✅"]
style Before fill:#fff4e6,stroke:#f90
style After fill:#d4edda,stroke:#28a
```
> [!info] 🧠 为什么用 `dummy` 能优雅处理所有边界?
> - **删除头节点**:如 `n = 5`(删除 1),此时 `dummy` 先走 5 步刚好到 `nil`,`slow` 还在 `dummy`,`slow.Next = slow.Next.Next` 删除的就是节点 1。
> - **删除尾节点**:如 `n = 1`(删除 5),此时 `slow` 停在节点 4,正常删除。
> - **单节点链表**:如 `head = [1], n = 1`,结果直接是空链表。
**时间复杂度:O(n)** — 每个节点最多被访问一次。
**空间复杂度:O(1)** — 只用了两个指针变量。
---
## 代码提示
### 快慢指针伪代码
```
// 第一步:创建虚拟头节点
dummy = &ListNode{Next: head}
slow = dummy
fast = dummy
// 第二步:fast 先走 n 步
for i = 0; i < n; i++ {
fast = fast.Next
}
// 第三步:fast 和 slow 同步前进,直到 fast 到达最后一个节点
for fast.Next != nil {
slow = slow.Next
fast = fast.Next
}
// 第四步:通过 slow 的 Next 跳过待删节点
slow.Next = slow.Next.Next
return dummy.Next
```
### 图解关键步骤
```mermaid
flowchart LR
Step1["dummy 节点\n防止删除头节点时需要特殊判断"] --> Step2["fast 先走 n 步\n制造 n 的间隔"]
Step2 --> Step3["同步前进\nfast 到头时\nslow 恰好在倒数第 n+1 个"]
Step3 --> Step4["跳过待删节点\nslow.Next = slow.Next.Next"]
style Step1 fill:#bbf,stroke:#333
style Step2 fill:#f9d,stroke:#333
style Step3 fill:#ffd700,stroke:#333
style Step4 fill:#d4edda,stroke:#28a
```
---
## 技巧
> [!tip] 🔑 快慢指针"间隔法"通用模式
> "让快指针先走 k 步,然后一起走"是一个极其通用的模板。它解决的问题范式是:**找到与末尾有固定距离的某个位置**。除了本题(倒数第 n 个),还衍生出:
> - 链表的中间节点(k = n/2)
> - 环形链表的入口(稍作变形)
> - 排序数组中的差值对(类似思想扩展到数组)
> [!warning] ⚠️ 常见错误 1:忘记加虚拟头节点
> 如果不用 `dummy`,删除头节点(如 `n = sz`)时需要单独处理。加上 `dummy` 后,所有节点都被"右移了一位",统一由 `slow.Next.Next` 处理。
> [!warning] ⚠️ 常见错误 2:fast 提前为空
> 当 n = sz 时,fast 走完 n 步后正好等于 `nil`。这时第三个循环(同步前进)不会执行,直接进入第四步——这正是正确的行为!不要误判为错误。
> [!note] 🔑 两遍扫描解法(辅助理解)
> 虽然不满足进阶要求,但两遍扫描的思路更直观:
> ① 第一遍遍历求长度 len;② 第二遍从头走到第 `len - n` 个节点的位置并删除。这种方法的优势是更容易扩展——比如你想删除倒数第 n ~ m 个节点时,比快慢指针更容易改写。**面试时可以先说两遍扫描的方案展示理解,再用快慢指针优化到一遍。**
> [!note] 🐹 Go 中的链表定义
> LeetCode 的 Go 环境内置如下结构体定义:
```go
type ListNode struct {
Val int
Next *ListNode
}
```
不需要手动定义,直接在解题中使用即可。
> [!info] 📊 两种扫描方式对比
| 维度 | 两遍扫描 | 一遍扫描(快慢指针)⭐ |
|------|---------|---------------------|
| 时间复杂度 | O(n)(两次遍历) | O(n)(一次遍历) |
| 空间复杂度 | O(1) | O(1) |
| 直观程度 | 非常直观 | 需要理解"间隔不变量" |
| 面试官满意度 | 合格,但不加分 | 优秀,满足进阶要求 |
| 适用场景 | 无法保证单次扫描时可用 | 绝大多数链表问题的首选 |
---
## 代码
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func removeNthFromEnd(head *ListNode, n int) *ListNode {
// 虚拟头节点:消除"删除头节点需要特殊处理"的边界情况
dummy := &ListNode{Next: head}
slow := dummy
fast := dummy
// 快指针先走 n 步,与慢指针拉开 n 的距离
for i := 0; i < n; i++ {
fast = fast.Next
}
// 快慢指针同步前进,直到 fast 到达最后一个节点
for fast.Next != nil {
slow = slow.Next
fast = fast.Next
}
// slow 现在停在倒数第 n 个节点的前驱
// slow.Next = slow.Next.Next 直接跳过待删节点
slow.Next = slow.Next.Next
return dummy.Next // 绕过虚拟头节点,返回真正的头节点
}
```
> [!success] ✅ 运行验证
> 这是 LeetCode 第 19 题,通过率约 40%+。看似简单的题目恰恰最能检验对链表操作的熟练度——虚拟头节点是否本能地想到、快慢指针的间隔不变量是否能清晰解释。这道题是后续更多复杂链表题目的基础:反转链表的一部分、合并 K 个有序链表、链表的 K 逆序等都依赖同样的"间隔控制"思想。建议配合 Mermaid 图示手写 2-3 遍,直到形成肌肉记忆。
> [!note] 🔗 相关题目串联
> - [141-环形链表](./25-环形链表.md) — 另一个经典的双指针应用
> - [142-环形链表 II](./26-环形链表 II.md) — 快慢指针找环入口
> - [23-反转链表](./23-反转链表.md) — 链表基础操作
> - [24-两数相加](./28-两数相加.md) — 链表遍历的高级应用