☰
合并K个升序链表全解析:顺序合并、分治与优先队列
2026/10/19 14:23:12 网站建设 项目流程

合并 K 个升序链表这道题,在算法刷题圈里算是“必吃榜”级别的经典了。不管是准备校招、社招,还是单纯想练数据结构,它都是一道绕不过去的坎。它看着就是“合并两个有序链表”的简单升级,但一旦 K 变大,隐藏在背后的时间复杂度和空间复杂度问题就全冒出来了。这篇文章我想彻底拆一遍这道题,把顺序合并、分治合并、优先队列三种思路的来龙去脉、代码细节、以及我实际踩过的坑都讲清楚,争取让你看完能直接上手。

如果你现在正在刷链表题,或者正准备面试,这篇内容应该能帮你省不少事。它适合三种人:第一种是刚学完“合并两个有序链表”,想看看多路归并怎么玩的;第二种是刷题刷到一半,知道要用堆但说不清为什么的;第三种是已经能写出解法,但想在复杂度分析、边界条件上做得更严谨的。下面我们直接开始。

1. 题目到底是什么:先看清输入和输出

1.1 题目描述与示例

题目输入是一个数组,数组里的每个元素都是一个链表的头节点,而且这些链表各自都是升序排列的。目标是把这 K 个升序链表合并成一个新的升序链表,返回新链表的头节点。

举例来说,假设有三个链表:

  • 链表 A:1 -> 4 -> 5
  • 链表 B:1 -> 3 -> 4
  • 链表 C:2 -> 6

合并之后的结果应该是:

1 -> 1 -> 2 -> 3 -> 4 -> 4 -> 5 -> 6

注意这里有一个很容易忽略的细节:题目说的是“升序链表”,而且没有说每个链表长度一样,也没有说 K 是几,更没有说链表里会不会有重复值。所有这些不确定性,恰恰是这道题最容易翻车的地方。

再强调一个点:这个题目输入的是“链表头节点数组”,不是“链表数组后拼在一起”。所以你不能简单地把所有节点摘下来排序,那样虽然也能得到有序结果,但完全违背了链表结构操作的初衷,而且面试官一眼就能看出来你这是投机取巧。

1.2 为什么这道题值得反复做

这道题之所以经典,是因为它一个题目背后挂了三个核心考点:

第一,它考察你对“合并两个有序链表”的熟练度。合并 K 个的基础,其实就是两两合并,所以“合并两个链表”如果写不熟练,后面所有解法都会卡壳。

第二,它考察你对分治思想的理解。把一个大问题拆成若干小问题,每个小问题解决完再合并,这是归并排序的核心思路,放到链表上一样适用。

第三,它考察你对优先队列(堆)的应用。K 路归并在外部排序、流式数据处理里非常常见,堆是解决这类问题的标准工具。

换句话说,你把这道题吃透了,等于同时复习了链表操作、递归分治、堆排序三个高频考点。这也是为什么很多刷题清单把这道题排在“链表专项”的中间位置,前后承接关系非常清楚。

2. 从最简单开始:顺序合并的思路与局限

2.1 顺序合并的推导:两个有序链表合并其实是老熟人

最先想到的思路肯定是最简单的暴力:先让第一个链表和第二个链表合并成一个有序链表,再把结果和第三个链表合并,接着和第四个合并……直到把 K 个链表全部合完。

这个思路不是凭空来的,它建立在“合并两个有序链表”这个基本功上。合并两个有序链表的标准做法是:用两个指针分别指向两个链表的头,谁的值小就先把谁接到结果链表的尾部,然后移动对应指针;如果其中一个链表走完了,直接把另一个链表剩下的部分接上。

顺序合并在代码上几乎是零成本复用,因为你只要写一个mergeTwoLists,然后循环调用就可以了。我在初学这道题的时候,第一反应就是这个,因为它最直白,完全不需要动脑子。

但这里有个问题:你有没有想过,如果 K 很大,第一个链表可能会被反复合并很多次?比如 K = 100,第一个链表就要参与 99 次合并。每次合并都要遍历它一遍,而它的长度可能会因为不断合并而越来越长。这个累加效应,直接导致了顺序合并的时间复杂度非常高。

2.2 顺序合并的时间复杂度为什么是 O(k² n)

我们来算一笔账。假设 K 个链表长度都是 n,总节点数是 K * n。

第一次合并:链表 1 和链表 2 合并,长度是 2n。 第二次合并:结果(长度 2n)和链表 3 合并,长度是 3n。 第三次合并:结果(长度 3n)和链表 4 合并,长度是 4n。 …… 第 K-1 次合并:结果(长度 (K-1)n)和链表 K 合并,长度是 Kn。

