目录
算法通关村 —— 堆能高效解决的经典问题
1 在数组中找第K大的元素
给定整数数组 nums 和整数 k,请返回数组中第k个最大的元素。请注意,需要找的是数组排序后的第k个最大的元素,而不是第k个不同的元素。
示例:
输入:[3,2,3,1,2,4,5,5,6] 和 k = 4
输出:4
这道题主要解决方法有三个,选择法,堆查找法和快速排序法。
⚪ 选择法就是先遍历一遍找到最大的元素,然后再遍历一遍找第二大的,然后再遍历一遍找第三大的,直到第K次就找到了目标值了。但是这种方法只适合在面试的时候预热,面试官不会让你这么简单就开始写代码,因为该方法的时间复杂度为O(NK)。
比较好的方法是堆排序法和快速排序法。快速排序我们已经分析过,这里先看堆排序如何解决问题。
⚪ 这个题其实用大堆小堆都可以解决的,但是我们推荐“找最大用小堆,找最小用大堆,找中间用两个堆”,这样更容易理解,适用范围也更广。
例如要查找数组中第4大的元素,我们只需构造一个大小只有4的小根堆,为了更好说明情况,我们扩展一下序列[3,2,3,1,2,4,5,1,5,6,2,3]。堆满了之后,对于小根堆,并不是所有新来的元素都可以入堆的,只有大于根元素的才可以插入到堆中,否则就直接抛弃。这是一个很重要的前提。另外元素进入的时候,先替换根元素,如果发现左右两人子树都小该怎么办呢?很显然应该与更小的那个比较,这样才能保证根元素一定是当前堆最小的。假如两个子孩子的值一样呢? 那就随便选一个。
新元素插入的时候只是替换根元素,然后重新构造成小堆,完成之后,你会神奇的发现此时根的根元素正好是第4大的元素。
这时候你会发现,不管要处理的序列有多大,或者是不是固定的,根元素每次都恰好是当前序列下的第K大元素。上面的图省略了部分调整环节。我们可以使用jdk的优先队列来解决。由于找第 K 大元素,其实就是整个数组排序以后后半部分最小的那个元素。因此,我们可以维护一个有 K个元素的最小堆:
⚪ 如果当前堆不满,直接添加;
堆满的时候,如果新读到的数小于等于堆顶,肯定不是我们要找的元素,只有新遍历到的数大于堆顶的时候,才将堆顶拿出,然后放入新读到的数,进而让堆自己去调整内部结构
说明: 这里最合适的操作其实是 replace(),即直接把新读进来的元素放在堆顶,然后执行下沉(siftDown())操作。Java 当中的 PriorityQueue 没有提供这个操作,只好先 poll() 再offer()。
优先队列的写法就很多了,这里只例举一个有代表性的,其它的写法大同小异,没有本质差别。
具体实现代码如下:
import java.util.PriorityQueue;
class Solution {
public int findKthLargest(int[] nums, int k) {
if(k > nums.length) return -1;
int len = nums.length;
// 寻找第k大元素,则使用含有k个元素的最小堆
PriorityQueue<Integer> minHeap = new PriorityQueue<>(k, (a,b) -> a-b);
// 构造含k个元素的堆
for (int i = 0; i < k; i++){
minHeap.add(nums[i]);
}
// 对堆中元素替换成更大的数
for (int i = k; i < len; i++){
// peek 堆顶元素
Integer topEle = minHeap.peek();
// 若当前遍历的元素比堆顶元素大,堆顶弹出,遍历元素进去
if(nums[i] > topEle){
minHeap.poll();
minHeap.offer(nums[i]);
}
}
// 返回堆顶元素,正好为第k大元素
return minHeap.peek();
}
}
2. 堆排序原理
查找: 找小用大,找大用小
排序: 升序用小,降序用大。
前面介绍了如何用堆来进行特殊情况的查找,堆的另一个很重要的作用是可以进行排序
在大顶堆中,根节点是整个结构最大的元素,我先将其拿走剩下的重排,此时根节点就是第二大的元素,我再将其拿走,再排,依次类推。最后堆只剩一个元素的时候,是不是拿走的数据也就排好序了?具体来说,建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为 n的位置。
这个过程有点类似上面讲的“删除堆顶元素”的操作,当堆顶元素移除之后,我们把下标为 n 的元素放到堆顶,然后再通过堆化的方法,将剩下的 n-1 个元素重新构建成堆。堆化完成之后我们再取堆顶的元素,放到下标是 n-1 的位置,一直重复这个过程,直到最后堆中只剩下标为1 的一个元素,排序工作就完成了。
看一个例子,我们对上面第一章的序列 [12 23 54 2 65 45 92 47 204 311进行排序,首先构建一个大顶堆,然后每次我们都让根元素出堆,剩下的继续调整为大顶堆!
出堆的序列刚好是: 204、92、65、54、47、45......。也就是刚好是从大到小的顺序排列的。
所以在排序的时候排序: 升序用小,降序用大。
这个与前面的查找是相反的。
3. 合并K个排序链表
给你一个链表数组,每个链表都已经按升序排列。请你将所有链表合并到一个升序链表中,返回合并后的链表。
示例1:
输入: lists =[[1,4,5],[1,3,4],[2,6]]
输出: [1,1,2,3,4,4,5,6]
解释:链表数组如下:
[
1->4->5,
1->3->4,
2->6
]
将它们合并到一个有序链表
1->1->2->3->4->4->5->6
给了几个数组,就建立多大的固定堆
这个问题五六种方法,我们主要来看堆排序如何解决。
因为每个队列都是从小到大排序的,我们每次都要找最小的元素,所以我们要用小根堆,构建方法和操作与大顶堆完全一样,不同的是每次比较谁更小。 使用堆合并的策略是不管几个链表,最终都是按照顺序来的。每次都将剩余节点的最小值加到输出链表尾部,然后进行堆调整,最后堆空的时候,合并也就完成了。
具体实现代码如下:
import java.util.PriorityQueue;
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if(lists == null || lists.length == 0) return null;
// 构造一个小顶堆(参数为一个比较器,比较链表的头结点值)
// PriorityQueue<ListNode> q = new PriorityQueue<>(Comparator.comparing(node -> node.val));
PriorityQueue<ListNode> q = new PriorityQueue<>(lists.length, (a, b) -> (a.val - b.val));
// 将所有链表头结点加入到堆中
for(int i = 0; i < lists.length; i++){
if(lists[i] != null){
q.add(lists[i]);
}
}
// 构造虚拟节点做为新排序后链表的头结点以及遍历填充新链表的指针
ListNode dummy = new ListNode(0);
ListNode tail = dummy;
// 将所有小顶堆的堆顶不断加入新链表
while(!q.isEmpty()){
tail.next = q.poll(); // 将堆顶加入新链表
tail = tail.next; // 指针继续后移一位
if(tail.next != null){
// 说明此时加入的结点值后面还有结点,则仍有堆中原有的数未排序
q.add(tail.next); // 则把当前结点后面未排序的结点加入到小顶堆中
}
}
// 返回合并排序后的新链表
return dummy.next;
}
}