1. 这道题到底在考什么
刷过一段时间算法题的同学,对 Add Two Numbers 这个名字应该不陌生。它表面上描述得很简单:给两个非空链表,每个链表表示一个非负整数,数字的每一位按逆序存储,每个节点存一位数字。要求把两个数相加,返回一个新的链表,同样按逆序存储。题目给了一个输入输出的样例,比如链表 2->4->3 和 5->6->4,结果是 7->0->8,含义是 342 + 465 = 807。
我第一次刷这道题的时候,心里还在想:这有什么难的?把两个链表分别转成整数,相加后再把结果转成链表,几行代码搞定了。但真写起来会发现,这条路在工程上根本走不通——两个链表都有可能是几百位长,C++ 内置的 int 和 long long 早就溢出了,就算用上什么大整数库,也完全违背了这道题要考察的本意。说白了,这道题想让你做的不是把数字还原出来重新加,而是直接对链表本身做逐位运算,模拟手算加法的过程。
所以在动手写代码之前,先把题目解构清楚。它考察的核心点有三层:
- 链表的遍历能力:能否在一条链上从头走到尾,同时在另一条链上同步移动。
- 进位处理的思维:加法中每一位的结果由三个数联合决定,两个加数位加上低位的进位,不能只看单个节点。
- 边界条件的完备性:两个链表长度不同、最高位产生额外进位、某个链表只有一个节点且值为 0,这些都容易漏掉。
面试场景里这题出现频率很高,基本上属于“链表题里的 Hello World”。它能在一道题里同时暴露你对指针操作、循环终止条件、内存管理这几块基本功的掌握程度。对新手来说,这也是理解链表操作思路的一道分水岭——如果这道题你能做到不看答案、自己把边界情况补全,链表的很多解题感觉就算建立起来了。
2. 解法背后的核心设计思路
2.1 为什么逆序存储反而是加分项
先讲一个很多初学者没想通的问题:题目里的链表为什么非要逆序存储数字?比如数字 342,链表存的是 2 在头、4 在中间、3 在尾。这其实是一个很实用的设计。
因为数学上手算加法就是从小位数开始加起,也就是从最低位向最高位逐位推进。而链表不管你存什么位,遍历顺序始终是从头节点往后走。如果按数字的正常书写顺序存储,头节点存的是最高位,那你一上来就要处理最高位相加,进位的方向是“从高往低”,这跟你遍历链表的方向恰好相反,处理起来极其别扭——你需要先走到链表尾才知道最低位加出来是什么结果,然后还要回头调整前一个节点的值。这种“反向依赖”在单链表里实现起来相当麻烦,要么递归,要么先反转链表。
逆序存储把最低位放在链表头,正好让“链表的遍历顺序”和“数学加法的运算顺序”完全重合。你从头部开始加,进位自然地向后传递,一路扫下去就能得到完整结果。所以在读题的时候,不要把逆序存储看成一道障碍,它是题目留给你的一个天然便利条件。理解这一点,后续写代码的思路会顺很多。
2.2 把加法拆解成“三数相加”
剥开整道题的外壳,最核心的数学模型其实是这样一句话:每一位上的新值等于两个节点值和进位值的和,然后对 10 取余得到当前位,对 10 取整得到新的进位。如果把当前位运算写一下,就是:
int sum = val1 + val2 + carry; int digit = sum % 10; carry = sum / 10;这三行代码就是整道题的心脏。为什么可以这样拆?因为十进制加法的本质就是这样一层一层向前传递信息:低位只影响高位的进位,不影响已计算完成的低位结果。每一位的计算只依赖当前的三个输入,跟更低位或更高位没有任何关系。这种“每个状态只依赖前一个有限状态”的特性,让逐位遍历成为可能。
想明白这个模型,代码结构就清晰了:一个循环遍历链表,每轮做一次三数相加,算出这一位的数字和进位数,然后把数字挂到新链表上,继续往后走。代码骨架一眼就能搭出来,剩下的全在细节。
2.3 空间换时间:新建链表 vs 原地修改
实现加法结果有两种思路:第一种是新建一个链表,把逐位结果挂载上去,原链表保持不变。第二种是直接复用其中一个链表的节点,把计算结果写回原链表,省掉新建节点的开销。两种写法我都试过。
新建链表的好处是逻辑清晰,不需要考虑原链表内存怎么处理,新链表的生命周期独立可控,对面试来说不容易出错。缺点是每个结果节点都要 new 一次,如果链表很长,频繁分配内存会有一定的性能损耗,但算法考核场景下这完全不是问题,复杂度并没有发生变化。
原地修改则要求你在遍历过程中把计算结果直接写进某一个链表的节点里。比如固定修改第一个链表,遇到不够长的情况时再去借用第二个链表的节点。这样做能省内存,但代码里要处理的情况会多不少:如果第二个链表更长,怎么把后半段接过来;如果最后还有进位,怎么追加新节点;还要注意不能误改了原链表,否则调试的时候数据全乱了。工程里如果原链表后续还有别处使用,原地修改很危险。
我在实战里推荐的做法是:面试或比赛中使用新建链表,思路清晰不容易被 bug 绕进去;项目代码里要处理高精度运算需要控制内存时,再考虑原地修改的方案。后面我会把两种写法的要点都讲一遍。
3. 手把手实现标准解法
3.1 哨兵节点的神奇作用
动手写代码前,先解决一个问题:我们要返回一个新链表,这个链表的头节点是什么时候确定的?听起来很简单——第一轮加法做完,第一个结果节点就是头节点。但如果链表为空呢?这道题虽然声明了输入链表非空,可还是要养成好习惯,考虑一下特殊情况。另一个麻烦是,在循环里每构造一个节点都要判断“当前是不是第一个节点”,如果是就赋值给头指针,如果不是就接到尾部。这个判断每轮都要做,很啰嗦,也容易漏。
有一个经典的解决方案,叫虚拟头节点,也叫哨兵节点。做法是先创建一个不存储实际数值的节点,标记为 dummy,然后让一个尾指针初始指向它。后续每产生一个新节点,都接在尾指针后面,不用管是不是第一个节点。循环结束后,dummy->next 就是真正结果链表的头节点。
ListNode *dummy = new ListNode(0); ListNode *cur = dummy;这个技巧在几乎所有“逐位构造链表”的题目里都适用。它省掉了对头节点的特殊判断,让循环体保持纯粹的计算逻辑。等你链表题刷多了会发现,dummy 节点的用法非常普遍:链表删除、合并、排序,只要结果链表的头节点可能变动,用它就对了。
3.2 主循环的推进逻辑
现在来写主循环。每次迭代需要从两个链表各取一个节点的值,加上进位,算出新节点。但两个链表并不能保证一样长,所以循环的条件写成“只要两个链表中还有节点没处理完,或者进位不为 0,就继续跑”。
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 || 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); cur = cur->next; carry = sum / 10; } return dummy->next; } };这段代码里有个细节值得琢磨:循环条件里我加上了carry != 0。很多版本只写l1 != nullptr || l2 != nullptr,那样最后如果还有一个进位,循环已经结束了,进位就成了无家可归的孩子,直接丢掉了。典型场景:999999 + 1。链表逐位加完到最后还剩一个进位 1,必须在结果链表末尾再补一个节点。把carry != 0直接放进循环条件,就省掉了循环结束后再单独补一次节点的判断,逻辑更紧凑。这个写法是我在踩过几次漏掉最后一位进位的坑之后总结出来的,特别好用。
3.3 完整代码与每一步的意图
扩展成一套完整的实现,加上必要的内存清理,逻辑如下:
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 = new ListNode(0); ListNode* tail = 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; } tail->next = new ListNode(sum % 10); tail = tail->next; carry = sum / 10; } return dummy->next; } };每部分的作用拆开看:
dummy节点用来占住结果链表的头部位置,避免头节点悬空。tail指针始终指向新链表的最后一个节点,方便追加。它是典型的“尾插法”操作。carry保存上一轮产生的进位值,每一轮先把进位加进去,再计算当前位。- 循环里分别判断
l1、l2是否为nullptr,是为了适配长度不齐的情况。短的链跑完了,剩下的轮次只跟长链和进位打交道。 sum % 10是当前位的值,sum / 10是贡献给下一位的进位。C++ 里两个整数相除会向下取整,所以sum / 10对 0 到 18 区间的数来说,只会得到 0 或 1,恰好符合十进制进位的需要。
整个过程模拟的就是你在纸上算两道加法竖式时做的事情:个位相加,写下个位,十位记得加一,然后继续。
4. 边界情况大盘点
4.1 长度不对称:一长一短怎么办
最典型的情况是2->4和5->6->4,也就是 42 + 465 = 507。较短的链 l1 只有两个节点,遍历到第三轮时 l1 已经空了,但我们依然要处理 l2 的第三个节点和可能的进位。处理手段就是循环开头对 l1 判空、对 l2 判空:空的取值为 0,非空的取真实值。这相当于给短链表“补了 0”,不会改变加法的结果。
这里我见过不少新手写出的替代方案:先在循环外把两个链表对齐补全,比如把短的链表尾部补上若干个值为 0 的节点,然后再统一遍历。这种写法也能跑通,但会修改原始链表,额外引入 O(n) 的时间去计算长度和补节点,还容易因为节点分配失误导致泄漏。相比之下,循环内直接判空明显更轻巧,运行时不改变任何输入链表,也天然支持任意长度差异。
4.2 最高位进位:999999 加上 1
把9->9->9->9->9->9和1->0->0->0->0->0(注意第二个表示数字 1,按题目规则其实是1这一条链)相加,每一位都在进位,等两个链表都遍历完了,进位 still 是 1。很多遗漏就是发生在这一步:只检查了链表非空,没有检查进位,结果最高位的 9 加上来的 1 丢了,输出成 999999,而正确答案应该是 1000000 的逆序存储,也就是0->0->0->0->0->0->1。把 carry 写进循环条件后,这个场景就自动处理好了,程序会在两个链都走完之后,再单独跑一轮,生成最后一个节点。
4.3 单个节点且值为 0:最不起眼的测试点
题目明确说输入链表非空,但可能出现“只有一个节点,值为 0”的情况,比如0->nullptr加上0->nullptr。这个例子的意义在于验证你的代码不会把 0 当成空指针。nullptr是节点为空,val == 0是节点存在但值为零,这是完全不相关的两件事。判断链表是否还有节点时,永远要基于指针本身是否为nullptr,不要拿val是否等于 0 去猜。见过有人写while (l1->val != 0 || l2->val != 0),典型地用错了判断维度,一旦出现数字中间某位是 0,循环就提前停了。
5. 复杂度分析与进阶优化
5.1 时间和空间复杂度
粗暴的做法是先把两个链表转成数字,相加,再转回链表,时间开销取决于你模拟十进制乘法和加法的代价。而标准的逐位解法,时间只花在把两个链表完整遍历一遍上,时间复杂度就是 O(max(m, n)),m 和 n 分别是两个链表长度。因为每轮循环只做常数次操作,循环轮数不会超过更长链表的长度加一。
空间复杂度上,新建链表的做法是 O(max(m, n)),因为结果链表的长度在最坏情况下比输入较长链多出一位。如果你硬要抠常数,可以原地修改其中一个链表,把空间复杂度压到 O(1),只额外分配最后进位需要的节点。但这种优化一般只在内存受限的场景才有意义,刷题和日常代码里反而增加了出错概率。
5.2 原地修改版本
想进一步节省内存,可以尝试把结果直接写进 l1 的节点里。思路是:遍历同时更新 l1 节点上的值,如果 l1 走完了而 l2 还有剩余,就把 l2 剩余的节点接到 l1 后面继续处理。
ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) { ListNode* head = l1; int carry = 0; ListNode* prev = nullptr; while (l1 != nullptr || l2 != nullptr || carry > 0) { int sum = carry; if (l1 != nullptr) { sum += l1->val; } if (l2 != nullptr) { sum += l2->val; l2 = l2->next; } if (l1 != nullptr) { l1->val = sum % 10; prev = l1; l1 = l1->next; } else { // l1 已经走到头,l2 可能还有剩余或还有进位,追加新节点 prev->next = new ListNode(sum % 10); prev = prev->next; l1 = prev->next; // 这里实际上需要更谨慎地处理 } carry = sum / 10; } return head; }这里要非常小心:把 l1 的尾节点接到 l2 剩余部分时,要处理指针之间的关系,否则可能出现环形链表或者丢节点。我实际调试这个版本时,为了处理 l1 走完后 l2 剩余部分的接入,额外做了不少判断。结论是:面试时如果没被明确要求“空间 O(1)”,我建议还是别在考场上玩这种优化,容易把自己绕晕。知道有这种思路即可,能跟面试官聊明白利弊就已经加分了。
5.3 递归写法:另一种思考角度
链表天然适合递归,因为每个节点都指向下一个同类结构。这道题也可以写成递归:每次只看当前两个节点和一个进位,算完这一位后,把剩余的链表和新的进位交给递归函数处理。下面是一个经典实现。
class Solution { public: ListNode* addTwoNumbers(ListNode* l1, ListNode* l2, int carry = 0) { if (l1 == nullptr && l2 == nullptr && carry == 0) { return nullptr; } int sum = carry; if (l1 != nullptr) { sum += l1->val; l1 = l1->next; } if (l2 != nullptr) { sum += l2->val; l2 = l2->next; } ListNode* node = new ListNode(sum % 10); node->next = addTwoNumbers(l1, l2, sum / 10); return node; } };递归版本代码很短,但有两个问题要注意:一是当链表很长时,递归深度可能达到链表长度,存在爆栈风险。C++ 默认调用栈一般在几 MB 规模,链表如果长达几十万甚至上百万节点,递归版本会直接栈溢出。二是每次递归调用都会在栈上保留现场,时间上相比迭代版本也慢一些。迭代版本则完全没有这两个问题,所以我个人在工程代码里几乎只用迭代写法,递归只用来讲思路、写演示代码。
6. 常见误区与排查技巧实录
6.1 空指针解引用
新手最容易踩的坑:在循环里直接取l1->val,但此时 l1 已经是nullptr。比如两个链表长度不等,短的链已经走到尽头后继续取节点值,程序就崩了。带着这个 bug 去跑测试用例,报错信息往往只会提示访问非法内存,根本看不出来是哪一行的问题。排查方法是把两个链表长度不同的用例放在最前面跑,比如1->2->3和1->2,如果崩了大概率就是这里。
6.2 漏掉最后的进位
这是逻辑型 bug,程序不会崩,但答案会错。典型场景:5和5相加,结果是0->1。如果循环条件没写carry != 0,当你处理完两个节点,carry 还是 1,循环却停了,输出0,答案错了 10 倍。这种 bug 隐蔽性很高,因为单个节点的普通用例都能过,只有卡在进位恰好超出两个链表长度的用例上才暴露。我的经验是写完代码后立刻自测三组用例:5 + 5、999 + 1、1 + 999,这三组能覆盖大部分进位泄露场景。
6.3 修改了输入链表导致后续判断受影响
有些人图省事,顺手就把 l1 或 l2 的next指针改了,比如把短链表的尾部接到长链表上凑长度。但函数返回后,外部还在用输入链表做后续逻辑,结果发现链表结构全变了。这在 leetcode 在线测试里不一定会出问题,但放到真实项目中就是重大事故。基本原则是:除非题目明确允许,否则不要修改输入数据。逐位解法里根本不需要改动原链表,全靠局部指针移动就能完成。
6.4 内存泄漏
C++ 版本里每new一个节点都对应一块堆内存,函数返回后,如果外部没有正确释放整条链表,内存就泄漏了。刷题平台通常不检查这个,但做工程时这是大问题。常见的释放方式是从头节点开始逐一delete,保证每个节点都被释放。dummy 节点也要记得单独释放,不要一返回dummy->next就把它忘了。
// 释放整条链表的示例 ListNode* p = result; while (p != nullptr) { ListNode* tmp = p->next; delete p; p = tmp; } delete dummy; // 别忘了6.5 快速自测检查单
整理一个自测清单,每一行都值得单独跑一遍:
| 用例 | 原因 | 预期输出 |
|---|---|---|
0+0 | 节点存在但值为 0 的边界 | 0 |
5+5 | 单个进位 | 0->1 |
999+1 | 连续进位溢出新节点 | 0->0->0->1 |
1+999 | 长链在后 | 0->0->0->1 |
999+999 | 全链进位 | 8->9->9->1 |
1->2->3+1->2 | 长度不一致 | 2->4->3 |
7. 面试追问与变体拓展
7.1 如果链表是正序存储怎么办
面试官很容易追加一个变形:输入链表按正常顺序存数字,也就是最高位在头部,如何相加?比如3->4->2加4->6->5,代表 342 + 465。这时候遍历顺序和进位方向冲突了,我有两个常用方案。
方案一是先反转两个链表,把问题转化成熟悉的逆序相加,得到结果后再反转回来。时间复杂度仍然是 O(n),额外空间 O(1)(不计算新建结果节点的话)。方案二是使用递归或者栈,先把两个链表的所有节点压入两个栈,然后从栈顶依次弹出最低位开始计算。栈天然帮你把顺序扭转过来了,不用修改原链表。
必须提醒的是,这两种方案都要处理一个共同的麻烦:结果无论如何都是逆序构造的,最终需要再反转一次,或者用头插法构造结果链表,每次新节点都插在头部,最后自然就是正序。这又是一个 dummy 节点发挥价值的地方。
7.2 从链表加法到通用大数运算思路
这道题的思路可以延伸到通用的大数运算。很多业务系统里会遇到超出 64 位整数范围的大数据,比如订单号、身份证号、加密相关的大数值计算,系统里一般会用字符串或字节数组来存储。用数组逐位相加的模式跟这道题完全同构:从最低位开始,维护一个进位,逐位计算当前位。
数组版本比链表更简单,因为你可以提前知道长度,结果数组的长度可以直接定位为 max(len1, len2) 加一。
string addStrings(string num1, string num2) { int i = num1.size() - 1; int j = num2.size() - 1; int carry = 0; string result; while (i >= 0 || j >= 0 || carry > 0) { int sum = carry; if (i >= 0) sum += num1[i--] - '0'; if (j >= 0) sum += num2[j--] - '0'; result.push_back('0' + sum % 10); carry = sum / 10; } reverse(result.begin(), result.end()); return result; }这套代码跟链表版本长的几乎一样,思路同源。学会链表版本后,字符串大数相加基本可以秒懂,反之亦然。
7.3 面试时怎么把这个题答出色
很多人觉得这题简单,回答得也很平淡。但要拿高分,光写出遍历代码是不够的。我建议在写代码之前先把思路跟面试官对齐一遍:我会用哨兵节点来简化头节点的处理,循环里同时维护当前进位,两个链表谁短谁就补零处理,循环截止条件是两链皆空且进位清零。这个沟通动作会让面试官看到你写代码是有规划的,而不是想一步写一步。
写完之后主动补充复杂度分析,明确说时间 O(max(m, n)),空间 O(max(m, n))。最后,如果有余力,提一句“这个题可以原地修改来省空间,但会破坏原链表,所以我一般按需选择”,这会让面试官觉得你有工程权衡意识,而不只是背答案。
我个人在实际操作中的体会是,链表题的调试难点往往不在算法而在指针。写完后手动画一张短的链表演算一次,跑通一个进位用例,比盲盯代码找 bug 高效得多。这道题我断断续续用不同语言写过不下十遍,每次写都还能发现新的细节:C 语言要手动管理节点内存,C++ 有 new 和 delete,到了 Rust 要学会所有权转移,语法变但核心模型一直是那个“三位相加,逐位推进”。把这套模型吃透,真的能帮你推倒一大片链表题和字符串题。
最后再分享一个小技巧:如果你需要快速验证解法正确性,不用立刻开大数据量测试,先用几个 5 以内的小数测试用例手算,比如 7 + 8、999 + 1、123 + 456,看输入输出是否符合直觉。小用例能过,边界用例针对性再测一遍,基本就可以放心提交了。这道题最值钱的不是代码本身,而是你通过它建立的“逐位运算 + 哨兵节点 + 进位传播”这一整套思维习惯,后面刷到链表的任何题目,都会感谢这次彻底的理解。