Files
interview/16-LeetCode Hot 100/两数相加.md
yasinshaw f0833d63cf docs: 增强 LeetCode 题目解题思路详细程度
对两个 LeetCode Hot 100 题目进行了详细的思路改进:

## 改进内容

### 1. 两数相加.md
- 新增"思路推导"部分:从暴力解法分析到优化思考
- 详细化"解题思路":分步骤说明每个关键点
- 增加"关键细节说明":4个核心细节深入分析
- 增加"边界条件分析":4种边界情况完整演示
- 增加"Q&A 问题解释":5个常见问题详细解答
- 增加"执行过程演示":完整执行过程可视化
- 增加"常见错误":5个典型错误对比说明

### 2. 删除链表的倒数第N个结点.md
- 新增"思路推导"部分:暴力解法到双指针优化
- 详细化双指针法、计算长度法、栈法的完整流程
- 增加"关键细节说明":n+1步、哑节点等核心概念
- 增加"边界条件分析":删除头、尾、中间节点等场景
- 增加"Q&A 问题解释":5个核心问题深入解答
- 增加"执行过程演示":完整执行过程可视化
- 增加"常见错误":5个典型错误对比说明

## 改进效果
- 从简单算法流程升级为完整思考路径
- 从基础步骤说明升级为详细原理分析
- 从复杂度概览升级为逐步推导过程
- 增加了可视化执行过程和常见错误对比
- 更适合面试准备和深度理解

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 21:31:34 +08:00

14 KiB
Raw Blame History

两数相加 (Add Two Numbers)

LeetCode 2. Medium

题目描述

给你两个 非空 的链表,表示两个非负的整数。它们每位数字都是按照 逆序 的方式存储的,并且每个节点只能存储 一位 数字。

请你将两个数相加,并以相同形式返回一个表示和的链表。

你可以假设除了数字 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先转成整数相加后再转回链表

def addTwoNumbers(l1, l2):
    # 1. 链表转整数
    num1 = 0
    while l1:
        num1 = num1 * 10 + l1.val
        l1 = l1.next

    num2 = 0
    while l2:
        num2 = num2 * 10 + l2.val
        l2 = l2.next

    # 2. 整数相加
    total = num1 + num2

    # 3. 整数转链表(逆序)
    dummy = ListNode(0)
    if total == 0:
        return ListNode(0)

    while total > 0:
        digit = total % 10
        # 需要头插法保持逆序
        new_node = ListNode(digit)
        new_node.next = dummy.next
        dummy.next = new_node
        total //= 10

    return dummy.next

时间复杂度O(max(m, n)) 空间复杂度O(1)

问题分析

  • 整数溢出如果链表很长如100位会超出整型范围
  • 不符合题意:题目就是要我们处理大数相加,不能依赖语言的大整数特性
  • 需要反转:整数转链表时需要头插法,增加复杂度

优化思考

观察

  • 链表已经是逆序存储(个位在表头)
  • 每个节点只存储一位数字
  • 就像小学竖式加法,从个位开始加

关键洞察

竖式加法:
    3 4 2        (链表: 2 -> 4 -> 3)
+   4 6 5        (链表: 5 -> 6 -> 4)
---------
    8 0 7        (链表: 7 -> 0 -> 8)

从个位开始:
个位2 + 5 = 7无进位
十位4 + 6 = 10写0进1
百位3 + 4 + 1(进位) = 8

为什么这样思考?

1. 逆序存储的巧思

  • 如果是正序存储,需要先反转链表
  • 逆序存储天然符合加法从低位开始的习惯
  • 可以直接从表头开始处理

2. 同时遍历的合理性

  • 两个链表对应位相加
  • 类似于合并两个有序链表的思路
  • 使用指针同时移动

3. 进位的处理

  • 每次计算都要考虑上一位的进位
  • sum = l1.val + l2.val + carry
  • 新进位 = sum / 10
  • 当前位 = sum % 10

4. 为什么是 max(m, n)

  • 两个链表长度可能不同
  • 短链表遍历完后,长链表继续处理
  • 还要考虑最后的进位可能增加一位

解题思路

