堆总结-bilibili大学左神课的上课笔记

281 阅读9分钟

堆-听左神科笔记

堆的结构

存储结构

首先,堆的存储结构是一个数组,将这个数组想象成完全二叉树,就是堆

%E5%A0%86%E6%95%B0%E7%BB%84.drawio.svg

普通数组的下标从0开始,比如这个数组,是8个元素,那下标就是0-7。

堆的大小

我们用 heapSize来表示,heapSize 就表示这个数组中属于堆的数据的长度,在这个数组中也就是8。要注意我们不管这个数组下标7后面有没有数字,堆关心的是 heapSize 范围内的数字,这个8指的是0-7的元素,如果数组的第九个位置也有值,但是堆的大小是8,那第九位跟堆没有任何关系。

完全二叉树

堆是用数组模拟构建的完全二叉树,二叉树的数节点个数也是堆的大小,完全二叉树的定义可看下面的文章 zhuanlan.zhihu.com/p/152285749

完全二叉树的父节点可能有两个孩子,左孩子和右孩子。如果只有一个子节点,那只能是左子节点。 %E5%A0%86%E5%B7%A6%E5%AD%A9%E5%AD%90%E5%8F%B3%E5%AD%A9%E5%AD%90.drawio.svg

左子节点

如果当前位置是数组索引下标 ii,那左孩子从数组中表示是 2i+12i+1,如[上图]所示

右子节点

如果当前位置是数组索引下标 ii,那右孩子从数组中表示是 2i+22i+2,如[上图]所示

父节点

当前节点 ii 的父节点可以如下图所示计算:

%E5%A0%86%E7%88%B6%E8%8A%82%E7%82%B9.drawio.svg

如果当前位置是数组下标 ii,那右孩子从数组中表示是 i12\frac{i - 1}{2},省略掉小数部分

大根堆

要保证父节点大于等于它的两个孩子节点里面最大的那一个(因为完全二叉树不要求倒数第二层是否都有两个孩子),父节点大于等于它的唯一的一个左孩子节点也是可以的(因为如果这个父节点有一个节点,考虑到完全二叉树那只能是左节点,这个自己想想)。

也就是说一定要是这种结构

%E5%A0%86-%E5%A4%A7%E9%A1%B6%E5%A0%86.drawio.svg

小根堆

要保证父节点小于等于它的两个孩子节点里面最小的那一个(因为完全二叉树不要求倒数第二层是否都有两个孩子),父节点小于等于它的唯一的一个左孩子节点也是可以的(因为如果这个父节点有一个节点,考虑到完全二叉树那只能是左节点,这个自己想想)。

也就是说一定要是这种结构

%E5%A0%86-%E5%B0%8F%E9%A1%B6%E5%A0%86.drawio.svg

完全二叉树的子树

完全二叉树的每个节点都可以看成整个二叉树的子树

%E5%A0%86%E5%B7%A6%E5%AD%A9%E5%AD%90%E5%8F%B3%E5%AD%A9%E5%AD%90.drawio.svg

比如这张图里面,以0为根节点的树包含了,以1为跟节点的子树 和 以 2为根节点的子树

堆的操作

堆初始化

一开始肯定给你一个普通数组,然后我们需要让这个数组变成稳定的完全二叉树,这就是堆的初始化。

😈 堆的初始化就是模拟将一个数组的不同下标进行交换,模拟出一个假想的完全二叉树的过程

什么叫堆的稳定?

如果该节点的父节点的值大于等于该节点的值,该节点的值大于等于子节点中的最大值, 那我们称该堆为大根堆

如果该节点的父节点的值小于等于该节点的值,该节点的值小于等于子节点中的最小值, 那我们称该堆为小根堆

在研究堆的初始化此之前,我们需要先看下初始化涉及的主要操作:heapInsertheapify

以下的操作都是按照大顶堆的方式构建堆

heapInsert : 堆中插入新元素,让堆稳定而执行的上浮操作

先把元素放在数组/完全二叉树的最后一个,然后对最后的这最后一位进行 heapInsert 操作,让这个堆保持大顶堆或小顶堆的稳定状态。

