我第一次把 LeetCode hot100 刷到“合并K个升序链表”这题时,心里想的是:合并两个升序链表我闭眼能写,K 个不就是套个循环吗?
结果这个“不就是”让我在模拟面试里差点当场翻车。这道题被标成困难,不是因为链表反转那种手速题,也不是因为边界条件有多隐蔽,而是它逼着你真正想清楚一件事:当多条有序数据流同时往前推进时,复杂度到底被谁吃掉了。
这道题在力扣里是第 23 题,属于 hot100 里链表题型的“分水岭”。合并两个有序链表,大部分人都会;合并 K 个,就需要你至少理解分治、堆、归并排序思想里的某一种,并且能说清楚它们之间的复杂度差异。它适合正在刷 hot100 的中期选手,也适合准备面试时想补一轮算法底层逻辑的人。这篇不是把官方题解抄一遍,而是把我自己从“背答案”到“敢跟面试官聊方案”的完整思路写下来。
1. 这道题到底在考什么:从“合并两个”到“合并K个”的真实差距
1.1 面试场景里最常见的错误答案
先还原一个很常见的现场。
面试官问:给你 K 个升序链表,怎么合并成一条升序链表?
很多人的第一反应是:“我会写 mergeTwoLists,那我在外面套一层循环,从左到右一个一个合并进去不就行了?”
这个答案能不能跑?能。但在面试官眼里,它只是把“两个链表合并”机械地复制粘贴了 K 次,没有体现出你理解“为什么 K 会让问题变复杂”。
这里的关键差异是:合并两个链表时,你只需要一个指针指向当前比较位置;但合并 K 个链表时,每一轮你都要从 K 个“当前头节点”里挑出最小的那个。如果每次都用线性扫描找最小,找一次是 O(K),总共有 N 个节点要挑,整体就是 O(NK)。更麻烦的是,这里的 N 是所有链表节点总数,如果每个链表平均长度是 n,那么 K 个链表就有 N = K*n 个节点,复杂度会变成 O(K²n),K 一旦上百,性能肉眼可见地崩。
所以这道题真正考的,不是你会不会操作链表指针,而是你怎么把“从 K 个候选里反复找最小”这个动作,从 O(K) 优化到 O(log K)。理解了这一点,困难题的门槛就迈过去一半了。
1.2 为什么说这是多路归并的经典雏形
如果只做题,你可能觉得它是一道链表题;但如果你接触过真实的后端数据任务,会发现这个模型特别眼熟。
想象一下,你有 100 个日志文件,每个文件内部按时间戳排好序,现在要把 100 个文件合并成一份全局有序的日志。你不可能把 100 个文件一次性全部读进内存,因为内存摆不下。你只能给每个文件开一个“当前光标”,每次从所有光标指向的那一行里挑时间戳最小的输出,然后把这个文件的光标往后挪一格。
这和合并 K 个链表在逻辑上一模一样:链表头就是文件光标,节点的 next 就是读取下一行。那个“从 K 个文件当前行里找最小”的操作,就是多路归并里最核心的动作。你在 LeetCode 上写的这个 PriorityQueue 解法,本质上就是一个内存版的多路归并器。很多人刷完这题觉得没什么用,其实是因为没把它跟真实场景挂上钩。
1.3 先给准备刷题的人一个路线参考
我对这道题的推荐学习顺序是这样的:先写暴力收集排序,确保思路通;再写顺序两两合并,强迫自己算一次复杂度;然后写分治合并,感受归并思想的复用;最后写最小堆,理解工程中最顺手的实现。
这个顺序不是从易到难排的,是从“依赖知识”排的。暴力解法只需要知道链表遍历;顺序合并只需要会 mergeTwoLists;分治需要一点点递归感觉;堆解法需要理解优先队列的特性。大部分人卡住,是因为一上来就奔着堆解法,结果堆是写出来了,面试官问一句“为什么空间是 O(K)”就愣住。建议按顺序来,每一步都是在给下一步铺路。
2. 先想暴力路线:收集排序和顺序合并为什么“能跑但不够”
2.1 收集所有节点后排序,为什么不能直接丢掉
在这道题的所有解法里,最简单粗暴的一条是:遍历 K 条链表,把所有节点放进一个数组,对数组按 val 排序,然后把排序后的节点一个个串起来。
代码逻辑相当直白:
public ListNode mergeKLists(ListNode[] lists) { List<ListNode> nodes = new ArrayList<>(); for (ListNode head : lists) { while (head != null) { nodes.add(head); head = head.next; } } nodes.sort((a, b) -> a.val - b.val); ListNode dummy = new ListNode(0); ListNode cur = dummy; for (ListNode node : nodes) { cur.next = node; cur = cur.next; } cur.next = null; return dummy.next; }这个解法胜在容易理解,也容易证明正确性:所有节点本来都是有序链表的一部分,现在把它们的顺序完全打散,再按 val 全局排序,得到的序列一定是全局升序的。
但它的时间复杂度是 O(N log N),其中 N 是所有节点的总数。而堆解法和分治解法能到 O(N log K)。当 K 比较小的时候,log N 和 log K 的差距还不明显;一旦 K 接近 N,每个链表只有一个节点,收集排序的 log N 就会明显比堆解法的复杂度差。实际写题的时候,这个方案最多用来“先确保思路没跑偏”,不推荐作为正式答案,因为面试官让你合并 K 个升序链表,想听的不是“调用库函数排序”。
更重要的一个细节是:它改变了节点的“来源顺序”。如果面试官后续追问“能不能做到原地合并,不额外占用 O(N) 的列表空间”,这个解法就接不上话了。
2.2 顺序两两合并的复杂度陷阱
第二种常见的思路,就是开头提到的:写一个 mergeTwoLists,然后从左往右把链表一个个合并进去。
public ListNode mergeKLists(ListNode[] lists) { if (lists == null || lists.length == 0) { return null; } ListNode res = lists[0]; for (int i = 1; i < lists.length; i++) { res = mergeTwo(res, lists[i]); } return res; }这段代码看起来比收集排序更“算法”,因为至少手动处理了链表的合并。但它的复杂度到底是多少?我建议你自己推一次。
假设平均每条链表长度是 n,合并前两条的代价是 O(2n),得到一条长度为 2n 的结果。接下来跟第三条合并,代价是 O(3n)。再跟第四条合并,代价是 O(4n)。一直到最后一条,代价是 O(Kn)。把所有代价加起来,大概是 n * (2 + 3 + ... + K) = O(K²n)。
如果用总节点数 N = K*n 来表达,这个复杂度是 O(KN)。也就是说,随着 K 线性变大,总耗时按 K 的倍数恶化。K=10 还勉强能看,K=1000 就已经能感到明显的卡顿。这跟收集排序的 O(N log N) 相比,哪个更好?这里有个反直觉的点:当 K 很大时,顺序两两合并因为平方项会变得比“全局排序”还慢,更比不上分治和堆。
2.3 这个“算账”的过程才是核心收获
我做这道题最大的收获,其实不是记住了堆解法,而是学会了给一道题“建账本”。每次写一个解法之前,先问自己三个问题:每一轮要做什么?这一轮操作要花多少时间?总共会做多少轮?
顺序两两合并之所以慢,是因为前面合并出来的结果越来越长,后面每一次合并都要把它从头再走一遍。归并排序之所以快,是因为它每次合并的两个子序列规模相当,避免了一个“巨人”反复被拖着走的情况。这个道理放到处理日志分片、数据库归并时同样成立。所以面试时就算你第一个抛出顺序合并,也一定要主动补一句:“这个写法复杂度是 O(KN),主要慢在每次合并的长度会累加,更好的办法是分治或者用堆。”主动展示你能识别自己的方案缺陷,通常比一次性给出最优解更拉好感。
3. 分治合并:最接近“归并排序”肌肉记忆的解法
3.1 把“线性累加”改成“两两配对”
前面说顺序两两合并慢,是因为第 i 次合并的输入越来越大。那怎么避免?一个很自然的思路是:不按顺序依次合并,而是先两两配对,把每对链表合并成一条,这样 K 条就变成 K/2 条;然后再两两配对,变成 K/4 条。每一轮里,每条链表都只参与一次合并,而且每次合并的两个链表长度差不多,不会出现“长链表反复被遍历”的浪费。
这个做法本质上就是归并排序的链表版。回忆一下你背过的归并排序:先递归拆成两半,排序,再合并。合并 K 个链表不需要拆,因为它们本来就是有序的,只需要从“单个链表”开始,向上两两合并。每一轮让参与合并的链表数量减半,合并的高度是 log K,每一层对全部 N 个节点都会处理一次,所以总复杂度是 O(N log K)。
我第一次看到这个思路时,心里想的是:这不就是把“合并两个有序链表”跟“归并排序的后半段”拼在一起吗?正确答案确实就这么朴素,算法题里的很多优化,靠的不是灵光一闪,而是把已有知识换一个组织单元。
3.2 分治合并的完整实现
先看一下我的 Java 实现。整体结构很清楚:mergeRange 负责数组下标的递归切分,mergeTwo 负责真正把两条链表拧成一条。
class Solution { 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]; } int mid = left + (right - left) / 2; ListNode l1 = mergeRange(lists, left, mid); ListNode l2 = mergeRange(lists, mid + 1, right); return mergeTwo(l1, l2); } private ListNode mergeTwo(ListNode a, ListNode b) { ListNode dummy = new ListNode(0); ListNode cur = dummy; while (a != null && b != null) { if (a.val <= b.val) { cur.next = a; a = a.next; } else { cur.next = b; b = b.next; } cur = cur.next; } cur.next = a != null ? a : b; return dummy.next; } }这里要特别说明的是递归边界。当 left == right 时,说明区间里只剩一条链表,直接返回它,不需要再做合并。这个边界是分治题最容易写错的地方,很多人习惯把空区间或者一条边界的条件搞混,一提交就栈溢出。
还有一个地方值得注意:mid 为什么要写成 left + (right - left) / 2,而不是 (left + right) / 2?在当前数组下标不溢出的情况下两种写法都一样,但 left + (right - left) / 2 是面试官更熟悉的写法,它可以避免 left + right 在极端情况下溢出。我一般会顺手写成这种形式,也算是个小小的代码习惯。
mergeTwo 里用了 dummy 头节点,这是链表合并里的常规操作。dummy 本身不是真实链表的一部分,它的作用只是让你不用单独处理“谁是新链表头”的分支判断。如果不理解这一点,很容易在一个节点一个节点拼接时把自己绕晕。
3.3 递归和迭代两种组织方式怎么选
mergeRange 的递归写法代码短,思路直观,但有一个隐藏成本:递归深度是 log K,所以额外栈空间是 O(log K)。在绝大多数情况下这都不是问题,因为 K 本身受输入规模限制。
但是如果你在一台对递归栈特别敏感的环境里写多路归并,或者 K 可能达到上万级别,就可以把递归改成迭代。思路是维护一个当前待合并链表列表,每次按步长两两处理,把合并结果放入下一轮列表。我看过一些题解把这段循环写得比较复杂,记录步长、处理奇数链表或者空链表,容易出小 bug。所以我的建议是:如果面试没明确说不允许递归,分治解法直接写递归,空间 O(log K) 这个量级完全能解释清楚,没必要为了炫技切换到迭代。
3.4 这段代码在面试表达上的价值
我自己平时练题,会把分治合并当成这道题的“标准答案”推荐给别人。原因很简单:它只依赖两个你已经熟悉的能力——递归分区间和合并双链表,不需要引入额外的数据结构。面试官听完你的思路,追问的复杂度也好推导:每一层总代价 O(N),层高 log K,答案就出来了。你不需要解释优先队列的堆内调整细节,也不用担心比较器写错。分治解法是稳稳当当的六十分以上答案,属于那种“你哪怕紧张到脑子空白,靠肌肉记忆也能写完”的方案。
4. 最小堆解法:多路归并的经典工程实现
4.1 为什么需要维护一个“最小候选池”
再来换一个角度思考这个题。假设你面前有 K 条链表,每一条当前都有一个头节点。整张合并结果的下一个节点,一定是这 K 个候选节点里最小的那个。
你可以线性扫描这 K 个候选,找出最小,代价 O(K);更聪明的办法是,用一个最小堆来维护候选节点。堆顶永远是当前 K 个候选里的最小值。每弹出一个节点,就把这个节点在原链表中的 next 节点补进堆里。这样每轮操作的代价从 O(K) 降到了 O(log K),整体复杂度 O(N log K),而堆的大小始终是 O(K)。
很多人会把堆解法理解成“用空间换时间”,但这里的空间非常小,只存 K 个节点引用,不是把所有节点都放进堆里。这也是它工程上吸引人的地方:即使链表总长度有千万级,堆只关心每个链表当前走到哪了,内存占用完全可控。
4.2 Java 里用 PriorityQueue 的完整写法
用 Java 写堆解法的天然优势是:PriorityQueue 可以直接传一个比较器,并且堆里允许放入节点对象本身,因为比较规则是你自定义的。
class Solution { public ListNode mergeKLists(ListNode[] lists) { if (lists == null || lists.length == 0) { return null; } PriorityQueue<ListNode> pq = new PriorityQueue<>( (a, b) -> Integer.compare(a.val, b.val) ); for (ListNode head : lists) { if (head != null) { pq.offer(head); } } ListNode dummy = new ListNode(0); ListNode cur = dummy; while (!pq.isEmpty()) { ListNode node = pq.poll(); cur.next = node; cur = cur.next; if (node.next != null) { pq.offer(node.next); } } return dummy.next; } }整个流程拆解下来就三步:第一步把 K 条链表的头节点都放进堆;第二步循环弹出堆顶最小节点,接到结果链表尾部,然后把这个节点的 next 补进来;第三步等堆空了,说明所有节点都处理完了。
有一个细节经常被忽略:第一次入堆的时候,链表的头节点可能为 null。LeetCode 的输入里经常出现例如 [null, node1, null, node2] 这种数组,如果你不做空判断直接 offer 进堆,堆就会出现空指针。很多人卡在这道题的边界测试上,不是算法不会写,而是没有在初始化时过滤掉空链表。
另一个小点:比较器建议写 Integer.compare(a.val, b.val),而不是 a.val - b.val。后者的差值在两数一正一负差值超过 int 范围时有溢出风险。链表节点的 val 虽然通常很小,但写成 Integer.compare 是一个不费力就能规避风险的习惯。
4.3 为什么 Python 解法里要在堆中额外存下标
如果你用 Python 写这道题,会发现一个 Java 里不存在的坑:heapq 比较元组时,如果第一个元素 val 相同,就会继续比较第二个元素。如果堆里直接放 (node.val, node),当两个节点值相等时,Python 会去比较两个 ListNode 对象,而 ListNode 默认没有实现小于比较,直接报 TypeError。
所以 Python 必须在元组里加一个不会重复的字段,最方便的就是链表在 lists 数组里的下标。
import heapq from typing import List, Optional class Solution: def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]: heap = [] for i, head in enumerate(lists): if head: heapq.heappush(heap, (head.val, i, head)) dummy = ListNode(0) cur = dummy while heap: val, i, node = heapq.heappop(heap) cur.next = node cur = cur.next if node.next: heapq.heappush(heap, (node.next.val, i, node.next)) return dummy.next这个 i 的作用仅仅是充当“破坏平局”的序号。因为每个链表的起点下标不同,即使 value 相同,(val, i, node) 也不会撞到同一个比较分支。这是一个语言特性和数据结构碰撞的经典案例,我见过不少人在 Python 里硬写 (node.val, node),结果跑测试时一遇到重复值就崩,找半天都不知道问题出在 heapq 的比较机制上。
4.4 堆解法最容易踩的四个坑
第一个坑是初始化时把空链表进堆,前面已经说过。第二个坑是弹出节点后忘记把 node.next 补回堆里。第三个坑是补回去的时候直接 offer(node),而不是 offer(node.next),那会把整条链表重复加进堆。第四个坑是语言层面的比较规则,Python 要处理值相等的情况,Java/C++ 则要确认比较器方向没有写反。
我自己复盘过很多次:做这题时如果一次性通过,大概率不是因为运气,而是因为提前把空判断、next 入堆、比较器三个点都检查了一遍。你也可以把这三点当成堆解法写完之后的自检清单。只要这三处没问题,堆解法基本不会跑错。
5. 复杂度账本:四种解法的实测对比与选择依据
5.1 把时间复杂度和空间复杂度摆到一起看
前面零零散散提到了复杂度,这里我用一张表把常见解法全部汇总起来,方便你做方案决策。
| 解法 | 平均/最坏时间复杂度 | 额外空间复杂度 | 一句话点评 |
|---|---|---|---|
| 收集全局排序 | O(N log N) | O(N) | 好理解,适合快速验证,不适合作为面试正解 |
| 顺序两两合并 | O(KN) | O(1) | 慢在长链表反复参与合并 |
| 分治合并 | O(N log K) | O(log K) | 思路接近归并排序,稳定且面试好讲 |
| 最小堆 | O(N log K) | O(K) | 工程语义清晰,最像多路归并器 |
这里 N 表示所有链表节点总数,K 表示链表条数。可以看到分治和堆在时间复杂度上打平,区别主要在空间:分治的额外空间来自递归栈,堆的额外空间来自堆存储。
5.2 K 的大小会直接影响你的选择
如果 K 很小,比如 K=2 或 K=3,其实不需要上任何花哨解法,直接用最原始的 mergeTwoLists 按顺序合并就够。复杂度多出来的那点系数无所谓,代码越短越不容易出错。
如果 K 很大,但每条链表很短,比如有 1000 条链表、每条只有 1 个节点,这时候 N=1000、K=1000。分治和堆都能很快解决;收集排序也不会差到哪里去,因为 O(N log N) 在这个量级上的常数很小。真正要警惕的是“K 和单链长度 n 同时都很大”的场景,这时候分治和堆的优势才能完全拉开。
如果面试官对空间要求比较敏感,不希望额外开 O(K) 的堆结构,分治合并会是更好的答案,因为它的递归栈深度只有 log K,而且分治本身不依赖额外容器。如果面试官追问“你怎么在多路归并里维护当前最小”,堆解法又成了最优解。实际面试中两种方案都答出来,比只答一种要稳得多。
5.3 从复杂度的角度反推“为什么题目叫困难”
LeetCode 的 easy、medium、hard 并不完全等价于实现难度,很多时候取决于“你有没有见过这类题背后的模型”。合并两个链表是 easy 偏中等的操作,因为它只需要线性推进;合并 K 个链表之所以困难,是因为它引入了“K 选 1”的反复操作,需要引入一个数据结构来维护排序,或者用分治降层。这个“K 选 1”的抽象如果没见过,第一次写出来的代码很可能就是 O(KN) 的顺序合并。
所以准备这道题时,我建议不要只背堆解法的代码,而是试着不看题解,自己把复杂度推导一遍。推导的过程比默写代码更有价值,因为你以后会遇到合并 K 个升序数组、合并 K 个有序文件流、数据库多路归并排序等同类问题,模型一样,只是换了个容器。
5.4 工程中的“内存是否可控”同样关键
工程里选择解法,不能只看时间复杂度。真实的多路归并经常发生在内存受限的环境里,比如外部排序的时候,K 路输入可能来自磁盘文件,每路只能读一小块数据进内存。这时候如果采用“把所有节点都收集进内存排序”,内存直接爆掉。分治合并虽然复杂度优秀,但如果递归加载太多输入片段,也可能造成缓冲区管理复杂。最小堆解法在这类场景中反而是最自然的:堆只保留 K 个当前输入块的最小值,内存占用稳定在 O(K)。
从这个角度看,这道题堆解法才是最能回答“工程上你会怎么做”的答案。它把面试问题从链表操作拉高到了系统设计层面,这也是很多人觉得“这题虽然会做,但面试官的追问永远接不住”的根本原因。
6. 边界用例与自测清单:交卷前最值得花的三分钟
6.1 必须测的几组输入,少一个都不稳
很多刷题的人习惯“写完代码,按题目示例跑一遍,能过就提交”,遇到这种 hard 题很容易被隐藏用例打败。我给你列一组自测清单,写完之后花三分钟把这些输入喂一遍,比盲目提交十次都有效率。
第一,空数组输入:lists = []。很多解法在访问 lists[0] 时会直接数组越界,所以一进来要先判断 null 或长度 0。第二,数组中包含空链表:lists = [null, node1, null, node2]。第三,K=1 的单链表输入,这时候结果就是原链表,算法需要原样返回。第四,全部链表都只有一个节点的极端场景,K=N,会放大复杂度模型差异。第五,大量重复值的输入,比如每个链表里都是 [1,1,1,1]。第六,链表长度极不均匀,例如一条非常长,剩余都只有一两个节点。第七,某个链表内部存在完全相同的连续值,用来检查合并稳定性。
需要注意,这道题返回值是新链表的头节点,但我见过不少人直接把某个内部节点指针当成 result 返回,结果开头漏掉一段。这个错误在数据简单时不容易暴露,到了乱序输入就原形毕露。
6.2 关于“到底能不能修改原链表”的话题
如果是在项目里写真实代码,你还要想清楚一个约束:这次合并允许修改传入链表的 next 指针吗?LeetCode 这道题默认允许原地拼接,因为题目只要求返回合并后的结果,并没有要求保留原始 K 条链表结构。
但工程中经常有“不能破坏输入数据”的约束。比如 K 条有序链表是某个共享缓存的一部分,别的地方还在使用,你不能为了合并就把它们的 next 全改了。这时候正确的做法是复制节点或者生成新节点。这个约束会影响实现方案的取舍:如果能改 next,分治和堆都只需操纵原节点;如果不能改 next,你需要考虑是否额外创建 N 个新节点,空间复杂度会多出 O(N)。
面试里如果产品型面试官紧抓这个问题,不妨多问一句:“原始链表之后还要不要用?”这既是为了确认需求,也是展示你的工程思维。很多边界问题不是算法问题,而是需求澄清问题。
6.3 用一句话自证正确性
我写完所有解法之后,习惯给自己讲一句“正确性依据”:每次从当前所有链表头部候选中选出全局最小节点,把它追加到结果尾部,并把该节点的后继作为新的候选加入,这个过程不会跳过任何节点,也不会改变候选池的排序语义,所以最终结果一定是全局升序。
这句话听起来有点像废话,但它能帮你在思路混乱阶段快速定位 bug。比如你用堆弹出节点后,如果忘了把 node.next 放进堆,那么某个链表的后半段就永远不会被访问,最终结果一定缺少节点。这时候如果你默念一遍“每个节点被弹出后,它的后继要进候选池”,bug 会立刻暴露出来。
7. 一些只有刷过才记得住的个人经验
7.1 做这道题之前,最好先顺手复习哪些题目
合并 K 个升序链表不算孤立题型,它的前置依赖非常明显:先会做第 21 题“合并两个有序链表”,再会做第 148 题“排序链表”,然后刷这道第 23 题,会觉得难度曲线平滑很多。如果你连 mergeTwoLists 的 dummy 节点写法都还没完全理解,直接硬啃这道题,很容易在 while 循环里迷失指针方向。
这道题还能横向迁移到“合并 K 个有序数组”。把链表换成数组后,堆解法依然适用,只不过每次弹出堆顶后,游标要变成下标加一。K 路归并是很多系统设计的底层,所以我在刷完这题之后,又去看了外部排序中多路归并的败者树优化,对“为什么堆已经够用但工程里还要用败者树”有了更直观的感受。
7.2 怎么向别人讲清楚这道题,才算真会
有时候验证自己是否懂一个算法,最好的办法是给别人讲一遍。我通常是这样组织讲解的:先问对方“合并两个链表怎么做”,把 mergeTwoLists 的代码写出来;然后再问“如果 K 个链表,你打算重复几次”,引导对方发现顺序合并的瓶颈;接着提出两个优化方向,一个是让合并次数变少的分治,一个是让每次找最小更快的堆。最后把两种方案的时间复杂度和空间复杂度写出来,让听的人自己选方案。
当你讲到最后一步时,实际上已经把 LeetCode hot100 里最常见的链表多路归并题完整拆解了一遍。这个讲解流程本身,也能作为面试时的回答结构,比直接甩代码清晰得多。
这道题刷到这里,我个人觉得最有价值的反而不是能默写堆解法,而是彻底理解了那笔复杂度账。以后再碰到“多路有序数据合并”的问题,我会先想清楚 K 和 N 的量级,再决定是线性扫描、分治、还是堆。很多困难题考的不是某个华丽技巧,而是你有没有把十年前学过的归并思想,迁移到一个新的容器里。