对两个 LeetCode Hot 100 题目进行了详细的思路改进: ## 改进内容 ### 1. 两数相加.md - 新增"思路推导"部分:从暴力解法分析到优化思考 - 详细化"解题思路":分步骤说明每个关键点 - 增加"关键细节说明":4个核心细节深入分析 - 增加"边界条件分析":4种边界情况完整演示 - 增加"Q&A 问题解释":5个常见问题详细解答 - 增加"执行过程演示":完整执行过程可视化 - 增加"常见错误":5个典型错误对比说明 ### 2. 删除链表的倒数第N个结点.md - 新增"思路推导"部分:暴力解法到双指针优化 - 详细化双指针法、计算长度法、栈法的完整流程 - 增加"关键细节说明":n+1步、哑节点等核心概念 - 增加"边界条件分析":删除头、尾、中间节点等场景 - 增加"Q&A 问题解释":5个核心问题深入解答 - 增加"执行过程演示":完整执行过程可视化 - 增加"常见错误":5个典型错误对比说明 ## 改进效果 - 从简单算法流程升级为完整思考路径 - 从基础步骤说明升级为详细原理分析 - 从复杂度概览升级为逐步推导过程 - 增加了可视化执行过程和常见错误对比 - 更适合面试准备和深度理解 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1263 lines
26 KiB
Markdown
1263 lines
26 KiB
Markdown
# 删除链表的倒数第N个结点 (Remove Nth Node From End of List)
|
||
|
||
## 题目描述
|
||
|
||
给你一个链表,删除链表的倒数第 `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`
|
||
|
||
### 进阶
|
||
|
||
你能尝试使用一趟扫描实现吗?
|
||
|
||
## 思路推导
|
||
|
||
### 暴力解法分析
|
||
|
||
**思路1:两次遍历 - 计算长度法**
|
||
```go
|
||
func removeNthFromEnd(head *ListNode, n int) *ListNode {
|
||
// 第一次遍历:计算链表长度
|
||
length := 0
|
||
current := head
|
||
for current != nil {
|
||
length++
|
||
current = current.Next
|
||
}
|
||
|
||
// 计算要删除的正数位置
|
||
pos := length - n
|
||
|
||
// 创建哑节点
|
||
dummy := &ListNode{0, head}
|
||
current = dummy
|
||
|
||
// 第二次遍历:移动到要删除节点的前一个节点
|
||
for i := 0; i < pos; i++ {
|
||
current = current.Next
|
||
}
|
||
|
||
// 删除节点
|
||
current.Next = current.Next.Next
|
||
|
||
return dummy.Next
|
||
}
|
||
```
|
||
|
||
**时间复杂度**:O(2L) = O(L),其中 L 是链表长度
|
||
**空间复杂度**:O(1)
|
||
|
||
**问题分析**:
|
||
- ✅ **思路清晰**:先算长度,再定位删除
|
||
- ❌ **两次遍历**:需要遍历链表两次
|
||
- ❌ **效率一般**:虽然时间复杂度是 O(L),但常数因子是2
|
||
|
||
### 优化思考
|
||
|
||
**观察**:
|
||
- 题目进阶要求:**能否尝试使用一趟扫描实现?**
|
||
- 关键问题:如何在一次遍历中找到倒数第N个节点?
|
||
|
||
**关键洞察**:
|
||
```
|
||
如果两个指针相距 N 个节点,当快指针到达末尾时,
|
||
慢指针恰好指向倒数第N个节点!
|
||
|
||
示例:删除倒数第2个节点 (n=2)
|
||
原始链表:1 -> 2 -> 3 -> 4 -> 5
|
||
|
||
步骤1:fast 先移动 n+1 = 3 步
|
||
fast
|
||
↓
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5
|
||
↑
|
||
slow
|
||
|
||
步骤2:fast 和 slow 同时移动,直到 fast 到达末尾
|
||
fast
|
||
↓
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil
|
||
↑
|
||
slow
|
||
|
||
步骤3:此时 slow 指向要删除节点的前一个节点
|
||
要删除的是 4,slow 指向 3
|
||
|
||
执行删除:slow.Next = slow.Next.Next
|
||
结果:1 -> 2 -> 3 -> 5
|
||
```
|
||
|
||
### 为什么这样思考?
|
||
|
||
**1. 双指针的巧妙之处**
|
||
- 快慢指针相距 N 个节点
|
||
- 当快指针到达末尾时,慢指针刚好在目标位置
|
||
- **类比**:就像两个人赛跑,一个人先跑N步,然后同时跑,当先跑的人到达终点时,后跑的人距离终点还有N步
|
||
|
||
**2. 为什么是 n+1 步而不是 n 步?**
|
||
```
|
||
如果移动 n 步:
|
||
fast
|
||
↓
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5
|
||
↑
|
||
slow
|
||
|
||
同时移动后:
|
||
fast
|
||
↓
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5
|
||
↑
|
||
slow
|
||
|
||
此时 slow 指向要删除的节点本身,而不是前一个节点
|
||
删除操作需要前一个节点:slow.Next = slow.Next.Next
|
||
|
||
如果移动 n+1 步:
|
||
fast
|
||
↓
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5
|
||
↑
|
||
slow
|
||
|
||
同时移动后:
|
||
fast
|
||
↓
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil
|
||
↑
|
||
slow
|
||
|
||
此时 slow 指向要删除节点的前一个节点
|
||
可以方便删除:slow.Next = slow.Next.Next
|
||
```
|
||
|
||
**3. 为什么需要哑节点?**
|
||
```
|
||
没有哑节点的情况:
|
||
head -> 1 -> 2 -> 3 -> 4 -> 5
|
||
|
||
如果要删除头节点(倒数第5个节点):
|
||
- slow 会指向 nil(head 的前一个节点不存在)
|
||
- 无法执行删除操作
|
||
|
||
有哑节点的情况:
|
||
dummy -> head -> 1 -> 2 -> 3 -> 4 -> 5
|
||
|
||
如果要删除头节点:
|
||
- slow 指向 dummy
|
||
- 执行 dummy.Next = dummy.Next.Next
|
||
- 成功删除 head 节点
|
||
```
|
||
|
||
**4. 时间复杂度的优化**
|
||
- 计算长度法:O(2L) - 两次遍历
|
||
- 双指针法:O(L) - 一次遍历
|
||
- 虽然都是 O(L),但双指针法的常数因子更小
|
||
|
||
---
|
||
|
||
## 解题思路
|
||
|
||
### 方法一:双指针法(推荐)
|
||
|
||
**核心思想**:使用两个指针 `fast` 和 `slow`,`fast` 先移动 `n+1` 步,然后 `fast` 和 `slow` 一起移动,直到 `fast` 到达链表末尾。此时 `slow` 指向要删除结点的前一个结点。
|
||
|
||
### 详细算法流程(双指针法)
|
||
|
||
**步骤1:初始化哑节点和指针**
|
||
```go
|
||
dummy := &ListNode{0, head} // 哑节点,简化头节点删除
|
||
fast := dummy // 快指针
|
||
slow := dummy // 慢指针
|
||
```
|
||
|
||
**关键点**:
|
||
- 为什么需要哑节点?
|
||
- 统一处理删除头节点的情况
|
||
- 避免 nil 指针的边界判断
|
||
- 为什么 fast 和 slow 都指向 dummy?
|
||
- 保证 fast 和 slow 的距离准确
|
||
- 从同一个起点开始,距离计算更清晰
|
||
|
||
**步骤2:fast 先移动 n+1 步**
|
||
```go
|
||
for i := 0; i <= n; i++ {
|
||
fast = fast.Next
|
||
}
|
||
```
|
||
|
||
**关键点**:
|
||
- 为什么是 `i <= n`(n+1 步)而不是 `i < n`(n 步)?
|
||
- n+1 步:slow 最终指向要删除节点的前一个节点
|
||
- n 步:slow 最终指向要删除的节点本身
|
||
- 删除操作需要前一个节点
|
||
- 为什么要移动 n+1 步?
|
||
- 让 fast 和 slow 之间拉开 n 个节点的距离
|
||
- 当 fast 到达末尾(nil)时,slow 刚好在目标位置
|
||
|
||
**示例**:
|
||
```
|
||
链表:1 -> 2 -> 3 -> 4 -> 5
|
||
删除倒数第 2 个节点(n=2)
|
||
|
||
fast 移动 3 步(n+1=3):
|
||
初始: dummy -> 1 -> 2 -> 3 -> 4 -> 5
|
||
↑fast/slow
|
||
|
||
第1步: dummy -> 1 -> 2 -> 3 -> 4 -> 5
|
||
↑fast
|
||
↑slow
|
||
|
||
第2步: dummy -> 1 -> 2 -> 3 -> 4 -> 5
|
||
↑fast
|
||
↑slow
|
||
|
||
第3步: dummy -> 1 -> 2 -> 3 -> 4 -> 5
|
||
↑fast
|
||
↑slow
|
||
```
|
||
|
||
**步骤3:fast 和 slow 同时移动,直到 fast 为 nil**
|
||
```go
|
||
for fast != nil {
|
||
fast = fast.Next
|
||
slow = slow.Next
|
||
}
|
||
```
|
||
|
||
**关键点**:
|
||
- 为什么条件是 `fast != nil`?
|
||
- fast 最终会指向最后一个节点的 Next,即 nil
|
||
- 当 fast 为 nil 时,slow 刚好在目标位置
|
||
- 为什么 fast 和 slow 都移动一步?
|
||
- 保持两者之间的距离不变
|
||
- 维持 n 个节点的间距
|
||
|
||
**示例**:
|
||
```
|
||
继续上面的例子:
|
||
fast 在节点 4,slow 在 dummy
|
||
|
||
第1次同时移动:
|
||
fast
|
||
↓
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5
|
||
↑
|
||
slow
|
||
|
||
第2次同时移动:
|
||
fast
|
||
↓
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil
|
||
↑
|
||
slow
|
||
|
||
fast 为 nil,循环结束
|
||
slow 指向节点 3,是要删除节点 4 的前一个节点
|
||
```
|
||
|
||
**步骤4:删除节点**
|
||
```go
|
||
slow.Next = slow.Next.Next
|
||
```
|
||
|
||
**关键点**:
|
||
- 为什么可以这样做?
|
||
- slow 指向要删除节点的前一个节点
|
||
- slow.Next 是要删除的节点
|
||
- slow.Next.Next 是要删除节点的下一个节点
|
||
- 直接跳过要删除的节点
|
||
|
||
**步骤5:返回结果**
|
||
```go
|
||
return dummy.Next
|
||
```
|
||
|
||
**关键点**:
|
||
- 为什么返回 `dummy.Next` 而不是 `dummy`?
|
||
- dummy 是哑节点,不是链表的一部分
|
||
- dummy.Next 才是真正的链表头
|
||
- 即使删除了头节点,也能正确返回
|
||
|
||
---
|
||
|
||
### 关键细节说明
|
||
|
||
**细节1:为什么是 n+1 步?**
|
||
```go
|
||
// ❌ 错误:移动 n 步
|
||
for i := 0; i < n; i++ {
|
||
fast = fast.Next
|
||
}
|
||
// 结果:slow 指向要删除的节点本身,无法删除
|
||
|
||
// ✅ 正确:移动 n+1 步
|
||
for i := 0; i <= n; i++ {
|
||
fast = fast.Next
|
||
}
|
||
// 结果:slow 指向要删除节点的前一个节点
|
||
```
|
||
|
||
**图解**:
|
||
```
|
||
链表:1 -> 2 -> 3 -> 4 -> 5
|
||
删除倒数第 2 个节点(n=2),即删除节点 4
|
||
|
||
移动 n 步(错误):
|
||
fast
|
||
↓
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil
|
||
↑
|
||
slow
|
||
|
||
同时移动后:
|
||
fast
|
||
↓
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil
|
||
↑
|
||
slow
|
||
|
||
slow 指向节点 4,无法删除自己!
|
||
❌ 无法执行 slow.Next = slow.Next.Next
|
||
|
||
移动 n+1 步(正确):
|
||
fast
|
||
↓
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil
|
||
↑
|
||
slow
|
||
|
||
同时移动后:
|
||
fast
|
||
↓
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil
|
||
↑
|
||
slow
|
||
|
||
slow 指向节点 3,可以删除下一个节点 4
|
||
✅ 执行 slow.Next = slow.Next.Next 成功
|
||
```
|
||
|
||
**细节2:为什么需要哑节点?**
|
||
```go
|
||
// ❌ 没有哑节点:删除头节点会出错
|
||
func removeNthFromEndWithoutDummy(head *ListNode, n int) *ListNode {
|
||
fast := head
|
||
slow := head
|
||
|
||
// fast 移动 n 步
|
||
for i := 0; i < n; i++ {
|
||
fast = fast.Next
|
||
}
|
||
|
||
// 同时移动
|
||
for fast != nil {
|
||
fast = fast.Next
|
||
slow = slow.Next
|
||
}
|
||
|
||
// 如果要删除的是头节点,slow 会是 nil
|
||
// slow.Next 会空指针异常
|
||
slow.Next = slow.Next.Next
|
||
|
||
return head
|
||
}
|
||
|
||
// ✅ 有哑节点:统一处理
|
||
func removeNthFromEndWithDummy(head *ListNode, n int) *ListNode {
|
||
dummy := &ListNode{0, head}
|
||
fast := dummy
|
||
slow := dummy
|
||
|
||
// fast 移动 n+1 步
|
||
for i := 0; i <= n; i++ {
|
||
fast = fast.Next
|
||
}
|
||
|
||
// 同时移动
|
||
for fast != nil {
|
||
fast = fast.Next
|
||
slow = slow.Next
|
||
}
|
||
|
||
// 即使删除头节点,slow 也指向 dummy,不会是 nil
|
||
slow.Next = slow.Next.Next
|
||
|
||
return dummy.Next
|
||
}
|
||
```
|
||
|
||
**细节3:边界条件 - 只有一个节点**
|
||
```
|
||
输入:head = [1], n = 1
|
||
|
||
步骤:
|
||
1. dummy -> 1 -> nil
|
||
2. fast 移动 2 步(n+1=2)
|
||
- 第1步:fast = 1
|
||
- 第2步:fast = nil
|
||
3. fast 已经是 nil,不进入同时移动的循环
|
||
4. slow 指向 dummy
|
||
5. 执行 slow.Next = slow.Next.Next
|
||
- dummy.Next = dummy.Next.Next
|
||
- dummy.Next = 1.Next = nil
|
||
6. 返回 dummy.Next = nil
|
||
|
||
输出:[]
|
||
```
|
||
|
||
**细节4:边界条件 - 删除最后一个节点**
|
||
```
|
||
输入:head = [1,2], n = 1
|
||
|
||
步骤:
|
||
1. dummy -> 1 -> 2 -> nil
|
||
2. fast 移动 2 步(n+1=2)
|
||
- 第1步:fast = 1
|
||
- 第2步:fast = 2
|
||
3. 同时移动
|
||
- 第1次:fast = nil, slow = 1
|
||
4. slow 指向节点 1
|
||
5. 执行 slow.Next = slow.Next.Next
|
||
- 1.Next = 1.Next.Next
|
||
- 1.Next = 2.Next = nil
|
||
6. 返回 dummy.Next = 1
|
||
|
||
输出:[1]
|
||
```
|
||
|
||
---
|
||
|
||
### 边界条件分析
|
||
|
||
**边界1:删除头节点**
|
||
```
|
||
输入:head = [1,2,3,4,5], n = 5
|
||
|
||
过程:
|
||
- fast 移动 6 步(n+1=6)
|
||
- fast 最终为 nil
|
||
- slow 还在 dummy,没有移动
|
||
- 执行 dummy.Next = dummy.Next.Next
|
||
- dummy.Next 原本指向 1,现在指向 2
|
||
|
||
输出:[2,3,4,5]
|
||
```
|
||
|
||
**边界2:删除尾节点**
|
||
```
|
||
输入:head = [1,2,3,4,5], n = 1
|
||
|
||
过程:
|
||
- fast 移动 2 步(n+1=2),fast 在节点 2
|
||
- 同时移动:
|
||
- 第1次:fast=3, slow=1
|
||
- 第2次:fast=4, slow=2
|
||
- 第3次:fast=5, slow=3
|
||
- 第4次:fast=nil, slow=4
|
||
- slow 指向节点 4
|
||
- 执行 4.Next = 4.Next.Next = nil
|
||
|
||
输出:[1,2,3,4]
|
||
```
|
||
|
||
**边界3:删除中间节点**
|
||
```
|
||
输入:head = [1,2,3,4,5], n = 3
|
||
|
||
过程:
|
||
- fast 移动 4 步(n+1=4),fast 在节点 4
|
||
- 同时移动:
|
||
- 第1次:fast=5, slow=1
|
||
- 第2次:fast=nil, slow=2
|
||
- slow 指向节点 2
|
||
- 执行 2.Next = 2.Next.Next = 4
|
||
|
||
输出:[1,2,4,5]
|
||
```
|
||
|
||
**边界4:只有一个节点**
|
||
```
|
||
输入:head = [1], n = 1
|
||
|
||
过程:
|
||
- fast 移动 2 步(n+1=2),fast 为 nil
|
||
- 没有进入同时移动循环
|
||
- slow 指向 dummy
|
||
- 执行 dummy.Next = dummy.Next.Next = nil
|
||
|
||
输出:[]
|
||
```
|
||
|
||
---
|
||
|
||
### Q&A 问题解释
|
||
|
||
**Q1:为什么双指针法只需要一次遍历?**
|
||
A:
|
||
- 快指针先走 n+1 步,建立了 n 个节点的距离
|
||
- 然后快慢指针同时移动,保持这个距离
|
||
- 当快指针到达末尾时,慢指针刚好在目标位置
|
||
- 整个过程只需要遍历链表一次
|
||
|
||
**Q2:如果 n 大于链表长度怎么办?**
|
||
A:
|
||
- 根据题目约束:`1 <= n <= sz`(sz 是链表长度)
|
||
- 所以 n 不会大于链表长度
|
||
- 但如果需要防御性编程,可以添加检查:
|
||
```go
|
||
// 计算链表长度
|
||
length := 0
|
||
current := head
|
||
for current != nil {
|
||
length++
|
||
current = current.Next
|
||
}
|
||
|
||
// 检查 n 是否合法
|
||
if n > length {
|
||
return head // n 超出范围,不删除
|
||
}
|
||
```
|
||
|
||
**Q3:为什么栈法的空间复杂度是 O(L)?**
|
||
A:
|
||
- 栈法需要将所有节点压入栈
|
||
- 栈的大小等于链表长度
|
||
- 所以空间复杂度是 O(L)
|
||
- 双指针法只需要几个指针变量,空间复杂度是 O(1)
|
||
|
||
**Q4:三种方法各有什么优缺点?**
|
||
A:
|
||
```
|
||
双指针法(推荐):
|
||
✅ 一次遍历,时间最优
|
||
✅ 空间复杂度 O(1)
|
||
✅ 代码简洁优雅
|
||
❌ 思路相对复杂
|
||
|
||
计算长度法:
|
||
✅ 思路清晰,容易理解
|
||
✅ 空间复杂度 O(1)
|
||
❌ 需要两次遍历
|
||
❌ 时间复杂度常数因子较大
|
||
|
||
栈法:
|
||
✅ 思路直观
|
||
✅ 容易理解和实现
|
||
❌ 空间复杂度 O(L)
|
||
❌ 不是最优解
|
||
```
|
||
|
||
**Q5:如何处理循环链表?**
|
||
A:
|
||
- 需要先检测链表是否有环
|
||
- 如果有环,计算环的长度
|
||
- 然后调整删除位置
|
||
- 详见"进阶问题"部分
|
||
|
||
---
|
||
|
||
### 复杂度分析(详细版)
|
||
|
||
**双指针法**:
|
||
```
|
||
时间复杂度:
|
||
- fast 先移动 n+1 步:O(n)
|
||
- fast 和 slow 同时移动:O(L-n)
|
||
- 总计:O(n) + O(L-n) = O(L)
|
||
|
||
空间复杂度:
|
||
- 只使用固定数量的指针:dummy, fast, slow
|
||
- 不随输入规模增长
|
||
- 总计:O(1)
|
||
```
|
||
|
||
**计算长度法**:
|
||
```
|
||
时间复杂度:
|
||
- 第一次遍历计算长度:O(L)
|
||
- 第二次遍历删除节点:O(L-n)
|
||
- 总计:O(L) + O(L-n) = O(2L) = O(L)
|
||
|
||
空间复杂度:
|
||
- 只使用固定数量的变量
|
||
- 总计:O(1)
|
||
```
|
||
|
||
**栈法**:
|
||
```
|
||
时间复杂度:
|
||
- 遍历链表入栈:O(L)
|
||
- 弹出 n 个节点:O(n)
|
||
- 总计:O(L) + O(n) = O(L)
|
||
|
||
空间复杂度:
|
||
- 栈存储所有节点:O(L)
|
||
- 总计:O(L)
|
||
```
|
||
|
||
---
|
||
|
||
### 执行过程演示
|
||
|
||
**示例输入**:head = [1,2,3,4,5], n = 2
|
||
|
||
```
|
||
=== 初始状态 ===
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5
|
||
↑
|
||
fast/slow
|
||
|
||
=== fast 移动 n+1 = 3 步 ===
|
||
|
||
第1步:
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5
|
||
↑fast
|
||
↑
|
||
slow
|
||
|
||
第2步:
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5
|
||
↑fast
|
||
↑
|
||
slow
|
||
|
||
第3步:
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5
|
||
↑fast
|
||
↑
|
||
slow
|
||
|
||
=== fast 和 slow 同时移动 ===
|
||
|
||
第1次同时移动:
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5
|
||
↑fast
|
||
↑
|
||
slow
|
||
|
||
第2次同时移动:
|
||
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil
|
||
↑fast
|
||
↑
|
||
slow
|
||
|
||
fast 为 nil,循环结束
|
||
|
||
=== 删除节点 ===
|
||
slow 指向节点 3
|
||
执行 slow.Next = slow.Next.Next
|
||
即 3.Next = 3.Next.Next = 5
|
||
|
||
=== 最终结果 ===
|
||
dummy -> 1 -> 2 -> 3 -> 5
|
||
返回 dummy.Next = [1,2,3,5]
|
||
```
|
||
|
||
---
|
||
|
||
### 常见错误
|
||
|
||
**错误1:fast 移动 n 步而不是 n+1 步**
|
||
```go
|
||
// ❌ 错误
|
||
for i := 0; i < n; i++ { // 只移动 n 步
|
||
fast = fast.Next
|
||
}
|
||
// slow 指向要删除的节点本身,无法删除
|
||
|
||
// ✅ 正确
|
||
for i := 0; i <= n; i++ { // 移动 n+1 步
|
||
fast = fast.Next
|
||
}
|
||
// slow 指向要删除节点的前一个节点
|
||
```
|
||
|
||
**错误2:没有使用哑节点**
|
||
```go
|
||
// ❌ 错误:删除头节点时会出错
|
||
func removeNthFromEnd(head *ListNode, n int) *ListNode {
|
||
fast := head
|
||
slow := head
|
||
// ... 移动逻辑
|
||
slow.Next = slow.Next.Next // 删除头节点时 slow 可能是 nil
|
||
return head
|
||
}
|
||
|
||
// ✅ 正确
|
||
func removeNthFromEnd(head *ListNode, n int) *ListNode {
|
||
dummy := &ListNode{0, head} // 使用哑节点
|
||
fast := dummy
|
||
slow := dummy
|
||
// ... 移动逻辑
|
||
slow.Next = slow.Next.Next // 不会空指针
|
||
return dummy.Next
|
||
}
|
||
```
|
||
|
||
**错误3:返回 dummy 而不是 dummy.Next**
|
||
```go
|
||
// ❌ 错误:多了一个哑节点
|
||
return dummy // 返回 [0,1,2,3,5]
|
||
|
||
// ✅ 正确
|
||
return dummy.Next // 返回 [1,2,3,5]
|
||
```
|
||
|
||
**错误4:同时移动时忘记移动 slow**
|
||
```go
|
||
// ❌ 错误:只移动了 fast
|
||
for fast != nil {
|
||
fast = fast.Next
|
||
// 忘记移动 slow
|
||
}
|
||
|
||
// ✅ 正确
|
||
for fast != nil {
|
||
fast = fast.Next
|
||
slow = slow.Next // 也要移动 slow
|
||
}
|
||
```
|
||
|
||
**错误5:删除节点时直接操作 head**
|
||
```go
|
||
// ❌ 错误:无法处理删除头节点的情况
|
||
func removeNthFromEnd(head *ListNode, n int) *ListNode {
|
||
// ... 找到要删除的节点
|
||
if 要删除的是头节点 {
|
||
head = head.Next // 这样修改不会影响返回值
|
||
}
|
||
return head
|
||
}
|
||
|
||
// ✅ 正确:使用哑节点统一处理
|
||
func removeNthFromEnd(head *ListNode, n int) *ListNode {
|
||
dummy := &ListNode{0, head}
|
||
// ... 删除逻辑
|
||
return dummy.Next // 即使删除头节点也能正确返回
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 方法二:计算长度法
|
||
|
||
**核心思想**:先遍历链表计算长度,然后计算要删除的正数位置,再遍历到该位置删除结点。
|
||
|
||
**详细算法流程**:
|
||
|
||
**步骤1:计算链表长度**
|
||
```go
|
||
length := 0
|
||
current := head
|
||
for current != nil {
|
||
length++
|
||
current = current.Next
|
||
}
|
||
```
|
||
|
||
**步骤2:计算要删除的正数位置**
|
||
```go
|
||
pos := length - n // 倒数第 n 个 = 正数第 (length-n) 个
|
||
```
|
||
|
||
**步骤3:创建哑节点并移动到目标位置**
|
||
```go
|
||
dummy := &ListNode{0, head}
|
||
current = dummy
|
||
for i := 0; i < pos; i++ {
|
||
current = current.Next
|
||
}
|
||
```
|
||
|
||
**步骤4:删除节点**
|
||
```go
|
||
current.Next = current.Next.Next
|
||
```
|
||
|
||
**步骤5:返回结果**
|
||
```go
|
||
return dummy.Next
|
||
```
|
||
|
||
---
|
||
|
||
### 方法三:栈法
|
||
|
||
**核心思想**:将所有结点压入栈中,然后弹出 `n` 个结点,栈顶就是要删除结点的前一个结点。
|
||
|
||
**详细算法流程**:
|
||
|
||
**步骤1:创建哑节点**
|
||
```go
|
||
dummy := &ListNode{0, head}
|
||
```
|
||
|
||
**步骤2:将所有节点压入栈**
|
||
```go
|
||
var stack []*ListNode
|
||
current := dummy
|
||
for current != nil {
|
||
stack = append(stack, current)
|
||
current = current.Next
|
||
}
|
||
```
|
||
|
||
**步骤3:弹出 n 个节点**
|
||
```go
|
||
for i := 0; i < n; i++ {
|
||
stack = stack[:len(stack)-1]
|
||
}
|
||
```
|
||
|
||
**步骤4:删除节点**
|
||
```go
|
||
prev := stack[len(stack)-1]
|
||
prev.Next = prev.Next.Next
|
||
```
|
||
|
||
**步骤5:返回结果**
|
||
```go
|
||
return dummy.Next
|
||
```
|
||
|
||
**优缺点**:
|
||
- ✅ 思路直观,容易理解
|
||
- ❌ 空间复杂度 O(L),不是最优解
|
||
- ❌ 不推荐使用,双指针法更优
|
||
|
||
## 解法
|
||
|
||
### Go 实现(双指针法)
|
||
|
||
```go
|
||
package main
|
||
|
||
import "fmt"
|
||
|
||
// ListNode 链表结点定义
|
||
type ListNode struct {
|
||
Val int
|
||
Next *ListNode
|
||
}
|
||
|
||
func removeNthFromEnd(head *ListNode, n int) *ListNode {
|
||
// 创建哑结点,处理删除头结点的特殊情况
|
||
dummy := &ListNode{0, head}
|
||
fast, slow := dummy, dummy
|
||
|
||
// fast 先移动 n + 1 步
|
||
for i := 0; i <= n; i++ {
|
||
fast = fast.Next
|
||
}
|
||
|
||
// fast 和 slow 一起移动,直到 fast 为 nil
|
||
for fast != nil {
|
||
fast = fast.Next
|
||
slow = slow.Next
|
||
}
|
||
|
||
// 删除 slow 的下一个结点
|
||
slow.Next = slow.Next.Next
|
||
|
||
return dummy.Next
|
||
}
|
||
|
||
// 辅助函数:创建链表
|
||
func createList(nums []int) *ListNode {
|
||
dummy := &ListNode{}
|
||
current := dummy
|
||
for _, num := range nums {
|
||
current.Next = &ListNode{num, nil}
|
||
current = current.Next
|
||
}
|
||
return dummy.Next
|
||
}
|
||
|
||
// 辅助函数:打印链表
|
||
func printList(head *ListNode) {
|
||
current := head
|
||
for current != nil {
|
||
fmt.Printf("%d", current.Val)
|
||
if current.Next != nil {
|
||
fmt.Printf(" -> ")
|
||
}
|
||
current = current.Next
|
||
}
|
||
fmt.Println()
|
||
}
|
||
|
||
// 测试用例
|
||
func main() {
|
||
// 测试用例1
|
||
head1 := createList([]int{1, 2, 3, 4, 5})
|
||
fmt.Print("输入: ")
|
||
printList(head1)
|
||
fmt.Printf("n = 2\n")
|
||
result1 := removeNthFromEnd(head1, 2)
|
||
fmt.Print("输出: ")
|
||
printList(result1)
|
||
|
||
// 测试用例2: 删除头结点
|
||
head2 := createList([]int{1})
|
||
fmt.Print("\n输入: ")
|
||
printList(head2)
|
||
fmt.Printf("n = 1\n")
|
||
result2 := removeNthFromEnd(head2, 1)
|
||
fmt.Print("输出: ")
|
||
printList(result2)
|
||
|
||
// 测试用例3: 删除最后一个结点
|
||
head3 := createList([]int{1, 2})
|
||
fmt.Print("\n输入: ")
|
||
printList(head3)
|
||
fmt.Printf("n = 1\n")
|
||
result3 := removeNthFromEnd(head3, 1)
|
||
fmt.Print("输出: ")
|
||
printList(result3)
|
||
|
||
// 测试用例4: 长链表
|
||
head4 := createList([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
|
||
fmt.Print("\n输入: ")
|
||
printList(head4)
|
||
fmt.Printf("n = 5\n")
|
||
result4 := removeNthFromEnd(head4, 5)
|
||
fmt.Print("输出: ")
|
||
printList(result4)
|
||
}
|
||
```
|
||
|
||
### Go 实现(计算长度法)
|
||
|
||
```go
|
||
func removeNthFromEndByLength(head *ListNode, n int) *ListNode {
|
||
if head == nil {
|
||
return nil
|
||
}
|
||
|
||
// 计算链表长度
|
||
length := 0
|
||
current := head
|
||
for current != nil {
|
||
length++
|
||
current = current.Next
|
||
}
|
||
|
||
// 要删除的正数位置
|
||
pos := length - n
|
||
|
||
// 创建哑结点
|
||
dummy := &ListNode{0, head}
|
||
current = dummy
|
||
|
||
// 移动到要删除结点的前一个结点
|
||
for i := 0; i < pos; i++ {
|
||
current = current.Next
|
||
}
|
||
|
||
// 删除结点
|
||
current.Next = current.Next.Next
|
||
|
||
return dummy.Next
|
||
}
|
||
```
|
||
|
||
### Go 实现(栈法)
|
||
|
||
```go
|
||
func removeNthFromEndByStack(head *ListNode, n int) *ListNode {
|
||
if head == nil {
|
||
return nil
|
||
}
|
||
|
||
// 创建哑结点
|
||
dummy := &ListNode{0, head}
|
||
|
||
// 将所有结点压入栈
|
||
var stack []*ListNode
|
||
current := dummy
|
||
for current != nil {
|
||
stack = append(stack, current)
|
||
current = current.Next
|
||
}
|
||
|
||
// 弹出 n 个结点
|
||
for i := 0; i < n; i++ {
|
||
stack = stack[:len(stack)-1]
|
||
}
|
||
|
||
// 栈顶就是要删除结点的前一个结点
|
||
prev := stack[len(stack)-1]
|
||
prev.Next = prev.Next.Next
|
||
|
||
return dummy.Next
|
||
}
|
||
```
|
||
|
||
## 复杂度分析
|
||
|
||
### 双指针法
|
||
|
||
- **时间复杂度:** O(L)
|
||
- 其中 L 是链表长度
|
||
- 只需遍历链表一次
|
||
|
||
- **空间复杂度:** O(1)
|
||
- 只使用了常数级别的额外空间
|
||
- 只需要几个指针变量
|
||
|
||
### 计算长度法
|
||
|
||
- **时间复杂度:** O(L)
|
||
- 第一次遍历计算长度:O(L)
|
||
- 第二次遍历删除结点:O(L)
|
||
- 总时间复杂度:O(2L) = O(L)
|
||
|
||
- **空间复杂度:** O(1)
|
||
- 只使用了常数级别的额外空间
|
||
|
||
### 栈法
|
||
|
||
- **时间复杂度:** O(L)
|
||
- 需要遍历链表一次
|
||
|
||
- **空间复杂度:** O(L)
|
||
- 需要额外的栈空间存储所有结点
|
||
|
||
## 进阶问题
|
||
|
||
### Q1: 如果链表是循环链表,应该如何处理?
|
||
|
||
**方法**:检测循环,计算长度,然后调整删除位置
|
||
|
||
```go
|
||
func removeNthFromEndCircular(head *ListNode, n int) *ListNode {
|
||
if head == nil {
|
||
return nil
|
||
}
|
||
|
||
// 计算链表长度并检测循环
|
||
length := 1
|
||
slow, fast := head, head.Next
|
||
for fast != nil && fast.Next != nil && slow != fast {
|
||
slow = slow.Next
|
||
fast = fast.Next.Next
|
||
length++
|
||
}
|
||
|
||
// 如果有循环
|
||
if slow == fast {
|
||
// 计算循环长度
|
||
cycleLength := 1
|
||
slow = slow.Next
|
||
for slow != fast {
|
||
slow = slow.Next
|
||
cycleLength++
|
||
}
|
||
|
||
// 总长度
|
||
totalLength := length + cycleLength - 1
|
||
pos := totalLength - n
|
||
|
||
// 处理位置调整
|
||
if pos < 0 {
|
||
pos += totalLength
|
||
}
|
||
|
||
// 执行删除
|
||
return removeNthFromEndByPosition(head, pos)
|
||
}
|
||
|
||
// 没有循环,使用原有方法
|
||
return removeNthFromEnd(head, n)
|
||
}
|
||
```
|
||
|
||
### Q2: 如果要求删除前 n 个结点,应该如何修改?
|
||
|
||
**方法**:直接删除前 n 个结点
|
||
|
||
```go
|
||
func removeFirstNNodes(head *ListNode, n int) *ListNode {
|
||
for i := 0; i < n && head != nil; i++ {
|
||
head = head.Next
|
||
}
|
||
return head
|
||
}
|
||
```
|
||
|
||
### Q3: 如果链表很长,如何优化内存使用?
|
||
|
||
**方法**:
|
||
1. 使用固定大小的滑动窗口
|
||
2. 避免存储整个链表
|
||
3. 使用递归(但会增加栈空间)
|
||
|
||
```go
|
||
func removeNthFromEndOptimized(head *ListNode, n int) *ListNode {
|
||
// 使用固定大小的窗口
|
||
dummy := &ListNode{0, head}
|
||
slow, fast := dummy, dummy
|
||
|
||
// fast 先移动 n + 1 步
|
||
for i := 0; i <= n; i++ {
|
||
if fast == nil {
|
||
return head // n > 链表长度
|
||
}
|
||
fast = fast.Next
|
||
}
|
||
|
||
// 移动窗口
|
||
for fast != nil {
|
||
slow = slow.Next
|
||
fast = fast.Next
|
||
}
|
||
|
||
// 删除结点
|
||
slow.Next = slow.Next.Next
|
||
|
||
return dummy.Next
|
||
}
|
||
```
|
||
|
||
## P7 加分项
|
||
|
||
### 1. 深度理解:为什么需要哑结点?
|
||
|
||
**关键点:**
|
||
- 处理删除头结点的特殊情况
|
||
- 统一处理逻辑,减少边界条件判断
|
||
- 简化代码,提高可读性
|
||
|
||
**哑结点的作用:**
|
||
```go
|
||
// 没有哑结点的情况
|
||
func removeNthFromEndWithoutDummy(head *ListNode, n int) *ListNode {
|
||
// 需要特殊处理删除头结点的情况
|
||
length := 0
|
||
current := head
|
||
for current != nil {
|
||
length++
|
||
current = current.Next
|
||
}
|
||
|
||
if length == n {
|
||
return head.Next // 删除头结点
|
||
}
|
||
|
||
// ... 其他逻辑
|
||
return head
|
||
}
|
||
```
|
||
|
||
### 2. 实战扩展:链表操作的通用技巧
|
||
|
||
**技巧总结:**
|
||
- 使用哑结点简化边界处理
|
||
- 双指针技巧:快慢指针、前后指针
|
||
- 递归处理链表问题
|
||
- 栈辅助解决链表问题
|
||
|
||
**通用模板:**
|
||
```go
|
||
func solveLinkedListProblem(head *ListNode) *ListNode {
|
||
dummy := &ListNode{0, head}
|
||
// ... 使用双指针或其他技巧
|
||
return dummy.Next
|
||
}
|
||
```
|
||
|
||
### 3. 变形题目
|
||
|
||
1. [19. 删除链表的倒数第N个节点](https://leetcode.cn/problems/remove-nth-node-from-end-of-list/) - 原题
|
||
2. [82. 删除排序链表中的重复元素 II](https://leetcode.cn/problems/remove-duplicates-from-sorted-list-ii/) - 删除所有重复元素
|
||
3. [83. 删除排序链表中的重复元素](https://leetcode.cn/problems/remove-duplicates-from-sorted-list/) - 删除重复元素保留一个
|
||
4. [203. 移除链表元素](https://leetcode.cn/problems/remove-linked-list-elements/) - 删除指定值的节点
|
||
|
||
### 4. 优化技巧
|
||
|
||
**空间优化:**
|
||
- 原地操作,不使用额外空间
|
||
- 递归改为迭代
|
||
|
||
**时间优化:**
|
||
- 一次遍历完成
|
||
- 提前终止条件
|
||
|
||
**代码优化:**
|
||
- 合并重复逻辑
|
||
- 减少不必要的变量
|
||
|
||
### 5. 实际应用场景
|
||
|
||
**应用场景:**
|
||
- 缓存淘汰策略(LRU)
|
||
- 音乐播放列表管理
|
||
- 浏览器历史记录
|
||
- 撤销/重做功能
|
||
|
||
**面试问题:**
|
||
- 如何处理并发访问的链表?
|
||
- 如何实现线程安全的链表操作?
|
||
|
||
### 6. 面试技巧
|
||
|
||
**常见面试问题:**
|
||
1. 时间/空间复杂度分析
|
||
2. 边界条件处理
|
||
3. 优化思路
|
||
4. 相关题目变体
|
||
|
||
**回答技巧:**
|
||
- 先给出暴力解法
|
||
- 逐步优化
|
||
- 说明权衡取舍
|
||
|
||
### 7. 相关题目推荐
|
||
|
||
**相关题目:**
|
||
1. [206. 反转链表](https://leetcode.cn/problems/reverse-linked-list/)
|
||
2. [21. 合并两个有序链表](https://leetcode.cn/problems/merge-two-sorted-lists/)
|
||
3. [141. 环形链表](https://leetcode.cn/problems/linked-list-cycle/)
|
||
4. [142. 环形链表 II](https://leetcode.cn/problems/linked-list-cycle-ii/)
|
||
|
||
## 总结
|
||
|
||
这道题的核心是:
|
||
1. **双指针法**:一次遍历,快慢指针配合
|
||
2. **边界处理**:使用哑结点简化删除头结点的处理
|
||
3. **多种解法**:双指针、计算长度、栈法各有优劣
|
||
|
||
**易错点**:
|
||
- 忘记处理删除头结点的情况
|
||
- 快指针移动步数错误(应该是 n+1)
|
||
- 空链表的特殊情况处理
|
||
- 循环链表的特殊情况
|
||
|
||
**最优解法**:双指针法,时间 O(L),空间 O(1) |