引言
在刷力扣链表相关题目时,"合并K个有序链表"这道题非常经典。题目要求将多个已经按升序排列的链表合并成一个新的升序链表。这个问题我已知是三种种解法(虽然只想到一种,但是还是想分享一下其他的),今天我分享一下我的思路。
题目描述
解法一:逐一合并链表(我的初始解法)
思路分析
其实我一开始压根没头绪,突然想到一句话——不会的直接先套递归、分治、回溯、贪心、动态规划...
想到合并两个有序链表使用递归。递归的基本思路是比较两个链表当前节点的值,将较小值的节点作为合并后链表的头节点,然后递归地处理剩余的节点。
代码实现
var mergeKLists = function(lists) {
if(lists.length === 0) return null;
let res = lists[0];
for(let i = 1; i < lists.length; i++) {
res = mergeTwoLists(res, lists[i]); // 合并
}
return res;
}
const mergeTwoLists = function(l1, l2) {
if(l1 === null) return l2;
if(l2 === null) return l1;
if(l1.val < l2.val) {
l1.next = mergeTwoLists(l1.next, l2); // 递归
return l1;
} else {
l2.next = mergeTwoLists(l1, l2.next); // 递归
return l2;
}
}
复杂度分析
时间复杂度:O(kN),其中k是链表的数量,N是所有链表中的节点总数。每次合并两个链表的时间复杂度是O(n),其中n是两个链表的总长度。在最坏情况下,每个链表的长度都是N/k,因此总的时间复杂度是O(kN)。
空间复杂度:O(1),只需要常数级的额外空间。
解法二:分治法(优化解法)
思路分析
虽然我的解法能过测试,但时间复杂度不行。看了题解,我学习到了一种更高效的方法——分治。
分治法的核心思想是将原问题分解为多个子问题,然后递归地解决这些子问题,最后将子问题的解合并得到原问题的解。对于合并K个有序链表这个问题,我们可以将链表数组分成两部分,分别合并这两部分,然后再将合并后的结果合并。
代码实现
var mergeKLists = function(lists) {
if(lists.length === 0) return null;
return merge(lists, 0, lists.length - 1);
}
const merge = function(lists, left, right) {
if(left === right) return lists[left];
const mid = Math.floor((left + right) / 2);
const l1 = merge(lists, left, mid);
const l2 = merge(lists, mid + 1, right);
return mergeTwoLists(l1, l2);
}
const mergeTwoLists = function(l1, l2) {
if(l1 === null) return l2;
if(l2 === null) return l1;
if(l1.val < l2.val) {
l1.next = mergeTwoLists(l1.next, l2);
return l1;
} else {
l2.next = mergeTwoLists(l1, l2.next);
return l2;
}
}
复杂度分析
递归已经是我的极限了,交完提解也是成功超过 5% 的玩家,我就知道肯定还有,这个思路得学啊。
时间复杂度:O(N log k),其中k是链表的数量,N是所有链表中的节点总数。分治法将问题的规模每次减少一半,因此需要进行log k次合并操作,每次合并操作的时间复杂度是O(N),因此总的时间复杂度是O(N log k)。
空间复杂度:O(log k),主要是递归调用栈的空间。
解法三:优先队列(最小堆)
思路分析
另一种高效的解法是使用优先队列(最小堆)。最小堆是一种数据结构,它可以在O(log n)的时间内插入元素和删除最小元素。我们可以利用最小堆来维护当前所有链表的最小节点。
具体做法是,首先将每个链表的头节点加入最小堆,然后每次从堆中取出最小的节点,将其加入结果链表,并将该节点的下一个节点(如果存在)加入堆中。重复这个过程,直到堆为空。
代码实现
var mergeKLists = function(lists) {
if(lists.length === 0) return null;
// 创建最小堆
const heap = new MinHeap();
// 将每个链表的头节点加入堆
for(let list of lists) {
if(list !== null) {
heap.insert(list);
}
}
// 创建哑节点
const dummy = new ListNode(0);
let current = dummy;
// 从堆中取出最小节点,加入结果链表
while(!heap.isEmpty()) {
const minNode = heap.extractMin();
current.next = minNode;
current = current.next;
// 如果该节点有下一个节点,将其加入堆
if(minNode.next !== null) {
heap.insert(minNode.next);
}
}
return dummy.next;
}
// 最小堆实现
class MinHeap {
constructor() {
this.heap = [];
}
isEmpty() {
return this.heap.length === 0;
}
insert(node) {
this.heap.push(node);
this.bubbleUp();
}
bubbleUp() {
let index = this.heap.length - 1;
while(index > 0) {
let parentIndex = Math.floor((index - 1) / 2);
if(this.heap[parentIndex].val <= this.heap[index].val) break;
[this.heap[parentIndex], this.heap[index]] = [this.heap[index], this.heap[parentIndex]];
index = parentIndex;
}
}
extractMin() {
if(this.heap.length === 0) return null;
if(this.heap.length === 1) return this.heap.pop();
const min = this.heap[0];
this.heap[0] = this.heap.pop();
this.sinkDown(0);
return min;
}
sinkDown(index) {
const left = 2 * index + 1;
const right = 2 * index + 2;
let smallest = index;
if(left < this.heap.length && this.heap[left].val < this.heap[smallest].val) {
smallest = left;
}
if(right < this.heap.length && this.heap[right].val < this.heap[smallest].val) {
smallest = right;
}
if(smallest !== index) {
[this.heap[smallest], this.heap[index]] = [this.heap[index], this.heap[smallest]];
this.sinkDown(smallest);
}
}
}
复杂度分析
小声bibi:其实这是用 tab “写的”。
时间复杂度:O(N log k),其中k是链表的数量,N是所有链表中的节点总数。每次从堆中取出最小节点的时间复杂度是O(log k),总共需要进行N次操作,因此总的时间复杂度是O(N log k)。
空间复杂度:O(k),主要是堆的空间,堆中最多同时存储k个节点。
总结
通过这道题,我学到了多种合并有序链表的方法。我的初始解法虽然直观,但效率实在不咋地。分治法和优先队列(最小堆)的解法更加高效,尤其是在处理大量链表时,性能优势更棒。
在实际应用中,我们可以根据链表的数量和长度选择合适的解法。如果链表数量较少,逐一合并的方法就也可以接受了;如果链表数量较多,那分治法或优先队列是更好的选择(但是一做题脑子就不往这地方来啊)。