docs: 增强 LeetCode 题目解题思路详细程度

对两个 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>
This commit is contained in:
2026-03-08 21:31:34 +08:00
parent 24d5923f5e
commit f0833d63cf
2 changed files with 1347 additions and 38 deletions

View File

@@ -29,23 +29,556 @@ LeetCode 2. Medium
输出:[8,9,9,9,0,0,0,1]
```
## 思路推导
### 暴力解法分析
**思路1先转成整数相加后再转回链表**
```python
def addTwoNumbers(l1, l2):
# 1. 链表转整数
num1 = 0
while l1:
num1 = num1 * 10 + l1.val
l1 = l1.next
num2 = 0
while l2:
num2 = num2 * 10 + l2.val
l2 = l2.next
# 2. 整数相加
total = num1 + num2
# 3. 整数转链表(逆序)
dummy = ListNode(0)
if total == 0:
return ListNode(0)
while total > 0:
digit = total % 10
# 需要头插法保持逆序
new_node = ListNode(digit)
new_node.next = dummy.next
dummy.next = new_node
total //= 10
return dummy.next
```
**时间复杂度**O(max(m, n))
**空间复杂度**O(1)
**问题分析**
-**整数溢出**如果链表很长如100位会超出整型范围
-**不符合题意**:题目就是要我们处理大数相加,不能依赖语言的大整数特性
-**需要反转**:整数转链表时需要头插法,增加复杂度
### 优化思考
**观察**
- ✅ 链表已经是逆序存储(个位在表头)
- ✅ 每个节点只存储一位数字
- ✅ 就像小学竖式加法,从个位开始加
**关键洞察**
```
竖式加法:
3 4 2 (链表: 2 -> 4 -> 3)
+ 4 6 5 (链表: 5 -> 6 -> 4)
---------
8 0 7 (链表: 7 -> 0 -> 8)
从个位开始:
个位2 + 5 = 7无进位
十位4 + 6 = 10写0进1
百位3 + 4 + 1(进位) = 8
```
### 为什么这样思考?
**1. 逆序存储的巧思**
- 如果是正序存储,需要先反转链表
- 逆序存储天然符合加法从低位开始的习惯
- 可以直接从表头开始处理
**2. 同时遍历的合理性**
- 两个链表对应位相加
- 类似于合并两个有序链表的思路
- 使用指针同时移动
**3. 进位的处理**
- 每次计算都要考虑上一位的进位
- `sum = l1.val + l2.val + carry`
- 新进位 = `sum / 10`
- 当前位 = `sum % 10`
**4. 为什么是 max(m, n)**
- 两个链表长度可能不同
- 短链表遍历完后,长链表继续处理
- 还要考虑最后的进位可能增加一位
---
## 解题思路
### 核心思想
模拟竖式加法同时遍历两个链表,逐位相加并处理进位。
**模拟竖式加法**同时遍历两个链表,逐位相加并处理进位,使用哑节点简化头节点处理
### 算法流程
1. 初始化哑节点和进位 carry = 0
2. 同时遍历两个链表:
- 计算当前位的和sum = l1.val + l2.val + carry
- 更新进位carry = sum / 10
- 创建新节点sum % 10
3. 处理最后的进位
4. 返回结果链表
### 详细算法流程
### 复杂度分析
- **时间复杂度**O(max(m, n))m 和 n 分别为两个链表的长度
- **空间复杂度**O(1),不考虑结果链表的空间
**步骤1初始化变量**
```go
dummy := &ListNode{0, nil} // 哑节点,简化头节点处理
curr := dummy // 当前节点指针
carry := 0 // 进位初始为0
```
**关键点**
- 为什么需要哑节点?
- 统一处理逻辑,不需要特殊判断第一个节点
- 简化代码减少if-else
- carry初始为什么是0
- 第一次相加前没有进位
**步骤2同时遍历两个链表**
```go
for l1 != nil || l2 != nil {
// 获取当前位的值如果链表已遍历完则为0
x := 0
if l1 != nil {
x = l1.Val
l1 = l1.Next
}
y := 0
if l2 != nil {
y = l2.Val
l2 = l2.Next
}
// 计算当前位的和
sum := x + y + carry
// 更新进位
carry = sum / 10
// 创建新节点(当前位的值)
curr.Next = &ListNode{sum % 10, nil}
curr = curr.Next
}
```
**关键点**
- 为什么用 `||` 而不是 `&&`
- 只要有一个链表没遍历完就要继续
- 长链表的剩余位需要单独处理
- 为什么 `l1 != nil` 时还要检查?
- 避免空指针异常
- 已遍历完的链表对应的位视为0
- 为什么 `sum / 10` 是进位?
- 整除,如 12 / 10 = 1进位
- 为什么 `sum % 10` 是当前位?
- 取余,如 12 % 10 = 2当前位
**步骤3处理最后的进位**
```go
if carry > 0 {
curr.Next = &ListNode{carry, nil}
}
```
**关键点**
- 为什么需要这个判断?
- 例如9 + 9 = 18需要创建新节点存储1
- 例如1 + 2 = 3不需要创建新节点
- 什么时候会进位?
- 最后一位相加 >= 10
**步骤4返回结果**
```go
return dummy.Next
```
**关键点**
- 为什么返回 `dummy.Next` 而不是 `dummy`
- `dummy` 是哑节点值为0
- `dummy.Next` 才是真正的结果链表头
---
### 关键细节说明
**细节1为什么用 `||` 而不是 `&&`**
```go
// ❌ 错误:会漏掉长链表的剩余部分
for l1 != nil && l2 != nil { }
// ✅ 正确:处理完所有位
for l1 != nil || l2 != nil { }
```
**示例**
```
l1: 9 -> 9 -> 9
l2: 9
使用 &&:只处理第一位 9+9=18漏掉 l1 的剩余两位
使用 ||:处理所有位 9+9=18, 0+9=9, 0+9=9
结果8 -> 9 -> 9 -> 1
```
**细节2为什么需要检查 `l1 != nil`**
```go
// 获取当前位的值
x := 0
if l1 != nil {
x = l1.Val
l1 = l1.Next
}
```
**原因**
- 长度不同的链表,短的遍历完后就为 `nil`
- 直接访问 `l1.Val` 会空指针异常
- 遍历完的位视为0不影响加法结果
**细节3哑节点的作用**
```go
// ❌ 没有哑节点:需要特殊处理第一个节点
var head *ListNode
var curr *ListNode
if l1 != nil || l2 != nil {
head = &ListNode{(l1.Val + l2.Val) % 10, nil}
curr = head
}
// 后续节点处理...
// ✅ 有哑节点:统一处理
dummy := &ListNode{0, nil}
curr := dummy
// 所有节点统一处理...
return dummy.Next // 跳过哑节点
```
**细节4为什么最后还要判断进位**
```go
if carry > 0 {
curr.Next = &ListNode{carry, nil}
}
```
**示例**
```
情况19 + 9 = 18
- 最后 carry = 1
- 需要创建新节点存储1
- 结果8 -> 1
情况21 + 2 = 3
- 最后 carry = 0
- 不需要创建新节点
- 结果3
```
---
### 边界条件分析
**边界1两个链表长度相同**
```
输入l1 = [2,4,3], l2 = [5,6,4]
过程:
- 第1位2+5+0=7, carry=0, 结果7
- 第2位4+6+0=10, carry=1, 结果7->0
- 第3位3+4+1=8, carry=0, 结果7->0->8
输出:[7,0,8]
```
**边界2一个链表很长**
```
输入l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9]
过程:
- 第1位9+9=18, carry=1
- 第2位9+9+1=19, carry=1
- 第3位9+9+1=19, carry=1
- 第4位9+9+1=19, carry=1
- 第5位9+0+1=10, carry=1
- 第6位9+0+1=10, carry=1
- 第7位9+0+1=10, carry=1
- 最后进位carry=1
输出:[8,9,9,9,0,0,0,1]
```
**边界3链表只有一个0**
```
输入l1 = [0], l2 = [0]
过程:
- 第1位0+0+0=0, carry=0
- 最后进位carry=0不需要创建新节点
输出:[0]
```
**边界4最后有进位**
```
输入l1 = [9,9], l2 = [1]
过程:
- 第1位9+1=10, carry=1
- 第2位9+0+1=10, carry=1
- 最后进位carry=1创建新节点
输出:[0,0,1]
```
---
### Q&A 问题解释
**Q1为什么链表是逆序存储的**
A这是设计上的巧思
- ✅ 符合加法从个位开始的习惯
- ✅ 不需要反转链表
- ✅ 可以直接从头指针开始遍历
**Q2如果链表是正序存储的怎么办**
A需要反转链表或使用栈
```go
// 方法1反转链表
func addTwoNumbersReverse(l1, l2 *ListNode) *ListNode {
l1 = reverseList(l1)
l2 = reverseList(l2)
result := addTwoNumbers(l1, l2)
return reverseList(result)
}
// 方法2使用栈
func addTwoNumbersStack(l1, l2 *ListNode) *ListNode {
stack1, stack2 := []*ListNode{}, []*ListNode{}
// 入栈
for l1 != nil {
stack1 = append(stack1, l1)
l1 = l1.Next
}
for l2 != nil {
stack2 = append(stack2, l2)
l2 = l2.Next
}
// 从栈顶(高位)开始相加
carry := 0
var head *ListNode
for len(stack1) > 0 || len(stack2) > 0 || carry > 0 {
x, y := 0, 0
if len(stack1) > 0 {
x = stack1[len(stack1)-1].Val
stack1 = stack1[:len(stack1)-1]
}
if len(stack2) > 0 {
y = stack2[len(stack2)-1].Val
stack2 = stack2[:len(stack2)-1]
}
sum := x + y + carry
carry = sum / 10
// 头插法(因为是正序)
node := &ListNode{sum % 10, head}
head = node
}
return head
}
```
**Q3时间复杂度为什么是 O(max(m, n))**
A
- 两个链表长度分别为 m 和 n
- 同时遍历,较短的先遍历完
- 最多遍历 max(m, n) 次
- 最后处理进位是 O(1)
- 总时间O(max(m, n))
**Q4空间复杂度为什么是 O(1)**
A
- 不考虑结果链表(这是输出必须的)
- 只使用了固定数量的变量dummy, curr, carry, sum, x, y
- 不随输入规模增长
- 所以是 O(1)
**Q5如何处理多个链表相加**
A
```go
// 方法1逐个相加
func addMultipleNumbers(lists []*ListNode) *ListNode {
if len(lists) == 0 {
return nil
}
result := lists[0]
for i := 1; i < len(lists); i++ {
result = addTwoNumbers(result, lists[i])
}
return result
}
// 方法2分治相加
func addMultipleNumbersDivide(lists []*ListNode) *ListNode {
if len(lists) == 0 {
return nil
}
return divide(lists, 0, len(lists)-1)
}
func divide(lists []*ListNode, left, right int) *ListNode {
if left == right {
return lists[left]
}
mid := left + (right-left)/2
l1 := divide(lists, left, mid)
l2 := divide(lists, mid+1, right)
return addTwoNumbers(l1, l2)
}
```
---
### 复杂度分析(详细版)
**时间复杂度**
```
- 遍历链表O(max(m, n))
- m 和 n 分别为两个链表的长度
- 同时遍历,短的先结束
- 处理进位O(1)
- 最多一次操作
- 创建新节点O(max(m, n) + 1)
- 最多比长链表多一位
总计O(max(m, n))
```
**空间复杂度**
```
- 不考虑结果链表O(1)
- 只使用固定数量变量
- dummy, curr, carry, sum, x, y
- 考虑结果链表O(max(m, n) + 1)
- 结果链表最多比长链表多一位
通常说O(1)(不考虑结果链表)
```
---
### 执行过程演示
**示例输入**l1 = [2,4,3], l2 = [5,6,4]
```
初始状态:
l1: 2 -> 4 -> 3
l2: 5 -> 6 -> 4
dummy -> 0
carry = 0
=== 第1次循环 ===
x = 2, y = 5
sum = 2 + 5 + 0 = 7
carry = 7 / 10 = 0
curr.Next = 7
dummy -> 7
l1: 4 -> 3, l2: 6 -> 4
=== 第2次循环 ===
x = 4, y = 6
sum = 4 + 6 + 0 = 10
carry = 10 / 10 = 1
curr.Next = 0
dummy -> 7 -> 0
l1: 3, l2: 4
=== 第3次循环 ===
x = 3, y = 4
sum = 3 + 4 + 1 = 8
carry = 8 / 10 = 0
curr.Next = 8
dummy -> 7 -> 0 -> 8
l1: nil, l2: nil
=== 循环结束 ===
carry = 0不需要创建新节点
=== 返回结果 ===
return dummy.Next -> [7,0,8]
```
---
### 常见错误
**错误1忘记处理最后的进位**
```go
// ❌ 错误
for l1 != nil || l2 != nil {
// ... 计算逻辑
}
return dummy.Next // 忘记检查carry
// ✅ 正确
for l1 != nil || l2 != nil {
// ... 计算逻辑
}
if carry > 0 { // 检查最后的进位
curr.Next = &ListNode{carry, nil}
}
return dummy.Next
```
**错误2使用 && 而不是 ||**
```go
// ❌ 错误:会漏掉长链表的剩余部分
for l1 != nil && l2 != nil {
// ...
}
// ✅ 正确
for l1 != nil || l2 != nil {
// ...
}
```
**错误3忘记检查链表是否为nil**
```go
// ❌ 错误:空指针异常
x := l1.Val // l1可能为nil
// ✅ 正确
x := 0
if l1 != nil {
x = l1.Val
}
```
**错误4返回dummy而不是dummy.Next**
```go
// ❌ 错误多了一个0
return dummy
// ✅ 正确
return dummy.Next
```
**错误5进位计算错误**
```go
// ❌ 错误:应该用整除
carry = sum / 10.0 // 浮点除法
// ✅ 正确
carry = sum / 10 // 整除
```
---

View File

@@ -35,46 +35,822 @@
你能尝试使用一趟扫描实现吗?
## 思路推导
### 暴力解法分析
**思路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
步骤1fast 先移动 n+1 = 3 步
fast
dummy -> 1 -> 2 -> 3 -> 4 -> 5
slow
步骤2fast 和 slow 同时移动,直到 fast 到达末尾
fast
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil
slow
步骤3此时 slow 指向要删除节点的前一个节点
要删除的是 4slow 指向 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 会指向 nilhead 的前一个节点不存在)
- 无法执行删除操作
有哑节点的情况:
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` 步,然后 `fast``slow` 一起移动,直到 `fast` 到达链表末尾。此时 `slow` 指向要删除结点的前一个结点。
**核心思想**:使用两个指针 `fast``slow``fast` 先移动 `n+1` 步,然后 `fast``slow` 一起移动,直到 `fast` 到达链表末尾。此时 `slow` 指向要删除结点的前一个结点。
**算法步骤:**
1. 创建哑结点 `dummy`,指向链表头
2. 初始化 `fast``slow` 指针都指向 `dummy`
3. `fast` 先移动 `n + 1`
4. `fast``slow` 同时移动,直到 `fast``nil`
5. 此时 `slow.next` 就是要删除的结点,执行 `slow.next = slow.next.next`
6. 返回 `dummy.next`
### 详细算法流程(双指针法)
**为什么移动 n + 1 步?**
- 这样 `slow` 最终会停在要删除结点的前一个结点
- 方便删除操作:`slow.next = slow.next.next`
**步骤1初始化哑节点和指针**
```go
dummy := &ListNode{0, head} // 哑节点,简化头节点删除
fast := dummy // 快指针
slow := dummy // 慢指针
```
**关键点**
- 为什么需要哑节点?
- 统一处理删除头节点的情况
- 避免 nil 指针的边界判断
- 为什么 fast 和 slow 都指向 dummy
- 保证 fast 和 slow 的距离准确
- 从同一个起点开始,距离计算更清晰
**步骤2fast 先移动 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 到达末尾nilslow 刚好在目标位置
**示例**
```
链表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
```
**步骤3fast 和 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 在节点 4slow 在 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=2fast 在节点 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=4fast 在节点 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=2fast 为 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]
```
---
### 常见错误
**错误1fast 移动 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. 遍历链表,计算长度 `length`
2. 要删除的正数位置为 `length - n`
3. 创建哑结点 `dummy`,指向链表头
4. 遍历到第 `length - n - 1` 个结点
5. 删除下一个结点
6. 返回 `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` 个结点,栈顶就是要删除结点的前一个结点。
**核心思想**:将所有结点压入栈中,然后弹出 `n` 个结点,栈顶就是要删除结点的前一个结点。
**算法步骤:**
1. 创建哑结点 `dummy`
2. 将所有结点压入栈
3. 弹出 `n` 个结点
4. 栈顶结点的 `next` 指向要删除结点的 `next`
5. 返回 `dummy.next`
**详细算法流程**
**步骤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),不是最优解
- ❌ 不推荐使用,双指针法更优
## 解法