堆的概念
首先堆是一个完全二叉树,堆可以分为大顶堆,小顶堆。大顶堆指的是root节点大于左右子孩子节点。小顶堆指的是root节点小于左右子孩子节点。
如何用数组来表示二叉树
首先看下面这个图:
我们把二叉树的节点按照层从左到右依次填充到数组中。如果当前节点是第i个数据,那么
- 如果节点i有left child,那么left child的坐标为 2 * i;
- 如果节点i有right child,那么right child 的坐标为2*i+1;
- 其节点的partent 节点坐标为i/2;
比如 B节点,B的坐标为2,left child 坐标为4,right child 坐标为5,父节点坐标为1。
针对非完全二叉树应该如何用数组表示呢?如下图所示:
我们在空的节点处用# 号表示,比如B节点没有左右子节点,那么在往数组中填充时要用#表示。这样一来,我们在上面总结的节点下标依然适用。比如C节点的下标为3,其左孩子节点D下标为6,右孩子E下标为7,父节点A下标为1.
为了表示方便,我们在用数组表示二叉树时,尽量不要出现上面的这种用特殊符号填充的情况,这也就从侧面反映了堆的一个定义,堆heap必须是一个完全二叉树。
大顶堆,小顶堆用数组表示
如下图所示:
我们可以从上图看到,堆都是一个完全二叉树。对于大顶堆来说,root节点都大于它的左右子节点。小顶堆,root节点都小于它的左右子节点。
堆的插入
接下来我们拿大顶堆来看一下,堆是如何插入一个元素的。
还是上面那个大顶堆,如果要插入一个元素60,应该把它插入到哪个位置呢?首先我们先把60放到50 这个位置上看一下:
因为60占了50的位置,然后根据大顶堆的定义,50要下沉,如果50和20比较,比20大,所以20下沉,20和16比较,比16大。16最后成为20的右孩子,最后如上图右所示。这样一来的话,就变成了一个非完全二叉树,不符合堆的定义。
那么正确的姿势是什么样子呢?
如上图所示,我们直接将60放在了数组末尾,此时下标为8,8是下标4 的左孩子,所以在二叉树的位置上应该是元素15的左孩子。此时这个树还不是一个大顶堆,接下来,就要开始调整这个树,让其重新成为大顶堆。
- 首先60和15比较,大于15,然后和15交换位置。
- 此时60再和30比较,大于30,然后和30交换位置。
- 最后60和50比较,大于50,然后和50交换位置。
调整过后就变成了上图右边的情形。我们分析一下其时间复杂度。从上图可知,我们经历了3次交换调整,这3次调整主要依赖树的高度,也就是说lgn,所以其时间复杂度为O(lgn).
堆的删除
接下来我们看一下如何从大顶堆中删除一个元素。比如删除root节点50. 比如按照以下方式删除50节点:
删除50后,需要孩子节点补位,30比较大,然后30补上去,然后30这个位置又空了,需要一个比较大的孩子节点再补上去,于是15上去了。最后变成了上图右的样子。但是,可以看出已经不是一个完全二叉树了,不符合堆的定义。所以此法行不通。
正确的方法应该是,删除50后,将最后一个节点16补位进行(选择16进行补位,主要是为了保证堆是一个完全二叉树的规则,选择最后一个节点补位到root节点,调整起来会更快一些),然后再对整个二叉树进行调整。如下图所示:
- 首先删除50这个节点,然后将最后的节点16进行补位。
- 接下来调整这个二叉树,16和其两个子节点相比,将30替换上去,和16进行交换
- 16再和其左右子节点相比,发现已经比左右子节点大了,无需调整。整个堆调整结束。
整体下来,删除一个元素最多发生3次交换,这也和树的层高有关,所以时间复杂度依然是O(lgn).
如果我们把删掉的元素50放在这个数组最后面,只不过下标7这个位置已经不属于这个二叉树了:
然后我们再删除堆顶元素30,调整后如下所示:
如果依次进行下去,数组的数据就变成了,[8,10,15,16,20,30,50], 其实这就是堆排序。
总结一下,堆的插入是一个从下往上调整的过程,而堆的删除是一个从上往下调整的过程。无论是插入还是删除的时间复杂度都是O(lgn)。
接下来我们讲一下堆排序。
堆排序
堆排序就是把一个数组按照堆的方式,将其最终形成有序数组。比如数组[10,20,15,30,40]。堆排序有两个步骤:
-
生成堆。
-
删除root节点,然后再调整堆。
接下来我们使用这个数组来走一遍堆排序的过程。
步骤一生成堆:
- 先遍历第一个元素10,相当于这个堆只有一个元素,无需调整。
- 然后遍历第二个元素20,插入这个堆,作为10的左孩子,20比10大,做一次调整,把20放到root节点,10作为左孩子节点。
- 遍历第三个元素15,插入堆,作为20的右孩子,此时也满足堆的定义,所以无需调整。
- 遍历第四个元素30,插入堆,作为10的左孩子,这个时候30大于其父节点10,所以要调整,30与10交换,然后再与20交换。
- 最后遍历第五个元素40,插入堆,40作为20的右孩子,比20大,做两次调整。
具体如下图所示:
这里我们分析一下堆排序的时间复杂度,因为要先遍历整个数组,所以是O(n),每次调整有可能要向上调整,这个跟层数有关,所以时间复杂度为O(lgn),综合起来就是O(nlgn)。
步骤二,删除堆:
- 首先删除头节点40,然后将最后一个节点20补到头节点处,最后调整堆,调整到最后头节点为30。
- 再删除头节点30,然后将最后一个节点10补到头节点处,最后调整堆,调整到最后头节点为20。
- 再删除头节点20,然后将最后一个节点15补到头节点处,最后调整堆,调整到最后头节点为15。
- 再删除头节点15,此时只剩下节点10。
- 最后删除节点10.
最后得到了排序后的数组[10,15,20,30,40]。总结一下上述步骤,构键堆,其实就是向堆插入新元素的过程。删除堆,其实就是删除元素的过程。整个过程的时间复杂度也是O(nlgn)。
堆排序的Java代码如下所示:
public class HeapSort {
int[] array;
public HeapSort(int[] array){
this.array=array;
}
public int[] solution(){
//创建大顶堆
creatHeap();
//将大顶堆的第一个元素换到末尾,然后调整剩余的数组为大顶堆
//这样每次调整下来,最大元素依次排到末尾,就完成了堆排序
deletion();
return array;
}
//堆的插入过程,从数组的最后一个元素开始插入,这个和我们上面说的思路有所出入
//这里一开始我们就把堆的范围设为最大,这样的好处是,第一次下来就有可能将整个数组排好序。
public void creatHeap(){
for(int i=array.length-1;i>=0;i--){
adjustHeap(i,array.length);
}
}
//调整大顶堆,size是目前大顶堆的范围,因为我们是基于数组构造的堆,在创建堆或者删除堆的时候,有些已经
// 有序的节点我们是不需要调整的,所以要加一个范围,来减少工作量
public void adjustHeap(int root,int size){
int left = leftChild(root);
int right = rightChild(root);
int largest = root;
//找到root节点,左右孩子节点的最大值
if(hasLeft(root,size) && array[root]<array[left]){
largest = left;
}
if(hasRight(root,size) && array[largest]<array[right]){
largest = right;
}
//如果当前节点不是最大,则交换
if(largest!=root){
int temp = array[root];
array[root] = array[largest];
array[largest] = temp;
adjustHeap(largest,size);
}
}
//堆的删除过程
public void deletion(){
for(int i=array.length-1;i>=1;i--){
//将maxHeap的顶端放到结果数组的末尾。即排序。
int temp = array[i];
array[i] = array[0];
array[0] = temp;
//这里要从顶部开始堆排序
adjustHeap(0,i);
}
}
//注意,数组是从0开始计数的。所以在上面我们总结的公式要加1.
public int leftChild(int i){
return i*2+1;
}
public int rightChild(int i){
return i*2+2;
}
public Boolean hasLeft(int i,int size){
return leftChild(i)<size;
}
public Boolean hasRight(int i,int size){
return rightChild(i)<size;
}
public static void main(String[] args){
int[]array = new int[]{10,20,15,30,40};
int [] result = new HeapSort(array).solution();
for(int i=0;i<result.length;i++){
System.out.print(result[i]+" ");
}
}
}
堆的应用-优先级序列
一个面试问题,如果要我们实现一个优先级队列,这个队列可以按照一个优先级吐出一个元素,该如何实现呢?
首先的思路我们可以使用一个数组来实现这个队列,那么怎么保证按照优先级的顺序get一个元素呢?如果每次put一个元素都排一次序,然后再取出优先级最大的元素,那样其实时间复杂度会变得复杂,因为我们只需要找到优先级最高的那个,不需要将整个数组排序在找出该元素。一个比较好的方式是使用堆。
比如我们使用大顶堆来表示数字大的优先级越高,这样一个优先级队列。每次put一个元素的过程其实就是大顶堆插入元素的过程,然后做一次调整。每次get目前队列中优先级最高的元素时,我们只需要将堆顶元素返回,然后删除即可,最后再次调整大顶堆即可。