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