刷 LeetCode 热题100的时候,大部分人会自然而然地把“两数相加”排到前面。这个题编号第2题,名字听着朴实,但它其实是少有的可以用 Python、C语言、JAVA语言分别写一遍,并且三种写法差异能让你对链表理解上几个台阶的题目。题目本身不长:两个非空链表分别表示两个非负整数,数字按逆序存储,每个节点只存一位,把它们相加后返回同样形式的链表。但就是这道看起来“人畜无害”的题,在面试手写、笔试AC、项目复盘的不同场景下,藏着一堆关于空指针、进位、内存释放和语言特性的坑。这篇文章会把三种语言的完整解法和踩坑记录都展开讲清楚,适合刚开始刷链表题的新手,也适合准备面试想对比多语言差异的开发者。
1. 题目拆解:两数相加到底在考什么
1.1 题干信息与隐藏条件
先看一个标准示例:l1 = 2 -> 4 -> 3,l2 = 5 -> 6 -> 4,返回7 -> 0 -> 8。因为链表是逆序存数字的,所以2 -> 4 -> 3实际代表342,5 -> 6 -> 4代表465,两者之和是807,按逆序输出7 -> 0 -> 8。这里最容易被忽略的是“逆序”两个字。逆序存储意味着链表头是各位,头节点就是数字的个位,下一个节点是十位,依次往上。这样做的好处非常明显:我们做加法时习惯从个位开始,而链表从头部开始遍历正好就是低位到高位,进位可以直接向next方向传递,不需要先翻转链表。
另一个隐藏条件是“非空链表”,题干保证了两个链表不会为空,但这不意味着循环里可以随便访问l1.next,因为两个链表长度不一定相等。比如l1 = 9 -> 9 -> 9,l2 = 1,短的链表先走到末尾变成null,如果你还在循环里无条件取l1.val,立刻就会出问题。所以这个题表面考加法,实际考的是“你能否在一个循环里同时控制两个链表的移动、处理一个为空时的取值、并正确传递进位”。
1.2 为什么它值得出现在热题100里
很多人觉得这题简单,但它被放进热门100题是有原因的。第一,链表遍历是最高频的基础操作,而这个题要求同时遍历两条链表,比单链表遍历多了一层“两个指针节奏不一致”的处理。第二,进位是模拟竖式加法的核心,它天然引出了“最终溢出”的边界,也就是两个链表都遍历完了,但进位还等于1,必须额外申请一个节点存放最高位。第三,这道题特别适合考察多语言功底,因为Python、C、Java对“空值”“内存”“对象引用”的处理完全不同,同一个人用三种语言写这个题,写出来的代码风格和需要注意的点完全不一样。
我见过不少刷题的人,用Python一遍写过去觉得太简单,然后换C语言写就卡在malloc和指针上;也见过Java选手三分钟Lie下解法,但追问一句“为什么用dummy node”就答不太上来。所以这道题的价值不在于算法本身,而在于它能否暴露出你对语言底层机制的敏感度。接下来先从核心思路讲起。
2. 核心思路:把竖式加法翻译成循环
2.1 为什么不建议先转成整数再加
直觉解法是遍历链表,把所有数字拼成一个整数,相加后再拆成节点。这个方法在Python里看起来能跑,因为Python的整数没有位数上限。但在C语言和Java里,整数类型是有上限的:long大约只能存到19位十进制数,链表长度超过19位就会溢出,而LeetCode的测试用例里节点数可以到100甚至1000,这个方案从根上就是错的。就算Python能容纳大整数,转换也需要先遍历一遍链表计算数值,相加后又要一遍遍取模构造新链表,时间开销并不比逐位相加低,而且完全丧失了链表操作的训练意义。
所以标准解法就是模拟手算竖式:从两个链表的头部开始,逐位相加,每一位的和对10取余作为结果节点值,商作为进位带到下一位。这个过程用while循环实现,循环条件不能只写while (l1 != null && l2 != null),因为两条链表长度可能不同,短的遍历完后,长的那条还有若干位;同时循环结束后还可能有最高位的进位。正确的循环条件应该是:l1 != null || l2 != null || carry > 0。这个条件把“长链表剩余部分”和“最终进位”一起包进去,是最不容易漏情况的写法。
2.2 一个通用的竖式加法框架
不管用什么语言,逻辑骨架是一样的。要先构造一个哑节点dummy作为结果链表的头前一个节点,然后让当前指针cur从dummy开始。每轮迭代做四件事:
- 取两个链表当前节点的值,如果某个链表已经为空,取值0。
- 计算
sum = v1 + v2 + carry。 - 创建新节点,节点值等于
sum % 10,更新carry = sum / 10。 - 将
cur的下一个指针指向新节点,移动cur,同时如果原链表不为空,则向后移动原链表指针。
为什么要用哑节点?因为从头创建链表时,第一个节点之前没有前驱,如果不加哑节点,就需要单独判断“这是不是第一个节点”,代码会多出一堆if。有了哑节点,所有新节点都统一挂到cur.next上,最后直接返回dummy.next就是结果链表的头。哑节点在链表题里是极其常见的技巧,不只是这一题适用,后面做“合并有序链表”“删除倒数第N个节点”都可以复用。
有一部分人会想到用递归做这个题,递归确实能写,但要注意递归深度等于链表长度,链表很长时可能栈溢出,而且递归代码里需要额外维护进位和节点构造顺序,不如迭代直观。我建议面试时优先说迭代法,如果面试官追问再提递归解法。
3. Python实现:短得像是伪代码,但陷阱也不少
3.1 Python核心代码与逐行解读
Python版本可能是三种语言里最容易写的,但依然有几个细节值得注意。先放完整解法:
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next def addTwoNumbers(l1: ListNode, l2: ListNode) -> ListNode: dummy = ListNode() cur = dummy carry = 0 while l1 or l2 or carry: v1 = l1.val if l1 else 0 v2 = l2.val if l2 else 0 total = v1 + v2 + carry carry = total // 10 cur.next = ListNode(total % 10) cur = cur.next if l1: l1 = l1.next if l2: l2 = l2.next return dummy.next这里最关键的一行是v1 = l1.val if l1 else 0。Python里判断一个对象为真,本质上调用bool(),只要l1不是None,条件就为真。所以这个写法等价于l1 is not None时取l1.val,否则取0。不建议写成l1.val or 0,因为如果节点的值恰好是0,l1.val会被当成False,结果就错误地用0替代了原来的0,虽然数值碰巧一致,但这个写法在逻辑上是错的,会误导阅读者。
while l1 or l2 or carry这个条件也很妙。它把链表遍历结束和进位未清零两种情况统一处理。比如两个链表都遍历完但carry是1,循环还会继续进入一轮,此时v1和v2都是0,total = 1,创建一个值为1的节点,最后carry变成0,循环退出。这一下就把“最高位进位”处理掉了,不用在循环外面再补一个if。
3.2 Python容易踩的隐蔽坑
第一个坑是循环里漏写cur = cur.next,或者漏更新l1 = l1.next,导致死循环或者结果链表全部串在同一节点上。这种错误在LeetCode本地运行时表现就是超时,但在面试白板上非常容易犯,强烈建议每次写完循环体,反向检查一遍所有移动的指针是否都移动了。
第二个坑是返回值写错。有人最后写成return cur,结果返回的是链表尾节点,只会有最后一个数字。这里cur始终指向当前已构建的最后一个节点,而dummy.next才是整个结果链表的头。哑节点存在的意义就是让你能轻松找到头节点,千万不要把引用搞混。
第三个坑是修改输入链表。如果直接使用l1 = l1.next遍历,这只是把局部变量指向下一个节点,并没有修改输入链表本身。但有些人会顺手写l1.next = something来节省节点,这样会破坏原始链表。虽然LeetCode判题器不会检查原链表是否被破坏,但工程习惯不好,而且万一题目后面要求“不能修改原链表”,这种写法就直接覆写正确答案了。这个题不靠修改输入来优化空间,老老实实新建节点就好。
4. C语言实现:指针和内存才是真正的考点
4.1 C语言核心代码与注意事项
C语言没有对象,没有自动内存管理,所以要把同样的逻辑翻译成结构体指针操作。LeetCode一般会给出结构体定义:
struct ListNode { int val; struct ListNode *next; };我的实现如下:
struct ListNode* addTwoNumbers(struct ListNode* l1, struct ListNode* l2) { struct ListNode dummy; struct ListNode* cur = &dummy; struct ListNode* node; int carry = 0; int v1, v2, sum; dummy.next = NULL; while (l1 || l2 || carry) { v1 = l1 ? l1->val : 0; v2 = l2 ? l2->val : 0; sum = v1 + v2 + carry; carry = sum / 10; node = (struct ListNode*)malloc(sizeof(struct ListNode)); node->val = sum % 10; node->next = NULL; cur->next = node; cur = node; if (l1) l1 = l1->next; if (l2) l2 = l2->next; } return dummy.next; }这里我用的是栈上的struct ListNode dummy作为哑节点,而不是malloc。原因后面会详细说。注意循环体内每创建一个新节点,必须把node->next置为NULL,否则新节点的next是一个随机地址,最后返回的结果链表尾部就会指向垃圾内存,本地调用时遍历到末尾就会段错误。LeetCode内部判题和打印结果时也会遍历链表,遇到乱指针就直接报runtime error。
4.2 内存管理的三个关键细节
C语言版第一个关键点是哑节点的创建方式。很多教程会写struct ListNode* dummy = malloc(sizeof(struct ListNode));,然后在结尾return dummy->next;。这个写法没有错,但会让哑节点本身变成一块“孤儿内存”——它不在结果链表里,调用者拿到返回的头指针后,无法访问到dummy指针来释放它,所以代码存在内存泄漏。LeetCode不检测内存泄漏,所以能通过,但如果在本地用Valgrind检查,就会报告一块内存泄漏。因此我更推荐在栈上声明一个dummy结构体变量,让cur指向它的地址,函数返回时哑节点作为栈变量自动释放,不需要手动管理,同时结果链表已经挂在dummy.next上,完全不受影响。这是一个面试时很加分的细节。
第二个关键点是malloc失败检查。严谨的工程代码应该判断node == NULL时怎么处理,但刷题场景里一般不会遇到,写了反而显得啰嗦。如果要在本地做健壮性验证,可以在malloc后加一句if (node == NULL) return NULL;,面试时提一句“这里理论上要检查malloc返回值”就够了。
第三个关键点是标准C没有bool类型,所以while (l1 || l2 || carry)里,整型carry的0和非0就可以作为循环条件。如果你写了#include <stdbool.h>然后用bool carry = false;也没问题,但完全没有必要。C语言里l1本身是指针,指针的直接判断等价于l1 != NULL,代码能短则短,读起来反而更快。
4.3 长度不一致与最终进位的C语言处理
C语言的指针操作很容易让人在“长度不一致”这里翻车。如果写下while (l1 && l2),那么一旦某个链表先走到NULL,循环就结束,剩下的链表高位没有被加进去,直接丢掉。比如[9,9]加[1],答案是[0,0,1],但错误循环只算到第二位的9+0+1=10,然后把carry丢了,返回[0,1],完全不对。正确写法必须用||把所有未完成条件合并,并且在循环体内部用三目运算符处理空指针。
三目运算符也有一点需要注意:v1 = l1 ? l1->val : 0;其实可以拆成int v1 = l1 == NULL ? 0 : l1->val;,两种写法含义一样。新手容易写的错误版本是v1 = l1->val ? l1->val : 0;,这个只有在val为0时会取0,但不巧的是0本身就是要表示的数字,所以能通过一些测试,但遇到非0值也没问题?如果val是0,它的条件为假,得到0,碰巧正确;如果val非0,条件为真,得到val,也正确。看起来对,但这是“碰巧正确”,它把“节点指针是否为空”和“节点值是否非零”混为一谈。只要val为0,逻辑就走错了分支,只不过数值还是0。这种代码会让读代码的人崩溃,面试官也会皱眉头。正确的判断对象一定是指针,不是值。
5. Java实现:面向对象的“引用版”链表达
5.1 Java核心代码与逐段拆解
Java的链表节点是一个类,持有当前值和指向下一个节点的引用。LeetCode环境里的ListNode通常长这样:
public class ListNode { int val; ListNode next; ListNode() {} ListNode(int val) { this.val = val; } ListNode(int val, ListNode next) { this.val = val; this.next = next; } }解法代码如下:
class Solution { public ListNode addTwoNumbers(ListNode l1, ListNode l2) { ListNode dummy = new ListNode(0); ListNode cur = dummy; int carry = 0; while (l1 != null || l2 != null || carry != 0) { int v1 = (l1 != null) ? l1.val : 0; int v2 = (l2 != null) ? l2.val : 0; int sum = v1 + v2 + carry; carry = sum / 10; cur.next = new ListNode(sum % 10); cur = cur.next; if (l1 != null) l1 = l1.next; if (l2 != null) l2 = l2.next; } return dummy.next; } }Java版和Python版结构几乎一样,差别主要在空值判断的方式。Java没有Python那种“if l1”的隐式布尔转换,写if里必须是布尔表达式,所以老老实实写l1 != null。三元运算符(l1 != null) ? l1.val : 0是处理空指针的标准姿势。注意三元运算符的优先级和结合性:整个表达式作为赋值右值,先计算条件,然后只执行选中的分支。所以不会在l1 == null时访问l1.val,这一点很关键。
5.2 Java与Python/C的差异点
Java的“引用”对很多从C转过来的人来说是一个奇妙的中间态。它不像C语言那样需要手动管理内存,也不像Python那样变量名完全动态。ListNode dummy = new ListNode(0);在堆上创建了一个对象,变量dummy持有它的引用。ListNode cur = dummy;让cur和dummy指向同一个对象。之后cur.next怎么改,dummy.next也会同步看到,因为它们是同一个对象。这个概念是理解这个解法的核心,如果不懂引用和对象的区别,很容易觉得“cur指向dummy,然后cur变了,dummy应该不变啊”,从而画错链表图。
Java里还要注意一个坑:new ListNode(0)构造器是必须存在的。LeetCode的ListNode类通常有带int val的构造器,所以可以直接用。如果你在本地自己定义类时写了ListNode()无参构造器,并且val没有初始化,默认是0,也可以。但建议显式传入0,一方面语义清楚,另一方面避免某些在线编辑器模板没有无参构造器导致编译失败。
另外一个区别是整数计算。Java的sum / 10对于正整数而言就是整除,sum % 10取余。这里没有负数情况,所以放心用。如果担心面试官考负数,题目明确说“非负整数”,所以不需要考虑。还有一些人会把carry = sum / 10;写成carry = (sum - sum % 10) / 10;,这是完全没有必要的绕路。直接整除最清晰。
5.3 Java的空指针排查习惯
Java最容易翻车的地方是空指针异常。错误示范是把循环体内部取值写成:
int v1 = l1 == null ? 0 : l1.val; int v2 = l2 == null ? 0 : l2.val;这是对的。但如果把条件反过来,写成l1.val != null,就是拿基本类型int和null比较,编译都过不了。另一个常见错误是在循环外先判断“如果l1走到末尾”,然后再在循环里无条件l1 = l1.next,导致短链表到末尾后下一次循环访问前没有判空。这也是为什么推荐在循环体内统一用三元表达式取值、统一移动指针,而不是把“取当前值”和“移动指针”拆成两个复杂的if分支。
Java的JVM自带垃圾回收,所以不用手动释放dummy节点,也不用担心内存泄漏。但在内存分析时要知道,dummy节点一直通过cur.next被原dummy引用?实际上dummy.next指向结果链表的第一个节点,所以整个结果链表都能从dummy出发访问到;当方法返回时,局部变量dummy消失,但返回给调用者的dummy.next引用才是根。如果调用者只保存了返回的头节点,那个头节点不是哑节点,哑节点本身没有被任何全局引用持有,会被GC回收。不会出现C语言那样的泄漏。Java笔试时不用关心这些,但如果面试官追问,说清楚引用生命周期会很加分。
6. 复杂度分析与边界用例
6.1 时间复杂度和空间复杂度
假设l1长度为m,l2长度为n,这个算法会遍历两个链表各一次,结果链表的长度最多是max(m, n) + 1,所以时间复杂度是O(max(m, n))。空间方面,我们创建了一个新链表,节点数同样是O(max(m, n)),额外使用常量级别的carry和几个指针,所以空间复杂度也是O(max(m, n)),这个“额外”指的是除了输入之外新增的节点。如果把创建结果链表视为必要输出,一般也这么说。
有人会问能不能在l1上原地修改,节省空间。理论上可以:先确定长链表,复用它的节点,在长链表上更新val,短链表走完后就只遍历长链表,最终如果还有进位就追加节点。但这会修改输入链表,工程上是个坏味道,而且实现时要分“哪个链表长”的预处理,代码复杂度和出错概率都会上升。LeetCode不会因为空间复杂度O(1)给你加分,所以标准解法用新链表最稳妥。
6.2 一组值得跑一遍的边界用例
把测试用例整理成表格,方便自测:
| 输入l1 | 输入l2 | 期望输出 | 说明 |
|---|---|---|---|
| [2,4,3] | [5,6,4] | [7,0,8] | 官方示例 |
| [0] | [0] | [0] | 都是零,不会出现空结果 |
| [9,9] | [1] | [0,0,1] | 长度不同且产生进位 |
| [9,9,9] | [1] | [0,0,0,1] | 连续进位到最高位 |
| [5] | [5] | [0,1] | 个位为0,产生最高位1 |
| 长度1000 | 长度999 | 长度1000或1001 | 验证长链表的非溢出处理 |
第3个用例最容易暴露循环条件错误。用while(l1 && l2)的写法会漏掉第一个链表的最后一个9,得到[0,1]而不是[0,0,1]。第4个用例专门测试“最终进位”:两个链表都遍历完时carry还是1,必须再创建一个节点。第5个用例测试“节点值为0”时是否正确处理,也能排除掉l1.val or 0这种错误写法。
在本地跑自测时,可以额外验证一件事:输入链表有没有被意外修改。如果你用Python写,遍历完l1后再重新遍历print一下;如果l1的最后一个节点变成了人为构造的新节点,那说明代码里误用了l1.next = something,要立即改掉。C语言则不需要太关心,因为手动改next很容易导致内存问题。
7. 三种语言实战踩坑记录与排查方法
7.1 Python:AttributeError: ‘NoneType’ object has no attribute ‘val’
这是Python版最常见的报错。场景是这样的:你写了while l1 and l2作为循环条件,循环结束后,以为已经处理完了,但实际上如果l1比l2长,后面的l1还有节点没参与运算;或者你在循环体内部无条件写v1 = l1.val,当某个链表先走到None时下一轮就崩了。报错信息会明确告诉你哪个对象是NoneType,但不会告诉你是因为循环条件写错了还是取值没判空。
排查方法是先看while条件。正确的条件必须是while l1 or l2 or carry,而循环体里取值必须用l1.val if l1 else 0。如果两处都正确,就不可能出现NoneType错误。还有一个隐蔽点:cur.next应该指向ListNode(total % 10),如果你不小心写成cur.next = cur,会导致循环链表,Python在输出时会死循环,LeetCode表现为超时而不是直接崩溃。这种错误需要靠打印每个节点地址去定位。
7.2 C语言:Segmentation fault 到底是谁的锅
C语言版的大部分崩溃都可以归到三件事上。
第一件事是没有初始化node->next。如果你在创建新节点后忘记写node->next = NULL;,那么cur->next = node;之后,结果链表最后一个节点的next是随机值。LeetCode遍历完结果时,会顺藤摸瓜访问一个非法地址,立刻段错误。这种崩溃有个特点:小用例可能不崩,因为随机值恰好是0或者低地址,但链表稍长或者内存布局变化时就崩,非常难复现。解决办法很简单,创建节点时立刻置NULL。
第二件事是循环条件错误导致空指针解引用。例如while (l1 && l2)的写法下,如果l1走到NULL但l2还有节点,循环退出,后续没有处理剩余节点,虽然不会段错误,但结果错误。如果继续在循环外访问l1->val,就会崩。正确做法是把取值判空内聚到循环体中。
第三件事是误释放了dummy节点。如果你用了堆上malloc的dummy,然后想在返回前free(dummy),这会导致返回的dummy->next变成一个悬空指针,调用者访问头节点时就段错误。记住:哑节点不能free,因为返回链表后外部没有哑节点的指针,无法安全释放,所以不如用栈变量dummy。我用栈上dummy以来,再没遇到过这个坑。
排查段错误时,先注释掉malloc相关的优化,加打印。比如在循环开头printf计算l1/l2的地址,看第几次迭代崩溃。也可以把node->next = NULL;放在malloc后立刻执行,基本能排除一类问题。
7.3 Java:NullPointerException 的经典来源
Java的空指针报错信息通常会给到具体行号,相对友好。最常见的来源是取值时把判断条件写反了,比如:
int v1 = l1 == null ? 0 : l1.val;这是对的。如果写成l1 == null ? l1.val : 0,当l1为null时三元运算符会执行l1.val,NPE立刻出现。因为三元运算符会评估被选中的表达式,而不是两个分支都评估。所以写三元表达式时,先写条件,再写“条件为真时”的结果,最后写“条件为假时”的结果,顺序别搞混。
另一个典型NPE出现在移动链表指针时。如果你在循环体里只移动了一次指针,比如只写了l1 = l1.next;而忘了l2 = l2.next;,会导致l2一直指向同一个节点,循环陷入死循环,最后可能因为list太大而内存溢出。虽然不叫NullPointerException,但也是不仔细的后果。排查这种问题,最快的办法是在循环里打印l1和l2当前的val,别用System.out.println去刷屏,可以用条件打印机。LeetCode上不允许打印太多输出,本地调试没问题。
7.4 一套可复用的自测脚手架
无论用哪种语言,我都建议准备一套“数组转链表、链表打印、用例驱动”的小工具。以Python为例,本地自测可以通过以下辅助函数快速构造用例:
def build_linked_list(nums): dummy = ListNode() cur = dummy for val in nums: cur.next = ListNode(val) cur = cur.next return dummy.next def print_linked_list(head): values = [] while head: values.append(head.val) head = head.next print(values)测试时写这样几行:
l1 = build_linked_list([9, 9, 9]) l2 = build_linked_list([1]) result = addTwoNumbers(l1, l2) print_linked_list(result) # 期望 [0, 0, 0, 1]还可以写一个简单断言函数,把表格里所有用例都跑一遍,确保最终结果列表和期望列表相等。C语言和Java也可以套用同样的思路,只是构造数组和打印稍麻烦一点。但正是这样一个脚手架,能让你在本地快速重现LeetCode上的错误,而不是毫无头绪地盯着红色Runtime Error发呆。我强烈建议把这几段工具代码存成自己的刷题模板,遇到链表题直接复用。
8. 我在三种语言切换中摸出的几条经验
这个题我用三种语言各写过不下五遍,每次写都有新感受。最开始是Python一遍过,觉得简单,后来用C语言写,被malloc和哑节点搞到头大;再后来用Java写,开始意识到引用和对象的区别。这里分享几条我在实际手写中总结出的经验。
第一条,写代码之前先画图。画两个链表,把他们像竖式一样上下对齐,用一个小箭头表示carry。这个题难的从来不是逻辑,而是指针和空值的边界,画图能让你在写while条件时不容易漏。面试的时候,动手写代码前花三十秒画图,面试官通常会认为你思路清晰。
第二条,返回值一定是哑节点的next,不是当前指针。当前指针会随着循环走到最后一个节点,它只是“最后一个已经构造的节点”,而哑节点的next才是整个结果链表的头。这个错误Python和Java都很常见,我甚至见过有经验的人在不经意间也会写错。
第三条,如果时间允许,三种语言都写一遍。这个题是少有的“用不同语言写,复杂度完全不同”的题。Python让你练条件表达式的简洁,C让你被迫关注内存的来龙去脉,Java让你理解对象引用。很多人在力扣上只追求AC,其实用两种语言各写一遍,比反复刷十道简单题更有收获。我个人实测下来,先写C再写Java,最后用Python优化表达式,整个人的指针观都会清晰很多。
最后再分享一个小技巧:这个题的循环条件while (l1 || l2 || carry)可以背下来,因为很多“链表逐位操作”的题都能套用,比如“字符串相加”“二进制链表转整数”的类似变体。遇到新题时,只要把val的取值改成“当前字符减‘0’”,把结果节点改成字符串追加,核心框架完全不动。把一道热题吃透,远比刷十道同质题有价值。希望这篇复盘能帮你把两数相加这个“入门题”彻底变成自己的“送分题”。