这里要注意,第 i 次合并时,两个链表分别是长度为 i*n 的“累计结果”和长度为 n 的新链表,合并过程需要遍历的长度最多是 (i+1)*n。把所有合并次数加起来,总工作量大约是:

2n + 3n + 4n + ... + K n = n * (2 + 3 + ... + K) = n * (K(K+1)/2 - 1) ≈ O(K^2 * n)

这个时间复杂度在 K 比较小的时候还好,但一旦 K 上了几十上百,平方级别的增长就会非常明显。我自己实测过,K = 100、每个链表长度 1000 时,顺序合并比后面要讲的优先队列解法慢了一个数量级,数据量再大一点差距会更夸张。

所以顺序合并适合用来理解题目,但绝对不是面试时最优解的终点。如果面试官顺着问一句“还能不能更快”,你就要立刻切换到下面的思路。

2.3 顺序合并的代码实现

虽然顺序合并不是最优解,但作为起点,代码还是要能快速写出来的。我用 Java 给大家写一个,核心就是复用mergeTwoLists。

public ListNode mergeKLists(ListNode[] lists) { if (lists == null || lists.length == 0) { return null; } ListNode result = null; for (ListNode list : lists) { result = mergeTwoLists(result, list); } return result; } private ListNode mergeTwoLists(ListNode l1, ListNode l2) { ListNode dummy = new ListNode(0); ListNode tail = dummy; while (l1 != null && l2 != null) { if (l1.val <= l2.val) { tail.next = l1; l1 = l1.next; } else { tail.next = l2; l2 = l2.next; } tail = tail.next; } tail.next = (l1 != null) ? l1 : l2; return dummy.next; }

这里有一个小细节:初始result我设置成了null,这样可以完美利用mergeTwoLists(null, list)会直接返回另一条链表的特性。实际上,在上面mergeTwoLists的实现中,l1为null时循环不执行,直接tail.next = l2,返回的就是l2。这个设计让代码非常简洁,不需要额外判断第一次合并。

但注意,这个版本的mergeTwoLists每次都是“原地”修改节点指针,没有申请新节点,这是链表题的标准做法。如果你在合并的时候搞出了很多new ListNode,那不仅浪费空间,还可能破坏原链表结构,一定要避免。

3. 分治合并:把复杂度降到 O(kn log k)

3.1 归并思想如何迁移到链表上

既然顺序合并的缺陷在于“累计结果”被反复遍历太多次,那我们能不能让每个链表被参与合并的次数少一点?这就引出了分治的思路。

分治的核心是:不要一个一个累加,而是两两配对合并。比如有 8 个链表,先让 1 和 2 合并、3 和 4 合并、5 和 6 合并、7 和 8 合并,得到 4 个结果链表;再让这 4 个结果两两合并,得到 2 个;再合并得到 1 个。整个过程是一棵二叉树,从叶子到根。

这样做的好处非常明显:每一轮,所有链表的长度总和差不多是 Kn(合并后总节点数不变),每一轮每个节点只会被“处理”一次。因为合并两个链表时,每个节点都会被比较并通过一次,整个一轮下来,所有节点都被处理了一遍,所以一轮的时间复杂度是 O(Kn)。而树的层数是 log K,所以总时间复杂度就是 O(Kn log K)。

这相比于顺序合并的 O(K^2 n),在 K 很大时是质的飞跃。比如 K = 1000,log K 约等于 10,而 K 是 1000,差距差不多 100 倍。

分治合并没有引入额外的堆结构,空间复杂度是 O(log K)(递归栈),在面试中也是一个很理想的答案。

3.2 分治合并的代码与细节

分治合并的代码实现有几个版本:递归版、迭代版。我建议你先掌握递归版,因为思路最清晰,代码也最短。

public ListNode mergeKLists(ListNode[] lists) { if (lists == null || lists.length == 0) { return null; } return mergeRange(lists, 0, lists.length - 1); } private ListNode mergeRange(ListNode[] lists, int left, int right) { if (left == right) { return lists[left]; } if (left > right) { return null; } int mid = left + (right - left) / 2; ListNode l1 = mergeRange(lists, left, mid); ListNode l2 = mergeRange(lists, mid + 1, right); return mergeTwoLists(l1, l2); } private ListNode mergeTwoLists(ListNode l1, ListNode l2) { ListNode dummy = new ListNode(0); ListNode tail = dummy; while (l1 != null && l2 != null) { if (l1.val <= l2.val) { tail.next = l1; l1 = l1.next; } else { tail.next = l2; l2 = l2.next; } tail = tail.next; } tail.next = (l1 != null) ? l1 : l2; return dummy.next; }

