1. 题目拆解与核心思路:这道题到底在考什么
“两数相加”在 LeetCode 热题 100 里属于链表章节的入门题,但如果你只把它当成“模拟加法”来做,面试会吃亏。
先看题面:给你两个非空的链表,每个节点存一个数字,数字按逆序存储,也就是个位在链表头。要求你返回一个新的链表,表示两个数相加的结果,同样按逆序存储。
举个具体例子:
输入:l1 = [2, 4, 3] 代表数字 342 l2 = [5, 6, 4] 代表数字 465 输出:[7, 0, 8] 代表数字 807为什么是逆序存储?这恰恰是这道题最“友好”的地方:从链表头部开始遍历,正好就是从个位开始加,符合我们手算加法的习惯,不需要做任何反转操作。你从两个链表的头节点出发,逐位相加,向后进位,遍历完两个链表之后剩下的进位再补一个节点,就结束了。
这道题表面上是链表操作,本质上考的是三件事:
- 能否熟练使用哨兵节点(dummy head)简化链表头部的处理逻辑
- 能否正确维护进位状态,尤其是遍历完所有节点后还残留进位的情况
- 能否稳定地处理两个链表长度不一致的边界条件
这三件事,任何一件没处理好,代码就会出现隐蔽的 bug。比如最常见的错误:循环条件写成while (l1 != null && l2 != null),两个链表一样长的时候没问题,一旦一个链表多出几位,就直接丢数据。这种错误在真实面试中出现的频率非常高。
适合谁来读这篇?如果你正在准备大厂算法面试,或者刷 LeetCode 过程中发现自己链表基础不牢,又或者你只是想把这道题吃透、理解它背后的“大数加法”思想,这篇都值得你看完。我会从最基础的实现开始,逐步拆解到面试追问、变体拓展和工程化优化,把一道题的完整知识图谱铺开。
2. 基础版实现详解:从伪代码到逐个节点操作
2.1 归零心态:先把链表结构定义说清楚
LeetCode 内置的链表节点定义是这样的(Java 版):
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; } }每个节点只保存一个 0~9 的数字,加上指向下一个节点的引用。所有链表题的操作,本质上都是围绕val的读取和next的重新指向展开的。
这道题里我们是“新建一条链表”来存结果,而不是在原地修改 l1 或 l2,这样思路最清晰,也符合大多数面试官对第一版代码的预期。新建链表时,最常用的手段就是哨兵节点:
ListNode dummy = new ListNode(0); ListNode tail = dummy;为什么要造一个不存实际数据的哑节点?因为在构造链表时,头节点往往是最后才知道具体值的(第一个节点的值可能是0也可能不是),如果不用哨兵,你就得单独处理“当前结果链表为空”的判断,代码会多出很多分支。用了哨兵之后,所有新增节点都统一挂在tail.next上,最后返回dummy.next,干净利落。
2.2 核心循环:一次遍历完成逐位相加
先给出最直接的实现,再逐行拆解:
class Solution { public ListNode addTwoNumbers(ListNode l1, ListNode l2) { ListNode dummy = new ListNode(0); ListNode tail = dummy; int carry = 0; while (l1 != null || l2 != null || carry != 0) { int sum = carry; if (l1 != null) { sum += l1.val; l1 = l1.next; } if (l2 != null) { sum += l2.val; l2 = l2.next; } tail.next = new ListNode(sum % 10); tail = tail.next; carry = sum / 10; } return dummy.next; } }这段代码的核心逻辑,我拆成四步来看:
第一步,循环条件写成l1 != null || l2 != null || carry != 0,而不是l1 != null && l2 != null。这是整道题最重要的一个设计决策。它保证了三种情况都能被处理:两个链表都不为空时正常相加;一个链表为空时继续处理非空链表的剩余节点;以及最后全部遍历完后,如果还有进位(比如 5 + 5 = 10),会再生成一个新节点来存放这个 1。
第二步,进入循环后先初始化sum = carry,把上一次的进位带进来。然后分别判断 l1 和 l2 是否为空,为空就当作 0 处理。这种写法避免了在循环外单独处理“某个链表遍历完了”的情况,也避免了对空指针调用.val的 NPE 风险——因为你每次都先判空再取节点并移动指针。
第三步,sum % 10是当前位的结果,sum / 10是新的进位。因为每个节点只存 0~9,所以结果一定在 0~9 之间,而两个一位数加上一个进位的最大和是 9 + 9 + 1 = 19,所以进位只可能是 0 或 1,不会出现进位等于 2 的情况(除非输入本身不合法)。
第四步,把新节点挂到tail.next上,然后tail = tail.next。注意,这里没有单独维护一个“当前节点的索引”,而是用 tail 指针一直指向结果链表的最后一个节点。每创建一个新节点,tail 就后移一位。
2.3 为什么一定要用哨兵节点:从痛苦经历说起
我第一次不写哨兵节点的时候,代码长这样:
ListNode head = null; ListNode tail = null; while (...) { if (head == null) { head = new ListNode(sum % 10); tail = head; } else { tail.next = new ListNode(sum % 10); tail = tail.next; } } return head;能跑,但每加一位都要判断一次 head 是否为 null。代码长了,冗余了,而且容易在循环内部引入拼写错误。面试时手写代码,这种不必要的分支判断会让你花更多时间在“保证逻辑正确”上,而不是把精力留给更核心的进位处理。
哨兵节点的本质是:预埋一个虚拟的前驱节点,让所有真实节点的处理方式完全一致。这是链表操作里最经典的统一化技巧,不仅在“两数相加”里用,在链表删除、合并、反转等一系列题目中都能用。面试时你写出哨兵节点,本身就是加分项,因为这表明你不是第一次写这种代码,而是有自己沉淀的工具箱。
3. 面试高频考点:这是面试官最想测试的五个细节
3.1 考点一:循环不变量——你能否说清楚每一行代码的意义
面试官大概率不会只满足于“代码能跑”。他会问你:为什么循环结束之后不需要再判断一次carry > 0?
答案是因为循环条件已经包含了carry != 0。这是循环不变量的典型体现:每次进入循环时,需要处理的状态可以用“l1 剩余部分、l2 剩余部分、进位”这三个变量完整描述,而循环体做的事情就是把这些状态归约到更小的规模。当三者都归零时,计算自然结束。
我在模拟面试中遇到过不少候选人,代码能写出来,但问他“为什么循环条件是三者的或关系”时答不上来。这说明他只是背下了模板,没有真正理解状态转移。面试官一问就露馅。
3.2 考点二:大数加法的本质——为什么链表适合处理超大整数
普通整形数相加的容量是有限的。Java 的int最大是2147483647,约 21 亿,long最大也才9223372036854775807,约 922 亿亿。但链表版本的“两数相加”,理论上可以处理任意位数的整数——因为每个节点只保存一位,链表可以无限长。
这就是“链表模拟大数加法”的底层逻辑:用线性结构打破基本数据类型的位数限制。类似的思路在字符串相加(415 题)、二进制求和(67 题)、大数乘法(43 题)里都有体现。你在这道题里掌握的“逐位相加、维护进位、反向遍历”思想,可以无缝迁移到那些题目中。
面试官如果追一句:“如果这两个链表表示的数是 10 的 100 次方量级,你的代码还成立吗?”正确答案是:成立,因为代码里从不把整个数字读入内存,而是按节点逐位处理,时间复杂度只与较长的链表长度成正比,与数字本身的大小无关。
3.3 考点三:边界条件检查清单
这道题常见的边界条件,我总结了一个清单,面试前建议反复过一遍:
| 场景 | 输入示例 | 关键点 |
|---|---|---|
| 两个链表长度相同,无进位 | 12 + 34 | 最常规场景,考验基本循环 |
| 两个链表长度相同,有进位 | 5 + 5 | 多出一个头节点,新链表长度比两者都长 |
| 长度不同,无进位 | 1 + 999 | 需要在短链表遍历完后继续处理长链表 |
| 长度不同,有进位 | 1 + 999 | 长链表遍历完后还有残留进位 |
| 其中一条链表为空 | null 或 [] | 题目说输入非空,但面试官会问如果为空怎么处理 |
| 进位不断传递 | 999 + 1 | 结果变成 1000,链表长度比输入多一位 |
最后一种情况最容易被忽视:999 + 1的结果是1000,需要从个位开始逐级进位,最终在最高位新增一个 1。很多新手在循环里只处理了sum / 10的进位,却没有在循环条件里加入carry != 0,导致结果变成[0, 0, 0],直接漏掉了最高位的 1。这种错误在 LeetCode 里会显示为“Wrong Answer”,在面试中则会被直接指出。
3.4 考点四:能否用递归实现?递归和迭代的取舍
这道题也可以用递归写。基本思路是“当前位的和,加上后续递归处理的结果”:
class Solution { public ListNode addTwoNumbers(ListNode l1, ListNode l2) { return addRecursive(l1, l2, 0); } private ListNode addRecursive(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); ListNode nextNode = addRecursive( l1 == null ? null : l1.next, l2 == null ? null : l2.next, sum / 10 ); node.next = nextNode; return node; } }递归版的优点在于代码更短,逻辑更接近“函数的定义自己”。但缺点也很明显:如果链表长度非常长(比如百万级别),递归会导致调用栈溢出(StackOverflowError),而迭代版本则没有这种问题。
所以我的建议是:面试时首选迭代实现,理由有三点:一是稳定性更好,不受递归深度限制;二是迭代的指针移动更直观,更容易向面试官解释;三是后续如果面试官追问优化,迭代版本更容易在此基础上做空间上的改进。但递归版本值得在脑子里有印象,因为面试官偶尔会问“你能不能换个写法”,你如果能现场改出递归版,会是非常好的加分表现。
3.5 考点五:能否原地修改 l1,减少空间占用
如果面试官进一步追问:“我可以直接复用 l1 的节点来存结果吗?”
答案是可以,但需要注意陷阱。原地修改的核心思路是:把相加的结果写回 l1 的节点上,只有当 l2 比 l1 长时,才需要继续遍历 l2 并把多余节点“接”到结果链表的末尾。
但陷阱在于:如果你直接把某个值写回 l1 节点,而这个节点恰好又被其他地方引用(比如面试官给你构造的测试用例中,l1 后续仍需要被访问),就会出现副作用。LeetCode 的判题系统通常不检查输入是否被修改,但真实项目里这种副作用往往会导致不可预期的 bug。更关键的是,原地修改并没有本质上的性能提升——因为无论如何你都需要遍历完两个链表,新建节点的开销在面试场景下完全可以忽略。
我给你的建议是:第一版代码用“新建节点”,面试官如果问能不能省空间,你再解释原地修改的思路和潜在风险。这样既展示了你对空间复杂度的敏感,又体现了一种“在效率与安全之间权衡”的工程判断力。
4. 从基础到进阶:常见变体与扩展思路
4.1 变体一:字符串相加(LeetCode 415)
这道题与“两数相加”几乎同构,只是把链表换成了字符串。思路完全一样:从两个字符串的末尾(个位)开始逐位相加,维护进位,把结果存到 StringBuilder 里,最后反转输出。
关键差异有两点:一是字符串可能长度相差很大,短的字符串在下标越界时要按 0 处理;二是字符串的“头部”是最高位,而“两数相加”的链表头部是个位,所以遍历方向相反。理解了“两种线性结构上的双向遍历”之后,这两道题其实可以当成同一道题来做。
4.2 变体二:链表正向相加(LeetCode 445:两数相加 II)
题面改成了链表按正向存储数字,比如[3, 4, 2]代表 342。这时候就不能直接从头遍历了,因为两个链表的头分别代表最高位,而不能先加最高位。
最经典的做法是先把两个链表反转,然后用我们熟悉的“逆序相加”逻辑,得到结果后再反转回来。或者更优雅一点,用两个栈先把所有节点压入栈中,弹出时自然就是逆序:
ArrayDeque<Integer> stack1 = new ArrayDeque<>(); ArrayDeque<Integer> stack2 = new ArrayDeque<>(); // 遍历 l1 和 l2,分别压入 stack1 和 stack2 // 然后依次弹出栈顶元素相加,结果用头插法构造用栈的好处是代码清晰,不需要写反转链表的辅助函数。坏处是额外的 O(n) 空间。而“反转 + 逆序相加 + 再反转”的空间复杂度可以做到 O(1)(除了结果链表),但代码更长。面试时优先选栈方案,思路好讲,不烧脑。
4.3 变体三:二进制链表求和(LeetCode 67)
如果把十进制数字换成二进制,逻辑几乎不变,唯一需要调整的是sum % 2和sum / 2。因为二进制的进位只有 0 和 1 两种,每位的和最小是 0,最大是 3(1 + 1 + 1),用了sum / 2之后进位也只会是 0 或 1。
这其实是在提醒你:这道题的核心并不是“十进制”,而是“按位加法 + 进位传递”这个通用的状态机思维。换成八进制、十六进制,甚至自定义进制,公式都写成sum % base和sum / base即可。
4.4 变体四:负数怎么处理
面试官偶尔会脑洞大开,问“如果链表表示的是负数怎么办”。
两个方向:一是按绝对值相加,然后根据符号位确定结果符号——前提是你能明确定义负数的链表存储方式(比如第一位存符号);二是在加法器中引入补码表示,用定长链表存储二进制补码,把减法问题转换成加法问题。第二种思路更接近真实计算机的硬件实现,但这已经超出了常规面试题的考察范围。
遇到这种问题时,先问清面试官的意图,再给出合理假设和方案。不要一根筋地往一个方向死磕,沟通本身就是面试评分的一部分。
5. 实战优化代码:冗余判断消除与可读性提升
5.1 优化点一:减少循环内部的计算
在最基础的版本中,每次循环都会执行sum % 10和sum / 10两个操作,这没问题,因为循环次数最多是max(m, n) + 1,而这个操作是 O(1) 的。但严格来说,sum % 10和sum / 10在 Java 里是两次除法运算,属于相对昂贵的操作。如果链表长度达到百万级,这种计算量就会变得可观。
当然,这道题在 LeetCode 上根本不会遇到百万级长度的输入,优化价值有限。但在真实业务中,如果你用同样的模式处理一个超大整数流,可以考虑用位运算优化,比如十进制下sum % 10可以用查表法,或者直接让 sum 保存为短整型以减少内存占用。不过这些优化属于“用代码复杂度换细微性能”的行为,面试中不建议主动做——除非面试官明确问你怎么优化。
5.2 优化点二:数组预填充结果
如果只是追求 LeetCode 上的极致性能,一个技巧是先遍历两个链表得到长度,然后分配一个max(m, n) + 1长度的数组,把结果写入数组对应下标,最后再根据数组构造链表:
int len1 = getLength(l1); int len2 = getLength(l2); int maxLen = Math.max(len1, len2) + 1; int[] res = new int[maxLen];这样做的好处是省去了大量节点创建的开销,坏处是空间复杂度从 O(1) 变成 O(n),而且代码更长。在面试中,我更推荐直接操作链表,因为题目考的是链表操作能力,而不是数组技巧;但在实际工程中,如果数字以连续内存形式存在,数组方式往往更快。
5.3 优化点三:关注内存与可读性之间的平衡
很多初学者会把代码写得特别“紧凑”,比如:
while (l1 != null || l2 != null || carry != 0) { int sum = (l1 != null ? l1.val : 0) + (l2 != null ? l2.val : 0) + carry; tail.next = new ListNode(sum % 10); tail = tail.next; carry = sum / 10; if (l1 != null) l1 = l1.next; if (l2 != null) l2 = l2.next; }这段代码比之前的版本省了几行,但可读性下降了——因为你在一个表达式里同时做了“取 val”“判空”两件事。面试时为了让面试官能快速跟上思路,我更倾向于先判空再相加的写法,清晰第一,性能第二。
5.4 一个更注重可读性的参考实现
综合以上考虑,我在面试中更偏爱下面这个版本,它把每一步的意图都摊开了:
class Solution { public ListNode addTwoNumbers(ListNode l1, ListNode l2) { ListNode dummy = new ListNode(0); ListNode tail = dummy; // tail 总是指向结果链表的末尾 int carry = 0; while (l1 != null || l2 != null || carry != 0) { int sum = carry; if (l1 != null) { sum += l1.val; l1 = l1.next; } if (l2 != null) { sum += l2.val; l2 = l2.next; } tail.next = new ListNode(sum % 10); tail = tail.next; carry = sum / 10; } return dummy.next; } }这段代码适合面试,也适合作为以后写链表题的“脚手架”:哨兵节点、tail 指针、进位维护,三件套齐全。
6. 复杂度分析与测试用例设计:为什么这是 O(max(m, n))
6.1 时间复杂度:与较长链表长度线性相关
设 l1 的长度为 m,l2 的长度为 n。我们的循环每执行一次,至少消耗 l1 中的一个节点或 l2 中的一个节点(实际可能是两者同时消耗)。循环终止的条件是 l1 和 l2 都为空且 carry 为 0,所以循环最多执行max(m, n) + 1次——为什么加 1?因为最后一次可能只是处理残留的进位。
所以时间复杂度是 O(max(m, n)),线性级别。
6.2 空间复杂度:准确说是 O(max(m, n)),但可以解释为 O(1)
新创建的链表就是结果本身,它的长度最多是max(m, n) + 1,所以如果不把结果链表算进额外空间,额外空间只有dummy、tail、carry几个变量,是 O(1)。如果面试官问的是总空间(包括输出),那就是 O(max(m, n))。
怎么回答更稳妥?你可以说:“我使用了额外常量级的辅助空间,但结果链表本身占用 O(max(m, n)) 空间。如果题目要求我们返回结果,这通常是必然的开销,不算额外空间。”
6.3 测试用例设计:不只是跑两个用例
我习惯在面试中当场写出几个测试用例给面试官看,而不是等面试官给用例,这是一种主动的表现:
用例1:l1 = [2, 4, 3], l2 = [5, 6, 4] 预期:[7, 0, 8] 用例2:l1 = [0], l2 = [0] 预期:[0] 注意:结果链表只有一个节点,dummy.next 指向这个节点 用例3:l1 = [9, 9, 9, 9, 9, 9, 9], l2 = [9, 9, 9, 9] 预期:[8, 9, 9, 9, 0, 0, 0, 1] 注意:这里 l1 长度大于 l2,过程中需要持续进位 用例4:l1 = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] l2 = [5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] 预期:[6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]当然,面试中不需要列 30 位的用例,但至少要把“短链表结束”“进位残留”这两个场景覆盖到。
6.4 调试技巧:打印链表而不是打印 node.val
我踩过一次坑:调试时只打印l1.val,结果发现输出永远只有头节点——因为我忘了在循环里更新 l1 指针。后来我养成习惯,写一个链表转字符串的辅助方法,把整条链表打印出来:
private String listToString(ListNode head) { StringBuilder sb = new StringBuilder(); while (head != null) { sb.append(head.val).append(" -> "); head = head.next; } sb.append("null"); return sb.toString(); }调试链表题时,把“当前状态”完整打出来,永远比只打印某个 val 高效得多。
7. 常见问题与排查:经验之谈,全是踩过的坑
7.1 问题一:结果缺少最高位的进位
症状:5 + 5输入,输出是[0],预期应该是[1, 0]。
原因:循环条件只写了while (l1 != null || l2 != null),没有包含carry != 0,导致最后一次进位没有节点可挂。
排查:手动模拟l1 = [5], l2 = [5],你会发现循环内算得 sum = 10,carry = 1,tail.next 指向 val=0 的节点,但循环体结束后不会再有新节点生成。
修复:循环条件加上|| carry != 0。
7.2 问题二:空指针异常
症状:执行时抛出NullPointerException。
原因:在循环里直接写了int sum = l1.val + l2.val + carry,没判断 l1 或 l2 是否已经为 null。如果较短的链表先遍历完,再访问l1.next或l1.val就会 NPE。
修复:要么在循环体内判空,要么在进入循环前把“已到尾部的链表”用一个值为 0 的哨兵节点替换(但这样要多构造一个 dummy 0 节点,并不优雅)。推荐判空。
7.3 问题三:循环后忘记更新指针导致死循环
症状:程序运行超时。
原因:在循环体内创建了新节点,tail 也移动了,但 l1 和 l2 的指针没有移动,导致每轮循环处理的是同一个节点。
排查:在循环体内检查每一行,看 l1 和 l2 的移动语句是否被执行到了。如果你把移动语句放在判空之前,可能会出现“l1 已为 null 但还访问 l1.next”的情况,或者干脆没写移动语句。
7.4 问题四:把进位传播写成sum = carry; sum += ...时漏掉了对 carry 的更新
症状:[2,4,3] + [5,6,4]期望[7,0,8],输出却是[7,0,7]。
原因:计算出sum = 10后,应该carry = sum / 10 = 1,但代码里写成carry = 1(写死了)。然后下一位sum = 4 + 6 + carry = 10 + 1 = 11,结果变成 1,进位又变成 1,最终最高位漏掉。
修复:进位永远用sum / 10计算,不要写死。
7.5 常见问题速查表
| 症状 | 可能原因 | 修复方向 |
|---|---|---|
| 输出缺少最高位进位 | 循环条件没包含 carry | while 条件加|| carry != 0 |
| 空指针异常 | 没判空就访问 val | 先判空,或把 null 视为 0 |
| 超时/死循环 | 忘记移动 l1/l2 指针 | 在循环体末尾统一移动指针 |
| 输出结果错位 | 进位计算错误 | 进位统一为 sum / 10 |
| 返回了哨兵节点本身 | 返回了 dummy 而不是 dummy.next | 返回 dummy.next |
7.6 一个隐藏的面试陷阱:输入链表能不能为 null
题目描述里说“非空链表”,但面试官可能会改条件:“如果其中一个链表是 null,你的代码怎么办?”
我的答案是:在循环里判空,天然支持 null 输入——因为l1 == null时按 0 处理。所以上述实现已经对 null 输入做了防御。如果某个版本的代码没有判空,你要能够现场说出来这种情况会 NPE。
这种“题目没说但现实可能出现”的边界条件,最能体现工程师的防御性编程意识。
8. 大整数加法思路:从算法题到真实业务场景
8.1 为什么我们需要链表模拟大数加法
真实业务中,“大数”场景非常常见:
- 金融系统里,金额计算要求极高精度,超出
double的安全范围 - 科学计算中,质因数分解、圆周率计算需要千位甚至万位的整数精度
- 密码学中,RSA 算法的密钥通常有 1024 位到 4096 位,远超
long类型的上限
Java 里有一个BigInteger类专门解决这类问题。但它的底层实现,本质上就是数组或链表逐位存储数字,并且用类似的“逐位相加”算法来执行加法。
8.2 从这道题抽象出的可复用模式
“两数相加”不是一道孤立的题目,它是一个基础模式:
- 模式一:双流同步遍历—— 两个输入结构并行前进,谁短谁先结束,剩余部分继续处理
- 模式二:状态变量维护—— 在循环体外部维护一个状态(如进位),每轮循环读取旧值、更新新值
- 模式三:哨兵节点避免特殊分支—— 让所有节点处理逻辑保持一致
这三个模式是链表题的地基。你刷了几十道链表题之后回头看,会发现它们全都在反复使用这三板斧。这也是为什么很多经验帖把“两数相加”列为链表题的“必修一”。
8.3 从算法题到工程实践的思维转换
如果在真实项目里,我们通常不会手写一个大数加法类,而是直接用BigDecimal或BigInteger。但算法题的价值在于让你理解这些类背后的原理,而不是让你在业务代码里重复造轮子。
更实际的应用场景是面试中的代码设计题:让你设计一个简历解析模块、一个订单金额汇总器,里面涉及超大数字的累加;或者让你处理一个 CSV 文件里存了超过long范围的 ID。这时候你学过“两数相加”,就自然知道怎么按位处理、怎么避免精度丢失、怎么做进位传递。
9. 敲定代码前的几个小习惯:适合所有链表题
9.1 先写注释再写代码
面试时我会在代码前先写三行注释,把思路固定住:
// 1. 双指针从两个链表头开始同步遍历 // 2. 维护 carry 进位,初始值为 0 // 3. 循环条件要包含 carry != 0,防止漏掉最高位进位写注释的过程,就是在脑子里把逻辑跑一遍。很多 bug 在写注释阶段就能被提前发现。
9.2 用变量名表达意图
l1、l2是 LeetCode 给定的参数名,但循环内部尽量用p、q这样简短的变量名,或者直接用curL1、curL2。tail 指针一定要叫tail或cur,别叫node——因为node太模糊,你不知道它指的是哪个节点。
9.3 边界条件测试要形成肌肉记忆
链表题的边界条件无非是四种:
- 链表为空
- 链表只有一个节点
- 操作发生在头节点
- 操作发生在尾节点之后
“两数相加”题涉及的是前两种和后一种:输入链表可能只有一位;结果可能比输入多一位。每次写完链表题,都强制自己把这四种边界过一遍,你就不会再犯低级错误。
9.4 善用null语义
在“两数相加”中,把 null 视为 0,是一个非常自然且优雅的设计。这个设计不仅简化了代码,也让逻辑更接近数学加法定义。类似的思路在设计模式中叫“空对象模式”(Null Object Pattern)—— 用空对象代替 null 判断,避免散落各处的条件分支。你可以在面试里顺嘴提一句这个概念,面试官会很受用。
10. 针对初学者的五个常见误区与进阶建议
10.1 误区一:把链表节点当成数组元素直接随机访问
链表不像数组,不支持按索引 O(1) 取值。你必须从头节点开始,通过next跳转。在“两数相加”里,这个特性反而带来了便利:因为你本来就要从个位开始按顺序处理。
10.2 误区二:以为结果链表的节点值可以是两位数
每个节点只允许存 0~9。如果你把 sum 直接赋给new ListNode(sum)而不是new ListNode(sum % 10),输出就会包含两位数甚至三位数的节点,直接违反题意。
10.3 误区三:只会在纸上模拟,不敢直接写代码
很多初学者习惯先在纸上画半天,确定每一步的值再动手。这很安全,但面试时时间有限。我建议你直接写一个“骨架版本”,先把循环搭出来,再把细节填进去——哪怕第一版代码有错,也比一张白纸强。面试官更看重你调试和修正的过程。
10.4 误区四:认为空间换了时间就是最优解
“两数相加”这道题,用 O(n) 的辅助空间(比如把链表转成数组再相加)确实更容易理解,但这不是最优算法。追求 O(1) 额外空间的原地处理,才是这道题的价值所在。当然,如果你能用数组方案先给出正确代码,再逐步优化到链表直接操作,这种递进式展示在面试中非常讨喜。
10.5 误区五:刷题时只看题解不写代码
这是最常见的学习误区。看题解时觉得“哦,原来这么简单”,关闭页面后自己写却卡壳。正确的刷题流程是:先自己思考 15 分钟,再写代码,再对照题解找差异。每道题至少独立写一遍代码,最好隔几天再重写一遍。
11. 最后再分享一个小技巧:徒步与链表之间没有本质区别
很多人觉得链表很难理解,是因为总是用一个抽象的“节点”概念去理解。我建议把链表想象成一条路上前后相连的站点:你从起点出发,每到一个站点,只能看到当前站点的名字(val)和指向下一站的路牌(next)。你不能跳着走,也不能回头。理解了“单向通行”这个限制,指针移动的每一步都是理所当然的。
“两数相加”只要顺着这条路走,处理了长度差和进位,就通关了。刷题时多想想这个类比,很多链表题都会变得直观起来。
我个人在反复讲这道题的过程中,最大的体会是:把一道基础题讲透,比粗刷十道题更有价值。每次回看这道题,我都会发现新的理解角度——比如从状态机的角度、从空间优化的角度、从递归与迭代对比的角度——每一个角度都在加深对链表的掌控感。如果你在刷题路上也遇到了反复出错的链表题,不妨用同样的方法:多问几个为什么,多写几个测试用例,多讲给身边的朋友听。能把它讲明白,才是真的会了。