讲讲堆Heap

679 阅读9分钟

堆的概念

首先堆是一个完全二叉树,堆可以分为大顶堆,小顶堆。大顶堆指的是root节点大于左右子孩子节点。小顶堆指的是root节点小于左右子孩子节点。

如何用数组来表示二叉树

首先看下面这个图:

image-20210311151430835

我们把二叉树的节点按照层从左到右依次填充到数组中。如果当前节点是第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。

针对非完全二叉树应该如何用数组表示呢?如下图所示:

image-20210311152952007

我们在空的节点处用# 号表示,比如B节点没有左右子节点,那么在往数组中填充时要用#表示。这样一来,我们在上面总结的节点下标依然适用。比如C节点的下标为3,其左孩子节点D下标为6,右孩子E下标为7,父节点A下标为1.

为了表示方便,我们在用数组表示二叉树时,尽量不要出现上面的这种用特殊符号填充的情况,这也就从侧面反映了堆的一个定义,堆heap必须是一个完全二叉树。

大顶堆,小顶堆用数组表示

如下图所示:

image-20210311154320397

我们可以从上图看到,堆都是一个完全二叉树。对于大顶堆来说,root节点都大于它的左右子节点。小顶堆,root节点都小于它的左右子节点。

堆的插入

接下来我们拿大顶堆来看一下,堆是如何插入一个元素的。

还是上面那个大顶堆,如果要插入一个元素60,应该把它插入到哪个位置呢?首先我们先把60放到50 这个位置上看一下:

image-20210311155637489

因为60占了50的位置,然后根据大顶堆的定义,50要下沉,如果50和20比较,比20大,所以20下沉,20和16比较,比16大。16最后成为20的右孩子,最后如上图右所示。这样一来的话,就变成了一个非完全二叉树,不符合堆的定义。

那么正确的姿势是什么样子呢?

image-20210311160204047

如上图所示,我们直接将60放在了数组末尾,此时下标为8,8是下标4 的左孩子,所以在二叉树的位置上应该是元素15的左孩子。此时这个树还不是一个大顶堆,接下来,就要开始调整这个树,让其重新成为大顶堆。

image-20210311163846821

  1. 首先60和15比较,大于15,然后和15交换位置。
  2. 此时60再和30比较,大于30,然后和30交换位置。
  3. 最后60和50比较,大于50,然后和50交换位置。

调整过后就变成了上图右边的情形。我们分析一下其时间复杂度。从上图可知,我们经历了3次交换调整,这3次调整主要依赖树的高度,也就是说lgn,所以其时间复杂度为O(lgn).

堆的删除

接下来我们看一下如何从大顶堆中删除一个元素。比如删除root节点50. 比如按照以下方式删除50节点:

image-20210311164505158

删除50后,需要孩子节点补位,30比较大,然后30补上去,然后30这个位置又空了,需要一个比较大的孩子节点再补上去,于是15上去了。最后变成了上图右的样子。但是,可以看出已经不是一个完全二叉树了,不符合堆的定义。所以此法行不通。

正确的方法应该是,删除50后,将最后一个节点16补位进行(选择16进行补位,主要是为了保证堆是一个完全二叉树的规则,选择最后一个节点补位到root节点,调整起来会更快一些),然后再对整个二叉树进行调整。如下图所示:

image-20210311165536306

  1. 首先删除50这个节点,然后将最后的节点16进行补位。
  2. 接下来调整这个二叉树,16和其两个子节点相比,将30替换上去,和16进行交换
  3. 16再和其左右子节点相比,发现已经比左右子节点大了,无需调整。整个堆调整结束。

整体下来,删除一个元素最多发生3次交换,这也和树的层高有关,所以时间复杂度依然是O(lgn).

如果我们把删掉的元素50放在这个数组最后面,只不过下标7这个位置已经不属于这个二叉树了:

image-20210311165948480

然后我们再删除堆顶元素30,调整后如下所示:

image-20210311170157237

如果依次进行下去,数组的数据就变成了,[8,10,15,16,20,30,50], 其实这就是堆排序。

总结一下,堆的插入是一个从下往上调整的过程,而堆的删除是一个从上往下调整的过程。无论是插入还是删除的时间复杂度都是O(lgn)。

接下来我们讲一下堆排序。

堆排序

堆排序就是把一个数组按照堆的方式,将其最终形成有序数组。比如数组[10,20,15,30,40]。堆排序有两个步骤:

  1. 生成堆。

  2. 删除root节点,然后再调整堆。

接下来我们使用这个数组来走一遍堆排序的过程。

步骤一生成堆:

  1. 先遍历第一个元素10,相当于这个堆只有一个元素,无需调整。
  2. 然后遍历第二个元素20,插入这个堆,作为10的左孩子,20比10大,做一次调整,把20放到root节点,10作为左孩子节点。
  3. 遍历第三个元素15,插入堆,作为20的右孩子,此时也满足堆的定义,所以无需调整。
  4. 遍历第四个元素30,插入堆,作为10的左孩子,这个时候30大于其父节点10,所以要调整,30与10交换,然后再与20交换。
  5. 最后遍历第五个元素40,插入堆,40作为20的右孩子,比20大,做两次调整。

具体如下图所示:

image-20210311192108756

image-20210311192123742

image-20210311192140712

这里我们分析一下堆排序的时间复杂度,因为要先遍历整个数组,所以是O(n),每次调整有可能要向上调整,这个跟层数有关,所以时间复杂度为O(lgn),综合起来就是O(nlgn)。

步骤二,删除堆:

  1. 首先删除头节点40,然后将最后一个节点20补到头节点处,最后调整堆,调整到最后头节点为30。
  2. 再删除头节点30,然后将最后一个节点10补到头节点处,最后调整堆,调整到最后头节点为20。
  3. 再删除头节点20,然后将最后一个节点15补到头节点处,最后调整堆,调整到最后头节点为15。
  4. 再删除头节点15,此时只剩下节点10。
  5. 最后删除节点10.

image-20210311195419993

image-20210311195436379

image-20210311195449853

image-20210311195505243

最后得到了排序后的数组[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目前队列中优先级最高的元素时,我们只需要将堆顶元素返回,然后删除即可,最后再次调整大顶堆即可。