需要注意几个细节:

  • mid的写法:用left + (right - left) / 2,不要写成(left + right) / 2。虽然这道题不会因为你写后者就越界,因为left + right在数组长度很大时才可能溢出。这是一个良好的编码习惯,很多面试官会看这种细节。
  • 递归边界:当left == right时,说明只有一个链表,直接返回它;如果left > right,说明区间为空,返回null。这两个边界的顺序,建议先判断left == right再判断left > right,逻辑更清晰。
  • mergeTwoLists必须复用第 2 节里那个实现。这里我特别提醒一下,分治合并最后还是要用到两两合并,所以“合并两个有序链表”的代码一定要闭着眼睛写出来。

3.3 为什么分治的代码要用 mid 切分而不是一个个往后挪

有朋友可能会问:我能不能用递归return mergeTwoLists(lists[0], mergeKLists(子数组))来做分治?这样也能把规模缩小。但这个思路其实还是“顺序合并”,只是换了个递归的壳子。它不是真正意义上的分治,因为第一个链表仍然会被合并很多次,时间复杂度并没有降下来。

真正的分治一定要让两个子树规模大致相当,这样每一轮参与合并的链表数量才能减半。如果你每次只把第一个摘出来,剩下的递归合并,那递归深度是 K,每一层的工作量是 O(Kn),总复杂度还是 O(K^2 n),等于白折腾。

这也是为什么分治写法的mid是整个算法的灵魂。没有 mid,你就没有把问题切成两半,也就没有 log K 的效果。我在面试别人的时候,看到有候选人写“从左往右递归合并”,我会直接指出这是顺序合并非分治,因为复杂度并没有变。

如果你担心递归深度(比如 K 很大时递归栈会不会爆),可以改成迭代式分治:每次把相邻两个链表两两合并,生成新一轮的链表数组,直到只剩一个。迭代版的思路也很清晰,类似自底向上的归并。不过在我实际面试经验里,递归版已经完全够用,因为 K 一般不会特别大,而且递归栈深度是 log K,非常浅。

4. 优先队列(堆)解法:面试官最常追问的版本

4.1 堆解法为什么是“多路归并”的标准答案

如果你说到分治解,面试官大概率会继续问:“还有其他解法吗?”这时候优先队列就是另一个标准答案。

堆解法的思路很直接:既然我们有 K 个升序链表,每个链表的头节点是当前这个链表里最小的元素,那么全局最小的节点一定在这 K 个头节点当中。我们可以用一个最小堆维护这 K 个“候选头节点”,每次从堆里弹出最小的节点,接到结果链表的尾部,然后把这个节点的下一个节点(如果存在)重新放入堆中。重复这个过程,直到堆为空。

这个思路其实就是多路归并。想象你在看 K 部纪录片,每部都有一盘带子,你要把所有带子的内容按时间顺序合并成一部。你只需要关注每盘带子当前播放的那一帧,从中挑时间最早的。堆就是帮你快速找到“最早”的工具。

为什么堆的效率高?因为最小堆的插入和删除操作时间复杂度都是 O(log K)。总共有 K * n 个节点要被处理,每个节点出堆一次、可能入堆一次,所以总时间复杂度是 O(Kn log K),和分治合并一样优秀。但是堆解法不需要递归,迭代过程非常直接,而且空间复杂度是 O(K)(堆的大小最多是 K),在 K 不太大时这个空间开销完全可以接受。

4.2 堆解法代码(Java / Python)

Java 版本我一般这么写:

public ListNode mergeKLists(ListNode[] lists) { if (lists == null || lists.length == 0) { return null; } PriorityQueue<ListNode> heap = new PriorityQueue<>( (a, b) -> a.val - b.val ); for (ListNode node : lists) { if (node != null) { heap.offer(node); } } ListNode dummy = new ListNode(0); ListNode tail = dummy; while (!heap.isEmpty()) { ListNode minNode = heap.poll(); tail.next = minNode; tail = tail.next; if (minNode.next != null) { heap.offer(minNode.next); } } return dummy.next; }

Python 版本如下:

import heapq def mergeKLists(lists): heap = [] # 为每个链表的头节点建立 (值, 索引, 节点) 的三元组 for i, node in enumerate(lists): if node: heapq.heappush(heap, (node.val, i, node)) dummy = ListNode(0) tail = dummy while heap: val, i, node = heapq.heappop(heap) tail.next = node tail = tail.next if node.next: heapq.heappush(heap, (node.val, i, node.next)) return dummy.next

