LeetCode 2. 两数相加这道题我刷了三遍才敢说真正吃透了。第一遍背答案,第二遍看完题解觉得懂了,第三遍在模拟面试现场被要求手写时,才发现最基础的几个细节并没有完全想明白。这题表面上是让两条“逆序链表”相加,底层考察的其实是两件事:链表怎么遍历、竖式加法怎么模拟。题目背景一句话就能概括——给你两条非空链表,它们分别表示一个非负整数,数字按逆序存储,每个节点只存一位数字,你要把两个数加起来,再用同样逆序的链表返回结果。之所以说这道题值得拆开揉碎看,是因为它虽然代码量很短,却一次覆盖了链表题的典型动作:双指针遍历、空节点判空、进位传递、结果链表构造。不管你是刚开始刷题的新手,还是准备面试想快速捡起链表手感的人,下面这套分析框架应该都能帮到你。
1. 逆序链表:为什么个位非得放在头节点
1.1 竖式加法与链表的天然对齐
先回答一个很多人第一次看题时的疑问:为什么数字不按正常书写顺序存,非要把个位放在链表头?比如数字 342,正常我们写出来是百位 3、十位 4、个位 2,但题目给的是 2 -> 4 -> 3。
原因很简单:因为我们手算加法时,从来都是从个位开始往高位算的。
你拿笔算 342 + 465,第一件事就是先算 2 + 5,再算 4 + 6,再算 3 + 4,如果有进位还要把进位带到左边一列。链表把个位放在头节点之后,从头到尾遍历链表的过程,恰好就等价于从低位到高位做竖式加法的过程。这样你不需要事先知道链表有多长,也不需要先把链表反转,就能从头开始一位一位同步相加。
这里的底层逻辑是:链表的第 i 个节点,本质上代表的是 10 的 i 次方这一位。所以从头节点开始读,正好是从 10^0 位往高位读。
1.2 藏在这道题里两个真正的考点
很多新手读完题后觉得简单,真正动手才发现问题集中在两个地方。
第一个考点是进位传递。竖式加法里,每一位的和可能超过 9,超过的部分要变成 1 带到下一位。这个“下一位”在链表里就是下一个节点。所以你每一轮都要记得把上一轮产生的进位加到当前的和里,同时不能忘记当前这一轮也可能继续产生新的进位。
第二个考点是两条链表长度可能不一样。比如 9999 + 1,第一条链表有 4 个节点,第二条链表只有 1 个节点。当第二条链表走完变成空指针之后,计算并没有结束,你还需要让短链表“假装”继续提供一个 0 参与加法,否则高位的 9 就没人处理了。
这两个考点几乎就是整道题的全部难点。把这两个问题想清楚,代码怎么写都是水到渠成的事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 主解法全链路拆解:虚拟头节点与进位传递
2.1 为什么需要一个 dummy 头节点
写链表题时,凡是需要“从零开始构建一条新链表并最终返回头指针”的场景,我习惯先创建一个虚拟头节点,也就是题目解法和社区里常说的 dummy head。
dummy 的作用非常直白:因为最终返回的链表第一节点是在循环过程中才动态创建的,你事先并不知道它什么时候产生,也不希望在循环里去判断“这到底是不是第一个节点”。如果不用 dummy,常见写法会长这样:
cpp复制ListNode* head = nullptr;
ListNode* cur = nullptr;
while (...) {
ListNode* node = new ListNode(val);
if (head == nullptr) {
head = cur = node;
} else {
cur->next = node;
cur = cur->next;
}
}
这样当然也能跑,但你会发现每一轮都要多一个 if 分支去处理 head 是否为空。代码一复杂就容易在分支逻辑里埋 bug。而用 dummy 的话,你可以直接无脑把新节点接到 cur 后面,最后返回 dummy->next 就行。
cpp复制ListNode* dummy = new ListNode(0);
ListNode* cur = dummy;
// 循环里只需要做:
// cur->next = new ListNode(sum % 10);
// cur = cur->next;
所以 dummy 本质上帮你把“第一个节点是特殊节点”这个逻辑干掉了。它不是算法的核心,却能显著减少边界判断。
2.2 主循环的结束条件不能写错
接下来是最容易翻车的部分:主循环到底应该怎么写。
先看一个反面例子。有人会把循环条件写成 while (l1 && l2),也就是两个链表都非空才继续。这看起来好像没问题,因为两个数相加嘛,总得两个都有数才能加。但实际上一旦一条链表比另一条短,循环会提前退出,长链表后面那几位数字就彻底被忽略了。
比如 9999 + 1,用 while (l1 && l2) 时,第二轮循环开始前 l2 已经变成 nullptr,循环直接结束,后面两个 9 都没参与运算,结果显然是错的。
正确写法是让循环在“两条链表都没处理完”的时候继续,也就是:
cpp复制while (l1 != nullptr || l2 != nullptr) {
int n1 = l1 ? l1->val : 0;
int n2 = l2 ? l2->val : 0;
// 具体加法和进位处理
}
当其中一条链表为空时,我们把它当前位的值按 0 处理。这就相当于竖式加法里,某个数字这一位没有数,写 0 占位就行,对结果没有任何影响。
但这里紧接着有一个隐藏很深的坑:既然要把空链表的当前值当成 0,那循环内部移动指针时也必须判空。你不能直接写 l1 = l1->next,因为当 l1 已经是 nullptr 时,再取 l1->next 就是空指针访问。正确做法是:
cpp复制if (l1) l1 = l1->next;
if (l2) l2 = l2->next;
2.3 循环结束后不能忘记最高位的进位
最后还有一个特别容易被忽略的收尾动作:检查最高位是否还有进位。
先思考一个问题:每一位上两个数字相加,最大是多少?9 + 9 = 18,再加上上一位最多可能的进位 1,也就是 9 + 9 + 1 = 19。所以进位变量的取值只可能是 0 或 1,永远不可能超过 1。
但正因为“进位的值很小”,很多人循环跑完就直接返回结果了,忽略了最后一次进位。典型的例子是 999 + 1。三轮循环都处理完之后,两个链表都空了,可是最高位还有一次 carry = 1 没有地方放。这时必须单独再创建一个新节点,存储这个进位,接到结果链表的末尾。
cpp复制if (carry > 0) {
cur->next = new ListNode(carry);
}
少了这一步,999 + 1 会得到 000,也就是 0,而不是 1000。这不是什么玄学问题,纯粹是竖式加法里“最高位也可能产生进位”这个事实在代码里的映射。
3. 图解推演:从 342 + 465 到 999 + 1
3.1 从最简单的例子理解每一轮状态
口说无凭,我直接把每一轮循环的状态画出来。第一个例子用题目自带的 342 + 465,也就是链表 [2, 4, 3] + [5, 6, 4]。
| 轮次 | 进入本轮时 carry | l1 当前值 | l2 当前值 | sum = n1 + n2 + carry | 新建节点值 | 本轮结束后的 carry |
|---|---|---|---|---|---|---|
| 1 | 0 | 2 | 5 | 7 | 7 | 0 |
| 2 | 0 | 4 | 6 | 10 | 0 | 1 |
| 3 | 1 | 3 | 4 | 8 | 8 | 0 |
第一轮,2 + 5 + 0 = 7,当前位放 7,没有进位。第二轮,4 + 6 + 0 = 10,当前位只能放 0,进位 1 带到下一轮。第三轮,3 + 4 + 1 = 8,当前位放 8,进位清 0。三轮结束后两条链表都为空,carry 也是 0,不需要补节点。最终结果链表为 7 -> 0 -> 8,正好表示 807。
如果你把结果链表头和题目给的输入对比一下就会发现一个很有意思的点:结果的第一个节点也是最低位。这就是逆序链表统一的好处,从头到尾一路 next,根本不用反向遍历。
3.2 用 999 + 1 逼出所有边界情况
第二个例子,我强烈建议你亲自在草稿纸上跑一遍:999 + 1,输入是 [9, 9, 9] 和 [1]。
| 轮次 | l1 当前值 | l2 当前值 | 进入本轮时 carry | sum | 新建节点值 | 本轮结束后的 carry |
|---|---|---|---|---|---|---|
| 1 | 9 | 1 | 0 | 10 | 0 | 1 |
| 2 | 9 | 0(l2 已空) | 1 | 10 | 0 | 1 |
| 3 | 9 | 0(l2 已空) | 1 | 10 | 0 | 1 |
| 循环结束后 | — | — | carry = 1 | 补一个节点 1 | 1 | 0 |
注意第二轮和第三轮,l2 其实已经是 nullptr 了。如果代码里没有“空链表按 0 处理”的逻辑,第二轮一开始访问 l2->val 就会直接报错。而正是因为 l2 的每一轮都贡献 0,所以后面两个 9 才能分别和上一轮带来的 1 相加,产生连续的进位。
循环结束后,carry 还是 1,因此必须补一个节点。最终结果是 0 -> 0 -> 0 -> 1,倒过来读是 1000,结果正确。
这个例子的价值在于,它同时覆盖了“短链表提前结束”和“最后还有进位”两个关键点。你在面试时如果能主动拿这个例子跑一遍自测,一般都能让面试官觉得你考虑到了边界情况。
4. 代码实现与易错点复盘
4.1 两种主流语言的直接可运行版本
先看 C++ 版本。LeetCode 已经帮你定义好了 ListNode 结构,你只需要在 Solution 类里补充函数即可。
cpp复制class Solution {
public:
ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) {
ListNode* dummy = new ListNode(0);
ListNode* cur = dummy;
int carry = 0;
while (l1 != nullptr || l2 != nullptr) {
int n1 = (l1 != nullptr) ? l1->val : 0;
int n2 = (l2 != nullptr) ? l2->val : 0;
int sum = n1 + n2 + carry;
carry = sum / 10;
cur->next = new ListNode(sum % 10);
cur = cur->next;
if (l1 != nullptr) l1 = l1->next;
if (l2 != nullptr) l2 = l2->next;
}
if (carry != 0) {
cur->next = new ListNode(carry);
}
return dummy->next;
}
};
有两点需要额外说明。第一,这个实现里 dummy 是用 new 创建的,严格来说函数结束前应该 delete,但 LeetCode 的在线评测环境并不要求你手动释放内存,运行结束由系统回收。要是你拿这段代码在自己的本地项目里跑,最好把 dummy 改成栈上对象,或者手动 delete,否则一直刷例子会积累内存泄漏。第二,sum / 10 得到的进位,上面我们已经推导过只可能是 0 或 1,所以这里用 int 存没有问题。
再看 Python 版,逻辑完全一样,只是语法上更简洁。
python复制from typing import Optional
class Solution:
def addTwoNumbers(self, l1: Optional[ListNode], l2: Optional[ListNode]) -> Optional[ListNode]:
dummy = ListNode(0)
cur = dummy
carry = 0
while l1 or l2:
n1 = l1.val if l1 else 0
n2 = l2.val if l2 else 0
total = n1 + n2 + carry
carry = total // 10
cur.next = ListNode(total % 10)
cur = cur.next
if l1:
l1 = l1.next
if l2:
l2 = l2.next
if carry:
cur.next = ListNode(carry)
return dummy.next
Python 的 if l1 判断比 C++ 的 l1 != nullptr 写起来顺手,但原理完全一样。如果你打算在本地跑,别忘了自己实现链表构建和打印函数,我一般会加两个小工具函数方便验证:
python复制def build(nums):
dummy = ListNode(0)
cur = dummy
for x in nums:
cur.next = ListNode(x)
cur = cur.next
return dummy.next
def to_list(head):
res = []
while head:
res.append(head.val)
head = head.next
return res
l1 = build([9, 9, 9])
l2 = build([1])
print(to_list(Solution().addTwoNumbers(l1, l2))) # [0, 0, 0, 1]
4.2 调试时最容易踩的坑清单
这道题代码量很小,但错误率一点也不低。下面这些坑我基本都踩过,或者帮别人 review 代码时见过。
第一个坑是把 while (l1 || l2) 写成 while (l1 && l2)。原因前文已经详细分析过,一旦两条链表长度不等,循环会提前退出,长链表后半部分直接被丢弃。
第二个坑是在循环内没有判空就移动指针。当 l1 已经为空时,l1 = l1->next 在 C++ 里就是空指针访问,程序直接崩溃。Python 里虽然不会崩溃,但也会报 AttributeError。所以要先判断当前节点是否存在,再移动指针。
第三个坑是忘记对 sum 取模。有些人会直接把 sum 作为新节点的值,结果节点里可能出现 10 甚至更大的值。需要记住的是:进位已经由 carry 保存了,当前节点只能存 sum % 10,也就是当前位的个位数。
第四个坑是忘记在循环结束后检查 carry 是否还有值。这个坑最隐蔽,因为它只会在最高位产生进位时暴露出来。比如 999 + 1、99 + 1、9 + 1 这类用例,少了最后一步就会导致结果差一位。
第五个坑是返回了 dummy 而不是 dummy->next。dummy 只是辅助节点,它本身并不属于结果。如果返回 dummy,结果链表开头就会多一个值为 0 的节点。
提示:刷完题自测时,一定要在草稿上跑几个边界用例,比如 [9,9,9] + [1]、[9] + [9]、[0] + [0],而不是只跑题目示例。LeetCode 的示例没有覆盖到的边界,恰恰是错误最集中的地方。
5. “高精度加法”的通用骨架:原来所有版本都是同一套招式
5.1 高精度加法到底在解决什么
聊到这里,有必要把话题拉高一点。题目标签里的“高精度加法”,看起来像是个高级词汇,实际意思却很简单:当一个整数大到超过语言内置类型能表示的范围时,你不能再用一个 int 或 long long 直接存储它,于是就把每一位数字拆开,存进数组、字符串,或者链表里,然后一位一位地模拟人工竖式加法。
C++ 的 long long 最大也就存到 9223372036854775807 左右,超过这个范围就是溢出;Python 的 int 虽然理论上没有上限,但在别的语言里大数处理依然是个现实问题。LeetCode 第 2 题之所以不让你直接把链表转成整数相加,一方面是为了考察链表操作,另一方面也是想让你体会大数按位存储的计算思路。
把代码和竖式加法对照起来看,核心只有四步:
- 从最低位开始,取出两个数当前位的数字;
- 加上上一位产生的进位;
- 当前位保留
sum % 10; - 把
sum / 10作为新的进位传给下一位。
这套骨架不只适用于链表,数组、字符串、栈,本质都在重复这四步。
5.2 和字符串加法版本逐项对比
同样是“两数相加”,LeetCode 415 题是给你两个字符串形式的数字,让你返回它们的和字符串。解法骨架和链表版几乎一模一样,区别只在存储结构和遍历方式。
| 对比维度 | 链表版(本题) | 字符串版(415 题) |
|---|---|---|
| 最低位在哪 | 头节点,从头开始遍历 | 字符串末尾,需要从右往左遍历 |
| 结束条件 | 两个链表都为空 | 两个下标都越界 |
| 长度不一致的处理 | 空链表当前位按 0 处理 | 越界的下标按字符 0 处理 |
| 结果存放 | 新建链表节点并向后串联 | 往结果字符串尾部 push,最后反转 |
字符串版代码可以这样写:
cpp复制string addStrings(string num1, string num2) {
int i = num1.size() - 1;
int j = num2.size() - 1;
int carry = 0;
string res;
while (i >= 0 || j >= 0 || carry) {
int x = (i >= 0) ? num1[i] - '0' : 0;
int y = (j >= 0) ? num2[j] - '0' : 0;
int sum = x + y + carry;
carry = sum / 10;
res.push_back(sum % 10 + '0');
i--;
j--;
}
reverse(res.begin(), res.end());
return res;
}
你注意看字符串版的 while 条件,写的是 i >= 0 || j >= 0 || carry,它把最后的进位检查直接并进了循环条件里。链表版没有这么写,是因为链表版由 long 节点数不确定,但在循环退出后补一个 carry 节点,两者本质上是一回事。
5.3 如果链表改成正序存储怎么办
理解了上面的骨架后,再延伸一个问题:如果题目把链表改成正序存储,也就是 342 存成 3 -> 4 -> 2,就不能直接用刚才的代码了,因为最低位在链表尾部,从头开始遍历时低位信息不足,进位没法提前处理。
常见的解法有两种。第一种是把两条链表反转,加完再反转回来;第二种是利用栈后进先出的特性,把节点值依次压栈,然后从栈顶开始取最低位。第二种思路实现起来更干净,也更能体现“栈在正序变逆序场景里的价值”。
cpp复制ListNode* addTwoNumbersII(ListNode* l1, ListNode* l2) {
stack<int> s1, s2;
while (l1) {
s1.push(l1->val);
l1 = l1->next;
}
while (l2) {
s2.push(l2->val);
l2 = l2->next;
}
int carry = 0;
ListNode* head = nullptr;
while (!s1.empty() || !s2.empty() || carry) {
int x = !s1.empty() ? s1.top() : 0;
int y = !s2.empty() ? s2.top() : 0;
if (!s1.empty()) s1.pop();
if (!s2.empty()) s2.pop();
int sum = x + y + carry;
carry = sum / 10;
ListNode* node = new ListNode(sum % 10);
node->next = head;
head = node;
}
return head;
}
注意这里构建结果链表用的是头插法