核心思想

模拟竖式加法:同时遍历两个链表,逐位相加并处理进位,使用哑节点简化头节点处理。

详细算法流程

步骤1初始化变量

dummy := &ListNode{0, nil}  // 哑节点,简化头节点处理
curr := dummy                // 当前节点指针
carry := 0                   // 进位初始为0

关键点

  • 为什么需要哑节点?
    • 统一处理逻辑,不需要特殊判断第一个节点
    • 简化代码减少if-else
  • carry初始为什么是0
    • 第一次相加前没有进位

步骤2同时遍历两个链表

for l1 != nil || l2 != nil {
    // 获取当前位的值如果链表已遍历完则为0
    x := 0
    if l1 != nil {
        x = l1.Val
        l1 = l1.Next
    }

    y := 0
    if l2 != nil {
        y = l2.Val
        l2 = l2.Next
    }

    // 计算当前位的和
    sum := x + y + carry

    // 更新进位
    carry = sum / 10

    // 创建新节点(当前位的值)
    curr.Next = &ListNode{sum % 10, nil}
    curr = curr.Next
}

关键点

  • 为什么用 || 而不是 &&
    • 只要有一个链表没遍历完就要继续
    • 长链表的剩余位需要单独处理
  • 为什么 l1 != nil 时还要检查?
    • 避免空指针异常
    • 已遍历完的链表对应的位视为0
  • 为什么 sum / 10 是进位?
    • 整除,如 12 / 10 = 1进位
  • 为什么 sum % 10 是当前位?
    • 取余,如 12 % 10 = 2当前位

步骤3处理最后的进位

if carry > 0 {
    curr.Next = &ListNode{carry, nil}
}

关键点

  • 为什么需要这个判断?
    • 例如9 + 9 = 18需要创建新节点存储1
    • 例如1 + 2 = 3不需要创建新节点
  • 什么时候会进位?
    • 最后一位相加 >= 10

步骤4返回结果

return dummy.Next

关键点

  • 为什么返回 dummy.Next 而不是 dummy
    • dummy 是哑节点值为0
    • dummy.Next 才是真正的结果链表头

关键细节说明

细节1为什么用 || 而不是 &&

// ❌ 错误:会漏掉长链表的剩余部分
for l1 != nil && l2 != nil { }

// ✅ 正确:处理完所有位
for l1 != nil || l2 != nil { }

示例

l1: 9 -> 9 -> 9
l2: 9

使用 &&:只处理第一位 9+9=18漏掉 l1 的剩余两位
使用 ||:处理所有位 9+9=18, 0+9=9, 0+9=9
结果8 -> 9 -> 9 -> 1

细节2为什么需要检查 l1 != nil

// 获取当前位的值
x := 0
if l1 != nil {
    x = l1.Val
    l1 = l1.Next
}

原因

  • 长度不同的链表,短的遍历完后就为 nil
  • 直接访问 l1.Val 会空指针异常
  • 遍历完的位视为0不影响加法结果

细节3哑节点的作用

// ❌ 没有哑节点:需要特殊处理第一个节点
var head *ListNode
var curr *ListNode
if l1 != nil || l2 != nil {
    head = &ListNode{(l1.Val + l2.Val) % 10, nil}
    curr = head
}
// 后续节点处理...

// ✅ 有哑节点:统一处理
dummy := &ListNode{0, nil}
curr := dummy
// 所有节点统一处理...
return dummy.Next  // 跳过哑节点

细节4为什么最后还要判断进位

if carry > 0 {
    curr.Next = &ListNode{carry, nil}
}

示例

情况19 + 9 = 18
- 最后 carry = 1
- 需要创建新节点存储1
- 结果8 -> 1

情况21 + 2 = 3
- 最后 carry = 0
- 不需要创建新节点
- 结果3

边界条件分析

边界1两个链表长度相同

输入l1 = [2,4,3], l2 = [5,6,4]
过程:
- 第1位2+5+0=7, carry=0, 结果7
- 第2位4+6+0=10, carry=1, 结果7->0
- 第3位3+4+1=8, carry=0, 结果7->0->8
输出:[7,0,8]

