☰
C++ 链表两数相加:哑节点与进位处理的实战解析
2026/10/19 9:16:33 网站建设 项目流程

很多人一开始刷链表题,都会觉得“不就是遍历嘛,有什么难的”。直到遇到 Add Two Numbers 这道题,才发现自己连“两个链表怎么同时走”都要想半天。这道题在不少团队的 C++ 面试题单里出现频率相当高,核心考察点非常集中:链表遍历、进位处理、哑节点技巧,以及最容易被忽略的边界情况。这篇内容我就围绕 C++ 实现两数相加的完整过程展开,把从读题到写码、从踩坑到优化的每个环节都讲透。适合正在刷题准备面试的开发者,也适合刚学完链表想找点实战练手的人。

1. 题目到底在考什么:拆开“逆序链表”这个包装

1.1 题目回顾与输入结构

题目本身不长:给定两个非空链表,表示两个非负整数,每位数字按照逆序方式存储,每个节点只能存一位数字。要求将两个数相加,并返回相同形式的链表。

举个例子,数字 342 在链表里长这样:2 -> 4 -> 3。也就是说,链表的第一个节点是个位,第二个节点是十位,第三个节点是百位。如果另一个数字是 465,链表是 5 -> 6 -> 4,那么 342 + 465 = 807,返回的链表是 7 -> 0 -> 8。

为什么题目要故意用逆序存储?因为这样最“好算”。你可以从两个链表的头节点同时开始遍历,天然就是按个位、十位、百位的顺序相加,完全不用额外对齐。如果改成顺序存储,反而要先遍历到链表尾部才能开始做加法,麻烦不少。

1.2 考点拆解:这题不是考你会不会加法

我见过不少人在这一题上吃亏,不是因为加法不会,而是因为暴露了三个基础问题:

第一,链表遍历的基本功。两个链表长度不一致时,短链表走完了怎么办?很多人会习惯性写while (l1 && l2),结果短的那条先走到空,循环退出,长链表剩下的位次直接被丢掉了。这个坑我在新手代码里看到过太多次。

第二,进位处理。加法必然涉及进位。9 + 9 = 18,当前位是 8,进位是 1。如果只想着“把两个节点的值相加然后存进新链表”,完全忽略“还有可能再进一位”,那999 + 1 = 1000这种用例就直接崩了,因为最高位还会多出一个节点。

第三,哑节点的使用。很多新手写链表题,第一反应是先处理头节点,再写一个 while 循环处理后面的节点。这样代码分叉多、容易漏。用哑节点(dummy node)可以让所有节点统一处理,代码会干净很多。

这三点叠加起来,正好合成了一道经典的“看起来简单,写起来容易漏”的题。它考察的其实是工程上处理边界条件的能力,而不只是“会不会加法”。

1.3 逆序存储的优势:顺着链表走,就是按位相加

这里多说一句,为什么逆序存储是“贴心设计”。如果做的是字符串形式的大数加法,你通常需要先把两个字符串对齐——短的补前导零,或者从末尾往前遍历。而链表逆序存储后,头部就是低位,尾部才是高位。你从头节点出发,每一步拿到的都是当前位,天然对齐。进位向右传递,正好对应链表的下一个节点,逻辑链条一气呵成。

理解了这一点,后面写代码就会顺畅很多:循环、进位、新节点,三者同步推进。

2. 为什么“转成数字加完再转回链表”这条路走不通

2.1 第一反应方案:先还原数值再相加

第一次看到这个题目,很多人脑子里的第一反应是:既然数字在链表里存着,那我先遍历链表,把每个节点的值拼成一个整数,两个数加起来,得到结果之后再逐个拆位生成新链表。这思路从数学上完全没错,甚至在小样本测试下也能跑通。

对应代码大概长这样:

long long l1Val = 0, l2Val = 0; long long base = 1; while (l1) { l1Val += l1->val * base; base *= 10; l1 = l1->next; } // l2 同理... // 然后相加,再逐位拆分构建新链表

可是这个方案只能处理很小的数字。链表长度稍微上来一点,long long就不够用了。

2.2 溢出的本质:链表能表达的长度远超整型范围

我们算一下:long long在绝大多数 C++ 实现里是 64 位,最大能表示大约 9.2e18,也就是 19 位十进制数。而链表长度呢?节点数量没有任何限制,你可以轻松构造一个 100 位、1000 位甚至更长的链表。题目本就假设输入是一个非负整数,并没有限制它的大小范围。

一旦数字超过 19 位,long long直接溢出,计算结果不可控。就算你用unsigned long long,也只是把上限扩展到 20 位左右,治标不治本。再往上的方案是引入高精度计算库或字符串模拟,但那就完全违背了这道题的考察目的。

