☰
LeetCode两数相加:链表加法核心解法与边界处理详解
2026/9/28 6:33:27 网站建设 项目流程

“两数相加”这道题,在LeetCode上几乎是所有刷题人绕不开的一道经典题。它出现在“热门100题”里,出现在各大厂面试题库里,也出现在无数人的入门推荐清单里。题面很简短:给你两个非空的链表,表示两个非负整数,数字按逆序存储在每个节点上,每个节点只存一位数字。让你把两个数相加,返回一个同样形式的链表。

我第一次做这道题的时候,觉得它又简单又啰嗦:不就是一位一位加过去吗?但后来刷得多了,面试也参加过几轮,才慢慢意识到这道题的价值根本不在“加法”本身,而在于它把链表遍历、进位处理、哑节点设计、边界条件控制全部压缩到了一道题里。无论你是刚开始接触算法的小白,还是准备冲刺高阶岗位的求职者,这道题都值得花时间吃透。这篇文章我会把迭代法、字符串修改法,以及我实际刷题中踩过的坑和总结的技巧全部写出来,希望能帮你一次性把这道题做明白。

1. 题目拆解与考点思路分析

1.1 题目到底在考什么

先看表面:链表加法。再看内核,它其实同时考了三件基本功。

第一是链表遍历能力。链表不像数组,你不知道长度,不能随机访问,只能一个节点一个节点往下走。很多人写循环时只盯着当前节点,忘了判断链表是否走完,所以经常出现空指针异常。

第二是进位处理。加法产生进位是小学数学常识,但在代码里,进位是一个需要贯穿整个循环的状态变量。最高位再加出进位怎么办?这是这道题最经典的边界陷阱。

第三是哑节点设计。如果直接用一个指针往新链表上挂节点,你会发现返回结果时非常别扭:到底该返回新链表的哪个节点?要么单独处理头节点,要么引入一个dummy head(哑节点)。看似只是一个编码技巧,实际上考察的是对链表结构本质的理解。

所以这道题根本不是在考“你会不会加法”,而是在考你“链表的增删改查基本功是否扎实、边界意识是否敏锐”。

1.2 逆序存储的玄机

题目专门强调数字是按照逆序存储的。这是什么意思?链表头就是数字的个位,第二个节点是十位,以此类推。

很多第一次刷题的人会觉得“逆序”是个干扰项,其实它反而是这道题最友好的设计。它保证了你从链表头部开始遍历时,正好是从低位到高位处理,这和我们日常做加法时的习惯完全一致,不需要任何反转操作。

这里可以顺便做一个延展思考:如果题目改成“正序存储”,应该怎么做?比如数字123在链表里是1→2→3这样存储。那这道题的难度会立刻上一个台阶。处理思路通常是:反转两个链表 → 做一遍逆序加法 → 把结果再反转回来。也就是说,逆序存储让我们省掉了两次反转操作。

所以下次看到“逆序存储”这四个字,别觉得复杂,它其实是题目在帮你简化问题,只是看你能不能意识到这一点。面试时如果能在题解里主动提一句“逆序存储天然适配我们从低位相加的顺序”,这会是一个让面试官眼睛一亮的小细节。

1.3 常见解法路径总览

这道题的解法远不止一种。我刷过几次之后,整理出三条路径:

迭代法,这是面试中最该掌握的解法。新建一个结果链表,遍历两个输入链表,边遍历边计算当前位的和,同时记录进位。思路直观、代码干净、复杂度最优。

字符串修改法,这是我见过的思路上最“取巧”的一种解法。把链表转成字符串,把字符串当成大数来做加法,最后再把结果转回链表。面试里不推荐主动使用,但作为兜底手段或者拓展思路,非常好使。

递归法,可以写,但不建议作为首选。递归的写法通常是把当前节点相加后的进位往下一层传递,逻辑确实简洁,但链表长度大时有栈溢出的风险,而且面试里递归的边界条件很容易说乱。

这三种路径的核心都是处理好进位。下面我分别把迭代法和字符串法的实现细节完整展开,递归法作为补充放在后面。

2. 迭代法:面试最推荐的实现

2.1 核心思路与变量设计

