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

12 KiB
Raw Blame History

tags, create time
tags create time
LeetCode
链表
模拟
简单
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] ✅ 相关题目串联


代码

迭代法

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