Python 里有个小坑:heapq比较元组时,如果第一个元素(值)相同,会比较第二个元素。如果你只存(node.val, node),当两个节点的值相等时,Python 会尝试比较第二个元素node。但ListNode对象本身没有定义比较方法,就会报TypeError。所以必须在中间加一个不会重复的索引,比如当前链表在数组中的位置i,保证任意两个元素的前两个字段不会完全一样。这一点我在面试中见到很多人栽过,网上搜“leetCode 23 python heap”也能看到不少相关报错讨论。

Java 的PriorityQueue则不会遇到这个问题,因为比较器是你自己定义的(a, b) -> a.val - b.val,它只比较 val,不比较对象本身。但这里也有一个风险:如果你用a.val - b.val作为比较器,当 val 是很大的整数(接近Integer.MAX_VALUE)且 a 是正数、b 是负数时,差值可能溢出,导致比较结果错误。更稳妥的写法是Integer.compare(a.val, b.val)。这是一个纯粹防御性的细节,但在 LeetCode 的测试数据里一般不会触发,只是写代码的习惯要养好。

4.3 边界条件处理:空链表、自定义比较器、堆中存节点还是存数组

堆解法看着简单,实际写的时候要处理几个边界条件,少了任何一个都可能直接报错或者死循环。

第一个边界:输入数组为空,或者数组中所有链表都是空的。我的代码开头已经判断了lists == null || lists.length == 0,这只是第一步。你还需要在初始化堆的时候跳过空链表,因为空链表头节点是null,如果直接入堆,后面poll()出来一个null,再访问.next就会空指针。所以入堆前必须if (node != null)。

第二个边界:堆中存什么。我可以存ListNode,也可以存一个包含值和节点引用的数组。如果语言支持自定义比较器,我倾向于直接存节点,因为代码更简洁。但如果用 Python 的heapq,就必须存元组并带上索引。如果你用的语言没有内置堆,比如 C++,你需要自己定义比较结构体,或者在 priority_queue 里存pair<int, ListNode*>,这时也得考虑值相等的情况。核心原则就是:堆里每个元素必须能方便地拿到“节点的值”和“节点的下一个指针”。

第三个边界:弹出节点后,如何处理它的后继。必须判断minNode.next != null,只有非空才重新入堆。如果不加这个判断,最后几个节点会出现null入堆,导致后面比较器或取值出错。

第四个边界(容易被忽略):堆中会不会出现“链表断裂”的情况?不会,因为我们弹出的是链表的一个节点,我们把它接到结果链表尾部后,保留它的.next作为该链表的下一个候选节点。这个.next指针在弹出前一直没被修改,所以链表结构没有被破坏。等你下次把某个节点出堆时,它的next仍然指向它原来的后继,整个链表链条没有被切断。

还有一个小细节:如果 K 个链表中有很多节点值相同,堆解法与分治解法的结果不一定完全相同,但“合并后的链表”只要满足全局升序即可,题目不要求保留原链表之间的相对顺序(当然通常不会破坏稳定性)。这里不必过度纠结。

5. 三种解法的对比与复杂度分析

5.1 时间与空间复杂度对比表

为了让你一眼看清三种解法的差异,我把核心指标整理成一张表。注意这里假设每个链表平均长度为 n,链表总数为 K。

解法时间复杂度空间复杂度适用场景
顺序合并O(K²·n)O(1) 额外空间(不算输出链表)K 很小,比如 K ≤ 10
分治合并O(K·n·logK)O(logK) 递归栈通用,无额外堆结构,面试优选
优先队列O(K·n·logK)O(K) 堆空间K 较大,需要迭代实现,工程常用

我多说一句:这里的空间复杂度没有把“结果链表本身”的空间算进去,因为通常我们直接复用原链表的节点,没有新建节点,所以额外空间主要是递归栈或堆。

在实际时间表现上,分治合并和优先队列在 K 和 n 中等规模时差距不大。但在我本地测试中,分治合并通常稍微快一丢丢,因为它不需要维护堆结构,每次合并两个有序链表是线性扫描,缓存局部性更好。优先队列的优势是代码更短、不用递归,而且在处理“动态增加新链表”的场景下扩展性更好。

5.2 实际面试/笔试中怎么选

如果面试官没有要求具体方法,我建议优先写分治合并。原因有三个:

第一,它不依赖语言内置堆,也不涉及自定义比较器的坑,代码逻辑非常扎实,面试官一看就能明白你的思路。

第二,空间复杂度 O(logK) 比堆的 O(K) 更优秀。如果面试官追问空间复杂度,分治合并更有优势。

