堆(Heap)
1.堆是什么?
堆其实就是一个完全二叉树,有以下两个特点:
- 堆是一个完全二叉树。
- 堆中每个节点的值都必须小于等于(或者大于等于)其子树的每个节点的值。
根据第二条特性堆可有分为大顶堆和小定堆。
大顶堆
大顶堆就是堆中的每个节点的值都大于等于其子节点的堆。
小顶堆
每个节点都小于等于其子树的每个节点的值的堆。
如何实现一个堆?
完全二叉树比较适合用堆来存储,堆是一个完全二叉树,所以数组完全可以用来存储堆。
用数组来存储堆的好处,不需要存储数组左右指针,通过一个节点就可以找到这个节点的,所有子节点和父节点。
2.堆的操作
a.往堆中插入一个元素
向堆中插入一个元素的,需要先把这个数据插入完全二叉树的最后边,然后就是保证插入完后还是要符合堆的两个特性,所以每次查插入完数据都需要维护这个堆(完全二叉树),维护的操作就叫做堆化。
/**
* 大顶堆代码
*/
public class Heap {
private int[] a; //数组,从下标1开始存储
private int capacity;//堆的容量
private int count;//堆的数据个数
public Heap(int capacity){
a = new int[capacity-1];
this.capacity = capacity;
count = 0;
}
public void insert(int data){
//堆已经存储满了
if(count==capacity)return;
count++;
a[count]=data;
int i = count;
while(i/2>0&&a[i]>a[i/2]){
swap(a,i,i/2);
i= i/2;
}
}
public void swap(int[] arr,int i,int j){
int temp = arr[i];
arr[i] = arr[j];
arr[j]=arr[i];
}
}
b.删除堆顶元素
删除堆顶元素,顾名思义,就是把堆最顶端的元素删除(就是删除二叉树的根节点),因为堆的第二条特性,其实就是删除堆的最大值或者删除堆的最小值。
删除完堆顶元素,我们需要把第二大元素放到堆顶的位置,然后再删除堆顶元素,然后迭代,知道删除到叶子节点,但是此时的堆已经不是一个完全二叉树了,也就说他不符合堆的第一条定义了。
可以这样来删除,每次把最后一个节点来替代堆顶元素,然后此时就又需要到我们的堆化了,去维护堆的第二条属性。此时的堆化跟插入元素的堆化刚好相反是从上往下堆化。
堆化代码:
public void removeMax(int data){
if(count==0)return ;
a[1]=a[count];
count--;
heapify(a,count,1);
}
public void heapify(int[] a,int count,int i){
while(true){
int maxPos = i;
if(i*2<=count&&a[i]<a[i*2])maxPos=i*2;
if(i*2+1<=count&&a[i]<a[i*2+1])maxPos=i*2+1;
if(maxPos==i)break;
swap(a,i,maxPos);
i = maxPos;
}
}
c.堆化
堆化就是顺着节点的路径向下或者向上,对比,然后交换。
堆化分为两种
- 从上往下堆化
- 从下往上堆化
从下往上堆化
沿着节点的路径向上比较,不符合堆的条件就交换。
堆的插入就是一个从下往上堆化的过程。
插入一个数据首先把这个数据插入到堆的末尾。然后比较次节点和父节点的大小关系(是否符合堆的第二条特性),不符合则交换,并且依次向上比较,知道到了跟节点或者满足了堆的第二条特性位置。
从上往下堆化
沿着节点的路径向下比较,不符合就交换。
和从下往上堆化刚好相反,可以自己脑补一下。这里就不多做解释了。
3.堆排序
堆排序主要分为两部。
- 建堆
- 排序
a.建堆
数组原地建立一个堆。原地就是不借助另外一个数组,在原来的数组的基础上操作。
第一种,就是借助插入数据的思路,假设堆中只有一个数据,就是下标为1的时候,然后依次从2到n把数据插入堆中。是从下往上堆化。
第二种实现思路,和第一种完全相反,是从上往下堆化的。需要找到第一个非叶子节点,然后根据叶子节点依次进行堆化。
第二种建堆代码如下:
public void heapify(int[] a,int count,int i){
while(true){
int maxPos = i;
if(i*2<=count&&a[i]<a[i*2])maxPos=i*2;
if(i*2+1<=count&&a[i]<a[i*2+1])maxPos=i*2+1;
if(maxPos==i)break;
swap(a,i,maxPos);
i = maxPos;
}
}
public void buildHeap(int[]arr,int n){
int i = n/2;//第一个不是叶子节点的节点
heapify(arr,n,i);
}
需要注意的是这里第一个不是叶子节点的节点,可以用反正法证明假如这个节点大于n/2,那么他的子节点必定会大于n,就查处了堆的范围。
b.进行堆排序
堆排序的思路特别简单,每次取堆顶元素和堆尾元素交换位置,然后在1-n-1 的索引位置开始进行堆化,一次类推,直到取到地n-1个元素的时候,排序完成。
堆排序代码:
public void sort(int[] arr,int n){
buildHeap(arr,n);//建堆
int count = n;
while(count>1){
swap(arr,1,count);
count--;
heapify(arr,count,1);
}
}
c.堆排序性能分析
时间复杂度
首先分析一下建堆的时间复杂度,叶子节点是不需要堆化的,所有从树的倒数第二层来分析,我们都知道树的时间复杂度都是根树的高度有关的,从第一个节点到倒数第二层的高度为,1,2,3,4…n
S1 = n + 2*(n-1)+ 2*2(n-2)+23*(n-3)+…+2^(n-1)*1
S2 = 2*n + 2^2(n-1) + 23(n-1)+…+2(n-1)2+ 2^n
S = S2-S1 =2^n+ 2^(n-1) + 2^(n-2)+…+2-n
S = 2^(n+1)-n-2
n= log2x
S = O(n)
所以可以看出堆化的时间复杂度O(n)。
接下来就是把分析排序的时间复杂度,很容易可以看出排序的时间复杂度就是O(nlogn),所以整个排序的时间复杂度就是O(nlogn)。并且堆排序的空间复杂度为O(1),所以堆排序是一个原地排序,堆排序会改变相同数据的位置,所以堆排序是一个不稳定的排序算法。
4.堆排序和快速排序的对比
- 堆排序的数据访问没有快速排序的好。堆排序的数据是跳着访问的,所以堆cpu缓存不友好,相反快速排序就是顺序排序的,cpu很容易找到对应的地址。
- 堆排序的交换次数要比快速排序的多。快速排序的交换次数是小于等于数组的逆序度的,但是堆排序很有可能会大于逆序度,因为建堆和堆化的操作会进行很多从交换操作。
-排序- | -时间复杂度- | -空间复杂度- | -是否为稳定排序- | -cpu缓存支持- | -交换次数于逆序度的关系- |
---|---|---|---|---|---|
堆排序 | O(nlogn) | O(1) | 不稳定 | 不友好 | 大于 |
快速排序 | O(nlogn) | O(1) | 不稳定 | 友好 | 小于 |
5.堆的应用
a.优先级队列
队列的特性就是先进先出,优先级队列的话就是优先级最高的先出来。
实现优先级的方式有很多种,利用堆来实现是最简单,最高效的。堆和优先级队列特别相似,一个堆就可以看成是优先级队列,只是概念不同而已。入队和出队的操作就相当于堆的插入和删除堆顶的操作。
优先级队列的两个应用
- 合并有序小文件
- 高效的定时器
合并有序小文件
高效的定时器
假设有一个定时器,有很多的定时任务,定时器没过一秒就会扫描一下所有的任务看有没有满足执行条件的任务,确定是否执行。这样做有两个缺点1.不一定每次扫描都会有任务执行,这样做太浪费时间。2.每次扫描都会扫描所有的文件,如果任务列表很多的话就会很耗时。
优先级队列就可以解决这两个问题,首先我们维护一个小顶堆,把所有要执行的任务都放在堆中,堆顶元素就是最近要执行的任务,计算与当前时间的差值,定时触发定时器去执行。然后删除堆顶元素,重新计算堆顶任务需要触发的时间即可。
b.利用堆求Top K
利用堆求 Top K可以分为两种:一种是针对静态数据集合,也就是说集合中的数据是基本上吧不会变化的,另外一种是基于动态数据集合,集合中的数据会经常发生变化。
静态数据集合处理
动态数据集合处理