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个节点。
所以,最好的解法取决于具体的情况和需求。你可以根据你的偏好和判断来选择合适的解法