☰
合并两个排序链表:归并排序核心步骤与指针操作详解
2026/10/22 13:43:34 网站建设 项目流程

合并两个排序的链表,这道题我估计出镜率能排进算法面试前五。搞过面试的人都有这感觉:第一眼看上去简单,真动手写起来,头节点丢了、尾指针忘移、空链表没判,翻车的花样多得是。它之所以被反复拿来考,是因为背后站着的是一整个家族的知识——归并排序的merge步骤、有序结构合并的通用模式、指针操作的基本功。这篇文章我会把这道题从思路到代码、从边界到变体完整拆开,结合我自己刷题和面试时的复盘,给出一份能直接拿来回练的参考。无论是准备校招的同学,还是想巩固链表基础的人,看完应该都能把这道题吃透。

1. 题目拆解:这道经典题到底在考什么

1.1 本质是一步归并排序中的 merge

先看清楚题目在干嘛:输入两条已经各自排好序的链表,要求我们把它们合并成一条仍然有序的链表。很多人只把它当成一个“链表操作题”,但它真正的身份,是归并排序里那个 merge 步骤——所谓归并排序,无非是先拆后合,拆到单元素,再两两合起来。而这个“合”的动作,就是今天这道题。

为什么这个动作重要?因为整个归并排序的时间复杂度是 O(n log n),其中每一层的合并操作都是线性的 O(n),所以 merge 的写法直接决定了排序的效率。如果你合并两个链表写成了“每次从剩余节点里找最小”,那就是 O(n²) 的选排思路,面试官一眼就能看穿你只是背了题,没有理解归并的真正含义。

我用一个生活化的方式给你描述:想象你手里有两副已经按从小到大排列好的扑克牌,现在要把它们合成一副新牌。你会怎么做?肯定是盯着两副牌的最上面,谁小就先抽谁,抽完把那张牌拿掉,露出下一张,继续比。这就是本题的全部逻辑。

1.2 一道题背后隐藏的三个考点

面试官选这道题,其实想从三个维度摸底。

第一个是指针操作。链表不像数组能随机访问,操作全靠 next 指针串联。合并过程中涉及“当前指向哪”“谁是新尾巴”“下一个节点有没有被覆盖”这些细节,写不好就会断链、丢节点、成环。这部分考察的是手上功夫。

第二个是边界思维。算法题里最容易挂的地方就是边界:一条链表为空怎么办?两条都为空呢?长度不等时最后多出来的半截怎么处理?能想到这些的人,说明写代码时脑子里带着一张完整的状态表,而不是只盯着正常路径走一遍。

第三个是复杂度意识。能不能说出时间是 O(m+n) 而不是 O(m×n)?能不能意识到迭代法空间是 O(1)、递归法空间是 O(m+n)?这种对“成本和收益”的敏感,是区分“会写代码”和“会设计代码”的分水岭。

所以你看,这道题表面上是一道简单题,实际上把指针基本功、边界思维、复杂度分析全考到了。这也是为什么不管面的是后端、客户端还是数据岗,它都高频出现。

2. 迭代合并法:双指针带哑节点,一条路走到黑

2.1 为什么普遍推荐先用哑节点

我最早写这道题的时候,没有用哑节点,写出来特别别扭。当时的思路是:先判断两个链表头谁小,把它当作结果链表的头,然后再循环去接后面的节点。逻辑上没问题,但代码里塞满了“如果是第一次拼接”“如果头还没赋值”这种分支,又丑又容易漏。

后来才明白,这种场景下应该用一个哑节点(dummy node)来坚守阵地。它的作用就是占一个虚拟位置,不管合并过程中选中的第一个节点来自 l1 还是 l2,我们都不需要专门记录“谁是结果的头”,只需要在循环结束后返回 dummy.next。这样处理,代码的行数和心智负担都低了一个档次。

你可以把哑节点理解成打地基时先竖在地上的那根定位标杆。房子盖好之后,从标杆旁边量出去,一定能找到大门的正确位置。它本身不参与最终结果,但让整个施工过程有了一个永远不变的参照点。

2.2 迭代合并的完整实现

