Files

384 lines
12 KiB
Markdown
Raw Permalink 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.
---
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 题:原理完全相同,只是逢二进一
> - [[30-两两交换链表中的节点]] — 同级别中等难度,多指针协作的另一角度
---
## 代码
### 迭代法
```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 节点 + 尾指针 + 边界循环条件。建议至少手写迭代版,递归版作为拓展理解即可。