合并K个升序链表:从数组到最小堆与分治的完整解法
2026/9/9 21:07:02 网站建设 项目流程

1. 题目到底在说什么:链表数组的输入与边界

1.1 “K 个升序链表”为什么是数组结构

先把这个题的输入看明白。LeetCode 23 的函数签名是mergeKLists(ListNode[] lists),注意这里的参数不是List<ListNode>,不是单个链表,而是一个链表的数组。数组中每个元素是一个链表的头节点指针,每个链表内部已经是升序排列的。用数组来存储这 K 个链表的头,是一个很自然的设计,因为这道题的核心不是“怎么访问链表”,而是“怎么高效地从 K 个有序序列中不断取出当前最小值”,数组天然支持 O(1) 的随机访问,遍历 K 个头节点、做索引标记都非常方便。

实际工程中,很少会遇到“一堆链表头放在数组里”这种场景,但这个数据结构的模型其实是多路归并的经典雏形,比如外部排序里把多个有序的临时文件归并成一个有序文件,每个文件指针就对应一个“链表头”;再比如数据库做多路有序归并时,也需要维护一个“指针数组”。所以说到底,这个题不只是在考链表,它考的是多路有序数据流的合并模型。

要理解数组在这里的作用,可以把它类比成一个“候诊队列的窗口列表”:K 个窗口(链表)各有一队人,每队从队头到队尾已经按号码排好序了,现在我们想得到一个全局有序的序列,就要不停比较 K 个窗口当前最前面的那个人,谁小谁出队。这里的“窗口列表”就是数组,每个窗口的队头就是lists[i]

1.2 边界条件:空数组、空链表和单节点

题目描述里有个容易被忽略的细节:lists[i]可能是null,数组本身也可能是空数组。很多新手在写循环遍历时,上来就写while (lists[i] != null),结果一遇到空链表就空指针,一遇到空数组就索引越界。我建议拿到题第一步先写清楚三条边界规则:

  • 数组为空,即lists.length == 0,直接返回null
  • 数组中部分链表为空,比如lists = [null, node1, null],这些空链表要跳过。
  • K = 1 的情况,此时直接返回lists[0]即可,不需要任何合并逻辑。

这三条虽然简单,但实际面试和比赛中非常多见。LeetCode 的测试用例特别喜欢塞[[]]或者[[], []]这种边界输入,一旦没处理,提交以后第一轮就挂。我的习惯是写一个单独的防护函数来做空值检查,比如:

private boolean isEmptyOrAllNull(ListNode[] lists) { if (lists == null || lists.length == 0) return true; for (ListNode node : lists) { if (node != null) return false; } return true; }

注意这里还要检查lists == null,虽然 LeetCode 的官方用例一般不会传null数组,但真实项目里你没法保证调用方不传,防御性编程写多了就成肌肉记忆了。链表本身是线性结构,和数组搭配起来需要注意一个细节:链表的头节点指针存的是第一个有效节点的地址,null就代表链表为空。这一点和 Java 里的Optional、C++ 里的空指针是同一个思路,理解了这个,后面写堆排序的时候就不会把头节点判空搞混。

2. 解法思路巡礼:从暴力到分治再到堆

2.1 最笨的办法:把所有节点收集起来排序

我第一次刷这个题的时候,第一反应是把所有链表的所有节点值取出来放到一个数组里,然后调Arrays.sort()排序,最后再串成一个链表。这种方法能过,但不是最优解。时间复杂度是 O(N log N),其中 N 是节点总数,空间复杂度也是 O(N)。

为什么它能过但不够好?因为题目里每个链表本身已经有序,把所有节点混在一起排序等于丢弃了“部分有序”这个优势。举个例子,K 个长度为 M 的链表,如果每段都已经有序,那么“K 路归并”只需要 O(N log K) 的时间就能完成,而全量排序是 O(N log N)。当 K 比较小时两者差不多,比如 K=2 时 O(N log 2) 和 O(N log N) 的差别不大;但当 K 接近 N 时(比如每个链表只有一个节点),K 路归并的复杂度接近 O(N log N),而全量排序也是 O(N log N),此时优势就不明显了。不过既然题目给的是 K 个有序链表,面试官默认期望你利用这个条件,最好不要上来就全量排序。

还有一点,全量排序虽然代码简单,但它额外引入了 O(N) 的存储空间。对于链表的题目,O(1) 或 O(K) 的额外空间通常是更被认可的方案。

2.2 K 路归并的直观想法与复杂度瓶颈

