不废话,直接进入正题。这道题很多人在面试前都会刷到,但真正吃透它的人不多。我见过太多人能把代码背下来,却被面试官追着问几句边界条件就卡壳。这篇博文会把“Add Two Numbers”从头到尾掰开揉碎讲清楚,从最基础的链表结构、为什么采用逆序存储,到进位处理的细节、内存释放的坑,再到时间复杂度的分析,一次说透。
适合的读者包括:刚接触链表的初学者、准备算法面试的开发者、以及想深入了解这题背后设计思想的老手。如果你已经AC了这道题,更建议你重点看第3、4部分,里面有不少只看题解根本学不到的东西。
1. 题目本质:其实是在做竖式加法
1.1 题目说的到底是什么
这道题的核心定义很直白:给你两个非空链表,每个链表代表一个非负整数,数字在链表中是逆序存储的,每个节点只存一位数字。要求你把两个数相加,返回一个同样以链表形式表示的和。
举个例子,链表的表示方式长这样:
- 数字 342 在链表中表示为 2 -> 4 -> 3
- 数字 465 在链表中表示为 5 -> 6 -> 4
- 相加的结果是 807,表示为 7 -> 0 -> 8
刚开始接触的人都会觉得别扭:正常人写数字都是从高位到低位,这题偏要从低位到高位。其实仔细想一想就明白了,这种设计不是出题人故意刁难,而是大幅简化了计算逻辑。
1.2 为什么逆序存储
我们手算加法的时候,是先从个位开始加的,也就是从最低位开始往前算。如果数字是正向存储的(高位在前),那你要先找到链表的末尾才能开始加,单链表又没法往回走,只能先遍历一遍找到末尾,操作起来非常麻烦。
逆序存储之后,链表的头节点恰好就是个位。你从头开始遍历,就相当于从个位开始逐位相加,同时处理进位问题。换句话说,链表的遍历顺序和手算的顺序完全一致,代码写起来非常符合直觉。
我刚开始学数据结构的时候也觉得这题很怪,后来想明白了:存储结构的设计往往是跟着算法需求走的,而不是跟着人的阅读习惯走的。这也是算法题里很常见的一种思维:用空间或者存储方式上的调整,换取算法实现的简化。
1.3 核心解题思路:模拟逐位相加
解题思路其实只有一句话:同时遍历两个链表,逐位相加,处理进位,最后把结果串成新链表。
这个描述听起来平平无奇,但里面藏了一个关键细节:进位。假设当前位的两个数字分别是 a 和 b,再加上上一位传来的进位 carry,那么:
sum = a + b + carry 当前位的结果 = sum % 10 新的进位 = sum / 10这个逻辑就是竖式加法的本质。很多初学者会忽略 carry 的存在,算完当前位就进入下一位,最后发现结果少了高位。记住一点:进位不处理干净,这题就永远写不对。
2. C++核心实现:从伪代码到可运行代码
2.1 链表节点的结构定义
这道题使用 C++ 实现时,链表节点的定义通常是这样的:
struct ListNode { int val; ListNode *next; ListNode() : val(0), next(nullptr) {} ListNode(int x) : val(x), next(nullptr) {} ListNode(int x, ListNode *next) : val(x), next(next) {} };这是力扣平台内置的结构体,提供了三个构造函数,分别对应不同初始化方式。本身很简单,但有一个细节值得注意:ListNode(int x, ListNode *next)这个构造函数允许你一次性初始化值和下一个节点,在构造链表时可以减少一些代码量。
2.2 完整代码实现
直接给出我写的核心版本:
class Solution { public: ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) { ListNode dummy(0); ListNode* cur = &dummy; int carry = 0; while (l1 != nullptr || l2 != nullptr || carry != 0) { int sum = carry; if (l1 != nullptr) { sum += l1->val; l1 = l1->next; } if (l2 != nullptr) { sum += l2->val; l2 = l2->next; } cur->next = new ListNode(sum % 10); carry = sum / 10; cur = cur->next; } return dummy.next; } };这个版本的代码量不算大,但里面有几个设计选择值得仔细分析,因为它们直接影响代码的健壮性和可读性。
2.3 虚拟头节点技巧:避免空指针判断
我先说ListNode dummy(0)这一行。这个虚拟头节点(dummy node)是这个解法的灵魂之一。
如果你不用虚拟头节点,代码会变成什么样呢?你需要在循环里判断result是不是第一个节点:
if (result == nullptr) { result = new ListNode(sum % 10); tail = result; } else { tail->next = new ListNode(sum % 10); tail = tail->next; }看起来也就多几行,但问题在于循环体里面多了分支判断,逻辑没法保持统一。而用了虚拟头节点之后,所有节点都是统一挂在cur->next上的,循环体里不需要区分是不是第一个节点,代码简洁度提升明显。
使用虚拟头节点时还要注意一个常见问题:最后返回的一定是dummy.next,而不是dummy本身。我有一次调试一个链表相关的问题,同学把return dummy.next写成了return &dummy,结果返回了一个栈上对象的地址,函数结束对象就销毁了,程序直接崩溃。栈上对象不能返回指针,这是基本常识,但忙起来的时候真的容易犯。
2.4 循环条件为什么是三个条件的或
再看while (l1 != nullptr || l2 != nullptr || carry != 0)这个条件。很多初学者会写while (l1 != nullptr && l2 != nullptr),这是最常见的错法之一。
为什么不能这么写?因为两个链表长度不一定相同。假设 l1 有三位数,l2 只有一位数,按&&的判断,遍历完 l2 之后循环就结束了,l1 剩下两位根本没处理。
那为什么还要把carry != 0加进循环条件?因为这个场景太典型了:比如 5 + 5 = 10,两个链表都遍历完了,但进位 carry = 1 还没处理。如果不考虑这个条件,最高位的 1 就丢了,结果就错了。
while条件用||把三个条件连接起来,循环体内部再分别判断l1 != nullptr、l2 != nullptr,这样就能一套逻辑覆盖所有情况,不需要额外的后置处理。
2.5 为什么用取模和整除
计算当前位和进位,用的是sum % 10和sum / 10,这是很自然的数学操作。这里我额外提一个 C++ 的特性:整数除法对于正数,结果直接截断小数部分。因为这里的 sum 最大也只是 9 + 9 + 1 = 19,所以sum / 10只会得到 0 或 1,不可能出现其他值。这也是这道题能简化的一个重要前提。
有些题解会写成carry = sum >= 10 ? 1 : 0,效果一样,但我个人还是推荐用除法,因为除法在语义上更通用。如果你把题目扩展成“两数相加但每个节点存两位数”,除法和取模的方式就不需要改逻辑,直接就能用。具体扩展方案我在第5部分会讲。
3. 边界情况与调试陷阱
3.1 五个必须考虑的边界场景
我在实际刷题和帮别人 review 代码时,总结出了这道题最典型的五个边界场景:
| 场景 | 示例输入 | 预期输出 | 容易踩的坑 |
|---|---|---|---|
| 两个链表长度相同,无进位 | 2->3, 5->6(32+65) | 7->9(97) | 基本操作,一般不会错 |
| 两个链表长度不相同 | 2->3->4, 5->6(432+65) | 7->9->4(497) | 短链表遍历完后忘记继续处理长链表 |
| 存在连续进位 | 9->9, 1(99+1) | 0->0->1(100) | 最后一位进位被丢掉 |
| 结果比两个操作数多一位 | 5, 5(5+5) | 0->1(10) | 循环结束后 extra carry 被忽略 |
| 一个链表只有一个节点 | 0, 0->1(0+10) | 0->1(10) | 没处理好节点为空的情况 |
这几个场景建议在提交之前全都手动推一遍。我在代码写完之后会先在脑子里跑一遍9->9 + 1这个用例,因为这基本上能覆盖到绝大多数容易出现问题的逻辑分支。
3.2 那道经典的“内存释放”坑
这个坑我要单独拎出来说,因为知道的人真的不多。
上面的标准解法里,l1和l2是函数参数传入的链表。在遍历过程中,我写了l1 = l1->next; l2 = l2->next;,这没问题。但如果你在循环里写的是ListNode* temp = l1->next; delete l1; l1 = temp;这种试图释放原链表的代码,那就要小心了。
原因在于:原链表节点是在调用方创建并管理的。你在函数里把它们 delete 掉,调用方后续可能还要访问或者释放这些内存。如果你在函数里 delete 了原链表节点,然后又 new 出新节点,最后返回的新链表虽然没问题,但调用方的链表已经被你破坏了,轻则 double-free,重则程序崩溃。
正规做法是:新链表的每个节点都用new单独创建,原链表节点一个都不要动。如果确实需要释放原链表,应该在调用方明确同意并配合的情况下,用一个单独的函数去统一处理,而不是在计算函数里顺手做掉。
3.3 避免内存泄漏
现在再聊聊内存泄漏的问题。这个解法里,每个结果节点都是new出来的,最后返回了链表头指针。如果调用方用完之后只释放头节点,那后面所有节点都会泄漏。
正确释放方式是遍历整条链表,逐个 delete:
ListNode* cleanup(ListNode* head) { while (head != nullptr) { ListNode* temp = head->next; delete head; head = temp; } return nullptr; }有个细节:释放之前先把head->next存到临时变量,再 delete 当前节点。否则你 delete 完就永远不知道下一个节点在哪了。这个习惯要养成,凡是涉及链表释放,基本都要这么做。
我在写实际项目代码的时候,有个朋友问过一个问题:为什么 C++ 不像 Java 那样有垃圾回收,非要手动释放内存?答案很简单:C++ 的设计哲学是“不为不需要的东西付费”,你不需要释放或回收集合,你只需释放它们。但手动管理内存的代价就是,你必须非常清楚内存的生命周期,否则就会出现内存泄漏或悬垂指针。
3.4 调试技巧:可视化链表
链表相关的题目,调试起来比数组麻烦,因为没法直接看输出。我的建议是写一个辅助函数,把链表打印成数组形式:
void printList(ListNode* head) { vector<int> vals; while (head != nullptr) { vals.push_back(head->val); head = head->next; } for (int v : vals) { cout << v << " -> "; } cout << "null" << endl; }把这个函数加到你的代码里,每次调试的时候直接调用,比断点看得清楚多了。尤其是处理进位问题时,很容易出现某个中间节点的值不对,可视化之后一眼就能看出来是哪一步出的问题。
4. 复杂度分析与是时候讲讲底层原理了
4.1 时间复杂度与空间复杂度
这个解法的时间复杂度是 O(max(m, n)),其中 m 和 n 分别是 l1 和 l2 的长度。为什么不是 O(m + n)?因为两个链表是同时遍历的,循环次数取决于较长那一个,短的那部分在遍历完成后就直接跳过了。空间复杂度同样是 O(max(m, n)),因为结果链表最多有 max(m, n) + 1 个节点,加 1 是因为可能多出一位进位。
很多人觉得空间复杂度是 O(1),这就错了。你 new 出来的每个节点都需要额外的内存空间,这些空间是随着输入规模线性增长的,所以空间复杂度不可能是 O(1)。
4.2 为什么这题能处理“大数相加”问题
一个很有意思的视角:这题本质上是解决了一个“大数相加”的问题。当你用语言自带的基础类型去存数字时,比如int,它最多只能存 32 位有符号整数,范围是 -2147483648 到 2147483647。long long也只有 64 位。当数字超过这个范围,基础类型就会溢出,结果错误。
而用链表表示数字,每个节点只存一位,理论上链表有多长,数字就有多大。只要内存够用,再大的数字都能精确表示和计算。这就是为什么这题可以作为“大数处理”的基础模型来学习。
延伸一下,大数相乘、大数减法、大数除法,其实都可以在这个思路上扩展。核心思想不变:用自定义的数据结构表示超长数字,模拟手算的逐位过程。
4.3 与字符串大数相加的对比
字符串大数相加是另一道常见题目:给定两个字符串表示的数字,返回它们的和的字符串形式。比如"123456789" + "987654321"。这题的思路和链表版本几乎完全一致,只是数据结构不同。
对比一下两者的差异:
| 特性 | 链表版 | 字符串版 |
|---|---|---|
| 存储方向 | 逆序,个位在头 | 正序,个位在尾 |
| 遍历方式 | 从头到尾就是低位到高位 | 需要从末尾往前遍历或用索引 |
| 进位处理 | 相同 | 相同 |
| 边界情况 | 链表为空 | 字符串为空 |
可以看出,链表版其实已经被出题人优化过了,把最舒服的遍历顺序交给了你。字符串版反而需要你先反转或者从末尾开始,处理起来稍麻烦一点。
5. 边界情况扩展:进位状态机与优化思路
5.1 用状态机视角理解进位
进位处理在简单场景下用carry = sum / 10就行了,但如果你把这道题看作一个状态机,理解会更深入一层:
- 状态:当前累计的进位值(0 或者 1)
- 输入:两个链表的当前节点值(可能有一个为空)
- 转移:根据当前值和进位算出新的进位和输出值
- 初始状态:carry = 0
- 终止条件:两个链表都遍历完且 carry = 0
状态机的视角能帮你抽象出这类问题的本质结构。无论是链表版、字符串版,还是二进制版,核心转移逻辑都没变,变化的只是输入数据的获取方式。这也意味着,一旦你理解了这题的状态机结构,其他版本的题目你几乎不需要重新学,只需要换一下前端的“数据读取方式”就行了。
5.2 如果你想把每题扩展:每个节点存两位
面试官可能会问:“如果每个节点不存一位,而是存两位数字,代码要怎么改?”这种问题就是考察你有没有真正理解这道题,而不只是背代码。
核心改动只有一个地方:sum % 10变成sum % 100,sum / 10变成sum / 100,其他逻辑不需要动。因为每个节点存两位,那么当前位最大是 99,加上进位 1,sum 最大是 199,取模和整除的基数就变成 100。
但这里有个新问题:存储方式变了,数字的表示方式也要变。比如数字 12345,如果每个节点存两位,逆序链表应该怎么存?从个位开始两位两位切分,结果是:45 -> 23 -> 1,也就是链表依次存储 45、23、1。最后输出的时候需要额外注意补零的问题,比如中间某个节点存的是 5,实际代表的可能是 05,但链表里没法区分。这就是扩展之后的新坑。
5.3 如果链表是正序存储怎么办
有些变体题要求链表正序存储,比如 3 -> 4 -> 2 表示 342。这种情况下,个位在链表的尾部,从头部开始遍历实际上是从高位开始算。解决方案有几种:
- 翻转链表,把正序变成逆序,然后用上面的方式解决,最后再翻转回来。
- 用栈存储,遍历链表压栈,弹栈顺序刚好就是逆序。
方案2我觉得更优雅,不需要修改原链表。但要注意,栈的深度等于链表的长度,如果链表太长,栈空间会占用比较多内存。实际项目里要根据数据规模决定用哪种方案。
5.4 时间复杂度还能不能再优化
有的同学可能会想:能不能把时间复杂度从 O(n) 降到 O(log n) 或者 O(1)?答案是不行。
原因是:你需要查看输入数据的每一位才能计算结果,而查看每一位本身就需要遍历链表,最少需要 O(n) 的时间。这就是所谓的信息论下界——任何算法都必须至少读取一遍全部输入,否则无法保证结果的正确性。
不过有一种思路可以优化常数:用递归替代迭代。递归版代码如下:
class Solution { public: ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) { return addTwoNumbersHelper(l1, l2, 0); } ListNode* addTwoNumbersHelper(ListNode* l1, ListNode* l2, int carry) { if (l1 == nullptr && l2 == nullptr && carry == 0) { return nullptr; } int sum = carry; if (l1 != nullptr) sum += l1->val; if (l2 != nullptr) sum += l2->val; ListNode* result = new ListNode(sum % 10); result->next = addTwoNumbersHelper( l1 != nullptr ? l1->next : nullptr, l2 != nullptr ? l2->next : nullptr, sum / 10 ); return result; } };这个版本在逻辑上更简洁,但要注意:递归深度等于链表长度。如果链表非常长(比如十万个节点),递归会导致栈溢出。实际开发中,迭代优先,递归次之。
6. 从这题延伸出去:一个完整的整数链表操作工具箱
6.1 链表的正向构造方式
前面我给的代码都是在力扣环境里运行的,链表已经构造好了。但如果你在本地自己测试,或者在实际项目中需要自己构造链表,推荐用递归或辅助函数的方式,而不是手动一个一个new。
一个常见的构造方式:
ListNode* buildList(vector<int>& nums) { ListNode dummy(0); ListNode* cur = &dummy; for (int num : nums) { cur->next = new ListNode(num); cur = cur->next; } return dummy.next; }注意这里同样用了虚拟头节点,构造过程中不需要处理“当前节点是否为空”的问题。这种写法和前面的解法形成呼应,都是用一个 dummy 头来统一逻辑。
6.2 本题的完整本地测试代码
很多人刷题只在平台上运行,没法本地调试。如果你在本地用 IDE 练习,可以参考下面的完整测试框架:
#include <iostream> #include <vector> using namespace std; struct ListNode { int val; ListNode *next; ListNode() : val(0), next(nullptr) {} ListNode(int x) : val(x), next(nullptr) {} ListNode(int x, ListNode *next) : val(x), next(next) {} }; class Solution { public: ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) { ListNode dummy(0); ListNode* cur = &dummy; int carry = 0; while (l1 != nullptr || l2 != nullptr || carry != 0) { int sum = carry; if (l1 != nullptr) { sum += l1->val; l1 = l1->next; } if (l2 != nullptr) { sum += l2->val; l2 = l2->next; } cur->next = new ListNode(sum % 10); carry = sum / 10; cur = cur->next; } return dummy.next; } }; ListNode* buildList(vector<int>& nums) { ListNode dummy(0); ListNode* cur = &dummy; for (int num : nums) { cur->next = new ListNode(num); cur = cur->next; } return dummy.next; } void printList(ListNode* head) { while (head != nullptr) { cout << head->val << " -> "; head = head->next; } cout << "null" << endl; } int main() { vector<int> nums1 = {2, 4, 3}; vector<int> nums2 = {5, 6, 4}; ListNode* l1 = buildList(nums1); ListNode* l2 = buildList(nums2); ListNode* result = Solution().addTwoNumbers(l1, l2); printList(result); return 0; }这段代码能在本地直接编译运行,方便你测试各种边界情况。实际测试中,建议把nums1和nums2换成 3.1 部分表格里的边界数据,逐项验证。
6.3 这题在面试中的常见追问形式
我总结了一些面试官针对这题的追加问题:
- 如果输入链表的首位存储的是最高位会怎么样?
- 如果链表非常长,递归会有什么问题?
- 能否在不额外分配结果链表空间的前提下完成加法?
- 如果有多个链表相加呢?
关于第 3 问,可以想到的思路是在原链表中复用节点。但这是有前置条件的:l1和l2的链表空间必须可以被修改,而且你要确保改动不会影响调用方。实现上,可以把计算结果写回l1或l2的节点上,这样就不需要 new 那么多新节点了。但如果两个链表长度不一致,那较长链表中未被覆盖的部分还是需要复用,逻辑会比默认解法复杂不少。
第 4 问是扩展题:三个链表相加,怎么做?思路是循环调用两次,或者用一个函数支持任意多个链表,每次把所有链表的当前位和进位累加,然后取模生成结果节点。本质还是状态机的思想,只是输入的“当前节点组”变多了。
7. 个人经验总结
这道题刷完之后最大的收获,是理解了“数据结构适应算法”这件事。逆序存储一开始看着别扭,但真正理解了它省掉了多少取尾节点的操作之后,就觉得这个设计很巧妙。很多时候我们觉得某个设计不合理,其实是因为还没站在算法实现的角度去思考。
我自己在带新人时,经常用这题检验基本功。判断标准不是能不能 AC,而是四个问题:虚拟头节点的作用是什么?为什么循环条件要用三个条件的或运算?如何处理最后的进位?原链表的节点能不能删?能把四个问题答清楚,说明这道题真的理解了,否则大概率只是背下来了。
最后强调一件事:题目会变,但核心思想是通用的。链表操作、进位处理、状态机拆解、复杂度分析,这些能力从这一道题上都能练习到。刷题,从来不是为了记住某道题的答案,而是为了形成一套可迁移的思维方法。这道题对你思维方法的训练价值,远大于代码本身。