大顶堆,小顶堆
大顶堆,小顶堆是一种二叉树的数据结构,以大顶堆为例,它满足父节点比左右子节点都大,要注意,大顶堆的条件仅仅是父节点比子节点要大,并不是一定满足高度更大的节点值更大,举个例子:
可以发现第三层的5,6都要比第二层的左节点4要大,但这符合大顶堆的要求。小顶堆与之相反,满足父节点值比左右子节点都要小。
大顶堆,小顶堆的作用
具体来说,我们要找到一个数组中第k大或者第k小的元素时,这种数据结构是非常方便的,我们维护一个大小为k的大顶堆(当找数组中第k小的元素时),遍历数组,并不断地向大顶堆中添加数据,最后将根节点取出即可。结合上图来看,加入我们要找数组中第7小的元素,若此时,我们要插入一个元素8,从当前要插入的位置的父节点开始比较(由于Java源码中大小顶堆是数组实现的,故上图要插入的位置应该是1的左子节点即此时大顶堆的size索引所指的位置),故要插入位置的父节点是1,8 > 1,故此插入位置不满足父节点比子节点大,继续找1的父节点4,仍然不满足,故找4的父节点9,发现满足,所以应该将8插入9的左节点位置。
具体过程看下图:
首先待插入的位置是1的左子节点位置,待插入节点是8,8首先和待插入位置的父节点1比较,发现不满足大顶堆,1下移到待插入位置,8暂时上移到1原位置:
此时带插入节点8的父节点是4,再与4比较,发现仍然不满足,故继续上移,4下移。
最后8与父节点9比较,发现满足,此时的8插入到了正确的位置,并且可以发现,仍满足大顶堆的条件。
下面看源码中的add函数的关键部分,k传入的是待插入位置(堆的size)+1,然后不断地与父节点比较,不满足大顶堆的条件则将父节点下移到子节点的位置(可以看出,若待插入节点位置位于左子节点(右子节点),则待插入节点的移动路径均位于左节点(右节点))。此数据结构也是可以自定义比较器的,默认是小顶堆。
private void siftUpUsingComparator(int k, E x) {
while (k > 0) {
int parent = (k - 1) >>> 1;
Object e = queue[parent];
if (comparator.compare(x, (E) e) >= 0)
break;
queue[k] = e;
k = parent;
}
queue[k] = x;
}
由于Java用数组实现这种二叉树的结构,故无符号右移即可找到父节点的索引。
由于我们要找第7小的数,所以我们要维护一个大小为7的大顶堆,此时大顶堆的大小为8,所以我们要删除大顶堆的最大元素,即根节点。
首先将根节点9移出大顶堆,接着比较根节点左右子节点的大小,由于是大顶堆,则将大的节点上移。
再比较空节点的左右子节点大小,大的节点上移。
最后将1上移即可。
private void siftDownComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>)x;
int half = size >>> 1; // loop while a non-leaf
while (k < half) {
int child = (k << 1) + 1; // assume left child is least
Object c = queue[child];
int right = child + 1;
if (right < size &&
((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
c = queue[child = right];
if (key.compareTo((E) c) <= 0)
break;
queue[k] = c;
k = child;
}
queue[k] = key;
}
有了大小顶堆这种数据结构,则Leetcode347,215,295都很简单了,先看347
解决这道题我们可以先遍历数组,将每个数字出现的频率统计出来,用一个HashMap统计,键是数组的数值,值是数组的数值出现的频率。接着维护一个k大小的小顶堆(找出现频率前k高的元素),传入比较器比较的是HashMap节点的值(频率)大小。下面是完整代码:
public int[] topKFrequent(int[] nums, int k) {
// 定义HashMap存储值和出现的次数
HashMap<Integer,Integer> map = new HashMap<Integer,Integer>();
for (int i: nums){
map.put(i, map.getOrDefault(i, 0) + 1);
}
// 定义优先级队列
PriorityQueue<Map.Entry<Integer,Integer>> pq = new PriorityQueue<>(
(o1, o2) -> o1.getValue() - o2.getValue()
);
for (Map.Entry<Integer,Integer> entry : map.entrySet()){
pq.offer(entry);
if (pq.size() > k) pq.poll();
}
int[] res = new int[k];
int i = 0;
for (Map.Entry<Integer,Integer> entry : pq){
res[i++] = entry.getKey();
}
return res;
}
下面看215
这道题用小顶堆代码非常简洁:
public int findKthLargest(int[] nums, int k) {
// 定义小顶堆,并维护小顶堆大小为k
PriorityQueue<Integer> queue = new PriorityQueue<>((a, b) -> a - b);
for (int i = 0; i < nums.length; i++) {
queue.add(nums[i]);
if (queue.size() > k){
queue.poll();
}
}
return queue.peek();
}
但对于这道题来说小顶堆不是时间复杂度最小的解法,我们这里只讨论用小顶堆的解法。
最后看295,数据流的中位数
这道题比较好想出来的思路是每次加入新元素将新元素插入到正确的位置,即先查找位置再插入元素,时间复杂度O(logN)(查找时间复杂度)+O(N)(插入时间复杂度),但这样做会超时,这里我们用大小顶堆维护中间元素在堆顶,这样插入元素的时间复杂度是O(logN)。下面说具体的做法:
我们用小顶堆维护添加元素中较大的一半,用大顶堆维护较小的一半,当前元素为奇数个时,我们在小顶堆中存放(N+1)/2个元素,在大顶堆中存放(N-1)/2个元素,当前元素为偶数个时,大小顶堆各存放N/2个元素。
再添加元素分成两种情况讨论,当前元素为偶数个时(大小顶堆元素相等),先将元素添加到大顶堆(因为再添加元素,总元素个数就为奇数个,我们要维持小顶堆元素比大顶堆元素多一个,所以我们先将新元素添加到大顶堆,再将大顶堆的最大元素出堆,添加到小顶堆),再将大顶堆顶部元素出堆,添加到小顶堆。当前元素为奇数个时(小顶堆比大顶堆多一个),先将元素添加到小顶堆,再将顶部元素出堆添加到大顶堆(先将元素添加到小顶堆后,小顶堆元素比大顶堆多两个,所以我们将大顶堆最小的元素出堆,添加到大顶堆)。在添加新元素的过程中,保持这样的规则,我们就能维护在小顶堆中存放较大一半的元素,在大顶堆中维护较小一半的元素,这样的作法比给数组排序更快,取出中间元素的时间复杂度也是O(1)。下面看具体代码实现:
class MedianFinder {
private PriorityQueue<Integer> pq_large;
private PriorityQueue<Integer> pq_small;
public MedianFinder() {
// 定义大小顶堆
// 小顶堆中存较大的一半
// 大顶堆中存较小的一半
pq_small = new PriorityQueue<>(50000);
pq_large = new PriorityQueue<>(50000, (x, y)->y-x);
}
public void addNum(int num) {
if (pq_large.size() == pq_small.size()){
pq_large.add(num);
pq_small.add(pq_large.poll());
}
else{
pq_small.add(num);
pq_large.add(pq_small.poll());
}
}
public double findMedian() {
return pq_small.size() == pq_large.size() ? (((double) pq_small.peek() + pq_large.peek()) / 2) : pq_small.peek();
}
}