Java版LeetCode热题100之「合并 K 个升序链表」详解
本文约9200字,全面深入剖析 LeetCode 第23题《合并 K 个升序链表》。涵盖题目解析、三种解法(顺序合并、分治合并、优先队列)、复杂度分析、面试高频问答、实际开发应用场景、相关题目推荐等,助你彻底掌握多路归并的核心技巧。
一、原题回顾
题目描述:
给你一个链表数组,每个链表都已经按升序排列。
请你将所有链表合并到一个升序链表中,返回合并后的链表。
示例 1:
输入:lists = [[1,4,5],[1,3,4],[2,6]] 输出:[1,1,2,3,4,4,5,6] 解释:链表数组如下: [ 1->4->5, 1->3->4, 2->6 ] 将它们合并到一个有序链表中得到。 1->1->2->3->4->4->5->6示例 2:
输入:lists = [] 输出:[]示例 3:
输入:lists = [[]] 输出:[]提示:
k == lists.length0 <= k <= 10⁴0 <= lists[i].length <= 500-10⁴ <= lists[i][j] <= 10⁴lists[i]按升序排列lists[i].length的总和不超过10⁴
二、原题分析
这道题是「合并两个有序链表」的扩展版本,要求合并K 个有序链表。
核心挑战:
- 效率问题:如何避免重复比较,达到最优时间复杂度?
- 空间限制:能否在有限空间内完成合并?
- 边界处理:空数组、空链表等特殊情况。
关键观察:
- 前置知识:必须掌握「合并两个有序链表」(LeetCode 21题);
- 多路归并:这是经典的K 路归并问题;
- 三种主流思路:
- 方法一:顺序合并(朴素但低效)
- 方法二:分治合并(平衡效率与空间)
- 方法三:优先队列(最直观的优化)
📌最优解:方法二和方法三的时间复杂度均为 O(kn log k),但空间复杂度不同。
三、答案构思
方法一:顺序合并(朴素解法)
- 思想:依次合并每个链表到结果中
- 流程:
- 初始化
ans = null - 遍历
lists,每次将lists[i]合并到ans
- 初始化
- 优点:代码简单,易于理解
- 缺点:时间复杂度高 O(k²n)
方法二:分治合并(推荐!)
- 思想:类似归并排序的分治策略
- 流程:
- 将 K 个链表分成两组
- 递归合并每组
- 合并两个结果
- 优点:时间复杂度 O(kn log k),空间 O(log k)
- 缺点:递归栈开销
方法三:优先队列(堆)
- 思想:维护 K 个链表的当前最小值
- 流程:
- 将每个链表的头节点加入最小堆
- 每次取出最小值,加入结果
- 将该节点的下一个节点加入堆
- 优点:逻辑清晰,时间复杂度 O(kn log k)
- 缺点:空间复杂度 O(k)
💡选择建议:
- 面试:优先展示方法二或方法三
- 工程:方法三更直观,方法二更节省空间
四、完整答案(Java 实现)
前置:合并两个有序链表
/** * 合并两个有序链表 */privateListNodemergeTwoLists(ListNodea,ListNodeb){if(a==null||b==null){returna!=null?a:b;}ListNodedummy=newListNode(0);ListNodetail=dummy;while(a!=null&&b!=null){if(a.val<=b.val){tail.next=a;a=a.next;}else{tail.next=b;b=b.next;}tail=tail.next;}tail.next=(a!=null)?a:b;returndummy.next;}方法一:顺序合并
classSolution{publicListNodemergeKLists(ListNode[]lists){ListNodeans=null;for(ListNodelist:lists){ans=mergeTwoLists(ans,list);}returnans;}// mergeTwoLists 方法如上}方法二:分治合并(推荐!)
classSolution{publicListNodemergeKLists(ListNode[]lists){if(lists==null||lists.length==0){returnnull;}returnmerge(lists,0,lists.length-1);}/** * 分治合并 [l, r] 范围内的链表 */privateListNodemerge(ListNode[]lists,intl,intr){if(l==r){returnlists[l];}if(l>r){returnnull;}intmid=l+(r-l)/2;// 防止溢出ListNodeleft=merge(lists,l,mid);ListNoderight=merge(lists,mid+1,r);returnmergeTwoLists(left,right);}// mergeTwoLists 方法如上}方法三:优先队列(堆)
classSolution{// 自定义比较类classNodeComparatorimplementsComparator<ListNode>{@Overridepublicintcompare(ListNodea,ListNodeb){returna.val-b.val;}}publicListNodemergeKLists(ListNode[]lists){if(lists==null||lists.length==0){returnnull;}// 创建最小堆PriorityQueue<ListNode>heap=newPriorityQueue<>(newNodeComparator());// 将每个非空链表的头节点加入堆for(ListNodehead:lists){if(head!=null){heap.offer(head);}}ListNodedummy=newListNode(0);ListNodecurrent=dummy;// 不断取出最小值while(!heap.isEmpty()){ListNodeminNode=heap.poll();current.next=minNode;current=current.next;// 将下一个节点加入堆if(minNode.next!=null){heap.offer(minNode.next);}}returndummy.next;}}✅方法二和方法三均达到最优时间复杂度 O(kn log k)!
五、代码分析
方法一:顺序合并
- 执行过程:
- 第1次:合并 list0 → 结果长度 n
- 第2次:合并 list1 → 结果长度 2n
- 第k次:合并 list(k-1) → 结果长度 kn
- 问题:后期合并的链表越来越长,效率低下
方法二:分治合并(重点!)
1. 递归结构
intmid=l+(r-l)/2;ListNodeleft=merge(lists,l,mid);ListNoderight=merge(lists,mid+1,r);- 类似归并排序,将问题规模减半
2. 边界处理
if(l==r)returnlists[l];// 单个链表if(l>r)returnnull;// 空范围3. 效率优势
- 每层合并的总工作量都是 O(kn)
- 共有 O(log k) 层
- 总时间:O(kn log k)
方法三:优先队列(重点!)
1. 自定义比较器
classNodeComparatorimplementsComparator<ListNode>{publicintcompare(ListNodea,ListNodeb){returna.val-b.val;}}- 或者让 ListNode 实现 Comparable 接口
- 注意:不能直接使用 lambda 表达式,因为 ListNode 未实现 Comparable
2. 堆操作流程
- 初始化:将 K 个头节点入堆
- 循环:
- 取出最小节点
- 将其 next 入堆(如果存在)
- 终止:堆为空
3. 空间优化
- 堆中最多有 K 个元素
- 每个节点只在堆中出现一次
⚠️关键细节:必须检查
head != null再入堆,避免 NullPointerException!
六、时间复杂度和空间复杂度分析
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 顺序合并 | O(k²n) | O(1) | K 很小的情况 |
| 分治合并 | O(kn log k) | O(log k) | 通用推荐 |
| 优先队列 | O(kn log k) | O(k) | 逻辑清晰 |
其中
k为链表数量,n为平均链表长度。
时间复杂度详解:
方法一(顺序合并):
- 第 i 次合并:O(n + (i-1)n) = O(in)
- 总时间:∑(i=1 to k) O(in) = O(k²n)
方法二 & 三(最优):
- 总节点数:N = kn
- 每个节点被处理一次
- 每次处理的代价:O(log k)(分治的递归深度 或 堆操作)
- 总时间:O(N log k) = O(kn log k)
空间复杂度详解:
方法一:仅用常数额外空间 → O(1)
方法二:递归栈深度 O(log k) → O(log k)
方法三:堆存储 K 个节点 → O(k)
💡工程建议:
- 当 K 较小时( - 当 K 较大时,优先选择方法二(空间更优)
七、常见问题解答(FAQ)
Q1:为什么方法三不用自定义 Status 类?
答:
官方题解使用了 Status 包装类,但其实可以直接使用 ListNode。
只要提供正确的 Comparator,PriorityQueue 就能正常工作。
直接使用 ListNode 更简洁,避免额外的包装开销。
Q2:分治方法中,为什么用 l + (r - l) / 2 而不是 (l + r) / 2?
答:
防止整数溢出!当 l 和 r 都很大时,l + r可能超过 Integer.MAX_VALUE。l + (r - l) / 2是计算中点的安全方式。
Q3:优先队列方法能否优化空间?
答:
空间复杂度 O(k) 已经是最优的,因为需要同时跟踪 K 个链表的当前位置。
无法进一步优化,除非改变算法思路。
Q4:如果链表中有重复元素,结果是否稳定?
答:
不稳定!当两个节点值相等时,优先队列的取出顺序不确定。
如果需要稳定排序,需要在比较器中加入额外的判断条件(如链表索引)。
八、优化思路
1. 提前过滤空链表
在所有方法开始前,先过滤掉空链表:
List<ListNode>nonEmptyLists=newArrayList<>();for(ListNodelist:lists){if(list!=null){nonEmptyLists.add(list);}}// 转换回数组或直接处理- 优点:减少不必要的操作
- 缺点:需要额外 O(k) 空间
2. 优化比较器(方法三)
使用 lambda 表达式(Java 8+):
PriorityQueue<ListNode>heap=newPriorityQueue<>((a,b)->a.val-b.val);- 更简洁,但要注意整数溢出问题(本题 val 范围安全)
3. 迭代版分治(方法二)
可改写为迭代版本,避免递归栈开销:
// 伪代码while(lists.length>1){// 两两合并,生成新数组// 重复直到只剩一个链表}- 优点:空间复杂度 O(1)
- 缺点:代码更复杂
4. 工程化增强
- 输入校验:检查 lists 是否为 null
- 日志记录:调试时打印合并过程
- 单元测试:覆盖各种边界情况
@TestvoidtestMergeKLists(){ListNode[]lists={createList(1,4,5),createList(1,3,4),createList(2,6)};ListNoderesult=solution.mergeKLists(lists);// 验证结果为 [1,1,2,3,4,4,5,6]}九、数据结构与算法基础知识点回顾
1. 多路归并(K-way Merge)
- 定义:将 K 个有序序列合并为一个有序序列
- 应用场景:外部排序、搜索引擎结果合并
- 经典算法:堆、分治
2. 优先队列(堆)
- 性质:完全二叉树,父节点 ≤ 子节点(最小堆)
- 操作:
- 插入:O(log k)
- 删除最小:O(log k)
- 查找最小:O(1)
- Java 实现:
PriorityQueue
3. 分治算法
- 思想:分解 → 解决 → 合并
- 典型应用:归并排序、快速排序
- 优势:降低问题复杂度
4. 链表操作基础
- 虚拟头节点:简化边界处理
- 指针操作:原地修改,O(1) 空间
- 合并技巧:双指针遍历
十、面试官提问环节(模拟)
❓ 问题1:你的优先队列解法中,如果两个节点值相同,哪个会先被取出?
回答:
优先队列不保证相同元素的顺序,这取决于具体的实现。
在 Java 中,PriorityQueue 使用二叉堆,相同元素的顺序是不确定的。
如果业务需要稳定排序,可以在比较器中加入额外的判断条件,比如节点的原始链表索引。
❓ 问题2:分治方法的空间复杂度真的是 O(log k) 吗?
回答:
是的。递归的深度是 log k,每一层递归调用需要常数空间存储局部变量。
虽然 mergeTwoLists 函数本身是 O(1) 空间,但递归调用栈的深度决定了总空间复杂度。
❓ 问题3:这个算法能处理环形链表吗?
回答:
不能,且题目假设链表无环。
如果输入包含环形链表,算法会陷入无限循环。
实际工程中,应该先检测并处理环形链表,或者在文档中明确说明输入要求。
❓ 问题4:如果 K 非常大(比如 10⁶),哪种方法更好?
回答:
当 K 很大时:
- 方法一:O(k²n) 时间,不可接受
- 方法二:O(kn log k) 时间,O(log k) 空间,推荐
- 方法三:O(kn log k) 时间,O(k) 空间,可能内存不足
因此,方法二更优,因为它的时间复杂度相同但空间复杂度更低。
十一、这道算法题在实际开发中的应用
虽然“合并 K 个链表”看似理论化,但其思想在实际系统中广泛应用:
1.搜索引擎结果合并
- 多个倒排索引分别返回有序结果
- 需要合并为全局有序的搜索结果
- 优先队列是标准解决方案
2.分布式数据库查询
- 数据分片存储在不同节点
- 每个节点返回局部有序结果
- 协调节点使用多路归并生成最终结果
3.日志聚合系统
- 多个服务产生时间戳有序的日志
- 需要按时间全局排序
- 分治或堆的方法都能高效处理
4.外部排序(External Sorting)
- 当数据量超过内存时,分块排序后归并
- 多路归并是外部排序的核心步骤
💡核心价值:掌握多路归并思想,这是处理大规模有序数据的基础技能。
十二、相关题目推荐
掌握本题后,可挑战以下 LeetCode 题目:
| 题号 | 题目 | 关联点 |
|---|---|---|
| 21. 合并两个有序链表 | 基础 | 本题子问题 |
| 148. 排序链表 | 扩展 | 归并排序 |
| 373. 查找和最小的 K 对数字 | 技巧 | 优先队列 |
| 378. 有序矩阵中第 K 小的元素 | 应用 | 多路归并 |
| 632. 最小区间 | 变种 | K 路扫描 |
| 786. 第 K 个最小的素数分数 | 高级 | 堆的应用 |
建议按顺序练习,逐步构建多路归并知识体系。
十三、总结与延伸
✅ 本题核心收获
- 多路归并思想:将复杂问题分解为简单的两两合并;
- 三种解法对比:理解时间-空间权衡;
- 优先队列应用:处理“动态最小值”问题的标准工具;
- 分治策略:降低问题复杂度的有效方法。
🔮 延伸思考
- 并行处理:能否并行合并不同的链表对?(理论上可以,但需考虑同步开销)
- 流式处理:如果链表是无限流,如何处理?(需要不同的算法,如滑动窗口)
- 内存映射:对于超大数据,如何结合磁盘 I/O 优化?(外部排序的经典场景)
🌟 最后建议
- 手写代码:在白板上写出优先队列或分治的主逻辑;
- 讲清思路:面试时先说“我有三种解法”,再分析优劣;
- 主动测试:提出测试空输入、单链表、大量小链表等 case,展现全面性。
“多路归并,化繁为简;堆与分治,各有所长。”
掌握本题,你就拥有了处理大规模有序数据的利器。继续前行,算法之路越走越宽!