2.3 这背后是个工程思维问题

“转数值再相加”还有一个隐藏问题:它需要两趟遍历(还原数字一趟,构建结果一趟),再做一次完整的大数取模拆位。而链表原地逐位相加只需要一趟遍历,边遍历边生成结果节点。

从面试角度看,如果你上来就写转数值方案,面试官大概率会追问一句“如果链表有一万位怎么办”。这一问就能看出你对数据规模和溢出问题的敏感度。真正扎实的做法是:无论数字多大,加法都能按位完成,不依赖整型范围。

2.4 什么情况下转数值方案合理

我也不是全盘否定这个思路。如果说链表长度被严格限制在 18 位以内,且你已经确认过这个约束,那转数值确实写起来快。比如某些嵌入式场景里,数据都是从固定长度字段里解析出来的。这种情况下,用整型累加再拆位,代码更短,也更容易验证。但刷题环境里,输入范围往往不受控制,选择按位相加才是更稳妥的方案。

3. 逐位相加的第一版实现:哑节点与进位循环

3.1 用哑节点统一处理头节点

写链表题的时候,我强烈建议养成“创建哑节点”的习惯。哑节点是一个不存实际业务数据的额外节点,它的作用是把“结果链表的第一个节点”也纳入统一的插入逻辑。

如果你不用哑节点,就得这样写:

ListNode* head = new ListNode(val); // 第一个节点单独创建 ListNode* cur = head;

一旦后续进入循环,每产生一个进位节点,都要把cur->next接上。这样代码出现两个分支:一个处理头节点,一个处理后续节点。只要有一行漏写,链表就断掉。

用了哑节点之后,代码变成这样:

ListNode dummy(0); // 栈上的哑节点,不需要手动 delete ListNode* cur = &dummy;

后续所有新节点都挂在cur->next上。循环结束后直接返回dummy.next就行。这样整个函数只有一条插入路径,逻辑清楚很多,也几乎不会漏掉头节点。

3.2 三个循环条件和进位传递规则

两个链表同时遍历时,最直观的写法是分三段:while (l1 && l2)、while (l1)、while (l2),最后再单独处理进位。每段代码里都重复“取节点值、加进位、算新进位、创建节点”这一套操作,代码会有明显的粘贴复制感。

更简洁的写法是把所有情况合并成一个循环条件:

while (l1 || l2 || carry)