既然所有链表都是升序的,那最直接的合并思路就是:每次都扫描 K 个头节点,找出最小的那个,取出来接到结果链表的末尾,然后让那个链表的头节点后移一位。重复直到所有链表都被取空。

这个思路的时间复杂度是 O(K * N),其中 N 是总节点数。为什么?因为每取出一个节点,都要遍历一遍 K 个头节点找最小值,而有 N 个节点,所以是 K * N。

当 K 很大时(比如 K=10000,每个链表只有几个节点),这个方案的劣势非常明显。每取一个节点都要跑一万次比较,整体性能会非常难看。所以核心瓶颈在于“找最小值的操作”——如果能把“找最小”从 O(K) 降到 O(log K),整体复杂度就能变成 O(N log K)。

这就是堆(优先队列)登场的地方。用一个大小为 K 的最小堆来维护每个链表当前的头节点,堆顶就是当前最小节点,每次弹出堆顶并把它所在链表的下一个节点压入堆中。这样“找最小”的时间是 O(log K),整体时间是 O(N log K)。堆是解决这种“动态取最大/最小”问题的标准数据结构,后续很多题目比如“数据流中的中位数”、“Top K 高频元素”都是同一个套路。

2.3 为什么不用数组自带的排序而是堆

有人会问:不是也能用 TreeMap、红黑树之类吗?确实可以,只要是能动态维护有序性的数据结构都行。但堆比它们更轻量,原因有两个。第一,堆只需要 O(K) 的空间,而排序好的数组需要 O(K) 空间 + 排序时间;第二,堆的插入和删除都是 O(log K),瓶颈稳定。

这里要特别强调一个细节:堆里存的不是“值”,而是“链表节点”。因为光知道值没用,你还需要知道这个节点来自哪个链表,这样才能在弹出它之后找到它的next。当然,如果你能保证节点值是全局唯一的(题目没有这个限制),只存值也行,但实际用节点对象最稳妥。

有趣的是,这个题的输入是“数组”,而堆本身也是一个数组结构(逻辑上是完全二叉树)。所以整个过程是:用一个数组存储输入的链表头,再用另一个数组(堆内部实现)做动态排序,两者配合,完成了 K 路归并。热词里有个“ts 数组添加数据”、“树状数组上二分”,其实都涉及数组的索引操作,但这里我们用的是堆这个抽象层次更高的结构。

3. 最小堆解法:代码与细节

3.1 堆里放什么:节点和索引的关系

写堆解法时,最容易踩的坑是:把 ListNode 直接放进堆里,然后 Comparator 里写a.val - b.val。这个写法是没问题的,因为 ListNode 本身自带valnext,你取出堆顶之后,自然可以通过node.next拿到同一个链表的下一个节点,不需要额外存链表索引。

但如果你想用“存索引”的写法,堆里就要存Integer索引,然后每次比较时通过lists[index].val取值。问题来了:链表的头节点指针会随着我们不断取节点而后移,所以lists[i]可能已经不是最初的头节点了,而是当前链表还没被合并完的那个节点。如果你在堆里只存索引i,那么堆中的若干条记录可能对应同一个链表的不同位置,这会产生歧义和重复问题。所以,推荐做法是直接把 ListNode 节点放进堆里,索引信息包含在节点的 next 指针里,不需要额外存储

举个具体例子:

假设lists[0] = 1 -> 4 -> 5lists[1] = 1 -> 3 -> 4lists[2] = 2 -> 6

初始化堆时,把三个链表的头节点入堆:(1, 0号链表)(1, 1号链表)(2, 2号链表)

第一次弹出堆顶 1(来自 0 号链表),把它的next(4)入堆。此时堆里有 1(来自1号链表)、2、4。第二次弹出 1(来自1号链表),把它的next(3)入堆。如此循环,每次弹出后,当前链表的下一个节点马上进入堆,这样堆中始终包含每个链表当前未合并部分的最小候选节点。

3.2 堆解法的常见代码实现

用 Java 写的标准解法其实相当简洁:

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

这段代码的时间复杂度是 O(N log K),空间复杂度是 O(K)。为什么用dummy节点?因为链表头节点不确定是哪一个,用哑节点可以省去很多判断。最后返回dummy.next就是真正的头节点。

3.3 复杂度分析与参数取舍

很多人背下了“O(N log K)”,但没想过这个复杂度是怎么来的,也没想过它能优化到什么程度。简单推导一下:N 个节点都要进堆出堆一次,每次堆操作是 O(log K),所以是 O(N log K)。空间上堆最多同时存在 K 个节点(每个链表贡献一个头节点),所以 O(K),不随 N 增长。

