对两个 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>
653 lines
14 KiB
Markdown
653 lines
14 KiB
Markdown
# 两数相加 (Add Two Numbers)
|
||
|
||
LeetCode 2. Medium
|
||
|
||
## 题目描述
|
||
|
||
给你两个 **非空** 的链表,表示两个非负的整数。它们每位数字都是按照 **逆序** 的方式存储的,并且每个节点只能存储 **一位** 数字。
|
||
|
||
请你将两个数相加,并以相同形式返回一个表示和的链表。
|
||
|
||
你可以假设除了数字 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:先转成整数,相加后再转回链表**
|
||
```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:初始化变量**
|
||
```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}
|
||
}
|
||
```
|
||
|
||
**示例**:
|
||
```
|
||
情况1:9 + 9 = 18
|
||
- 最后 carry = 1
|
||
- 需要创建新节点存储1
|
||
- 结果:8 -> 1
|
||
|
||
情况2:1 + 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 // 整除
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 进阶问题
|
||
|
||
### Q1: 如果链表是正序存储的,如何解决?
|
||
**方法**:反转链表 + 上述算法,或使用栈
|
||
|
||
```go
|
||
func addTwoNumbersReverse(l1, l2 *ListNode) *ListNode {
|
||
// 反转链表
|
||
l1 = reverseList(l1)
|
||
l2 = reverseList(l2)
|
||
|
||
// 使用相同算法相加
|
||
result := addTwoNumbers(l1, l2)
|
||
|
||
// 反转结果
|
||
return reverseList(result)
|
||
}
|
||
|
||
func reverseList(head *ListNode) *ListNode {
|
||
var prev *ListNode
|
||
for head != nil {
|
||
next := head.Next
|
||
head.Next = prev
|
||
prev = head
|
||
head = next
|
||
}
|
||
return prev
|
||
}
|
||
```
|
||
|
||
### Q2: 如何处理多个链表相加?
|
||
**方法**:逐个相加或使用优先队列(最小堆)
|
||
|
||
---
|
||
|
||
## P7 加分项
|
||
|
||
### 深度理解
|
||
- **链表操作**:指针移动、内存管理
|
||
- **边界处理**:不同长度链表、最后进位
|
||
- **哑节点技巧**:简化代码逻辑
|
||
|
||
### 实战扩展
|
||
- **大数据场景**:链表表示超大整数相加
|
||
- **分布式场景**:MapReduce 实现大数相加
|
||
- **面试追问**:时间复杂度能否优化?如何测试?
|
||
|
||
### 变形题目
|
||
1. [445. 两数相加 II](https://leetcode.cn/problems/add-two-numbers-ii/) - 链表正序存储
|
||
2. [链表相加系列](https://leetcode.cn/tag/linked-list/) - 更多链表操作
|
||
|
||
---
|
||
|
||
## 总结
|
||
|
||
这道题的核心是:
|
||
1. **模拟加法**:逐位相加,处理进位
|
||
2. **链表遍历**:同时遍历两条链表
|
||
3. **边界处理**:不同长度、最后进位
|
||
|
||
**易错点**:
|
||
- 忘记处理最后的进位
|
||
- 链表长度不一致时的处理
|
||
- 哑节点的使用(简化代码)
|
||
|
||
**最优解法**:一次遍历,时间 O(max(m,n)),空间 O(1)
|