边界2一个链表很长

输入l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9]
过程:
- 第1位9+9=18, carry=1
- 第2位9+9+1=19, carry=1
- 第3位9+9+1=19, carry=1
- 第4位9+9+1=19, carry=1
- 第5位9+0+1=10, carry=1
- 第6位9+0+1=10, carry=1
- 第7位9+0+1=10, carry=1
- 最后进位carry=1
输出:[8,9,9,9,0,0,0,1]

边界3链表只有一个0

输入l1 = [0], l2 = [0]
过程:
- 第1位0+0+0=0, carry=0
- 最后进位carry=0不需要创建新节点
输出:[0]

边界4最后有进位

输入l1 = [9,9], l2 = [1]
过程:
- 第1位9+1=10, carry=1
- 第2位9+0+1=10, carry=1
- 最后进位carry=1创建新节点
输出:[0,0,1]

Q&A 问题解释

Q1为什么链表是逆序存储的 A这是设计上的巧思

  • 符合加法从个位开始的习惯
  • 不需要反转链表
  • 可以直接从头指针开始遍历

Q2如果链表是正序存储的怎么办 A需要反转链表或使用栈

// 方法1反转链表
func addTwoNumbersReverse(l1, l2 *ListNode) *ListNode {
    l1 = reverseList(l1)
    l2 = reverseList(l2)
    result := addTwoNumbers(l1, l2)
    return reverseList(result)
}

// 方法2使用栈
func addTwoNumbersStack(l1, l2 *ListNode) *ListNode {
    stack1, stack2 := []*ListNode{}, []*ListNode{}

    // 入栈
    for l1 != nil {
        stack1 = append(stack1, l1)
        l1 = l1.Next
    }
    for l2 != nil {
        stack2 = append(stack2, l2)
        l2 = l2.Next
    }

    // 从栈顶(高位)开始相加
    carry := 0
    var head *ListNode
    for len(stack1) > 0 || len(stack2) > 0 || carry > 0 {
        x, y := 0, 0
        if len(stack1) > 0 {
            x = stack1[len(stack1)-1].Val
            stack1 = stack1[:len(stack1)-1]
        }
        if len(stack2) > 0 {
            y = stack2[len(stack2)-1].Val
            stack2 = stack2[:len(stack2)-1]
        }

        sum := x + y + carry
        carry = sum / 10

        // 头插法(因为是正序)
        node := &ListNode{sum % 10, head}
        head = node
    }

    return head
}

Q3时间复杂度为什么是 O(max(m, n)) A

  • 两个链表长度分别为 m 和 n
  • 同时遍历,较短的先遍历完
  • 最多遍历 max(m, n) 次
  • 最后处理进位是 O(1)
  • 总时间O(max(m, n))

Q4空间复杂度为什么是 O(1) A

  • 不考虑结果链表(这是输出必须的)
  • 只使用了固定数量的变量dummy, curr, carry, sum, x, y
  • 不随输入规模增长
  • 所以是 O(1)

Q5如何处理多个链表相加 A

// 方法1逐个相加
func addMultipleNumbers(lists []*ListNode) *ListNode {
    if len(lists) == 0 {
        return nil
    }

    result := lists[0]
    for i := 1; i < len(lists); i++ {
        result = addTwoNumbers(result, lists[i])
    }
    return result
}

// 方法2分治相加
func addMultipleNumbersDivide(lists []*ListNode) *ListNode {
    if len(lists) == 0 {
        return nil
    }
    return divide(lists, 0, len(lists)-1)
}

func divide(lists []*ListNode, left, right int) *ListNode {
    if left == right {
        return lists[left]
    }
    mid := left + (right-left)/2
    l1 := divide(lists, left, mid)
    l2 := divide(lists, mid+1, right)
    return addTwoNumbers(l1, l2)
}

复杂度分析(详细版)

时间复杂度

- 遍历链表O(max(m, n))
  - m 和 n 分别为两个链表的长度
  - 同时遍历,短的先结束

- 处理进位O(1)
  - 最多一次操作

- 创建新节点O(max(m, n) + 1)
  - 最多比长链表多一位