迭代法是一个典型的“三位一体”结构:输入链表的两个遍历指针、一个进位变量、一个结果链表的构建指针。

代码里的关键角色有三个:

dummyHead,也就是哑节点。它本身不存有效数据,唯一的使命是让我们不需要对“结果链表为空”做特殊处理,最后直接返回dummyHead.next就是真实的头节点。这个技巧在链表重建类题目中非常常用。

curr指针,指向结果链表的当前尾节点,新节点依次挂在它后面。

carry变量,保存进位。每次计算某个位的和时,真正的逻辑是当前位的值 = (节点1值 + 节点2值 + carry) % 10,新的进位 = (节点1值 + 节点2值 + carry) / 10。

这里有一个我自己总结的小心得:把carry放进循环终止条件里,循环结束的判断不要只盯着两个链表是否为空,而是写while (l1 != null || l2 != null || carry > 0)。为什么?因为如果两个链表都遍历完了,但最后一步相加产生了进位,这时候必须再创建一个新节点来存放这个进位。如果循环条件漏了carry,最后的进位就会直接丢掉。这是我见过最多的错误之一,也是面试官最爱设置的陷阱。

2.2 完整代码实现与逐行注释

下面给出Java版本的完整实现,这也是我在面试中默认书写的版本。

public ListNode addTwoNumbers(ListNode l1, ListNode l2) { // 哑节点,省去对结果链表头部的特殊判断 ListNode dummyHead = new ListNode(0); ListNode curr = dummyHead; int carry = 0; // 只要还有链表节点没遍历完,或者还有进位,就继续循环 while (l1 != null || l2 != null || carry > 0) { // 如果某个链表已经遍历完,对应的值按0处理 int val1 = (l1 != null) ? l1.val : 0; int val2 = (l2 != null) ? l2.val : 0; // 当前位的完整和 int sum = val1 + val2 + carry; // 新的进位 carry = sum / 10; // 当前位留在结果中的值 int digit = sum % 10; // 挂上新节点,后移结果链表的尾指针 curr.next = new ListNode(digit); curr = curr.next; // 两个输入链表各自向后移动(注意判空) if (l1 != null) { l1 = l1.next; } if (l2 != null) { l2 = l2.next; } } return dummyHead.next; }

这段代码有几点值得逐一说明。

输入链表判空后按0处理,这是一个非常优雅的处理方式。这样一来,两个链表长度不一致的情况不再需要单独写if else分支,因为短的链表遍历完之后,每次循环都会把它的值当作0。这保证了循环体内的逻辑是统一的,无意中也减少了很多潜在的空指针风险。

最后返回dummyHead.next而不是dummyHead本身,这算是哑节点用法的常识。面试时如果你能顺口说一句“哑节点主要用来避免头节点空判断”,面试官基本上就知道你懂链表。

2.3 边界情况逐一验证

我们在面试中写代码,最怕的就是“看起来对,边界一跑就崩”。这道题至少有三个边界情况必须验证:

两个链表长度不同。比如1→2→3和4→5相加,一位一位对应完后,长的链表还会剩下节点。我们的循环条件保证了只要l2走完,l2对应的值就一直是0,所以是正常相加。

产生最高位进位。比如9→9→9和1相加,结果是0→0→0→1。循环终止条件里包含carry > 0就保证了这个最后的1会被正确地作为节点挂载上去。如果条件写成while (l1 != null && l2 != null),结果就是0→0→0,直接丢失最高位。

其中一个链表为空。题目虽说两个链表都是非空,但面试时面试官很可能随口追问“如果其中一个链表为null怎么办”。其实上面的代码已经天然兼容了这种情况,因为val1和val2的判空逻辑已经覆盖了。你可以在面试时主动补充一句,这会显得你对代码有更全面的理解。

2.4 补充:递归写法的取舍

顺带说说递归。递归版本的核心思路是每次处理一个节点,然后把进位传给下一层递归。写出来很简洁,大概长这样:

public ListNode addTwoNumbers(ListNode l1, ListNode l2) { return helper(l1, l2, 0); } private ListNode helper(ListNode l1, ListNode l2, int carry) { if (l1 == null && l2 == null && carry == 0) { return null; } int sum = carry; if (l1 != null) { sum += l1.val; } if (l2 != null) { sum += l2.val; } ListNode node = new ListNode(sum % 10); node.next = helper( (l1 != null) ? l1.next : null, (l2 != null) ? l2.next : null, sum / 10 ); return node; }

代码颜值确实高,但我个人不建议在面试这种场景使用。因为链表长度一旦较长,递归深度就会跟着变大,而Java默认的栈深度并不算深,很容易StackOverflow。面试官在考察链表题时,更希望看到你展示迭代的循环控制能力,而不是递归的套壳技巧。递归适合在理解了迭代法之后,作为一道思考题的延伸去练习。

3. 字符串修改法:思路最直观的兜底手段

3.1 为什么会有这种解法

迭代法是标准答案,但我在刷题初期其实最先想到的其实是字符串法。原因很简单:我们人类自己算两个大数相加时,从来不会把它拆成链表一位一位处理。我们的第一反应是,能不能把链表还原成一个整数?然后再把结果转回链表?

这个想法非常自然。把链表转成字符串,再对字符串做字符级相加,最后把结果字符串拆成节点。这个思路在编码上很不“算法”,但作为“思路最直观的解法”,它的存在是有价值的。尤其是当你完全没思路、又必须写点什么出来的时候,字符串法是你最容易在短时间内写对的办法。

不过这里必须立刻指出一个关键陷阱:不能把链表转成数字后直接用int或long相加。为什么?因为链表可以非常长,LeetCode测试用例里完全可能出现20位甚至100位的“大数”,int最多只能存约21亿,long也才64位,一旦超范围,数值直接溢出。所以字符串法的核心不是“转成数字”,而是“模拟大数加法”。

3.2 大数加法的基础逻辑

回顾我们在纸上做加法的过程:从最低位开始,逐位相加,满十进一。字符串法无非就是把这件事用代码模拟一遍。

既然输入链表是逆序存储的,那链表的第一个节点就是最低位。遍历链表,把每个节点的值依次append到一个StringBuilder里,得到的字符串正好也是“最低位在索引0”的形态。所以我们在做字符加法时,应该从字符串的末尾(最高位)开始,向前遍历。

结果同样要按“最低位在前”的顺序生成,也就是每算出一位,就append到结果StringBuilder里,这样得到的结果字符串天然就是逆序的,直接按字符顺序创建链表节点即可。这里绕了一个弯,但逻辑是自洽的。

3.3 代码实现与关键注释

我用Java完整实现一遍。

public ListNode addTwoNumbers(ListNode l1, ListNode l2) { // 第一步:链表转字符串 StringBuilder sb1 = new StringBuilder(); StringBuilder sb2 = new StringBuilder(); while (l1 != null) { sb1.append(l1.val); l1 = l1.next; } while (l2 != null) { sb2.append(l2.val); l2 = l2.next; } String s1 = sb1.toString(); String s2 = sb2.toString(); // 第二步:从低位到高位做字符加法 int i = s1.length() - 1; int j = s2.length() - 1; int carry = 0; StringBuilder result = new StringBuilder(); while (i >= 0 || j >= 0 || carry > 0) { int digit1 = (i >= 0) ? s1.charAt(i) - '0' : 0; int digit2 = (j >= 0) ? s2.charAt(j) - '0' : 0; int sum = digit1 + digit2 + carry; carry = sum / 10; result.append(sum % 10); i--; j--; } // 第三步:结果字符串直接按顺序转链表 ListNode dummyHead = new ListNode(0); ListNode curr = dummyHead; for (int k = 0; k < result.length(); k++) { curr.next = new ListNode(result.charAt(k) - '0'); curr = curr.next; } return dummyHead.next; }

写这段代码时有三个细节非常容易出错,我把它们单独拎出来。

第一个细节是s1.charAt(i) - '0'。很多人刚写字符转数字时,会用Integer.parseInt(String.valueOf(s1.charAt(i))),功能没问题,但每次都做一次字符串转换,性能上会有多余开销。直接用字符的ASCII码差值,一步到位,也更优雅。

第二个细节是循环结束后要检查carry。我在上面的代码里已经把carry > 0加进了循环条件,所以最后一个进位会被自动处理。如果脑子一抽只写while (i >= 0 || j >= 0),那最高位进位会静默丢失。

第三个细节是结果的字符顺序。因为原链表是逆序存储,append后的字符串也是低位在左、高位在右。从索引末尾开始加,得到的每一位依次append到result里,结果字符串就同样是低位在左、高位在右。正对应题目要求的链表存储顺序。这一步不需要任何reverse操作,一旦主动reverse反而错了。

3.4 面试中用不用它

我的建议是:不主动用,但可以提。

面试时如果一上来就写字符串法,面试官很可能会觉得你基本功不够扎实。因为字符串法本质上绕开了链表的核心操作,让你用字符串API去解决链表问题,这既不是最优解,也没有体现链表能力。

但如果你先用迭代法写完了标准答案,面试官问“还有别的思路吗”,这时候把字符串法作为“另一种思路”讲出来,效果完全不一样。你可以主动指出它的优点:思路直观,适合快速验证结果;再指出它的局限:额外空间和耗时更高。这种“先标准解、再多元解”的展示方式,反而能让面试官看到你的思维广度和对方案的判断力。

4. 不同方案的时间空间复杂度与选型建议

4.1 复杂度计算演示

算法题不聊复杂度等于没写。先算迭代法。

假设l1长度是m,l2长度是n。迭代法遍历两个链表直到较长的那个走完,再加上可能的最后进位,循环次数是O(max(m, n))。循环体内只做常数次的赋值和指针移动,所以时间复杂度是O(max(m, n))。

空间上,除了结果链表本身,我们只用了几个指针变量和一个哑节点,额外空间是O(1)。注意,如果把结果链表占用的空间也算进去,则是O(max(m, n)),但算法分析默认不把结果存储空间计入额外空间。

再算字符串法。

链表转字符串需要O(m + n)。字符串加法按两个字符串中较长的长度进行,是O(max(m, n))。结果字符串转链表也是O(max(m, n))。整体时间复杂度是O(m + n),和迭代法同一数量级,但多出几倍的常数操作。

空间上,字符串法需要存储两个输入字符串和一个结果字符串,额外空间是O(m + n)。这比迭代法的O(1)差了很多。链表长度上万的时候,字符串法的空间占用会很明显。

所以结论很清晰:迭代法在时间、空间两方面全面占优。

4.2 面试考察维度对照表

这里我整理了一张对照表,方便你在准备面试时快速把握每一种方案的定位。

对比维度迭代法字符串修改法递归法
核心考察点链表遍历、哑节点、进位控制大数加法思维、字符串API使用递归终止条件设计
时间复杂度O(max(m, n))O(m + n)O(max(m, n))
额外空间O(1)O(m + n)O(max(m, n)),递归栈
面试推荐度强推,作为第一方案可用于补充回答可作为聊天的延伸
典型风险忘记处理最后进位数值溢出、字符串顺序搞反深链表下栈溢出

面试官真正想从这道题里看到的是:你能否快速定位到“逐位相加 + 进位”这个核心模型,并且用干净的代码把循环边界控制住。迭代法是最能体现这些素质的写法。

4.3 算法变体:正序存储的链表怎么做

前面我提过,如果题目换个形式,变成正序存储,也就是数字123在链表里是1→2→3,解法思路会变。这里我把它展开讲透。

正序存储时,链表的头节点是最高位,但我们算加法需要从最低位开始。一种做法是先把两个链表分别反转,用我们熟悉的迭代法完成加法,再把结果链表反转回去返回。整个过程的时间复杂度还是O(max(m, n)),但代码量会多出两个反转函数。

如果你不想反转,也可以借助栈来实现。把两个链表的所有节点值分别压入两个栈,然后依次弹栈相加,因为栈天然具备“后进先出”的特性,刚好能帮我们从低位到高位处理。最后用头插法构建结果链表,保证结果是正序的。这个方法代码稍多,但思路很经典。

所以当你把“两数相加(逆序)”这道题吃透之后,正序变体其实也等于解决了一大半。这也是为什么我一直强调,刷题不要只背答案,要把题的底层模型抽出来。

5. 常见问题与排查技巧实录

5.1 高频错误速查表

这道题在LeetCode提交区里,最常见的报错和错误输出,我整理成了下面这张表,每一行都是真实高频出现的问题。

常见错误现象根本原因解决办法
丢失最后进位输出比预期少一位循环终止条件漏掉carry循环条件写成 `while (l1 != null
空指针异常代码访问了null.val遍历时未判空就移动指针每次访问值前先判空,再移动指针
结果多出前导0比如结果是0→7,却输出0→0→7哑节点自身被当作有效节点挂进结果返回dummyHead.next,不是dummyHead
死循环/超时程序一直跑不完短的链表没移动指针,导致死循环每次循环末尾都要移动不为空的链表指针
字符串法结果顺序反了正确输出123却得到321结果字符串方向理解错误记住逆序存储对应低位在前,不要reverse
5.2 本地调试利器:手写链表打印工具

刷LeetCode时,很多人有个习惯:代码在编辑器里写了直接粘贴到提交框,报错了就在那干瞪眼。我强烈建议本地把链表题的基础设施搭起来,会省下大量调试时间。

你需要两个函数。一个是根据数组创建链表,另一个是遍历链表打印结果。

private static ListNode buildList(int[] arr) { ListNode dummy = new ListNode(0); ListNode curr = dummy; for (int v : arr) { curr.next = new ListNode(v); curr = curr.next; } return dummy.next; } private static void printList(ListNode head) { while (head != null) { System.out.print(head.val + " -> "); head = head.next; } System.out.println("null"); }

有了这两个工具,你可以把所有边界用例在本地快速验证:

ListNode l1 = buildList(new int[]{2, 4, 3}); ListNode l2 = buildList(new int[]{5, 6, 4}); ListNode result = addTwoNumbers(l1, l2); printList(result); // 7 -> 0 -> 8 -> null

把这些工具函数存在本地一个公共类里,以后刷到任何链表题,直接复制使用。整套流程熟练之后,你的调试效率会提升一大截。

5.3 提交超时和报错时的排查顺序

如果提交后报超时,先别急着怀疑算法复杂度。这道题的思路已经是线性复杂度了,超时大概率是代码里有死循环。排查顺序我建议固定成这套流程:

第一步,检查循环终止条件。看while里的条件是否可能永远为真。最常见的就是链表指针移动只写了一边,短的链表走到null后不再移动指针,但循环条件里又判断它不为空,于是卡死。

第二步,检查指针移动。确认每次处理完一个节点,l1和l2的指针都按条件移动了。很多人用if (l1 != null) l1 = l1.next;时只在其中一个分支写了移动,另一个放到了else里,导致某个链表走进末尾后无法前进。

第三步,打印中间值。在循环里加一行调试输出,打印当前的val1、val2、sum、carry,逐轮检查逻辑是否和手算一致。这种“带状态跑一遍”的方式能快速定位问题出在哪一轮。

如果报错不是超时而是答案错误,优先检查进位。把测试用例换成9→9→9和1,看看结果是不是0→0→0→1,这是最经典的“进位陷阱”用例。任何一个通过了这组用例的实现,大概率边界都没问题了。

一次性把这题吃透

“两数相加”这道题,解法不算难,但它把链表题里最常见的基础操作和边界陷阱串在了一起。我刷题这几年,看到太多人在链表上栽跟头,不是因为思路不对,而是基本功不牢:不会用哑节点、判空不严谨、指针移动漏写、进位处理不彻底。这些错误,几乎都会在这道题上集中暴露出来。

我个人在实际操作中的体会是,链表题千万别急着写代码。先把dummyHead、curr、carry这几个角色想清楚,把循环终止条件写完整,代码自然就顺了。尤其是“carry要参与循环终止条件”这一点,我每次带新人刷题都会专门强调,因为这是最隐蔽、又最典型的链表边界陷阱。

最后再分享一个小技巧:把题解写完以后,不要急着提交,先自己在脑子里把三组用例跑一遍。一组是两数长度相同,一组是长度不同,一组是会触发最高位进位。这三组用例跑通,这道题基本上就是稳的了。刷题不是比谁提交得快,而是比谁对边界更敏感,这道题正是训练这种敏感度的绝佳素材。

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

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

立即咨询