先看 C++ 版本,这也是面试中最常见的写法:

ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) { ListNode dummy(0); // 哑节点,不参与最终结果 ListNode* tail = &dummy; // 尾指针,始终指向已合并链表的最后一个节点 while (l1 != nullptr && l2 != nullptr) { if (l1->val <= l2->val) { tail->next = l1; l1 = l1->next; } else { tail->next = l2; l2 = l2->next; } tail = tail->next; // 移动尾指针,让新节点成为链表尾部 } // 其中一条链表走完了,把另一条剩余部分直接接上 tail->next = (l1 != nullptr) ? l1 : l2; return dummy.next; }

如果你平时用 Python 刷题,对照版本是这样的:

def merge_two_lists(l1, l2): dummy = ListNode(0) tail = dummy while l1 and l2: if l1.val <= l2.val: tail.next = l1 l1 = l1.next else: tail.next = l2 l2 = l2.next tail = tail.next tail.next = l1 or l2 return dummy.next

我来一行一行解释关键点。

while (l1 != nullptr && l2 != nullptr)这个循环条件的语义是:只有两条链表都还有节点时,才需要比较大小。只要有一条走完了,就不需要再比了,直接把另外那条剩下的部分接上去就好。

循环体里的比较很直接:谁的值小就接谁。接到 tail 后面,然后把那条链表的头指针向后移动一位,表示这个节点已经被“消耗”掉了。这两步顺序很关键,tail->next = l1之后再执行l1 = l1->next,是安全的,因为此时 l1 的下一跳已经被记录在节点自身的 next 里了。

循环结束后那句tail->next = (l1 != nullptr) ? l1 : l2是整个算法的点睛之笔。很多初学者会在这里写一个 while 循环,把剩余节点一个个搬运,其实完全没必要——剩下的半截链表天然已经是有序的,直接把当前尾巴指向它的头部即可。这一步把 O(n) 的操作缩短成了 O(1)。

2.3 复杂度与空间优势

时间上,每轮循环处理一个节点,两个链表的节点总数是 m+n,所以总时间是 O(m+n)。空间上,整个合并过程没有新建任何 ListNode 节点,只是通过调整 next 指针把现有节点串起来,只用了几个指针变量,所以空间是 O(1)。

这个复杂度结论在面试里要主动说出来。面试官问完思路,你顺手补一句“时间 O(m+n),空间 O(1)”,比等对方追问要加分很多。它可以证明你有意识地在评估自己的方案,而不是碰巧写对了一段代码。

3. 递归合并法:反向堆积,栈里走出顺序

3.1 递归函数的返回值就是合并后的头

迭代法好理解,但很多人在面试中更喜欢用递归写法,因为代码更短,而且思路一旦想通,几乎不会有指针操作的笔误。核心思想就一句话:定义一个函数,它接收两条链表的头节点,返回合并后链表的头节点。

递归版本的 C++ 代码:

ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) { if (l1 == nullptr) return l2; if (l2 == nullptr) return l1; if (l1->val <= l2->val) { l1->next = mergeTwoLists(l1->next, l2); return l1; } else { l2->next = mergeTwoLists(l1, l2->next); return l2; } }

Python 版本也给你:

def merge_two_lists(l1, l2): if not l1: return l2 if not l2: return l1 if l1.val <= l2.val: l1.next = merge_two_lists(l1.next, l2) return l1 else: l2.next = merge_two_lists(l1, l2.next) return l2

这个写法需要想明白一件事:我们比较 l1 和 l2 的头节点,值较小的那个,一定就是合并后链表的头节点。当我确定头节点是 l1 时,剩下的问题就变成了“合并 l1.next 和 l2”,这刚好又是同一个函数要做的事情。于是递归调用自然展开,每一层都解决一个“谁当头”的子问题,最终把所有节点串起来。

注意两行递归基的顺序,先判断 l1 为空返回 l2,再判断 l2 为空返回 l1。如果两个都为空,其实返回 l2 是个空指针,也能得到正确结果。顺序上没有严格限制,但建议把 l1 为空放前面,逻辑更对称,也不容易漏判。

3.2 用一组小例子看递归展开

为了让你彻底搞懂递归是怎么逐步拼接的,我们用一个最小例子演示:l1 是 1 → 3,l2 是 2 → 4。