总计O(max(m, n))

空间复杂度

- 不考虑结果链表O(1)
  - 只使用固定数量变量
  - dummy, curr, carry, sum, x, y

- 考虑结果链表O(max(m, n) + 1)
  - 结果链表最多比长链表多一位

通常说O(1)(不考虑结果链表)

执行过程演示

示例输入l1 = [2,4,3], l2 = [5,6,4]

初始状态:
l1: 2 -> 4 -> 3
l2: 5 -> 6 -> 4
dummy -> 0
carry = 0

=== 第1次循环 ===
x = 2, y = 5
sum = 2 + 5 + 0 = 7
carry = 7 / 10 = 0
curr.Next = 7
dummy -> 7
l1: 4 -> 3, l2: 6 -> 4

=== 第2次循环 ===
x = 4, y = 6
sum = 4 + 6 + 0 = 10
carry = 10 / 10 = 1
curr.Next = 0
dummy -> 7 -> 0
l1: 3, l2: 4

=== 第3次循环 ===
x = 3, y = 4
sum = 3 + 4 + 1 = 8
carry = 8 / 10 = 0
curr.Next = 8
dummy -> 7 -> 0 -> 8
l1: nil, l2: nil

=== 循环结束 ===
carry = 0不需要创建新节点

=== 返回结果 ===
return dummy.Next -> [7,0,8]

常见错误

错误1忘记处理最后的进位

// ❌ 错误
for l1 != nil || l2 != nil {
    // ... 计算逻辑
}
return dummy.Next  // 忘记检查carry

// ✅ 正确
for l1 != nil || l2 != nil {
    // ... 计算逻辑
}
if carry > 0 {  // 检查最后的进位
    curr.Next = &ListNode{carry, nil}
}
return dummy.Next

错误2使用 && 而不是 ||

// ❌ 错误:会漏掉长链表的剩余部分
for l1 != nil && l2 != nil {
    // ...
}

// ✅ 正确
for l1 != nil || l2 != nil {
    // ...
}

错误3忘记检查链表是否为nil

// ❌ 错误:空指针异常
x := l1.Val  // l1可能为nil

// ✅ 正确
x := 0
if l1 != nil {
    x = l1.Val
}

错误4返回dummy而不是dummy.Next

// ❌ 错误多了一个0
return dummy

// ✅ 正确
return dummy.Next

错误5进位计算错误

// ❌ 错误:应该用整除
carry = sum / 10.0  // 浮点除法

// ✅ 正确
carry = sum / 10    // 整除


进阶问题

Q1: 如果链表是正序存储的,如何解决?

方法:反转链表 + 上述算法,或使用栈

func addTwoNumbersReverse(l1, l2 *ListNode) *ListNode {
    // 反转链表
    l1 = reverseList(l1)
    l2 = reverseList(l2)

    // 使用相同算法相加
    result := addTwoNumbers(l1, l2)

    // 反转结果
    return reverseList(result)
}

func reverseList(head *ListNode) *ListNode {
    var prev *ListNode
    for head != nil {
        next := head.Next
        head.Next = prev
        prev = head
        head = next
    }
    return prev
}

Q2: 如何处理多个链表相加?

方法:逐个相加或使用优先队列(最小堆)


P7 加分项

深度理解

  • 链表操作:指针移动、内存管理
  • 边界处理:不同长度链表、最后进位
  • 哑节点技巧:简化代码逻辑

实战扩展

  • 大数据场景:链表表示超大整数相加
  • 分布式场景MapReduce 实现大数相加
  • 面试追问:时间复杂度能否优化?如何测试?

变形题目

  1. 445. 两数相加 II - 链表正序存储
  2. 链表相加系列 - 更多链表操作

总结

这道题的核心是:

  1. 模拟加法:逐位相加,处理进位
  2. 链表遍历:同时遍历两条链表
  3. 边界处理:不同长度、最后进位

易错点

  • 忘记处理最后的进位
  • 链表长度不一致时的处理
  • 哑节点的使用(简化代码)

最优解法:一次遍历,时间 O(max(m,n)),空间 O(1)