假设当前插入到了ii 位置,首先判断当前节点的父节点,也就是 当前数组的 i12\frac{i - 1}{2} 位置的元素是否比 当前值要小,如果父节点比子节点小,就交换他们

然后再判断到新位置之后,再跟其父节点进行比较,重复这个过程,直到该节点找到了父节点比自己大,子节点比自己小的位置!堆就稳定了!

😈 具体的heapInsert操作可以看 [一个完整的堆初始化的过程],初始化流程里面执行的是完整的 heapInsert 操作

代码表示为

// index 表示该节点从哪里插入
// 如果是按照从堆最后位置插入,那就是完全二叉树最后一个节点
// 当然也可以从堆的任意位置插入,反正完全二叉树的每个节点都可以看成整个二叉树的子树
public static void heapInsert(int[] arr, int index) {
        // 如果父节点的值小于当前节点,就进入操作
        // 这个 while 判断包含两个判断
        // 1. 是否当前节点比父节点大
        // 2. 是否当前节点到根节点了,也就是(index - 1) / 2 == 0 ,就进不来了  
        while (arr[index] > arr[(index - 1)/2]){
                // 交换 父节点 和 当前节点
                swap(arr, (index - 1)/2, index);
                // 更新成父节点的指针
                index = (index - 1)/2;
        }
}

heapify:堆中删除某一个节点时,让堆稳定而执行的下沉操作

最常见的使用场景是,比如我有一个大顶堆,稳定的,现在我想让你把这个堆中最大的值返回,并从堆中删除,实现这个操作的核心逻辑就是 heapify!

%E5%A0%86%E6%95%B0%E7%BB%84.drawio.svg

比如用这个数组生成的堆如下:

%E4%B8%80%E4%B8%AA%E6%95%B0%E5%88%97.drawio.svg

将这个堆最后一位复制到第一位,然后对这个堆从第一位开始执行 heapify 操作的

😈 其核心是判断当前位置的节点跟子节点最大值的关系,然后将当前节点下沉到让堆稳定的位置,即该节点的父节点的值大于等于该节点的值,该节点的值大于等于子节点的最大值

过程如下:

堆-获取堆顶并删除,为了使堆稳定的而执行的下沉的操作.drawio.png

代码如下

// index 表示从哪个位置开始要进行 heapify操作,可能是根,也可能是其他位置,因为完全二叉树任意节点作为根节点的树都是整个完全二叉树的子树
// heapSize 是堆的大小,是这个堆占用这个数组的大小,数组的其他位置,不属于这个大小的位置可以右值,但是根堆没啥关系
public static void heapify(int[] arr, int index, int heapSize) {
        // 左孩子的 下标
        int leftIndex = 2 * index + 1; 
​
        // 如果左孩子没有超过堆的大小,即该节点有左孩子
        while (leftIndex < heapSize){
            
                // 如果有右孩子,就判断左孩子和右孩子的值谁大,找出对应大的子节点的下标
                // 如果没有右孩子,就直接用左孩子的下标
                int largeSonIndex = (leftIndex + 1 < heapSize) && (arr[leftIndex + 1] > arr[leftIndex])
                                        ? leftIndex + 1 : leftIndex;
                
                // 将子节点的值与自己的值比较,如果自己大,那就不用比较了,已经稳定了,不需要交换
                int largeIndex = arr[index] > arr[largeSonIndex]            
                                        ? index : largeSonIndex;
                
                // 如果最大值索引还是自己,那就不用动了
                if (largeIndex == index){
                        break;
                }
​
                // 找出这个最大子节点的值,将它与当前节点的值交换
                swap(arr, largeIndex, index);
                // 更新当前节点的索引为这个最大子节点的索引
                index = largeIndex ;
                // 更新左子节点的值,准备进入下一轮
                leftIndex = 2 * index + 1;
        }
}

一个完整的堆初始化的过程

用之前的这个数组 arrarr ,用大顶堆的方式初始化

%E5%A0%86%E6%95%B0%E7%BB%84.drawio.svg

初始化的过程如下:

堆-堆初始化.drawio.png

堆的其他知识

