力扣刷题日记:合并K个有序链表的三种解法

160 阅读5分钟

引言

在刷力扣链表相关题目时,"合并K个有序链表"这道题非常经典。题目要求将多个已经按升序排列的链表合并成一个新的升序链表。这个问题我已知是三种种解法(虽然只想到一种,但是还是想分享一下其他的),今天我分享一下我的思路。

题目描述

image.png

解法一:逐一合并链表(我的初始解法)

思路分析

其实我一开始压根没头绪,突然想到一句话——不会的直接先套递归、分治、回溯、贪心、动态规划...

想到合并两个有序链表使用递归。递归的基本思路是比较两个链表当前节点的值,将较小值的节点作为合并后链表的头节点,然后递归地处理剩余的节点。

代码实现

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个节点。

总结

通过这道题,我学到了多种合并有序链表的方法。我的初始解法虽然直观,但效率实在不咋地。分治法和优先队列(最小堆)的解法更加高效,尤其是在处理大量链表时,性能优势更棒。

在实际应用中,我们可以根据链表的数量和长度选择合适的解法。如果链表数量较少,逐一合并的方法就也可以接受了;如果链表数量较多,那分治法或优先队列是更好的选择(但是一做题脑子就不往这地方来啊)。