只要还有节点没处理完,或者进位不为 0,循环就继续。节点不存在时,对应的值直接取 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) {} }; ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) { ListNode dummy(0); // 哑节点,结果链表的哨兵 ListNode* cur = &dummy; int carry = 0; // 进位 while (l1 || l2 || carry) { int sum = carry; if (l1) { sum += l1->val; l1 = l1->next; } if (l2) { sum += l2->val; l2 = l2->next; } carry = sum / 10; // 新一轮进位 cur->next = new ListNode(sum % 10); cur = cur->next; } return dummy.next; // 跳开哑节点,返回真正的头 }

这个函数只有 22 行左右。核心逻辑只有一个 while 循环,分支只在取节点值的时候出现。相比三段式循环,结构简单很多,调试起来也更容易。

3.4 为什么sum % 10和sum / 10一起算

sum是当前位的数值加上进位后的总和。因为每个节点只存一位数字,所以当前位的最终值一定是sum % 10,而往更高一位进的数是sum / 10。比如sum是 15,那么当前位存 5,进位是 1。sum最大也就是 19(9 + 9 + 1),所以carry只可能是 0 或 1。这里的除法、取模运算可以合并计算,编译器也会自动优化。

3.5 内存释放的隐性问题

用new创建节点后,理论上需要delete。但题目环境通常不要求释放整个链表的内存,评测系统跑完就回收进程空间了。不过在工程实践中,如果你真的写一个工具函数,返回一个动态分配的链表,使用者用完是要负责清理的。这里我提一句:面试手写代码时不需要纠结释放,但如果做本地测试、写长跑程序,建议单独写一个释放链表的辅助函数,避免内存泄漏。

4. 边界情况实战:六个让代码现原形的用例

4.1 两个链表都只有一位:这种简单用例反而能测出低级错误

l1 = [3],l2 = [4],预期结果[7]。

这个用例看起来简单,却能快速验证几件事:哑节点返回是否正确、cur指针有没有移动、新节点是不是被正确挂到结果链表的末尾。我见过有人把cur = cur->next和cur->next = new ListNode(...)两行顺序写反,结果链表只保留了最后一个节点。每题写完先用这种最小用例跑一遍,能节省大量调试时间。

4.2 最高位连续进位:999 + 1 = 1000

这个用例是很多人的“翻车现场”。l1 = 9 -> 9 -> 9,l2 = 1,手算结果是 1000,对应链表应该是0 -> 0 -> 0 -> 1。

注意,l2 只有一位,遍历完 l2 之后,循环还必须继续处理 l1 的剩余节点。同时,9 + 1 = 10 产生进位 1,这个进位会一路传递:第二位 9 + 1 = 10,再进位;第三位 9 + 1 = 10,又进位。最后一次进位发生在 l1 也走完之后,carry仍然为 1,循环会再执行一轮,新创建一个值为 1 的节点。

如果循环条件只写while (l1 || l2),最后一个进位节点就丢了,结果变成0 -> 0 -> 0,等于 0。整个用例就错了。

4.3 长度不一致的链表:123 + 4567

l1 = 3 -> 2 -> 1(即 123),l2 = 7 -> 6 -> 5 -> 4(即 4567)。两个数相加应该得到 4690,链表是0 -> 9 -> 6 -> 4。

这个用例专门考察短链表遍历完之后,代码是否还在处理长链表剩下的节点。第一版代码里,l1 走完变空,后续循环只要 l2 还有节点就继续走。因为 l1 已经为空,表达式l1 ? l1->val : 0取了 0,实际就是只加 l2 的值。

这里的坑在于:如果有人在循环里写了l1 = l1->next但没判空,程序会直接解引用空指针崩溃。判空取值的写法,虽然代码多一点,但从根源上规避了这类错误。

4.4 中间过程出现连续进位:99 + 1 = 100

这个用例和 4.2 类似,但我想单独拿出来强调:它测的是“连续进位是否会停不下来”。流程是:第一位 9 + 1 = 10,当前位 0,进位 1;第二位 9 + 进位 1 = 10,当前位 0,进位 1;l1、l2 都遍历完毕,但进位还是 1,所以循环进入第三轮,创建值为 1 的新节点,得到0 -> 0 -> 1,正确。

如果没有循环条件里的|| carry,输出会是0 -> 0,等于 0。这类“边界位产生新节点”的情况,是最容易被忽略的。

4.5 空链表输入

题目说输入是非空链表,但很多面试官会问一句:如果其中一条或两条是空指针怎么办?第一版代码其实不害怕这种情况:l1为空时,sum只加l2->val;两条都为空但carry = 0时,循环直接不执行,返回dummy.next,这是一个空链表。注意,空链表返回空指针通常比返回一个“值为 0 的节点”更合理,符合“0 + 0 = 空”的语义。

不过,还是要看题目约定。如果题目要求返回表示 0 的链表,也就是单节点值为 0,那就要单独特判一下。写代码之前先和提问方确认清楚,比写完再猜要好。

4.6 超大链表下的时间和内存行为

假设 l1 有 100 万个节点,l2 也有 100 万个节点。第一版代码的时间复杂度是 O(max(m, n)),也就是最多遍历较长的链表一遍,加上可能的进位处理,总共也就 100 万 + 1 次循环。这个量级在现代 CPU 上是非常快的,毫秒级就能完成。

新节点的创建数量也等于结果链表的长度,不会超过 max(m, n) + 1。虽然每个节点都是堆分配,100 万个分配也不能说毫无压力,但对于现代内存管理器和默认 8MB 以上的堆来说,通常不是问题。面试里提到这个量级,重点是想确认你有没有复杂度意识,而不是真的纠结一两次分配耗时。

5. 复杂度分析、代码演进与隐藏在背后的延伸考法

5.1 时间复杂度:一次遍历,没有回头路

整个循环最多执行 max(m, n) + 1 次,m 和 n 分别是两个输入链表的长度。加 1 就是因为可能产生最高位进位。在循环内部的操作都是常数级:加法、除法、取模、赋值、指针移动。所以时间复杂度是 O(max(m, n))。

空间复杂度方面,如果不计输入链表占用的空间,只考虑返回的新链表,那么空间复杂度同样为 O(max(m, n)),因为结果链表最多有 max(m, n) + 1 个节点。哑节点本身占用的空间是常数级,可以忽略不计。

有些版本会在原链表上直接修改节点值来复用节点,那样空间复杂度可以降到 O(1)(除了进位新节点外不额外分配)。但从工程角度看,复用输入链表节点会让函数产生副作用,破坏了调用方的数据。除非性能极端敏感,否则我倾向于新建链表,保证输入不被破坏。

5.2 如果链表改成正序存储,题目会难在哪

面试官很喜欢在写完这道题之后追加一个问题:如果链表是正序存储,也就是最高位在头节点,应该怎么做?

正序存储下,个位在链表尾部。逐位相加时,你又不能直接从尾部往前走,因为单向链表没有 prev 指针。常见解法有以下几种:

  • 先把两个链表反转,然后用刚才这套逐位相加逻辑处理,最后再把结果反转回来。思路直接,但需要两趟反转操作。
  • 用两个栈先压入节点,再依次出栈相加。栈的解法和反转其实本质相同,只是用额外的存储隐式完成了逆序。
  • 先用递归计算出长度差,然后对齐两个链表的尾部,从尾部向头部递归处理进位。这个方案对递归栈深度有要求,链表太长会爆栈。

正序版本的核心难点是“低位对齐”。每到这种追问,原题的逆序存储反而显得无比亲切,因为在逆序情况下根本不需要这些额外操作。

5.3 从链表到字符串:大数相加其实是同一道题

如果你已经理解了“逐位相加 + 进位传递”,那么看字符串形式的大数相加就会觉得非常眼熟。两个字符串"123"和"99",从最后一位开始往前逐位加,维护进位,构造结果字符串。区别只在于:字符串要从尾部对齐,链表则天然在头对齐。

很多大数仓库就是从这道题演化出去的:核心逻辑是逐位加法,只是载体从链表变成了数组、字符串或者字节流。以后你遇到二进制大数相加、十六进制大数相加,思路完全一致,无非是“当前位的和”在不同进制下的取模和进位不一样。

5.4 关于题目环境的“非空”约束和一些实际提问

原题明确写了“两个非空链表”,但很多在线评测系统给出的测试数据里依然可能出现一个空指针。真正规范的写法是处理这种异常:如果传入空指针,直接返回另一个链表。虽然主函数里可以用断言保证非空,但作为通用函数,防御式编程不是坏事。

另外,节点值val理论上被约束在 0 到 9 之间。如果数据不守规矩,比如某个节点的值是 15,那sum / 10就不再是 0 或 1,进位逻辑会崩。不过大多数题目环境都会保证数据的规范性,这里只需要在代码注释里写明前提假设即可。

6. 从“会做”到“写漂亮”:风格、测试与个人体会

6.1 手写代码的三个建议

第一,先和提问方确认清楚:链表是否非空、数值是否有位数限制、结果是否允许复用输入节点。这些确认的过程本身会给你加分,因为它展现了需求分析意识。

第二,动手之前先在脑子里跑一遍999 + 1的用例。把三个关键节点(l1 遍历结束、l2 遍历结束、carry 不为 0)都过一遍,确认代码路径都覆盖到。

第三,写完代码之后,不要急着说自己写完了。花三十秒在草稿纸上列出几个关键测试用例,逐行走一遍,检查哑节点返回值、最高位进位节点是否创建。这个习惯能帮你在现场发现至少一半的笔误。

6.2 我实际写这道题时踩过的小坑

第一次按三段式循环写的时候,我在最后一个while (l1)分支里漏掉了“加 carry”。结果是:如果前一次相加产生了进位,而后面 l1 还有节点,进位就丢了。比如1 -> 9加上9,应该等于 108,我却输出了 108 中的0 -> 0 -> 8?不对,重新算一下。19 + 9 = 28,链表应是8 -> 2。我的代码当时丢掉了进位,输出8 -> 1,变成 18。这种 bug 非常隐蔽,因为简单用例12 + 3 = 15完全正确,只有到“刚好进位且长链表还有剩余位”时才出错。

后来我改成单循环写法,把每个节点的取值都写成l1 ? l1->val : 0,这个 bug 就再也没出现过。所以我个人强烈推荐单循环写法,而不是三段式。

6.3 这套思路还能迁移到什么场景

这个题的核心模式是“两个同构数据源 + 一个进位状态”。很多场景都可以套用:两个数组对应位置相加、两个字符串数字相加、两个字节流做压缩前对齐、两棵树的对应节点做操作,本质上都是“同步遍历,状态传递”。你只要抓住一个核心循环、一个共享状态(carry)、一个可能越界但用 0 补位的取值方式,就能很快迁移到其他题目上。

最后分享一个我个人习惯的练习方式:做这类链表题的时候,不要只写主逻辑,顺手写一个小的辅助函数,把链表转成字符串用于打印日志。调试的时候会省非常多力气。真要细究起来,“Add Two Numbers”的难度不取决于数学,而取决于你对数据结构边界的敏感度。把这个边界感练出来,后续再看其他链表题都会轻松不少。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询