1. 为什么我建议你把这题刷三遍以上
我第一次遇到“合并两个有序链表”是在刷 LeetCode Hot100 的时候,当时已经刷了二十来道题,觉得自己链表操作还算熟练,结果被这题卡了一个多小时。卡点不是不会做,而是做出来的解法又长又乱,还伴随着一堆 if 分支,代码自己都看不下去。后来我把这题反复刷了三遍,间隔大概是两周、一个月,每次都有新的收获。这题表面上是个简单题,实际上它把链表题里最核心的几个考点全串起来了:虚拟头节点、双指针遍历、指针的语义理解、递归的返回值逻辑、边界条件的处理。很多声称“链表基础扎实”的候选人,在这道题上用十分钟写出的代码,基本就能看出他是不是真的理解指针。
为什么值得反复刷?因为它在不同难度区间的题目里反复出现。你可以在 23. 合并K个升序链表里用它做分治的合并步骤,在 148. 排序链表里用它做归并排序的核心 merge,在 剑指 Offer 25 里原题出现,还经常作为面试手写题的敲门砖。也就是说,如果你把这道题吃透了,等于拿到了一把“合并类链表题目”的万能钥匙。
这题适合谁来刷?我认为是所有正在准备算法面试的开发者,不管你是用 Python、Java、C++ 还是 Go。它对语言要求不高,但对思维要求很清晰。如果你刚接触链表,这题是一个绝佳的练手点;如果你已经刷题上百道,我也建议你停下来重新审视一下自己的解法,看是不是真的理解每一次指针的赋值,而不仅仅是背下了代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 题目拆解:链表场景下的核心考点到底是什么
题目本身不复杂:给你两个升序排列的链表,比如 l1 = [1,2,4],l2 = [1,3,4],要求合并成一个新的升序链表 [1,1,2,3,4,4]。注意一个关键细节:新链表要通过拼接原有节点来构建,而不是创建全新的节点。这个要求意味着你的解法应当修改现有节点的 next 指针,让它们排列成新顺序。对于内存敏感的场景来说,这就是“零额外内存开销”的解决方案。
2.1 链表和数组的“合并”思维差异
如果给你两个有序数组让你合并,你第一时间会想到用一个新数组,然后双指针扫描,最后把剩余部分拷贝进来。但链表合并和数组合并有一个本质区别:数组的合并需要额外 O(m+n) 的空间来存放结果,而链表合并如果允许修改 next 指针,额外空间可以是 O(1)。这个差异正是链表数据结构的特点——它不要求元素在内存中连续存放,而是通过指针把零散的节点串起来。你只需要“重排指针”,不需要搬移数据。
很多初学者会踩一个坑:为了合并两个链表,先遍历 l1 把值存到数组里,再遍历 l2 存到另一个数组,然后合并数组,最后再根据数组重建链表。这样做当然能通过测试,但完全违背了链表这种数据结构的初衷,空间复杂度直接变成 O(m+n),代码量也长了几倍。面试官如果看到这样的写法,通常会追一个问题:你能不能用 O(1) 的额外空间完成?这就是在考察你是否理解链表指针操作的本质。
2.2 循环不变量:合并过程中始终保持什么性质
写链表操作代码,最高频的说法是“指针别搞丢了”。怎么做到不丢?你要时刻清楚一个循环不变量:在每一步迭代中,当前结果链表的尾部节点 cur 已经正确指向了已合并部分,两个原链表各自从当前节点开始,仍然保持升序,并且后续所有节点都是未处理的原始顺序。换句话说,前一步做的操作不会破坏后一步所需的数据结构状态。
举例来说,假设当前 l1 = [2,4],l2 = [1,3,4],已经选走了节点 1 和节点 1,现在结果链表是 1->1,cur 指向第二个 1。这时候 l1 和 l2 的状态依然是两个独立的有序链表,不会因为我们“拿走”了一些节点而断裂。正是这个不变量保证了双指针向前推进的正确性。
2.3 虚拟头节点的作用:把特殊情况变成普通情况
链表题里最让人头疼的就是头节点的处理——如果合并结果的最小节点来自 l2,那么新链表的头节点就不是 l1 的头节点。你不能预先知道头节点是谁,于是就有了两种常见策略:一种是用 if 判断第一次操作特殊处理,另一种是创建一个虚拟头节点 dummy,让结果链表从头节点的 next 开始构建,最后返回 dummy.next。
虚拟头节点的本质是“用一个额外的占位节点,消除对边界条件的特殊分支”。这在实现上看起来只省了两三行 if,但它让你的代码逻辑变得直线化:每次只需要比较 l1.val 和 l2.val,然后把较小的那个接到 cur.next 后面,再移动 cur。循环结束后,把剩余的那个非空链表直接接到结果后面,返回 dummy.next 即可。这是一种我强烈推荐的写法,因为它把思维负担降到了最低。
3. 迭代法系列:虚拟头节点技巧的完整落地
迭代法是这道题最直观、也是我认为最适合面试现场写出的解法。它不需要递归的系统栈,不需要担心深度,而且空间复杂度是 O(1)。下面我直接给出一份 Python 实现,然后逐行拆解关键点。
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def mergeTwoLists(self, l1: ListNode, l2: ListNode) -> ListNode:
dummy = ListNode(0) # 虚拟头节点
cur = dummy # cur 始终指向结果链表的尾部
while l1 and l2:
if l1.val <= l2.val:
cur.next = l1
l1 = l1.next
else:
cur.next = l2
l2 = l2.next
cur = cur.next
# 循环结束后,最多还有一个链表非空,直接拼接
cur.next = l1 if l1 else l2
return dummy.next
这段代码一共十几行,但每一步都有讲究。
dummy = ListNode(0):虚拟节点本身的值可以是任意数字,因为它永远不会被返回,也不参与任何值的比较。它的唯一作用就是给结果链表一个“前驱节点”,让第一次拼接变得和后面的拼接完全一致。cur = dummy:cur 是游标指针,它指向当前已构建好的链表部分的末尾。每选中一个节点,就把它挂到 cur.next 上,然后 cur 前进到刚挂上的那个节点。while l1 and l2:循环条件是两个链表都不为空。只要有一个链表走完了,循环就停止,因为剩下的部分不需要再比较了——它们本身就是有序的,直接整体挂上即可。if l1.val <= l2.val:这里的等号很重要。当两个节点的值相等时,优先取 l1 的节点。这不会影响正确性,因为两个值是相等的,但保持这个惯用写法可以让你在处理更复杂的排序问题时养成稳定选择偏好的习惯。cur.next = l1 if l1 else l2:这一行处理循环结束后的“善后”,是很多初学者容易遗漏的地方。不要用循环去遍历剩余链表,那是多余操作,链表已经有序,直接整段拼接即可。
3.1 为什么 cur.next = l1 if l1 else l2 是必须的
我见过很多人在这个位置卡住。当 while 循环结束时,有两种可能:l1 空了,或者 l2 空了。这时候两个链表里至少有一个已经全部处理完,另一个可能还剩几个节点(也可能也刚好结束,此时 l1/l2 都为 None)。你只需要把非空的那个剩余链表接到结果尾部。如果两个都为空,那么 l1 if l1 else l2 会得到 None,把它赋值给 cur.next 也就是把尾部清空,这同样正确。
还有一个小细节:为什么不能用 l1.next 或 l2.next?因为此时剩余链表保留了原有的内部顺序,你只需要把它们整个接到结果尾部,任何多余的节点调整都可能破坏有序性或者造成指针错乱。要相信剩余那一整段已经是有序的,不需要再逐个遍历。
3.2 迭代法的空间复杂度是怎么做到 O(1) 的
整个迭代过程只创建了一个新的虚拟头节点 dummy 和一个游标指针 cur,其余操作全部是修改既有节点的 next 引用。虚拟头节点本身是一个固定开销,不随输入规模增长,因此在空间复杂度的大 O 表示下,它是常数级 O(1)。
这个特性对比数组实现有巨大优势。假设两个链表各有 10 万个节点,数组方式需要额外创建 20 万个元素的存储空间来排序;而链表方式只需要一个 dummy 节点——哪怕有 1000 万个节点,也只需要一个 dummy 节点。这个差异在大数据量场景下直接影响程序是否能跑得动。
4. 递归法背后的逻辑链条与写作捷径
迭代法讲完,我想再展开讲递归法。很多刷题的人觉得递归难写,其实是没找到递归的“信任链”。递归解法的核心可以浓缩成一句话:假定 mergeTwoLists 已经能正确合并两个有序链表,那么你只需要处理一个节点,剩下的交给递归自己去完成。
4.1 递归的终止条件和递归关系
先看代码。
python复制class Solution:
def mergeTwoLists(self, l1: ListNode, l2: ListNode) -> ListNode:
if l1 is None:
return l2
if l2 is None:
return l1
if l1.val < l2.val:
l1.next = self.mergeTwoLists(l1.next, l2)
return l1
else:
l2.next = self.mergeTwoLists(l1, l2.next)
return l2
递归的终止条件有两个:l1 为空时返回 l2,l2 为空时返回 l1。这其实是边界条件的另一种优雅表达——如果一个链表已经被取空了,剩下的工作就是把另一个链表整体返回。递归关系是:比较两个链表当前头节点的值,较小的那个节点作为合并结果的第一个节点,它的 next 指针应该指向“将较小节点的下一个节点与另一个链表继续合并”的结果。
打个比方:你面前有两副排好序的扑克牌,你的任务是把它们合成一副。递归的做法是,你先从两副牌里各抽出最上面一张,比较大小,小的那张你先拿住,然后告诉另一个人:“你帮我完成剩下的所有合并工作,我把手里这张牌接到你完成的结果最前面。”你只需要独立完成这一张牌的“决策”,剩下的整副牌都有其他人帮你排好,而这个“其他人”就是函数自己。
4.2 递归的空间复杂度为什么不是 O(1)
递归解法里没有显式创建任何新节点,看起来是 O(1),但实际上每次递归调用都会在系统栈上压入一层函数调用记录,包含参数、返回地址、局部变量等信息。当链表长度为 m 和 n 时,递归深度最多是 m+n,因此空间复杂度是 O(m+n)。在极端情况下,比如一个链表有 100 万个节点,递归解法可能因为系统栈溢出而崩溃,而迭代解法完全不会。
这并不意味着递归解法没有价值。它的代码极度简洁,逻辑自明,在面试中如果写成递归,面试官会知道你对递归有清晰理解。但如果你被追问“空间复杂度如何”,能说出 O(m+n) 并能解释为什么,那才是真正加分的关键。
4.3 递归和迭代怎么选:从面试和工程两个维度看
面试场景中,如果题目只是要求“合并两个有序链表”,两种解法都可以,你最好能说出两种,并解释各自的复杂度。工程场景中,我强烈建议默认写迭代解法,因为它不依赖系统栈,健壮性更好,也不容易遇到 Python 或 Java 中的递归深度限制。但在一些支持尾递归优化的函数式语言里,递归的栈开销可以被编译器优化掉,这时候递归写法的简洁性就更突出。
还有一点要提醒:不要把递归改成那种“看起来像递归但其实是两层递归”的写法。比如有人会写:
python复制if l1.val < l2.val:
return ListNode(l1.val, self.mergeTwoLists(l1.next, l2))
else:
return ListNode(l2.val, self.mergeTwoLists(l1, l2.next))
这个写法在功能上没错,但它在每次合并时创建了一个新节点 ListNode(l1.val),这会导致空间复杂度额外增加 O(m+n)(因为新节点属于结果,不算额外辅助空间的话勉强能接受,但它破坏了“拼接原节点”这个要求),同时递归深度依然 O(m+n)。如果你的面试官明确说了“不能创建新节点,只能修改指针”,这种写法就直接出局。遇到这类要求,优先迭代法,return 的节点都来自原链表。
5. 边界处理与典型踩坑:从空链表到指针悬挂
这一节是我最想写的部分。做链表题,代码写对很容易,但能一次通过所有边界用例,并且能解释清楚每个边界为什么这么处理,是另一种水平。我把自己刷这题时踩过的坑和身边朋友踩过的坑集中整理一下,希望你能避开。
5.1 两个链表都为空的场景
输入:l1 = null,l2 = null,期望输出也是 null。
迭代法里,while 循环条件 l1 and l2 一开始就为假,直接进入 cur.next = l1 if l1 else l2。此时 l1 和 l2 都是 None,表达式结果是 None,cur.next = None,最后返回 dummy.next,也就是 None。逻辑正确。
递归法里,进入函数后先判断 if l1 is None: return l2,此时 l2 是 None,返回 None;第二个终止条件同理。也正确。
这类场景是最简单的边界,却也是我最常看到有人写错的地方。错误往往出现在“手动创建了虚拟头节点但返回了 dummy 本身,而不是 dummy.next”。如果你返回 dummy,就相当于在结果链表最前面多了一个值为 0 的节点。这个问题在空链表用例里会立刻暴露——返回了一个 0 而不是 null。
5.2 一个链表为空,另一个有多个节点
输入:l1 = null,l2 = [1,3,5]。正确结果显然应该是 [1,3,5]。
迭代法里,while 循环不执行,直接执行 cur.next = l2,返回 dummy.next,也就是原封不动的 l2。这里有个容易被忽略的点:你“合并”完以后,l2 的节点没有任何一个被修改,它们的 next 关系和原链表完全一样。这正是因为剩余链表原本就是有序的,你不需要做任何加工。
递归法里,if l1 is None: return l2,一句话搞定。这个终止条件不是“特殊情况特殊处理”,而是递归函数的正确归约基础。
5.3 两个链表长度差异极大
比如 l1 只有 1 个节点,l2 有 10000 个节点,而且 l1 的值大于 l2 最后一个节点的值。这种情况下,迭代法会从 l2 的头节点开始逐个比较,直到 l2 遍历完,然后 l1 还剩一个节点,直接拼到尾部。整个过程中,游标 cur 走完了 l2 的全部节点,但结果链表的总节点数没有变化,只是顺序调整了。
这里最常见的坑是:在 while 循环内部错误地提前终止,比如写成 while l1 and l2.next,或者 while l1.next and l2。这些写法都会让你漏掉比较条件,最终拼出来的链表缺失节点或出现乱序。记住一句话:循环条件是判断节点本身是否存在,而不是判断它的 next 是否存在。
5.4 指针悬挂与节点丢失的经典场景
假设你写下了这样的代码:
python复制while l1 and l2:
if l1.val <= l2.val:
cur.next = l1
l1 = l1.next
else:
cur.next = l2
l2 = l2.next
cur = cur.next
这个顺序看起来没问题,但它藏着一个隐患:当你执行 cur.next = l1 时,l1 原来指向的下一个节点现在成为了新链表的一部分,但此刻你还没有移动 l1。紧接着的 l1 = l1.next 就是在更新指针。如果你把移动 l1 的语句放到最后,或者忘记移动 l1,就会导致同样的节点被处理两次,甚至形成环形引用。
具体来说,如果少了 l1 = l1.next,循环下一次还会再次取到同一个 l1 节点,cur.next 再次指向它,形成一个自环,链表循环永不终止,最终导致程序超时或者内存溢出。这个 bug 在使用 Python 刷题时不容易被及时发现,因为本地可能数据量小看不出问题,但提交到 LeetCode 上就会得到 Time Limit Exceeded。
正确做法是严格按照“先修改节点指向,再移动指针游标”的顺序。你可以给自己立一个规矩:所有链表循环题,写完代码后立刻检查每个指针的 next 是否被修改、每个游标是否都有前进语句、循环结束的边界条件是什么。
5.5 值的比较:相等时稳定选择的影响
当 l1.val == l2.val 时,选择哪一个都可以,但不同写法会影响代码的可读性和一致性。我习惯用 <= 取 l1,这样在语义上可以理解为“当遇到相等值时,优先保留原链表 l1 的节点”。这个偏好本身不重要,重要的是它让你形成稳定的选择模式,后续在归并排序或其他需要稳定排序的变种题中能避免困惑。
补充一个 LeetCode 上常见的争议点:如果两个链表包含相同值,预期结果是不是唯一的?是的,因为最终链表元素的值序列是 [1,1,2,3,4,4],不管你是从 l1 还是 l2 取值,最终节点的值序列都一样。但如果你要关心“引用的来源”,比如后续需要按原链表追踪节点来源,那么稳定选择策略就有意义了。
6. 多语言视角:同样的思路在不同语言里的水土
同一个算法思路,在 Python、C++、Java、Go 里写出来,形态差异很大,但核心逻辑完全一致。我建议每个刷题的人至少用两种语言各写一遍,因为这能帮你区分“算法不理解”和“语言 API 不熟”这两个问题。
6.1 Python:对象引用的天然优势
Python 里节点就是一个普通对象,l1 变量本质上是对象的引用。cur.next = l1 这行代码的意思是:让 cur 这个对象的 next 字段指向 l1 引用的那个节点。Python 不需要你显式区分“指针”和“对象”,一切都通过引用来操作,出问题的概率低,但这也意味着如果同一份代码交给 C++ 读者,他们会觉得“这写的什么,指针呢?”
我刷题时最常用 Python 写 LeetCode,因为写得快、逻辑清晰,适合快速验证思路。但需要注意 Python 默认的递归深度限制大约是 1000,如果链表数据量特别大,递归解法可能直接报 RecursionError。虽然 LeetCode 的测试数据相对温和,但自己在本地用长链表测试时就要留意。
6.2 C++:指针操作需要显式、谨慎
C++ 的链表节点一般定义为:
cpp复制struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
C++ 里的指针操作比 Python 更接近底层,每个 cur->next = l1 都意味着一次真实的内存地址赋值。这里最容易出问题的点是空指针解引用。比如你要访问 l1->val,必须确保 l1 != nullptr;while 条件已经保证了这一点,但如果在循环内部错误地提前移动了 l1,下一次比较时 l1 可能已经是空指针,直接崩溃。
用 C++ 写这题的优势是性能极佳,而且你能真切地感受到“指针移动”到底在做什么。我记得自己第一次用 C++ 写出迭代解法后,再回到 Python 写链表题,脑子里的指针图清晰了很多。所以如果你有时间,强烈建议用 C++ 再写一遍这题。
C++ 的参考实现:
cpp复制class Solution {
public:
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
ListNode dummy(0);
ListNode* cur = &dummy;
while (l1 && l2) {
if (l1->val <= l2->val) {
cur->next = l1;
l1 = l1->next;
} else {
cur->next = l2;
l2 = l2->next;
}
cur = cur->next;
}
cur->next = l1 ? l1 : l2;
return dummy.next;
}
};
注意这里的 dummy 是栈上对象而非指针,所以 dummy.next 用于返回,这在 C++ 里是标准写法,避免了手动 delete 的麻烦。但如果你用 ListNode* dummy = new ListNode(0),那返回前就必须 delete dummy,否则会内存泄漏,这也是工程面试中经常被追问的点。
6.3 Java:引用类型与垃圾回收的权衡
Java 的节点类型和 C++ 类似,也是引用传递。但因为 Java 有垃圾回收,你不需要手动释放不再使用的节点,这让代码比 C++ 简洁。你只需要注意一个容易混淆的点:Java 中 ListNode cur = dummy 复制的是引用而不是对象,所以后续 cur.next = xxx 修改的是 dummy 所指向的那个对象,不是修改 cur 变量本身。这一点和 Python 一样,但不少从 C++ 转来的人会误以为 cur 和 dummy 各持有独立的“指针”。
6.4 多语言实现的复杂度对比
| 语言 | 迭代代码量 | 递归代码量 | 主要注意点 |
|---|---|---|---|
| Python | 12-15 行 | 8-10 行 | 递归深度限制、引用对象 |
| C++ | 15-18 行 | 10-12 行 | 空指针判断、内存释放 |
| Java | 15-17 行 | 10-11 行 | 引用类型、垃圾回收 |
| Go | 15-18 行 | 10-12 行 | nil 指针、切片思维切换到指针思维 |
时间复杂度和空间复杂度的结论不同语言完全一致:迭代法时间 O(m+n)、额外空间 O(1);递归法时间 O(m+n)、额外空间 O(m+n)(系统栈)。
7. 从这道题出发:合并类问题的统一打法
最后一个章节,我想跳出这一道题本身,说说怎么把这道题的方法论迁移到其他题目。因为 Hot100 是一个序列,刷到第 24 题时,你真正的收获不是这道题的答案,而是它给你打下的基础。
7.1 双指针合并思路在有序数组中的迁移
LeetCode 88. 合并两个有序数组就是一个直接变种。它要求把两个有序数组合并到第一个数组里(第一个数组空间足够大),从后往前填充可以避免额外空间。虽然数据结构从链表换成了数组,但“每次取两个序列头部较小的元素”这个核心判断没有变。如果你把链表合并练成了肌肉记忆,再看数组合并就会很快明白为什么从后往前能避免覆盖。
7.2 合并K个有序链表的递归分治思路
LeetCode 23. 合并K个升序链表可以看作是这道题的高阶扩展。朴素思路是每两个合并一次,总共合并 k 次;优化思路是分治,两两合并,最终把“合并两个链表”作为一个子过程调用。如果这一题还没吃透,做第 23 题时会在 merge 部分反复修改 bug。我身边有个朋友刷第 23 题时卡了很久,后来我把第 21 题的虚拟头节点写法讲给他,他换掉旧的复杂写法后,第 23 题瞬间思路清晰了。
7.3 归并排序中的 Merge 操作
链表排序(LeetCode 148)的归并排序版本,核心就是“找到中点、拆成两半、递归排序、合并两个有序链表”。其中合并那一步,正是这题的代码。不夸张地说,链表的归并排序 = 链表中点查找 + 这题的合并代码。你把这题练熟了,等于完成了归并排序最核心的一半工作。
所以在刷题规划上,我的建议是:第 21 题不要只满足于“通过”,而是把迭代法和递归法都写到熟练,然后立刻去做第 23 题和第 148 题,感受一下同一段合并逻辑在不同题目里的位置。这样做一次迁移,比你单独刷十道不相关的题更有收获。
最后分享一个小技巧:我在做任何链表题之前,都会先在纸上画一遍指针移动的过程。画完这三个节点串联的顺序,基本不会写错代码。这一题的逻辑其实很朴素:两个有序链表合并,就是重复“取出头节点较小的那个,接上去”。每次递归或循环,本质上都在做同一件事。想清楚这件事,代码怎么写都不会太差。
