12 KiB
tags, create time
| tags | 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 是向下一位的进位 |
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] 为例):
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 判断:
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 环境内置如下结构体定义:
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 题:原理完全相同,只是逢二进一
代码
迭代法
/**
* 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
}
递归法
/**
* 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 节点 + 尾指针 + 边界循环条件。建议至少手写迭代版,递归版作为拓展理解即可。