第一次调用,l1.val=1,l2.val=2,1 更小,所以决定头节点是 l1,需要递归解决“合并 l1.next(也就是 3)和 l2(2→4)”。

第二次调用,比较 3 和 2,2 更小,决定头节点是 l2 的第一个节点,需要递归解决“合并 l1(3)和 l2.next(4)”。

第三次调用,比较 3 和 4,3 更小,决定头节点是 l1 当前节点 3,需要递归解决“合并 l1.next(空)和 l2(4)”。

第四次调用,l1 为空,直接返回 l2(4)。这一层结束,把返回值接到 3 的 next 上,得到 3 → 4。再往上一层,把 3 → 4 接到 2 的 next 上,得到 2 → 3 → 4。再往最上层,把 2 → 3 → 4 接到 1 的 next 上,最终得到 1 → 2 → 3 → 4。

可以这么理解:递归是“先往下探,探到底再回头拼接”。每一层都在等更小规模的子问题先解决,子问题返回的头节点拼到自己后面。一层层弹栈,链表也就一层层接好了。

3.3 递归的空间代价与选型判断

递归写法最大的代价是调用栈空间。每递归一层,系统就要压一个栈帧,深度最大会达到 m+n。所以这个版本的空间复杂度是 O(m+n),而不是迭代法的 O(1)。

面试中如果被问到“递归和迭代怎么选”,可以这样回应:如果链表长度不大,递归写法简洁、可读性好,适合快速表达思路;如果链表可能非常长,比如几十万节点,递归可能导致栈溢出,工程上更倾向于用迭代法。

我个人刷题时的经验是:两种解法都要能闭着眼写出来。因为面试官经常在你写完迭代法之后追问一句“还能用递归试试吗”,如果你只会其中一种,场面会比较尴尬。背熟两种写法,本质上就是给自己加了一层保险。

4. 用一组数据走一遍完整合并流程

4.1 手把手动一轮:1→3→5 与 2→4→6

光看代码不如亲手推一遍。我选两组有代表性的数据:l1 是 1 → 3 → 5,l2 是 2 → 4 → 6。它们长度相同,且节点值交错,能完整展示每一轮的比较和指针移动。

轮次当前 l1当前 l2被选中的节点当前结果链
11211
23221 → 2
33431 → 2 → 3
45441 → 2 → 3 → 4
55651 → 2 → 3 → 4 → 5
6空661 → 2 → 3 → 4 → 5 → 6

注意看第 5 轮结束后,l1 已经走到空,循环条件不再成立。此时 l2 还剩下一个 6,按照迭代法的收尾逻辑,直接让tail->next = l2,整个合并就此完成。整个过程一共比了 5 次,最后一次 6 是被“直接接上”的,没有再走比较逻辑。

4.2 为什么这个流程能保证结果天然有序

推完这一轮,你应该能感受到一个关键规律:每一轮被选中的节点,都是当前两条链表头部中较小的那个。而两条链表的头部,分别是各自剩余部分的最小值。两个“当前最小”再取一个较小的,得到的一定就是所有剩余节点里的全局最小。

这个性质保证了我们不需要回头调整,不需要像插入排序那样反复挪位,只需要一直往结果链尾部追加节点就行。这也是“多路归并”能够线性完成的核心原因——每次取最小,取完就排除,全局有序自然成立。

我以前面试别人时,经常让候选人把指针移动过程口头走一遍。能流畅说清楚“每次都是当前 l1 和 l2 的头部在比大小”的人,往往是真的理解了;只会背代码的人,走到第 3 轮就开始语无伦次。所以你也值得花几分钟亲手推一次,而不是直接抄答案。

5. 边界条件与测试用例:防不胜防的空指针和尾节点

5.1 边界条件一网打尽

很多代码在正常输入下跑得顺顺利利,一到边界就出问题。合并排序链表这道题的边界其实不算多,但每一个都值得单独验证。我整理了一张清单,可以直接当测试用例用:

