vault backup: 2026-05-16 16:26:24
This commit is contained in:
+386
@@ -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 判圈算法却是图论中一个经典而优雅的技巧——仅凭两个指针的速度差异就能判断整个图的拓扑结构。掌握这个模式,不仅能秒杀环形链表系列题目,也为后续学习图论中的环路检测打下基础。
|
||||
@@ -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 就完成了从"检测环"到"定位入口"的跨越。建议在纸上画图推导一遍这个公式,理解后会感受到数学之美。
|
||||
@@ -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
@@ -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) — 链表遍历的高级应用
|
||||
Reference in New Issue
Block a user