链表求和算是我最早接触的一批算法题,当年刷到它的时候,我写了将近70行代码:先遍历两个链表取出所有数字塞进vector,逐位加完再new一串节点拼回去。当时跑通测试用例就再没回头看过,还觉得自己挺厉害。直到最近整理C++学习笔记,重新翻开这道“旧题”,才真正觉得不好意思——那版代码本质上是在用数组思维处理链表问题,完全没有吃透“逆序存储”这四个字给我们的便利。这篇就算一个迟到的重做记录:从旧代码的问题出发,把链表求和的迭代、递归和空间优化三套方案都重新推导一遍,也会把重做过程中踩过的一些边界条件坑一并写出来。如果你也在用C++刷链表题,这篇文章里的细节应该能帮你少走一半弯路。
1. 为什么一道“旧题”值得重写一遍:初版代码的三个问题
1.1 初版“能跑”代码长什么样
先说清楚我当时是怎么写的。整体思路是“先把链表变成数组,算完再把数组变回链表”。代码大概是下面这个样子:
cpp复制// 初版写法:不推荐,仅展示我当时的问题
ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) {
vector<int> a, b;
while (l1) {
a.push_back(l1->val);
l1 = l1->next;
}
while (l2) {
b.push_back(l2->val);
l2 = l2->next;
}
int n = max(a.size(), b.size());
vector<int> res(n, 0);
int carry = 0;
for (int i = 0; i < n; i++) {
int sum = carry;
if (i < a.size()) sum += a[i];
if (i < b.size()) sum += b[i];
res[i] = sum % 10;
carry = sum / 10;
}
if (carry) res.push_back(carry);
ListNode* head = new ListNode(res[0]);
ListNode* cur = head;
for (int i = 1; i < res.size(); i++) {
cur->next = new ListNode(res[i]);
cur = cur->next;
}
return head;
}
如果只看“提交通过”这个标准,它没有任何问题。但等我对链表更熟悉之后回头读这段代码,心里就只剩一个念头:这真的是在写链表题吗?本质上它把链表遍历当成“读取数组的前置步骤”,把结果重新组织成链表时又重复造了一遍节点。整个过程里链表本身的结构特点、逐位推进的逻辑,全被消解掉了。
1.2 三个让我想删除它的理由
第一个问题是额外空间。这个方法需要两个O(n)的临时数组存原始值,再需要一个O(n)的结果数组,最后还要new出O(n)的节点。一个本来可以在常数级额外空间里做完的题,被我变成了三份线性空间的拷贝。
第二个问题是思路割裂。链表最大的特点就是“一个节点指向下一个节点”,天然支持你从头到尾逐位处理。我却先把所有节点拉平成数组,再用下标去模拟“当前位置”,等于放弃了这个数据结构自带的遍历能力。
第三个问题更实际:如果链表代表的是一个超过int范围的大数,比如有几十上百位,你把数值整体转成整数这条路就走不通。虽然我用的是vector而不是int,但我的算法结构仍然停留在“先集中取值、再统一计算”的思路上,一旦题目变形成“二进制链表求和”或者“正序存储求和”,这套结构就需要大改,完全没有复用性。
1.3 从数组思维切换到链表思维
“旧题新做”最有价值的地方,不是把代码写得更短,而是完成一次思维模型的切换:处理链表时,尽量让遍历顺序、计算顺序和结果构建顺序保持一致。这道题恰恰是练习这种思维的最佳载体,因为加法本身也是“从低位到高位、逐位相加、保留进位”,和链表从头部到尾部遍历的顺序完全重合。
也正因为如此,我后来养成了一个习惯:遇到链表题目先问自己一句,题目允许我可以从哪个方向遍历?如果需要从尾部开始,是反转链表用栈,还是直接递归?这个习惯让我后续刷C++结构体链表基本语法相关的题目时,明显没那么容易卡壳了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. C++链表与十进制进位的天然契合:读懂题目的隐藏信息
2.1 逆序存放不是故意刁难,恰恰是把优势递到你手里
很多初学者第一次看到题目说“数字按逆序存储”时,第一反应是“为什么要反着存?这不增加理解成本吗?”其实只要想一想竖式加法就明白了。
你手算342加465时,会先把个位对齐,然后是十位,然后是百位。如果链表头部就存放个位数,那么从指针指向的头节点开始,第一个处理的恰好是个位,第二个处理的是十位,第三个是百位。也就是说,当你从头遍历链表时,你正在做一次完整的竖式加法:当前节点就是当前位,进位存到下一次循环即可。
如果题目改成“正序存储”,比如3->4->2代表342,那你反而需要先把两个链表反转,或者借助栈来倒着取数,计算完再反转回去,多出两遍O(n)的操作。所以逆序存储这个设计,是题目主动降低你的实现成本。
2.2 手工模拟:342 + 465 在链表上是如何一步步发生的
我以力扣第2题的标准示例来说明。两个链表分别是:
- l1:2 -> 4 -> 3,代表数字342
- l2:5 -> 6 -> 4,代表数字465
逐位计算过程如下:
| 步骤 | 当前节点值 | 上一位进位 | sum | 当前位结果 | 下一位进位 |
|---|---|---|---|---|---|
| 第1位 | 2 + 5 | 0 | 7 | 7 | 0 |
| 第2位 | 4 + 6 | 0 | 10 | 0 | 1 |
| 第3位 | 3 + 4 | 1 | 8 | 8 | 0 |
最终得到结果链表:7 -> 0 -> 8,对应807,和342+465=807完全一致。注意第2位加出10时,当前位要写成0,并把进位置为1;第三位要把这个进位1一起算进去,得到8。这就是整个算法的全部内容。
用代码表达这一步时,核心状态转移是两个表达式:
- 当前位数字:
digit = (a + b + carry) % 10 - 下一轮进位:
carry = (a + b + carry) / 10
因为单个节点最大是9,进位最大是1,所以a+b+carry最大是19,int类型完全够用。这也提醒了一个细节:不需要为“两个数相加超过9”这件事单独写一长串if判断,直接用取模和整除就行,简洁而且不容易漏分支。
2.3 ListNode结构体与指针操作的细节复习
这道题也等于复习了一遍C++单链表的基本语法。标准定义一般是:
cpp复制struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
};
这里有两个C++细节值得注意。第一,为什么用struct而不是class?算法题里这个结构体通常只需要存数据和指针,不需要封装、继承、虚函数,用struct让所有成员默认public,写起来最直接。第二,构造函数里的next(nullptr)不能省略。如果忘了初始化next,新建的节点next是个野指针,后面再用tail->next拼接时就会读到垃圾地址。
访问节点值时要区分l1->val和(*l1).val,两者等价,但日常写代码基本用->。这是C++指针语法的基本功,也是初学链表最容易卡住的地方。
3. 迭代解法的完整推导:哑节点、循环条件与进位处理一步到位
3.1 标准解法代码
不绕弯子,先给出我最终推荐的迭代版本:
cpp复制ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) {
ListNode dummy(0);
ListNode* tail = &dummy;
int carry = 0;
while (l1 || l2 || carry) {
int sum = carry;
if (l1 != nullptr) {
sum += l1->val;
l1 = l1->next;
}
if (l2 != nullptr) {
sum += l2->val;
l2 = l2->next;
}
tail->next = new ListNode(sum % 10);
carry = sum / 10;
tail = tail->next;
}
return dummy.next;
}
这段代码短,但每个地方都有讲究,下面拆开讲。
3.2 三个关键选择背后的原因
第一个关键选择是ListNode dummy(0)。这里在栈上创建了一个“哑节点”,它的next才是真正的头节点。这样做最大的意义,是让“第一个节点”和“后续节点”的处理逻辑完全一致。如果没有哑节点,你每次创建新节点时都要判断“这是不是第一个节点”,代码会变成:
cpp复制if (head == nullptr) {
head = new ListNode(digit);
tail = head;
} else {
tail->next = new ListNode(digit);
tail = tail->next;
}
这么写不是不行,就是多了一层和算法无关的分支判断,读起来费劲。哑节点在链表构建类题目里是非常常见的技巧,强烈建议上手就用。
第二个关键选择是循环条件l1 || l2 || carry。这个条件的精妙之处在于,它把“两个链表都遍历完了但进位还没处理干净”这个特殊情况直接放进循环里。比如[9,9]加[1],最后一位加完会剩一个1进位,如果不把carry放进条件里,最后这个1就会丢失。这一点非常容易踩坑,后面我会单独讲。
第三个关键选择是循环内写成sum = carry,然后分别判断l1、l2是否为空来累加值,再把两个指针推进。有些写法是先判断while (l1 && l2),循环结束后再分别处理l1或l2剩余部分,那样代码会臃肿很多,而且极易在第二个循环里忘记处理进位。用if逐个累加,天然兼容两条链表长度不一致的情况。
3.3 常见错误写法对照
我见过很多初学版本的错误写法,这里列两个最容易犯的:
cpp复制// 错误示例1:循环条件写错,漏处理剩余链表
while (l1 && l2) {
// ...
l1 = l1->next;
l2 = l2->next;
}
// 循环结束后,l1或l2可能还有节点没人管
cpp复制// 错误示例2:最后一位进位被丢掉
while (l1 || l2) {
// ...
}
// 没有把carry放进循环条件或循环结束后单独处理
// 比如 [9,9] + [1],结果应该是 [0,0,1],这里的最后一个1会丢
这两种错误在本地跑简单用例时很难发现,因为正常情况下加法并不会产生额外进位,只有跑到999 + 1这类用例时才会暴露。所以后来我不管是自己写代码还是帮别人review,都会优先检查“最后一位进位有没有地方放”。
4. 递归解法:更贴合语义的另一种路径与容易翻车的细节
4.1 递归代码与调用栈拆解
迭代法把“逐位相加”用while循环表达得清清楚楚,但有的场景下递归写起来更贴合“当前节点处理完,交给下一个节点继续”的语义。递归版长这样:
cpp复制ListNode* addTwoNumbers(ListNode* l1, ListNode* l2, int carry = 0) {
if (l1 == nullptr && l2 == nullptr && carry == 0) {
return nullptr;
}
int sum = carry;
if (l1 != nullptr) sum += l1->val;
if (l2 != nullptr) sum += l2->val;
ListNode* node = new ListNode(sum % 10);
node->next = addTwoNumbers(
l1 ? l1->next : nullptr,
l2 ? l2->next : nullptr,
sum / 10
);
return node;
}
还是用342加465这个例子。第一层递归处理2和5,sum=7,创建节点7,然后递归传入l1->next和l2->next;第二层处理4和6,sum=10,创建节点0,递归时把进位1传下去;第三层处理3和4,再加上第二层传下来的进位1,sum=8,创建节点8;第四层三个参数全空,返回nullptr。层层返回后,链表就串成了7->0->8。
递归版简洁的美感在于:返回值直接就是“从当前节点开始的后续结果链表”,node->next接住递归结果,每个子问题完全独立。
4.2 三个递归陷阱
第一个陷阱是递归出口的遗漏。如果写成if (l1 == nullptr && l2 == nullptr) return nullptr;,最后一位进位就没人处理。比如[9,9]加[1],第三层递归时l1和l2都为空,但carry是1,正确的做法是创建节点1再返回,而不是直接返回空。
第二个陷阱是节点创建和递归调用的顺序。先创建node,再通过递归调用给node->next赋值,这个顺序不能反。如果先递归返回结果再创建当前节点,那递归返回的链表就无法和当前节点衔接。
第三个陷阱是递归入参的移动条件。l1 ? l1->next : nullptr这个写法能优雅处理长度不一致的链表。如果写成l1->next而不判空,当l1为空时会直接解引用空指针导致崩溃。这个错误在链表类递归题目里非常致命。
4.3 迭代和递归怎么选
我的选择标准很简单:如果面试官明确要求“不能修改输入链表”且“额外空间尽量小”,用迭代;如果题目本身就是树形结构的递归延伸,或者写链表反转这类需要保存父节点的场景,递归会让你思路更顺。
这道题用递归的好处是代码量少、语义清楚;代价是递归深度取决于链表长度,极端情况下如果链表有几万个节点,系统栈可能不够用。刷题阶段一般不用太担心,但如果你在某些内存受限的环境下写代码,优先选迭代。
5. 空间优化进阶:不新建链表,直接在长链上原地修改
5.1 原理解析与完整代码
迭代和递归的常规做法,都是新建一条结果链表。但题目并没有说不许修改输入的链表,所以我们可以走一条进阶路线:不额外创建节点,直接在较长的那个链表上原地修改节点值,最后如果还有进位,再在末尾追加一个新节点。
这样做的好处是额外空间降到O(1),不用new出一长串节点。坏处是输入数据被修改了,如果后续逻辑还需要原链表,就不能用这个方案。它是一种典型的空间换数据的取舍。
实现的完整代码如下:
cpp复制ListNode* addTwoNumbersInPlace(ListNode* l1, ListNode* l2) {
if (l1 == nullptr) return l2;
if (l2 == nullptr) return l1;
// 统计两个链表长度,选长的作为结果载体
int len1 = 0, len2 = 0;
ListNode* p1 = l1;
ListNode* p2 = l2;
while (p1) { len1++; p1 = p1->next; }
while (p2) { len2++; p2 = p2->next; }
// 保证l1指向较长链表
if (len1 < len2) {
std::swap(l1, l2);
std::swap(len1, len2);
}
ListNode* res = l1;
ListNode* prev = nullptr;
int carry = 0;
while (l1) {
int sum = carry;
if (l2) {
sum += l2->val;
l2 = l2->next;
}
sum += l1->val;
l1->val = sum % 10;
carry = sum / 10;
prev = l1;
l1 = l1->next;
}
// 循环结束后如果还有进位,追加新节点
if (carry) {
prev->next = new ListNode(carry);
}
return res;
}
这里的核心技巧有两个:一是在修改前先把l1和l2的指针结构对齐,让l1一定是较长的那条链表,这样结果一定可以从l1的头部返回,不会出现结果长度比l1短的情况;二是用一个prev指针记录最后一个有效节点,方便在循环结束后追加进位节点。
5.2 追加节点:最后一位进位的归宿
原地修改最隐蔽的坑,恰恰是最后的进位。比如[9,9]加[1],l1指向[9,9],l2指向[1],逐位处理完之后l1走到nullptr,carry是1。如果不在末尾新增节点,返回的链表就是[0,9],正确答案应该是[0,0,1]。所以必须用prev保存最后一个节点,在处理完所有老节点之后,判断carry是否非0,是就prev->next = new ListNode(carry)。
注意这里的carry只能是0或1,因为两位数求和最大是19,进位最多是1。但如果题目改成三位数,进位就可能达到两位数,这属于题目变形,不在本文讨论范围内。看到这个注释的同学,至少能明白进位不是永远只有1位。
5.3 空间优化到底什么时候值得用
有意思的是,力扣的判定系统并不会因为你的额外空间从O(n)降到O(1)而把运行时间明显缩短,因为new节点的操作本身没有慢到那个量级。但这个问题在工程里是有意义的:当链表的节点数量达到百万级,且你处于内存受限的嵌入式或实时系统里时,每少分配一个节点都是实打实的收益。
我在实际刷题中,会把原地修改方案当作“对链表理解的加分项”来写,而不会作为首选方案。因为优化方案的代码里涉及了链表的长度统计、指针交换、prev回溯,逻辑分支比基础迭代版多出不少,稍不留意就会写出bug。先把基础解法写对,再在时间宽裕的情况下展示优化思路,是我比较推荐的节奏。
6. 边界用例与调试心得:WA往往藏在这些地方
6.1 边界用例表格与验证点
我自己重写这道题时,第一遍提交就翻车了,原因就是漏了最高位的进位。后来我会把这几个用例固定放在测试代码里,每写完一版就本地跑一遍:
| 用例 | l1 | l2 | 期望结果 | 主要验证点 |
|---|---|---|---|---|
| 普通加法 | [2,4,3] | [5,6,4] | [7,0,8] | 基本流程 |
| 最高位进位 | [9,9,9,9,9,9,9] | [9,9,9,9] | [8,9,9,9,0,0,0,1] | 最后新增一位节点 |
| 长度不一致 | [1,8] | [0] | [1,8] | 短链表遍历完后不再取值 |
| 单节点进位 | [9] | [1] | [0,1] | 只有一个节点时发生进位 |
| 全零相加 | [0] | [0] | [0] | 结果不能是空链表 |
| 合并进位 | [9,9] | [1] | [0,0,1] | 连续进位到最高位 |
每次“旧题新做”,我都会把这几个用例先跑通,再去看提交评测。它基本覆盖了这道题所有的边界逻辑。
6.2 链表调试三件套:打印函数、构造函数、内存检查
链表题看不见摸不着,出了bug很难直观发现。我强烈建议在本地维护两个辅助函数,刷链表题时直接复用:
cpp复制void printList(ListNode* head) {
while (head != nullptr) {
std::cout << head->val;
if (head->next != nullptr) std::cout << " -> ";
head = head->next;
}
std::cout << std::endl;
}
ListNode* makeList(std::initializer_list<int> vals) {
ListNode dummy(0);
ListNode* tail = &dummy;
for (int v : vals) {
tail->next = new ListNode(v);
tail = tail->next;
}
return dummy.next;
}
makeList用了std::initializer_list,可以很方便地写makeList({9,9})就构造出一条链表。printList用于在每轮循环后打印结果,很多时候问题的根源一眼就能看出来。
比这更隐蔽的是内存问题。C++里new出来的节点如果忘记delete,测试用例越多,内存泄漏越严重。如果使用valgrind跑一遍,会看到大量“definitely lost”的报告。刷题阶段通常不检查内存,但如果这个代码要被你复用进工程,建议在main函数结束时遍历链表把所有节点delete掉,或者直接用std::unique_ptr管理,养成好习惯。
6.3 提交失败后的排查复盘
我重写这道题时犯过一个很典型的错误:第一次提交用的基础迭代版,因为循环条件写成了while (l1 || l2),把最后的carry漏掉了。跑用例[9,9]加[1]时,期望结果是[0,0,1],实际输出[0,0]。当时第一反应是打印一下链表,看到结果少了一位才恍然大悟,原来是循环退出的时机早了。
还有一次是递归版提交,我把递归出口写成了if (!l1 && !l2) return nullptr;,于是[9]加[9]得到[8]而不是[8,1]。这个bug从递归代码的局部视角来看非常隐蔽,因为第三层递归传入的l1和l2确实都是空,只有carry是1,但出口函数根本不会去看这个carry。
复盘下来,两个错误都指向同一个问题:只盯着“正常流程”写代码,没把“异常但合法”的输入放进思考范围。链表题尤其如此,因为指针一旦为空,所有后续操作都可能崩;而进位一旦不为零,又会让结果链表多出一截。这大概是链表求和这道旧题给我最大的启发了。
最后分享一个我个人的小习惯:遇到链表题,先别急着看答案,在纸上模拟一次“两个节点加出10”的情况,把carry的变化过程写成一行a + b + carry -> digit + newCarry。这一步想清楚了,迭代、递归、原地修改,换哪套方案都不容易出大错。这道题后续还能延伸出正序链表求和、二进制链表求和、甚至三条链表同时相加的变体,值得在笔记里打个“可二刷”的标签,过几个月再回来做一次,体会又会不一样。