用例编号输入预期结果考的是什么
1两条都为空空链表对空指针的判断
2l1 为空,l2 非空原样返回 l2递归基/收尾逻辑
3l1 只有单节点,l2 也只有单节点两个节点按序拼接最小规模的完整合并
4两条长度不等短链先耗尽,剩余长链被接上尾部半截的处理
5所有节点值都相同任意顺序合并,但不应丢节点相等值的稳定性
6包含负数从小到大正常排序比较符号是否正确
7其中一条只有一个大值节点大值节点排在最后头节点选择是否正确

以用例 4 为例,这个场景最容易暴露问题。如果你在循环结束后写的是“把剩余节点逐个搬过去”的代码,虽然也能得到正确结果,但多写了好几行,还可能漏掉最后一次指针移动。而正确做法就是那句tail->next = l1 ? l1 : l2,一行解决。

5.2 针对边界条件的自测顺序

写完之后怎么快速自测?我建议按下面这个顺序跑,每一组用例都是在前一组基础上加一点复杂度:

先跑空链表和另一个空链表,确认能正常返回空;再跑空链表和单个节点,确认能直接返回那条非空链;然后跑两条单节点链,确认能拼出两个节点;接着跑一条长链一条短链,确认短链耗完时尾部能接上;最后跑全相等值和带负数的用例,确认排序稳定且比较符号没写反。

这一套跑下来,代码里大部分隐藏问题都会暴露。我见过不少人面试时写完代码就交卷,结果一跑测试用例,第一条空链表就挂了。代码在正常情况下跑通不等于正确,边界和异常才是真正拉开差距的地方。

5.3 边界处理背后的设计观

边界条件不是靠背用例解决的,而是靠想清楚“当输入状态发生突变时,这段逻辑能不能自然收尾”。比如迭代法里循环条件是l1 && l2,当其中一条为空,自然跳出;递归法里递归基if (l1 == nullptr) return l2,直接交接剩余链表。这些设计都不是硬凑的,而是把“空”当作一种合法的输入状态来对待。

如果你写代码时,脑子里有一个对链表状态的全局认识——知道每个指针在每轮循环前和循环后分别指向哪里——那么边界题基本不会难倒你。这也是为什么我一直建议:不要光看代码,要在纸上画链表、画指针。

6. 面试变体与进阶:从两个链表到 K 个链表的归并

6.1 K 个有序链表的合并:从两个到多个

面试官问你“两个有序链表合并”之后,最喜欢加问的一句话是:“那如果给你 K 个有序链表呢?”这就是经典的合并 K 个排序链表问题。

最朴素的思路是两两合并:先合并前两个,再和第三个合并,以此类推。假设总节点数是 N,这种做法的复杂度是 O(K × N),因为每轮合并都要扫一遍已经累积出来的长链表。虽然也能过,但不是最优。

更好的做法是用一个最小堆来维护 K 条链表的当前头节点。每次从堆里弹出最小的节点,把它接入结果链,然后把这个节点的 next 放进堆里。由于堆的插入和删除都是 O(log K),而每个节点恰好进堆出堆一次,总时间复杂度是 O(N log K),空间复杂度是 O(K)。节点数量大、K 也不小的时候,这个优势非常明显。伪代码思路如下:

import heapq def merge_k_lists(lists): dummy = ListNode(0) tail = dummy heap = [] for i, head in enumerate(lists): if head: heapq.heappush(heap, (head.val, i, head)) while heap: val, i, node = heapq.heappop(heap) tail.next = node tail = tail.next if node.next: heapq.heappush(heap, (node.next.val, i, node.next)) return dummy.next

这段代码里堆元素存了 (节点值, 链表编号, 节点本身),链表编号的作用是避免两个节点的值相同时,堆去比较 ListNode 对象本身而报错。这个小细节我踩过一次,特意提醒你。

6.2 两种变体思路:反向合并与原地拷贝

除了 K 路合并,还有两种常见变体也值得心里有数。

第一种是反向合并。如果题目给的是两条按降序排列的链表,要求合并成升序,有两条路可走:一是先各自反转,再走标准的升序合并;二是调整比较方向,每次取较大值,从头构造降序链。后者的好处是省掉两次反转的时间,但思路没有前者直观,面试时建议先说出最简单的方案,再补充优化。

