LeetCode 中等难度经典题——合并 K 个升序链表,这道题是链表操作的高频考点,也是面试中常被问到的题目,核心考察对链表、堆、分治思想的综合运用。下面会从题目分析入手,一步步讲解两种最优解法(最小堆解法 + 分治解法),附上完整代码和详细注释,新手也能轻松看懂。
一、题目回顾
题目描述:给你一个链表数组,每个链表都已经按升序排列。请你将所有链表合并到一个升序链表中,返回合并后的链表。
示例(辅助理解):
输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
二、核心思路分析
首先明确核心需求:将 K 个“升序链表”合并为一个“整体升序链表”。关键在于「高效找到每次的最小元素」,因为每个链表本身是升序的,所以每个链表的头节点是当前链表的最小值,我们只需要在这 K 个头节点中找最小的,依次拼接即可。
基于这个核心,有两种高效思路:
-
最小堆(优先队列):用最小堆维护所有链表的头节点,每次弹出堆顶(当前最小元素),再将该节点的下一个节点加入堆,循环直至堆空,拼接成结果链表。
-
分治思想:将 K 个链表拆分成两个子问题,分别合并两个子问题的结果,最终合并为一个链表(本质是“合并两个升序链表”的升级版,递归拆分 + 回溯合并)。
先补充链表节点的定义(题目已给出,这里统一梳理,方便后续理解代码):
class ListNode {
val: number
next: ListNode | null
constructor(val?: number, next?: ListNode | null) {
this.val = (val === undefined ? 0 : val)
this.next = (next === undefined ? null : next)
}
}
三、解法一:最小堆解法(直观高效,易理解)
3.1 解法思路
-
初始化一个最小堆,将所有非空链表的头节点加入堆中(堆会自动维护最小值在堆顶);
-
初始化一个虚拟头节点(dummy 节点),用于拼接结果链表,避免处理头节点为空的边界情况;
-
循环弹出堆顶元素(当前最小节点),将其接入结果链表,然后将该节点的下一个节点(如果存在)加入堆中;
-
当堆为空时,所有节点都已拼接完成,返回 dummy.next 即为合并后的链表。
3.2 完整代码(带详细注释)
// 实现最小堆(优先队列),用于维护链表头节点的最小值
class MyMinHeap {
private heap: ListNode[];
constructor() {
this.heap = []; // 堆的底层存储结构:数组
}
// 插入节点:插入到堆尾,再向上调整堆(维护最小堆性质)
push(node: ListNode) {
this.heap.push(node);
this.bubbleUp(this.heap.length - 1); // 从最后一个元素开始向上调整
}
// 弹出堆顶(最小值):将堆顶与堆尾元素交换,弹出堆尾,再向下调整堆
pop(): ListNode | null {
if (this.isEmpty()) return null; // 堆空返回null
if (this.heap.length === 1) return this.heap.pop()!; // 只有一个元素,直接弹出
const top = this.heap[0]; // 堆顶(最小值)
this.heap[0] = this.heap.pop()!; // 堆尾元素移到堆顶
this.bubbleDown(0); // 从堆顶开始向下调整
return top;
}
// 判断堆是否为空
isEmpty(): boolean {
return this.heap.length === 0;
}
// 向上调整堆:当插入元素时,确保父节点始终小于子节点(最小堆核心)
private bubbleUp(index: number) {
while (index > 0) { // 索引大于0,说明不是根节点
const parentIndex = Math.floor((index - 1) / 2); // 父节点索引 = (子节点索引 - 1) // 2
// 如果当前节点值 < 父节点值,交换两者(维护最小堆)
if (this.heap[index].val < this.heap[parentIndex].val) {
[this.heap[index], this.heap[parentIndex]] = [this.heap[parentIndex], this.heap[index]];
index = parentIndex; // 继续向上调整父节点
} else {
break; // 满足最小堆性质,停止调整
}
}
}
// 向下调整堆:当弹出堆顶后,确保堆顶是最小值
private bubbleDown(index: number) {
const length = this.heap.length;
while (true) {
let leftChildIdx = 2 * index + 1; // 左孩子索引 = 2*父节点索引 + 1
let rightChildIdx = 2 * index + 2; // 右孩子索引 = 2*父节点索引 + 2
let smallestIdx = index; // 初始化最小值索引为当前节点
// 找到当前节点、左孩子、右孩子中的最小值索引
if (leftChildIdx < length && this.heap[leftChildIdx].val < this.heap[smallestIdx].val) {
smallestIdx = leftChildIdx;
}
if (rightChildIdx < length && this.heap[rightChildIdx].val < this.heap[smallestIdx].val) {
smallestIdx = rightChildIdx;
}
// 如果最小值不是当前节点,交换并继续向下调整
if (smallestIdx !== index) {
[this.heap[index], this.heap[smallestIdx]] = [this.heap[smallestIdx], this.heap[index]];
index = smallestIdx; // 继续向下调整子节点
} else {
break; // 满足最小堆性质,停止调整
}
}
}
}
// 合并K个升序链表(最小堆解法)
function mergeKLists_1(lists: Array<ListNode | null>): ListNode | null {
const minHeap = new MyMinHeap();
// 1. 将所有非空链表的头节点加入最小堆
for (const list of lists) {
if (list) { // 排除空链表,避免堆中存入null
minHeap.push(list);
}
}
// 2. 初始化虚拟头节点,用于拼接结果链表
const dummy = new ListNode(0);
let current = dummy; // current指针用于遍历结果链表,拼接节点
// 3. 循环弹出堆顶,拼接链表
while (!minHeap.isEmpty()) {
const minNode = minHeap.pop()!; // 弹出当前最小节点
current.next = minNode; // 将最小节点接入结果链表
current = current.next; // current指针向后移动
// 如果当前最小节点有下一个节点,将其加入堆中
if (minNode.next) {
minHeap.push(minNode.next);
}
}
// 4. 返回合并后的链表(跳过虚拟头节点)
return dummy.next;
};
3.3 复杂度分析
时间复杂度:O(N log K),其中 N 是所有链表的总节点数,K 是链表的个数。
-
每个节点都需要入堆和出堆各一次,每次入堆/出堆的时间复杂度是 O(log K)(堆的大小最多为 K);
-
总共有 N 个节点,因此总时间复杂度是 O(N log K)。
空间复杂度:O(K),堆的大小最多为 K(存储所有链表的头节点),虚拟头节点的空间可忽略不计。
四、解法二:分治解法(最优时间,空间更优)
4.1 解法思路
分治思想的核心是“分而治之”,将复杂问题拆分成多个简单子问题,解决子问题后再合并结果:
-
拆分:将 K 个链表拆分成两个部分,左半部分和右半部分;
-
递归:分别递归合并左半部分和右半部分,得到两个升序链表;
-
合并:将两个升序链表合并为一个升序链表(复用“合并两个升序链表”的逻辑);
-
终止条件:当拆分到只剩 1 个链表时,直接返回该链表;当拆分到 0 个链表时,返回 null。
补充:合并两个升序链表是这道题的子问题,也是 LeetCode 21 题,思路很简单——双指针遍历两个链表,每次拼接较小的节点。
4.2 完整代码(带详细注释)
// 合并K个升序链表(分治解法)
function mergeKLists_2(lists: Array<ListNode | null>): ListNode | null {
// 分治核心函数:合并lists[start..end]范围内的链表
const merge = (lists: Array<ListNode | null>, start: number, end: number): ListNode | null => {
if (start == end) { // 只剩一个链表,直接返回
return lists[start];
}
if (start > end) { // 没有链表,返回null(边界情况:lists为空)
return null;
}
const mid = (start + end) >> 1; // 中间索引,等价于Math.floor((start+end)/2)
// 递归合并左半部分和右半部分,再合并两个结果
return mergeTwoLists(merge(lists, start, mid), merge(lists, mid + 1, end));
}
// 辅助函数:合并两个升序链表(LeetCode 21题解法)
const mergeTwoLists = (a: ListNode | null, b: ListNode | null): ListNode | null => {
if (a == null || b == null) { // 一个链表为空,直接返回另一个
return a != null ? a : b;
}
let head: ListNode = new ListNode(0); // 虚拟头节点
let tail: ListNode = head; // tail指针用于拼接节点
let curA: ListNode | null = a; // 遍历链表a的指针
let curB: ListNode | null = b; // 遍历链表b的指针
// 双指针遍历,拼接较小的节点
while (curA != null && curB != null) {
if (curA.val < curB.val) {
tail.next = curA;
curA = curA.next;
} else {
tail.next = curB;
curB = curB.next;
}
tail = tail.next; // tail指针向后移动
}
// 拼接剩余节点(其中一个链表已遍历完)
tail.next = (curA != null ? curA : curB);
return head.next; // 返回合并后的链表(跳过虚拟头节点)
}
// 调用分治函数,合并整个lists数组
return merge(lists, 0, lists.length - 1);
};
4.3 复杂度分析
时间复杂度:O(N log K),与最小堆解法一致,但常数项更小,实际运行更快。
-
拆分过程:将 K 个链表拆分成 log K 层(类似二叉树的高度);
-
合并过程:每一层的合并总节点数都是 N,每一层的时间复杂度是 O(N);
-
总时间复杂度 = 层数 × 每一层的时间 = O(log K × N) = O(N log K)。
空间复杂度:O(log K),主要是递归调用栈的空间,递归深度为 log K(拆分 K 个链表的深度)。如果用迭代实现分治,空间复杂度可优化到 O(1)。
五、两种解法对比与选择
| 解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| 最小堆解法 | O(N log K) | O(K) | 思路直观,易实现,适合新手 | 需要额外维护堆,空间开销稍大 |
| 分治解法 | O(N log K) | O(log K)(递归)/ O(1)(迭代) | 空间更优,实际运行效率更高 | 递归思路需要理解分治思想,相对抽象 |
选择建议:
-
面试时,若要求空间优化,优先写分治解法(递归版即可,迭代版可作为加分项);
-
日常练习或追求代码简洁,优先写最小堆解法,思路更直接,不易出错;
-
两种解法都需要掌握,因为它们分别考察了堆和分治两种核心算法思想,都是面试高频考点。
六、边界情况测试
刷题时一定要考虑边界情况,避免代码漏洞,这道题的常见边界情况如下:
-
lists 为空(K=0):返回 null;
-
lists 中有空链表(如 [null, [1,2]]):跳过空链表,只处理非空链表;
-
只有一个链表(K=1):直接返回该链表;
-
所有链表只有一个节点(如 [[1], [2], [3]]):两种解法都能正常处理,堆解法更直观。
七、总结
合并 K 个升序链表的核心是「高效找到每次的最小元素」,两种最优解法分别对应两种思路:
-
最小堆:用数据结构(堆)直接维护最小值,适合直观理解,代码易实现;
-
分治:用算法思想(分治)拆分问题,空间更优,效率更高。
这道题的本质是“合并两个升序链表”的延伸,掌握了子问题和两种核心思想,就能轻松解决。建议大家两种解法都动手写一遍,理解堆的维护逻辑和分治的拆分合并过程,既能巩固链表操作,也能加深对堆和分治思想的理解。