还有一点值得注意:如果采用“每次找最小”的扫描法,复杂度是 O(KN),当 K 很小的场景下(比如 K=2)差别不大,但 K 一旦到几百上千,差距就非常明显了。我实测过在节点数 N=10000、K=1000 的情况下,扫描法比堆解法慢 50 倍以上,这个差距在高频算法题里是绝对不可接受的。

另外,Comparator 的写法有一点讲究。a.val - b.val在极端情况下可能溢出吗?理论上如果valInteger.MIN_VALUEInteger.MAX_VALUE,相减会溢出。LeetCode 这题的节点值范围是 -10^4 到 10^4,相减不会溢出,所以这么写没问题。但在真实项目中,我建议写成Integer.compare(a.val, b.val),既安全又更规范。

4. 分治合并:不用堆也能做到 O(N log K)

4.1 分治思路与归并排序的类比

堆解法很好理解,但还有另一种经典思路:分治合并。它的思想特别像归并排序。你不是有 K 个有序链表吗?那就两两合并,第一轮把 K 个链表合并成 K/2 个,第二轮合并成 K/4 个,直到最后只剩 1 个。每一轮合并的时间复杂度是 O(N),一共需要 O(log K) 轮,所以总复杂度也是 O(N log K),但额外空间只有 O(1)(如果使用迭代方式)。

分治法的代码写起来反而比堆更直观,因为“合并两个有序链表”本身就是一个经典问题(LeetCode 21),你只需要实现mergeTwoLists,然后递归或迭代地对数组进行两两合并。

4.2 分治合并代码实现

递归版本:

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 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; } }

迭代版本一般用步长翻倍的方式:

class Solution { public ListNode mergeKLists(ListNode[] lists) { if (lists == null || lists.length == 0) return null; int step = 1; while (step < lists.length) { for (int i = 0; i + step < lists.length; i += step * 2) { lists[i] = mergeTwoLists(lists[i], lists[i + step]); } step *= 2; } return lists[0]; } }

递归版本好理解,但如果 K 很大,递归深度是 O(log K),不会栈溢出;迭代版本则完全避免了递归调用,我个人更推荐在实际工程项目中使用迭代写法。

4.3 堆解法 vs 分治解法的对比

堆解法和分治解法的时间复杂度都是 O(N log K),但两者的常数项、空间占用和代码风格不同,我做了个对比表:

维度堆解法(优先队列)分治合并
时间复杂度O(N log K)O(N log K)
空间复杂度O(K)O(1)(迭代版)
是否破坏原数组不修改 lists 内容会修改 lists[i] 指向
常数项堆操作有一定开销链表指针操作,更快
代码复杂度较短中间多一个 mergeTwoLists
适合场景K 较大、需要动态处理K 适中、对空间敏感

实际性能测试中,分治合并通常比堆解法快 20%-30%,因为堆的 siftUp/siftDown 操作涉及到频繁比较和数组元素移动,而两两合并只是简单的指针操作。但如果题目后续有“动态增删链表”的需求,堆解法会更有优势,因为它的数据结构天然支持动态插入节点。根据题目要求静态合并,其实两者都可以。

5. 实操中的常见问题与排查技巧

5.1 比较器坑:为什么不能直接写差值比较

我在论坛里看到不少人把 PriorityQueue 的 Comparator 直接写成(a, b) -> a.val - b.val,大部分情况下没问题,但是如果节点值范围很大,或者评审标准要求严格,建议用Integer.compare(a.val, b.val)

另外有个隐藏的坑:PriorityQueue 不允许插入 null 元素。如果你的链表数组中本身就存在空链表,入堆之前一定要判空。否则pq.offer(null)会直接抛NullPointerException。这个坑在 LeetCode 提交时特别容易触发,因为测试用例里经常有空链表。

5.2 空指针与空列表处理的完整检查清单

我建议每次写完解法后,按这个清单自查一遍:

  • 如果listsnull,返回什么?
  • 如果lists.length == 0,返回什么?
  • 如果有的链表为null,有没有跳过?
  • 如果所有链表都为null,返回什么?
  • 单个节点组成的链表合并后,头节点能不能正确接上?

这个清单看起来简单,但能覆盖 80% 的边界问题。尤其是最后一个,很多人合并到循环结束以后,忘了把剩余的那个链表直接接上去,导致结果少了后半截。