第二种是“不允许修改原链表”。标准解法是直接复用原节点,但如果题目要求合并后的新链表和原链表完全独立,就必须为每个节点申请新的空间,再依次拼接。这种变体下空间复杂度就不能再说 O(1) 了,而是 O(m+n)。所以拿到题目先确认清楚“能否修改原链表”,再动手,能避免很多无用功。

6.3 变体题背后的通用套路

把二路合并、K 路合并、反向合并放在一起看,你会发现它们的核心骨架都是一样的:双指针(或多指针)扫描,每次取当前最小的节点,然后移动对应指针。变的只是比较的维度、路的数量,或者“是否允许动原数据”的限制。

这就是刷题的意义所在——不是背下每道题的答案,而是抓住模式。你吃透了“合并两个排序链表”这个最小模型,后面遇到跳表合并、有序数组归并、外排序中的多路归并,本质上都能迅速联想到同一套方法。这个迁移能力,才是面试官真正想看到的。

7. 常见错误与排查心得

7.1 高频翻车现场

我在面试候选人和自己刷题时,最常看到几个错误,一个个说。

第一个是把循环条件写成 l1 || l2。这个 bug 很隐蔽:循环体内总有一个指针已经为空,却还在访问它的 val,立刻触发空指针异常。正确写法是l1 && l2,确保比较的两个节点都存在。

第二个是忘了移动 tail 指针。有人写完tail->next = l1之后,只移动了 l1,没移动 tail,下一轮又往同一个位置接节点,结果链表就断了,最终只留下最后一个节点。这个错误排查起来很费劲,因为代码看着好像没什么大问题。

第三个是返回值写错。迭代法返回的是dummy.next,结果有人返回了tail或者dummy。返回 tail 会得到整个链表的尾节点,返回 dummy 则会把那个虚拟节点也带进结果里。这两种都是致命错误。

第四个是递归基顺序写反或漏写。递归 if 条件写错,比如先访问l1->val而不判断 l1 是否为空,栈就会一路压到溢出。判断的顺序一定得是“先判空,再取值”。

7.2 排查清单速查表

如果你写完代码发现结果不对,可以直接对照这张表定位问题:

现象可能原因解决方式
程序死循环tail 没有移动,或移动了原链表头导致指针无法前进检查循环体最后是否执行 tail = tail->next
结果少了节点提前覆盖了节点的 next,导致部分节点失联拼接前先保存下一个节点,或严格按 先接再移 的顺序
空指针异常循环条件写成 l1
输出链表不是有序比较符号写反,或者拼接的是错误节点用 1→3 和 2→4 这种数据手动走一遍
返回结果为空返回了 dummy 而不是 dummy.next确认最终返回的是哑节点的下一跳
递归栈溢出递归基缺失,或两个链表都非常长补全判空逻辑,长链表场景改用迭代法

7.3 我踩过的坑和心得

按我自己的实战经验,这道题想一次写对,关键不是手速,而是写前的思路整理。我一般在面试时会在白板角落画一条最短的示例链表,把 l1、l2、tail 三个指针用不同颜色标出来,然后口头推一轮,再正式写代码。这个动作看起来简单,但它能把脑子里的逻辑固定下来,而不是边写边想,那样最容易出低级错误。

另外还要提醒一点:拿到题先确认清楚约束。面试官可能默认允许修改原链表,也可能期望你新建节点,还有可能要求结果链表保持原链表节点的先后相对顺序。这些信息直接决定你能不能复用原节点、该用<=还是<、返回值怎么处理。我见过太多候选人一上来就默认为“可以改原链表”,结果被追问“如果我不允许呢”就乱了阵脚。

最后分享一个我觉得很有效的练习方式:把代码写好之后,故意构造几个极端输入,比如一条空链一条长链、两条几乎完全相同的链、两条长度相差悬殊的链,逐个跑一遍。每次跑之前先写下预期结果,跑完再对比。这样重复几轮之后,边界条件的敏感度会明显提升,以后再遇到同类问题,第一反应就不再是“赶紧写循环”,而是“先想清楚状态和边界”。这道题虽然基础,但能把基础题做出稳定感和确信感,才是面试状态最好的表现。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询