Files
interview/16-LeetCode Hot 100/两数相加.md
yasinshaw f0833d63cf 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>
2026-03-08 21:31:34 +08:00

653 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 两数相加 (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}
}
```
**示例**
```
情况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 // 整除
```
---
---
## 进阶问题
### 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)