Files
leetcode-go/链表/28-两数相加.md
T

383 lines
12 KiB
Markdown
Raw Normal View History

2026-05-16 16:26:24 +08:00
---
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 节点 + 尾指针 + 边界循环条件。建议至少手写迭代版,递归版作为拓展理解即可。