第三,分治合并思路和归并排序一脉相承,面试官常常会顺着这个问题和你聊归并排序、外部排序等扩展知识,你提前准备到这一步会显得知识体系很连贯。

如果面试官明确想要“多路归并”方案,或者你写代码时觉得递归容易出错,那就选优先队列。优先队列解法也是很多标准教科书里推荐的解法,同样是正确答案。

我个人还有一个小建议:刷题阶段两种解法都要练到能闭眼写出来。因为笔试平台有时候会随机限制某些库,或者面试官会故意让你用不熟悉的语言现场写堆,如果没有提前练过,现场很容易翻车。

6. 常见问题与排查技巧实录

6.1 空指针错误

最常见的空指针就是访问null.next。出现这种错误,优先检查三处:

  • mergeTwoLists循环里tail.next = (l1 != null) ? l1 : l2是否写成了直接l1?
  • 堆解法里初始化堆时是否忘了跳过null链表?
  • 分治递归里left > right时返回的是不是null?如果是,调用方的mergeTwoLists能不能正确处理null参数?

我自己刷题时,空指针九成都是第二处出的问题。尤其是输入[[]]这种只有一个空链表的情况,很多人的代码会在heap.offer(node)时直接炸掉。

6.2 死循环或结果变短

死循环一般出现在mergeTwoLists的 while 条件写成了while (l1 != null || l2 != null)。这样循环内部如果有一个链表已经走完,你还去访问它的val,就会空指针;如果侥幸没空指针,你就没有移动该指针,就会死循环。正确的条件必须是while (l1 != null && l2 != null),退出循环后再处理剩余链表。

结果变短则通常是你在合并过程中推进了错误指针,导致部分节点丢失。比如tail没有在每次接入节点后后移,造成后面的节点覆盖前面的节点。写完代码后建议用一个三节点的小例子在纸上模拟一遍,比如1->3和2->4,能有效发现这种低级错误。

6.3 优先队列的 Comparator 写错导致类型转换异常

在 Python 中,如果不带索引直接比较ListNode,会报TypeError: '<' not supported between instances of 'ListNode' and 'ListNode'。在 Java 中比较器返回0时不会报错,但如果你写了return a.val > b.val ? 1 : -1;,当两个值相等时永远返回-1,会让堆认为所有元素都不相等,逻辑上虽然不至于出错,但会破坏堆的稳定性。更严谨的写法是return Integer.compare(a.val, b.val);。

我在 LeetCode 讨论区看到过很多次“Java 优先队列解法报错”的帖子,点进去一看,全是比较器写成了(a, b) -> a.val - b.val,而且链表的节点值可能为负,其实没问题;真正的问题是他们把比较器写成了(a, b) -> a.val - b.val同时 a 或 b 为 null,堆里混入了 null。所以再次强调:入堆之前判断空。

6.4 分治里“K 个链表”的合并顺序对结果有影响吗

没有影响。只要是稳定有序合并,并且最终结果保持全局升序,合并顺序不改变最终链表的节点集合和值顺序。但分治顺序会影响中间过程的时间和递归栈深度。

有朋友会担心:我把链表数组左半部分和右半部分分别合并,最后再合并两个结果,这个顺序会不会导致结果和优先队列的结果不一致?不会。因为所有元素的值是固定的,只要每次合并时采用“取两个链表中较小节点”的策略,最终结果在值层面一定是唯一的升序序列。如果有相等值,不同的合并顺序可能产生不同排列,但题目只要求升序,不要求稳定,所以没问题。

如果你真遇到了提交结果不对的情况,先检查mergeTwoLists是否使用了<=还是<。题目要求升序,所以用<=是安全的。用<也不会错,因为相等时你取哪个都行,但取值范围涉及“值相等”时,两种写法结果顺序不同,不影响判定。

我的实操体会

这道题我刷过不下十遍,每次重刷都能感觉到自己有新的理解。最开始我只能写出顺序合并,后来学会了分治合并和堆解法,再到后来写代码前会把三种思路的复杂度先默写一遍,确保自己不是“背代码”。我给你的建议是:刷完这道题,立刻去把“合并两个有序链表”也重写一遍,再把这道题改编成“合并 K 个无序链表”(先排序再合并),甚至试试“合并 K 个有序数组”。把一道题吃透,比盲目刷十道题有用得多。

最后分享一个小技巧:无论是分治还是堆解法,都可以先把mergeTwoLists单独写成工具函数,甚至可以本地单元测试。因为这道题的复杂度分析和边界条件,90% 都集中在mergeTwoLists的正确性上。这个函数写稳了,整个题的框架也就稳了。

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

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

立即咨询