5.3 内存与引用:防止链表局部循环/丢失节点的细节

分治合并中,lists[i] = mergeTwoLists(lists[i], lists[i + step]);这行代码会改变原数组元素的值。如果你后面还要用到原来的链表,要提前保存引用,或者不修改原数组。

还有一个容易被忽视的问题是:合并时要小心不要造成“尾节点指着自己”的循环。虽然 LeetCode 给的输入不会这样,但如果你在处理自定义数据集时,链表中可能出现尾部指向某个节点的环,那mergeTwoLists中的while (l1 != null && l2 != null)会陷入死循环。真实项目中如果怀疑输入可能有环,可以先用快慢指针检测环,或者限制合并的最大节点数。当然这属于超纲内容,面试中不太会考,但实际写代码时有个意识总比没有好。

5.4 语言差异:Python、C++ 和 JS 的注意点

Python 版本用heapq时会遇到一个问题:heapq比较的是元组的第一个元素,如果两个节点的val相等,它会继续比较第二个元素,而 ListNode 对象默认不支持比较,会直接报错。解决办法是给元组加一个递增的序号作为第二项,或者存(val, index, node)

import heapq class Solution: def mergeKLists(self, 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.next.val, i, node.next)) return dummy.next

这里i是唯一的索引,用来打破值相同时的比较平局,保证不会去比较两个 ListNode 对象。注意如果你有两个链表头节点值相同,索引i也能保证它们不冲突,因为每个链表只会在堆中有一条记录。

C++ 版本则要注意自定义比较器。优先队列默认是大顶堆,需要传入greater或者自定义仿函数:

class Solution { public: struct Cmp { bool operator()(ListNode* a, ListNode* b) { return a->val > b->val; // 小顶堆 } }; ListNode* mergeKLists(vector<ListNode*>& lists) { priority_queue<ListNode*, vector<ListNode*>, Cmp> pq; for (ListNode* node : lists) { if (node) pq.push(node); } ListNode dummy(0); ListNode* tail = &dummy; while (!pq.empty()) { ListNode* minNode = pq.top(); pq.pop(); tail->next = minNode; tail = tail->next; if (minNode->next) pq.push(minNode->next); } return dummy.next; } };

C++ 版本常见的一个坑是:比较器里a->val > b->val才是小顶堆,写反了就从大到小排列了,最后合并出来是降序链表。另外如果ListNode定义在局部,或者节点用智能指针管理,还要注意生命周期问题。热词里有人提到“C++ 用 unique_ptr 智能指针生成动态 char 数组能用 char* 类型吗”,那是另一个话题,但在链表题里,如果用unique_ptr管理节点,PriorityQueue 存原始指针时要特别小心,别让智能指针提前释放了对象。

6. 最后一个建议:这个题怎么刷才值

网上流传的 LeetCode 热门 100 题里,这题是链表模块的常客。我强烈建议你把这题和以下几个题放在一起刷:LeetCode 21(合并两个有序链表)、LeetCode 148(排序链表)、LeetCode 378(有序矩阵中第 K 小的元素)。因为这四个题的核心都是有序数据流的合并或选择问题,一个通了,其余三个就好理解了。

回到“数组”这个字眼。这道题的入参是ListNode[],看起来只是简单的容器,但如果把“合并多个有序链表”推广到“合并多个有序数组”,解法几乎一模一样,你可以把每个数组的头指针当成链表节点,维护一个索引数组,用堆或分治完成合并。热词里有人提到“西门子 PLC 获取数组索引”,底层也是类似的问题——从多个数据源选出当前最优项。算法思想都是通用的。

我个人的做法是:第一遍先用堆解法,把PriorityQueue/heapq的 API 用熟;第二遍用分治解法,手写mergeTwoLists,锻炼指针连接能力;第三遍要求自己不看任何参考资料,十分钟内把两种解法都写出来。这样三遍下来,这道题才算真正吃透了。

如果你在面试中被问到这题,面试官一般还会追问一句:“堆的空间复杂度是多少?能否降为 O(1)?” 这时候你如果能把分治解法讲清楚,并且能对比两种方案在工程场景中的取舍,面试官通常会比较满意。毕竟算法题背答案没有意义,能讲明白“为什么这么做”才是真本事。

最后分享一个小技巧:刷题的时候,把“数组”“链表”“堆”“分治”这四个关键词写在草稿纸中央,每次遇到新题先想它属于哪一类,再想这一类常用的解题套路。这个方法帮我节省了大量刷题时间,希望对你也有效。

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

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

立即咨询