1. 题目

https://leetcode.cn/problems/merge-k-sorted-lists/
考察点
Leetcode 23 是一个关于合并 k 个有序链表的问题。它考察的是排序、分治和合并排序的知识。
你需要使用一个优先队列或者一个最小堆来维护 k 个链表的当前最小元素,
然后每次从中取出最小的一个,加入到结果链表中,再把它所在的链表的下一个元素放入优先队列或者最小堆中,直到所有的链表都为空为止。
这样可以保证时间复杂度为 O(nlogk),其中 n 是所有链表的总节点数,k 是链表的个数
2. 解法
有两种解法
- 使用优先队列,也叫最小堆
- 使用分治法
使用优先队列,也叫最小堆
思路
- 我想要把k个有序的链表合并成一个有序的链表,那么我需要每次从k个链表中找出最小的节点,加入到结果链表中,直到所有链表都为空。
- 为了方便地找出最小的节点,我想到了使用优先队列这种数据结构,它可以让我快速地取出最小的元素,并且可以动态地更新队列中的元素。
- 我把每个链表的头节点放入优先队列中,然后每次从队列中取出最小的节点,加入到结果链表中,然后把该节点的下一个节点放入队列中,直到队列为空。
- 这样,我就可以保证结果链表是有序的,并且时间复杂度和空间复杂度都比较低。
代码逻辑
代码的逻辑是这样的:
- 首先,判断输入的链表数组是否为空,如果为空,直接返回null。
- 然后,创建一个优先队列,这是一种特殊的数据结构,它可以按照一定的规则(比如节点的值)来排序队列中的元素,并且可以快速地取出最小(或最大)的元素。我们需要传入一个比较器(Comparator)来定义排序的规则,这里我们让节点的值越小,优先级越高。
- 接着,遍历输入的链表数组,把每个链表的头节点(如果不为空)放入优先队列中。这样,队列中就有k个节点,分别是k个链表的第一个节点。
- 然后,创建一个虚拟头节点和一个指针,用来构造结果链表。虚拟头节点是一个没有实际意义的节点,它只是为了方便操作结果链表,它的下一个节点才是真正的结果链表的头节点。指针是用来遍历结果链表的,它始终指向结果链表的最后一个节点。
- 接着,当优先队列不为空时,重复以下操作:
- 从优先队列中取出最小的节点,这个节点就是当前k个链表中最小的节点。把这个节点加入到结果链表中,也就是让指针的下一个节点指向这个节点,并且更新指针。
- 如果这个节点有下一个节点,说明它所在的链表还没有遍历完,那么就把它的下一个节点放入优先队列中。这样,队列中又有k个节点,分别是k个链表中当前最小的节点。
- 最后,当优先队列为空时,说明所有链表都遍历完了,结果链表也构造完了。返回虚拟头节点的下一个节点,就是结果链表的头节点。
具体实现
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) return null;
// 创建一个优先队列,按照节点的值从小到大排序
PriorityQueue<ListNode> queue = new PriorityQueue<>(lists.length, new Comparator<ListNode>() {
@Override
public int compare(ListNode o1, ListNode o2) {
return o1.val - o2.val;
}
});
// 把每个链表的头节点入队
for (ListNode node : lists) {
if (node != null) queue.offer(node);
}
// 创建一个虚拟头节点和一个指针
ListNode dummy = new ListNode(0);
ListNode p = dummy;
// 当队列不为空时,循环操作
while (!queue.isEmpty()) {
// 取出队列中的最小节点,加入到结果链表中
ListNode node = queue.poll();
p.next = node;
p = p.next;
// 如果该节点有下一个节点,把下一个节点入队
if (node.next != null) queue.offer(node.next);
}
// 返回结果链表的头节点
return dummy.next;
}
使用分治法
思路
另一种解法是使用分治法,也就是把k个链表分成两半,然后递归地合并每一半,最后再合并两个已经有序的链表。这样可以减少比较的次数,时间复杂度是O(nlogk),空间复杂度是O(logk),其中n是所有链表的节点总数,k是链表的个数。
代码逻辑
以上代码的逻辑是使用分治法来合并k个有序的链表。具体的步骤是:
- 首先,判断输入的链表数组是否为空,如果为空,直接返回null。
- 然后,调用一个辅助函数,传入链表数组和左右边界,表示要合并的链表范围。
- 在辅助函数中,如果左右相等,说明只有一个链表,直接返回该链表;如果左边大于右边,说明没有链表,返回null。
- 然后,计算中间位置,把链表数组分成两半,递归地调用辅助函数来合并每一半的链表,得到两个有序的链表。
- 最后,调用另一个辅助函数,传入两个有序的链表,用一个虚拟头节点和一个指针来构造结果链表。每次比较两个链表的当前节点,取出较小的节点,加入到结果链表中,并且更新指针和链表。如果有一个链表为空,把另一个链表的剩余部分加入到结果链表中。返回虚拟头节点的下一个节点,就是结果链表的头节点。
具体实现
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) return null;
return merge(lists, 0, lists.length - 1);
}
// 分治法,把k个链表分成两半,递归地合并每一半,最后再合并两个有序的链表
private ListNode merge(ListNode[] lists, int left, int right) {
// 如果左右相等,说明只有一个链表,直接返回
if (left == right) return lists[left];
// 如果左边大于右边,说明没有链表,返回null
if (left > right) return null;
// 计算中间位置
int mid = left + (right - left) / 2;
// 递归地合并左半部分的链表
ListNode l1 = merge(lists, left, mid);
// 递归地合并右半部分的链表
ListNode l2 = merge(lists, mid + 1, right);
// 合并两个有序的链表
return mergeTwoLists(l1, l2);
}
// 合并两个有序的链表
private ListNode mergeTwoLists(ListNode l1, ListNode l2) {
// 创建一个虚拟头节点和一个指针
ListNode dummy = new ListNode(0);
ListNode p = dummy;
// 当两个链表都不为空时,循环操作
while (l1 != null && l2 != null) {
// 比较两个链表的当前节点,取出较小的节点,加入到结果链表中
if (l1.val < l2.val) {
p.next = l1;
l1 = l1.next;
} else {
p.next = l2;
l2 = l2.next;
}
p = p.next;
}
// 如果有一个链表为空,把另一个链表的剩余部分加入到结果链表中
if (l1 != null) p.next = l1;
if (l2 != null) p.next = l2;
// 返回结果链表的头节点
return dummy.next;
}
两种解法对比
这个问题没有一个确定的答案,不同的解法有不同的优缺点。在这个情况下,一个人可能会说:
- 如果k很大,那么优先队列的解法可能更好,因为它可以减少空间复杂度,只需要存储k个节点,而不是logk层递归调用的栈空间。
- 如果k很小,那么分治法的解法可能更好,因为它可以减少时间复杂度,只需要比较nlogk次,而不是nlogk*k次。
- 如果链表的长度不均匀,那么分治法的解法可能更好,因为它可以平衡地合并链表,而不是每次都从最长的链表中取出节点。
- 如果链表的长度比较均匀,那么优先队列的解法可能更好,因为它可以避免多余的比较,只需要比较队列中的k个节点。
所以,最好的解法取决于具体的情况和需求。你可以根据你的偏好和判断来选择合适的解法
浙公网安备 33010602011771号