复杂度

完全二叉树的节点和高度的关系是 logNlogN

在进行 heapInsert 的操作时,是针对以个高度的一条线上的节点左处理,所以是 logNlogN

在进行 heapify 操作的时候,也是针对二叉树一条线上的节点做处理,所以也是 logNlogN

堆排序

其实堆排序就是先将数组生成大根堆或小根堆之后,一次一次的重复将堆顶拿走,将堆最后一个扔到堆顶,之后进行堆的稳定操作的过程

比如一个小根堆,堆顶是堆数组里面最小的数,我们把堆顶拿走之后,将堆最后一位拿到堆顶,进行heapify 操作,稳定后,继续重复这个过程,每次拿到的都是数组中剩余的最小的值,这样就就能得到排好序的数组

当然那种方式需要使用新的数组去存储每次出来的值,我们也可以用原数组的不属于堆的位置来存储对应的值,所以这样就可以让大根堆可以排序出从小到大的数组。

public static void main(String[] args) {
​
        int[] arr = {7, 6, 5, 11, 3, 2, 1};
​
        // 循环将该堆进行初始化
        for (int i = 0; i < arr.length; ++i) {  // O(N)
            heapInsert(arr, i);  // O(log(N))
        }
​
        // 所以上面的时间复杂度是 O(N * log(N))
​
        // heapSize
        int heapSize = arr.length;
        // 将最大值扔到数组的最后一位,并让heapSize减少一个
        swap(arr, 0, --heapSize);
        // 此时堆不稳定了,循环让堆保持稳定,然后依次将最大值扔到堆不要的数组位置上
        while (heapSize > 0){  // O(N)
            // 进行下沉操作将堆稳定
            heapify(arr, 0, heapSize); // O(log(N))
            // 将最大值扔到 heapSize - 1的位置,并让 --heapify
            swap(arr, 0, --heapSize);
        }
​
        // 所以最终时间复杂度是 O(N * log(N))
        MergeSort.printArr(arr);
    }

拓展:只是为了堆初始化,非要用heapInsert吗?

并不是,我们可能会发现一个堆数组要从头开始 初始化,是有一些浪费的,因为这个二叉树的叶子节点是不需要主动动的,所以一开始我们从头开始不是最优的

最优解是从这个树的倒数第二层开始往回进行 heapify,这样只用 部分节点的 heapify 就可以将整个堆初始化,而且减少了一定的时间复杂度!

// 循环将该堆进行初始化
for (int i = 0; i < arr.length; ++i) {  // O(N)
    heapInsert(arr, i);  // O(log(N))
}

可以写成

// 循环将该堆进行初始化
for (int i = (arr.length - 1)/2 ; i > 0; --i) {  // 不到 O(N)
    heapify(arr, i, arr.length);  // O(log(N))
}

重点复习

  1. 堆结构就是用数组实现的完全二叉树结构
  2. 完全二又树中如果每棵子树的最大值都在顶部就是大根堆
  3. 完全二又树中如果每棵子树的最小值都在顶部就是小根堆
  4. 堆结构的heapInsert与heapify操作
  5. 堆结构的增大和减少
  6. 优先级队列结构,就是堆结构

堆拓展

Untitled 1.png

可以用堆 ,用 PriorityQueue 就行,见 leetCode 面试题 17.14. 最小K个数

java中的小根堆

PriorityQueue 默认是小根堆,直接用就好,就比如这个题 [面试题 17.14. 最小K个数]

/**
     * 测试 优先队列
     *
     * @author jy
     * @date 2022/11/5 16:29:50
     */
    public static int[] testPriorityQueue(int[] arr, int k) {
        PriorityQueue<Integer> heap = new PriorityQueue<>();
        int i = 0;
        int j = 0;
        // 都加到 heap 里面
        for (; i < arr.length; ++i) {
            heap.add(arr[i]);
        }
                // 弹出 k 个
        while (!heap.isEmpty() && j < k) {
            temp[j++] = heap.poll();
        }
        return temp;
    }

如何扩容?

成倍扩容 ,扩容的代价也是 LogN,所以不用担心复杂度