
1. 从两数相加开始这道经典链表题到底在考什么1.1 题目原貌与高频出现的原因两数相加是算法刷题路上绕不开的一道基础题。你会在LeetCode的hot 100、剑指offer、各类算法入门清单、甚至不少公司的笔试面试里反复看到它原因很简单它考察的是链表遍历、进位处理、边界判断这三件事的组合恰好是大多数真实业务系统里逐位处理数据流的缩影。我见过太多人第一次做这道题时第一反应是把链表转成数字相加再转回链表。这种思路本身就是一个经典反模式它能把题做出来但完全绕开了出题人想让你练的东西。而且一旦链表节点数超过几十位整型根本存不下BigInteger之类的方案又会引入额外复杂度纯属给自己挖坑。这道题的核心约束只有一条**两个非空链表分别表示两个非负整数数字按逆序存储每个节点存一位数字要求返回一个新链表作为两者之和。**逆序存储这个设定初看很反直觉——我们平时写数字是从高位到低位为什么这里要反过来答案其实很贴心因为加法是从低位开始算的。链表只能从头节点开始遍历把个位数放在头节点等于让遍历顺序和计算顺序天然对齐这样你从头走到尾正好就是从个位加到最高位。出题人不是想为难你而是在帮你降低实现成本。1.2 数字逆序链表的三个直觉解释理解逆序存储可以用三个场景来建立直觉。第一个场景是竖式加法。小学学加法时都是个位对齐从右往左逐位相加满十进一。把这个竖式横过来最右边是个位对应链表头节点往左是十位、百位对应链表的next指针方向。所以链表头节点就是竖式的最低位每次操作直接把两个表头节点相加进位往后传就完事。第二个场景是现实中的计算器输入。你在计算器里敲123 456它内部存储和计算时并不一定把数字存成高位在前。对于加法器电路来说低位先到位、低位先计算是更自然的流水线方式。链表的逆序设计模拟的正是这种低位先行的硬件思维。第三个场景是边界友好性。两个数位数不同的时候比如1和9999如果你用正向存储的链表你得先知道谁更长、从哪一位开始对齐代码复杂度马上上来了。而逆序存储下两个链表虽然长度不同但你从头走就是了短的走完了就把它的贡献视为0根本不需要做对齐这件事。搞清楚了逆序存储的用意这道题的大框架也就浮出水面了维护一个进位变量同步遍历两条链表每次取出当前节点的值相加再加上进位生成新节点然后把进位传给下一位。2. 拆解核心逻辑逐位相加、进位传播与循环终止条件2.1 为什么必须用一个 dummy 头节点动手写代码之前先聊一个几乎所有链表插入类题目都会用到的技巧dummy head也就是哑头节点。如果不建dummy你的代码会写成这样先特判第一个节点单独生成结果链表的头节点然后while循环里再处理后续节点。这导致循环内外的逻辑割裂每添加一个节点都要写一遍if head is None: head node else: tail.next node烦不胜烦。用了dummy之后代码统一成一套逻辑所有新节点一律挂到当前尾节点的next上最后返回dummy.next。dummy本身不存储有效数据它的作用只是让链表的头节点也有一个统一的前驱避免对当前结果链表是否是空这件事做分支判断。这个技巧在后续很多链表题里都会反复用到比如合并两个有序链表、链表区间反转、删除倒数第N个节点。你甚至可以把它当作一个固定套路记下来凡是需要从空链表开始逐步构建结果的题一律dummy起步。2.2 每一次循环发生了什么核心循环的逻辑可以用三句话概括取值、求和、进位。取值阶段当前节点存在就取val不存在就用0填补。这一步同时处理了两个链表长度不一致的情况非常优雅。求和阶段把两个值加上进位变量得到当前位的总和。进位阶段total对10取整和对10取模前者是向下一位的进位后者是当前位的实际数字。这里有一个很多初学者会卡住的点进位变量到底应该在什么时候清零答案是永远不需要手动清零因为每一轮循环都有重新计算carry total // 10。就算上一轮有进位这一轮也会被覆盖。如果你发现代码里出现了if total 10这种写法反而容易漏——因为你可能会忘了处理total等于10和大于10的差异。用整除一行搞定干净利落。循环结束的条件是l1 or l2 or carry即三个条件同时为假才停止。前两个好理解表示两条链表都遍历完了第三个是关键——最高位相加可能产生新的进位比如5 5会得到一位进位1此时两条链表都走完了但结果链表还得追加一个节点。如果你写的循环条件只有l1 or l2这最后一位进位就被丢了整个结果会少一位这是这道题最著名的送分陷阱。2.3 一个完整样例的手推过程拿经典的342 465 807来手推一遍确保逻辑无死角。链表l1是2 - 4 - 3代表342链表l2是5 - 6 - 4代表465。第一轮2加5等于7进位为0新节点值为7。第二轮4加6等于10当前位为0进位为1。第三轮3加4加进位1等于8当前位为8进位为0。链表走完循环结束结果是7 - 0 - 8正好807。再推一个触发最高位进位的例子l1是9 - 9l2是1。第一轮9加1等于10当前位0进位1。第二轮9加进位1等于10当前位0进位1。此时两个链表都走完了但carry依然是1循环条件carry为真继续执行第三轮两个值都取00加0加1等于1当前位1进位归0。循环退出结果是0 - 0 - 1即100。如果漏了carry这个循环条件结果会变成0 - 0等于99加1算成了0错得离谱。3. 两种主流实现方案对比迭代法 vs 递归法3.1 迭代法的标准写法Python与C迭代法是这道题最主流的解法直观、可控、不会爆栈面试时我推荐优先用它。先看Python版本class Solution: def addTwoNumbers(self, l1: ListNode, l2: ListNode) - ListNode: dummy ListNode(0) 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再看C版本链表题在C里写起来更贴近底层指针操作class Solution { public: ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) { ListNode* dummy new ListNode(0); ListNode* cur dummy; int carry 0; while (l1 || l2 || carry) { int v1 l1 ? l1-val : 0; int v2 l2 ? l2-val : 0; int total v1 v2 carry; carry total / 10; cur-next new ListNode(total % 10); cur cur-next; if (l1) l1 l1-next; if (l2) l2 l2-next; } return dummy-next; } };这两个版本连行数都差不多。你注意看Python里的v1 l1.val if l1 else 0和C里的int v1 l1 ? l1-val : 0本质都是在空节点即视为0这个三元表达式是整个循环简洁性的关键建议形成肌肉记忆。3.2 递归法的写法与深层次的取舍递归版本的思路是每一层的任务就是计算当前位的和生成当前节点然后递归处理下一位。用嵌套函数可以避免默认参数带来的状态污染class Solution: def addTwoNumbers(self, l1: ListNode, l2: ListNode) - ListNode: def dfs(n1, n2, carry): if not n1 and not n2 and not carry: return None v1 n1.val if n1 else 0 v2 n2.val if n2 else 0 total v1 v2 carry node ListNode(total % 10) node.next dfs( n1.next if n1 else None, n2.next if n2 else None, total // 10 ) return node return dfs(l1, l2, 0)递归版本的代码量更少逻辑也集中在同一段代码里。但它的隐患在于递归深度极端情况下两条链表各有几千个节点递归调用栈就会叠几千层在一些环境里可能触发栈溢出。LeetCode默认用例通常不会这么极端但生产环境的代码评审里无界递归通常是被拒的。我的建议是刷题练习时迭代、递归各写一遍有助于加深理解面试答题时如果面试官没特别要求优先迭代版本。原因很实际——递归版本虽然短但你需要在脑子里维护每一层递归结束后返回给上一层的是什么这个状态紧张的时候容易把自己绕晕。迭代版本则是一根筋走到底出错概率更低。3.3 评论区常见的第三种暴力思路为什么不行每次这道题的讨论区都会有人问能不能先把两个链表转成数组或者整数加完再转回链表确实可以而且代码写起来更快在小数据量下也能通过测试。但它至少有四个问题第一链表节点数一旦超过语言整数类型的最大位数比如C的long long只有64位就会溢出结果完全错误第二你为了把链表转成数字至少要遍历一遍链表再反转一次这白白增加了时间消耗第三你额外开辟了数组或字符串来存储中间结果空间复杂度也上去了第四也是最根本的这道题考察的是逐位相加、处理进位的链表操作能力你用数组转换等于把这个能力完全绕开了练了等于没练。我见过一位读者评论我直接用Python的int(.join())然后相加再转回list20行不到就AC了。确实Python大整数甚至能处理几百位。但你要是拿这套思路去面大厂面试官一追问那如果链表有一万个节点呢你就只能尬在原地。记住解题是手段练的是拆解问题的能力AC只是结果。4. 边界条件与隐藏陷阱提交记录里红过一片的地方4.1 最高位仍然进位这是头号陷阱正如前面手推的9 - 9 加 1例子最高位相加可能多产生一位。这个case在LeetCode的测试用例里一定存在很多人第一次提交挂在它上面。处理方案已经写进循环条件里了也就是while l1 or l2 or carry里的or carry。你要理解的是为什么carry在循环体中可能再次变为0当两条链表都走完时v1和v2都取0total等于carry本身carry变成total // 10而total最大只有1因为进位最多是1所以carry很快归0。整个过程正好处理完最高位进位就退出不多不少。4.2 两个链表长度不一致时的空节点补0长度不一致是这道题最常见的输入形态比如2 - 4 - 3加5 - 6。初学者容易写出先判断len(l1)和len(l2)、把短的链表补齐到和长的一样长的代码。这个思路不能说错但你多写了至少五六行代码还得处理补位节点的生成。实际上完全不需要补齐直接在循环体里对空节点取0就完了。具体到代码里if l1:和if l2:这两个指针移动判断也很关键它们保证了访问到空指针时不会报NullPointerExceptionC里是空指针解引用未定义行为Python里是AttributeError。很多人在这一点上吃过亏写l1 l1.next时没加判空当l1已经是None时这一行直接崩溃。4.3 其他几个容易忽略的输入形态单节点链表是最简单的case两个链表都只有一个节点没有进位结果也只有一个节点一般不会出错但值得单独跑一遍确认。空链表理论上不会出现因为题目限定两个非空链表。但如果你在面试手写代码面试官很可能会追问如果l1或l2是空指针你的代码会怎样答案是while条件里已经处理了l1 or l2的情况空链表进来直接走正常的循环逻辑取0操作会让它等效于和0相加不会有问题。这一点可以在答追问时主动提出来属于加分项。另外结果链表本身不允许复用l1或l2的节点这个概念也要清楚。题目要求返回一个新链表虽然复用旧节点的值在某些情况下也能通过但新链表语义更清晰也避免了对原链表数据的意外修改。5. 复杂度分析与进阶变形一次学透一类逐项操作问题5.1 时间复杂度和空间复杂度到底是多少先说时间复杂度。循环每执行一轮处理一位数字。循环次数取决于两个链表长度的最大值以及是否还有额外进位。假设l1有m个节点l2有n个节点那么最多循环max(m, n) 1次其中1就是最高位进位的那一轮。因此时间复杂度是O(max(m, n))。这个复杂度是线性的不存在更优的解法——因为至少要遍历完两条链表的每一位才能算出结果下限就是线性。再看空间复杂度。不考虑输出结果链表本身占用的空间迭代法只用了dummy指针、cur指针和int变量额外空间是O(1)。但要注意如果你把结果链表也算进去那它本身有O(max(m, n) 1)个节点这部分空间是题目要求产生的不算额外。递归法的额外空间则是O(max(m, n))因为每一层递归都需要栈帧。这也是在实际中更倾向迭代的一个量化理由。5.2 把这道题的思维迁移到同类题目学会两数相加之后有几道题可以趁热打铁链表中的两数相加正向存储版两个数按正常顺序存链表相加。因为加法得从低位开始所以你要么先反转链表要么借助栈存逆序。这个变体很好地考察了你能否根据计算顺序调整数据结构。如果你理解了逆序存储为什么好你就明白正向版为什么不直接逐位加。两数相加II逆序但要求结果也逆序这题其实是原题的对称版本处理逻辑类似但要注意最后把结果再反转一次。字符串相加大数加法本质上和链表两数相加一摸一样只是载体从链表换成了字符串。其核心还是逐位相加、维护进位。我强烈建议你做完链表版之后顺手做一下字符串版你会发现两者的循环结构几乎可以互相平移。二进制链表转整数、合并两个有序链表这类题也都能复用双指针同步遍历条件判断的框架。5.3 如果面试官追问你能原地修改输入链表吗有些面试官会在你答完基础版本后问假设可以修改输入的链表你能把空间复杂度压到更低或者把代码写得更简洁吗这里的实质是你可以把结果直接写回l1或更长的那个链表从而省掉新链表的构建。思路是以l1为主体把l2的值塞进去同时维护进位。如果l1走完了l2还没走完就把l2剩余部分接到l1尾部如果最后还有进位就再新加一个节点。这样确实把结果链表的空间省掉了但代码复杂度会明显上升而且l2会被丢弃有一定破坏性。面试场合我会先明确说出取舍常规解法清晰且安全原地修改虽然省空间但可读性差、且有副作用。然后表示我可以写一版原地修改但需要你确认允许破坏输入这样既展现了技术深度又体现了工程意识。提示面试里主动讨论取舍比闷头写一个高级解法更加分。面试官想看的是你能权衡利弊而不是机械地追求炫技。6. 写在最后我从这道题里悟到的刷题方法论回到最初那句每天学一点算法。这类刷题系列最大的价值不是让你记住某个题的答案而是让你在反复拆解中形成一套稳定的解题流程先理解数据结构为什么这样设计再拆出核心逻辑再写代码最后强迫自己把边界条件全部列一遍。两数相加就是这样一个完美范本。它包含了为什么数据是逆序存储的设计思想包含了dummy节点这种万能工具包含了进位传播的状态管理还包含了长度不一致、最高位进位这类典型边界。把这道题吃透你等于把链表遍历类题目80%的套路都过了一遍。我个人的习惯是刷完一道题之后隔两天再不看题解重写一遍。如果两写都能一次通过这道题才算真正消化了。特别是边界条件我建议你重点重写9 - 9 加 1和0 加 0两个case前者测最高位进位后者测全零输入都是最容易翻车的地方。最后再分享一个复盘小技巧每次提交失败别急着看题解先自己猜一下是哪类边界问题。猜对了就说明你对题目的理解模型是对的只是漏了实现细节猜不对说明你对题目本质的理解还有偏差。坚持这样复盘一两百道题刷下来你会明显感觉到自己一眼看穿边界陷阱的能力变强了。