面试怕写堆算法?7分钟带你搞定原理!

327 阅读6分钟

堆的基本概念

    堆是一棵二叉树,且是一棵完全二叉树。什么是完全二叉树?先介绍一下什么是满二叉树,满二叉树是指除最后一层叶节点没有子节点外,其余每个节点都有两个子节点的二叉树。如下图所示:



    满二叉树一定是一棵完全二叉树,当满二叉树最后一层不满时,且最后一层从左到右都为连续的,则被称为完全二叉树,下面几种都是完全二叉树。


​堆的性质

    由于堆是完全二叉树,从完全二叉树的根节点开始,从上到下,从左到右依次从0开始编号。


    观察编号规则我们可以得出一条重要结论:如果完全二叉树的某一节点编号为n,则其左子节点(如果存在)编号为2n+1,其右子节点(如果存在)编号为2n+2,其父节点(如果存在)编号为(n-1)/2。


     从上图可以看出,堆作为一棵完全二叉树,其存储结构可以直接使用数组进行存储,数组的下标即为堆的节点位置标号。

大顶堆与小顶堆

    堆的这种看似无序的数据结构,如果给堆中父节点和其子节点增加某种规则,则可以生成两种非常有用的结构:大顶堆和小顶堆。

大顶堆:父节点的键值大于等于任一子节点的键值。

小顶堆:父节点的键值小于等于任一子节点的键值



    因此,大顶堆根节点是堆中最大的节点,其他节点都小于等于根节点;小顶堆则相反。

堆的算法

    结合堆的数组存储结构和大小顶堆的定义,堆的操作主要为“添加元素”和“删除元素”。本文以大顶堆为例,介绍堆操作的实现原理。

添加元素

    如果堆为空,则直接创建堆的根节点;若不为空,假设堆为[88,66,43,45,33,8,24,16,29,4],添加76,则需要执行以下步骤:

  1. 将该元素添加在堆末尾;

  2. 比较该元素和其父节点,若大于父节点,则与父节点交换后,再和新的父节点比较,如果还大于父节点,则继续交换,直到没有父节点或者小于等于父节点为止。

step1: 先将76添加到堆末尾


step2:比较其与父节点大小,76 > 33,交换两者位置,继续向上比较。


step3: 继续比较其和父节点大小,76 > 66,交换两者位置,继续向上比较。


step4:继续比较,此时该节点小于其父节点,向上比较结束,添加的元素76找到了最终的位置。


    可以看出,堆中添加一个元素,需要比较交换的次数最多为该堆的高度,即log2(N)。这种自底向上调整使得重新满足堆的性质过程,我们称为向上调整(siftUp)。

删除元素

    堆删除元素与添加元素不同,添加元素始终先添加在堆末尾,然后再向上调整至结束;而删除元素则可以是删除堆中任一位置的元素。下面以大顶堆为例介绍堆中删除元素操作的实现原理。

1、当删除的节点是根节点时,操作如下:

  1. 将待删根节点和堆尾节点交换后删除;

  2. 此时将新的头节点与其左右子节点比较,将其中较大的节点与头节点比较交换;此时再比较该被交换下来的“头节点”和其子节点,并继续比较交换,直到其无左右子节点或大于等于任一子节点为止。

    这种自顶向下调整的过程,我们称为向下调整(siftDown)。

step1: 待删除元素88为堆的根节点。


steps2:将堆尾节点33和待删除的根节点88交换后,删除堆尾节点88。


step3:比较33和其左右子节点76和43,将较大76和33进行比较,大于33,交换76和33的位置。


step4:继续比较33和其左右子节点45,66,将较大的66和33比较,大于33,交换66和33的位置。


step5:继续比较33和其左右子节点4,其只有一个节点,且33>4,则无需交换,向下比较结束。


2、当删除的节点是中间某节点时,操作如下:

  1. 与删除根节点一样,将待删节点和堆尾节点交换后删除。

  2. 由于被删除的是中间节点,若交换后节点大于其父节点,则与其父节点交换并进行向上调整(siftUp)直至调整结束;若交换后节点小于其子节点,则将其中较大的子节点与其比较交换并进行向下调整(siftDown)直至调整结束。

step1:待删除元素76为中间节点。


step2:将待删节点76与堆尾节点33交换后删除。


step3:交换后的33节点小于其父节点88,满足大顶堆的性质,则无需向上调整;则将其与子节点进行比较,向下调整,将其中较大的子节点66和33比较交换位置。


step4:将33继续进行向下调整,33大于其子节点,则调整结束。


3、当删除的节点是堆尾节点时,则直接删除即可,因为并不会影响堆的性质。


堆的算法实现

    从前文可知堆的操作始终围绕两个核心过程:向上调整(siftUp)向下调整(siftDown)。以大顶堆为例实现代码如下:

/**
* arr[i],父节点arr[(i-1)/2]
**/
private void siftUp(int i) {
    int parent = (i - 1) / 2;
    while (parent >= 0) {
        if (((T)elements[i]).compareTo((T)elements[parent]) > 0) {
            swap(elements, i, parent);
            i = parent;
            parent = (i - 1) / 2;
        } else {
            break;
        }
    }
}

/**
*arr[i],其子节点为 arr[2i+1],arr[2i+2]
*/
private void siftDown(int i) {
    int l = 2 * i + 1;
    while (l < count) {
        //假定l是最大的
        int r = l +1;
        //将最大的设置为l
        if (r < count && ((T)elements[r]).compareTo((T)elements[l]) > 0){
            l = r;
        }
        //最大的child比较交换
        if (((T)elements[l]).compareTo((T)elements[i]) > 0){
            swap(elements,l,i);
            i = l;
            l = 2 * i + 1;
        }
    }
}

完整代码

https://gitee.com/programmer_online/codes/7vgta3ymekr80ucx2fjnq33

总结

本文主要讲述了堆的数据结构,及其元素添加、删除算法的实现原理,最后给出了完整代码。下一篇将介绍堆在Java中的实现:PriorityQueue(优先级队列),随后我们可以轻松搞定关于大小堆的大部分算法难题。

欢迎关注公众号:程序员修仙,收看更多精彩修仙内容!