堆的引入与实现
引言
在介绍堆之前,我们先要来看看完全二叉树结构,那什么是完全二叉树呢?不明白的小伙伴可以百度一下,这个概念很简单,相信你几分钟就能明白什么是完全二叉树
对于完全二叉树中的某个节点 i,我们有以下的结论:
① 如果节点 i 有左孩子,那么节点 i 的左孩子下标为 2 i + 1
② 如果节点 i 有左孩子,那么节点 i 的右孩子下标为 2 i + 2
③ 节点 i 的父节点下标为 ( i - 1 ) / 2
你可以在上述示例图中任意找节点来验证一下结论是否正确
定义
堆结构就是用数组实现的完全二叉树结构,我们可以将数组从下标 0 出发的连续一段数据对应成完全二叉树
举个栗子如下:
在数组结构中,我们定义一个变量 size 来记录完全二叉树对应的数组那一段的长度,如上述例子,size = 5,表明此时二叉树对应的是数组从 0 位置到 5 位置的这一段数据;那么二叉树中的位置关系怎么描述呢,我们可以使用刚刚提到过的结论,即节点对应的左孩子下标、右孩子下标和父节点下标的计算方法,我们就可以得出数组中每个数对应的左孩子是哪个,右孩子是哪个,以及父亲节点是哪个。举例说明一下,对于下标为 2 的节点,它的左孩子下标为 2 i + 1 = 5;右孩子下标为 2 i + 2 = 6 > size (size = 5), 所以它不存在右孩子;它的父亲节点的下标为 ( i - 1 ) / 2 = 0 。 对于节点下标为 0 的节点,它的父节点为 ( i - 1 ) / 2 = 0 ,即它的父节点就为它本身
通过 size 和父子节点下标之间的关系,我们就可以成功地将完全二叉树和数组对应起来
堆是特殊的完全二叉树,它分为了大根堆和小根堆两种,在大根堆中,父节点的值比每个子节点的值都要大;在小根堆中,父节点的值比每个子节点的值都要小,举例如下:
在左边的大根堆中,6 比它的子节点 5 和 4 都大,5 也比它的子节点 3 和 4 都大,4 也比它的子节点 3 和 0 都大;对于右边的小根堆,每个父节点的值都比它的子节点要小
实现
堆的上浮
思路
我们现在知道了堆的定义,也知道堆分为大根堆和小根堆,那么我们要怎么实现大根堆或小根堆呢?
假设用户依次给我们一个数字,我们需要存入数组中,并使得数组所对应的是一个大根堆,我们现在来看看要怎么达到这个要求
我们先定义一个变量 heapsize = 0 ,这个变量用于表明数组中从 0 出发的 heapsize 个数构成我们的堆,最开始是 0 个数
(1)现在用户给了我们一个数字 5,我们要把它放在哪个位置呢?由 heapsize 可知,我们应该把它放在 0 位置上,并把 heapsize + 1,表明数组中从 0 出发的 1 个数构成我们的堆,那么它此时是不是大根堆呢?当然是大根堆
(2)然后用户又给了我们一个数字 3,此时是不是大根堆呢,还是大根堆 ;3 放在数组下标为 1 的位置上,并且 heapsize + 1, 此时 heapsize = 2
(3)用户接着给我们一个数字 6,这时候就不再是大根堆了, 那怎么调整呢?让这个数字和它的父节点进行比较。由 heapsize 可知,这个数字 6 被放在了数组下标为 2 的位置上,并且 heapsize = heapsize + 1 = 3
我们根据引言中说到的父子节点下标的关系,可以通过数字 6 的下标 2 计算出它的父节点下标为 ( 2 - 1 )/2 = 0,于是数字 6 和它的父节点进行比较,即与下标为 0 位置上的 5 进行比较,发现子节点 6 是大于父节点 5 的,因此将它们两的位置调换,这样调整完之后就又是大根堆了
(4)用户继续给我们一个数字 7,此时 7 应该放在数组中下标为 3 的位置,heapsize ++ ,此时 heapsize = 4
数字 7 根据自己的下标可以找到它的父节点下标为 ( 3 - 1 )/ 2 = 1,将数字 7 与它的父节点进行比较,7 > 3, 于是它们两个交换位置
数字 7 继续通过下标找到它此时的父节点,得到此时父节点下标为 ( 2 - 1 )/ 2 = 0,而 7 > 6 , 所以它们两个交换位置
通过上面几个步骤的讲解说明,我们可以得出一个规律,即不断地与父节点进行比较,如果你比你的父大,你就与你的父交换位置;直到你不再比你的父大,或者到达了根节点
代码
将这思路换为代码,我们就得到了如下方法 heapInsert
public void heapInsert(int[] arr, int index) {
while (arr[index] > arr[(index - 1) / 2]) { // 子节点与父节点的值进行比较,如果子节点的值大于父节点的值
swap(arr, index, (index - 1) / 2); // 子节点和父节点交换位置
index = (index - 1) / 2; // index 来到他父的位置,继续循环
}
}
这里我要提一嘴,我们刚才分析到字节点与父进行比较的结束标志为 ① 你没你的父大 ② 到达了根节点,而代码中似乎并没有判断子节点是否到达了根节点,而实际上,当子节点到达了根节点后, index = 0,此时 ( index - 1)/ 2 也等于 0,不满足循环条件 arr[index] > arr[(index - 1) / 2] , 因此同样退出循环。这里虽然只写了一个判断语句,但这一句同时包含了上述两个结束标志
堆的下沉
思路
假设用户现在想得到大根堆里的最大值,并且把这个最大值从大根堆里去掉,我们该怎么做呢?
我们首先知道大根堆里的最大值一定是根节点,我们先用一个临时变量记录一下根节点的值,然后我们要做的就是去掉这个最大值后,还要使得堆为大根堆
现举例说明如下:
数组 arr 和 对应的大根堆如下
(1)我们先用一个临时变量记录 arr[0] 上的数,然后根据 heapsize 的值得到最后一个数在数组下标为 5 的位置上,将这个数放到 arr[0] 上,并把 heapsize - 1
(2)我们将 arr[0] 的值与它的左右孩子的值进行比较,如果它比左右孩子小,那它孩子中最大的值就与它交换位置,在这个例子中,2 的左孩子为 6,右孩子为 5,所以左孩子要与它交换位置
(3)继续将数字 2 与它此时的左右孩子进行比较,得到应该与 4 交换位置
(4)这时候数字 2 没有孩子节点,于是就停止了比较,我们也就得到了大根堆
通过这几个步骤,我们同样可以得出一个规律,即将最后一个数换到数组下标为 0 的位置,然后依次与左、右孩子进行比较,如果孩子节点更大,就选择孩子节点中的最大值与自己交换;直到你孩子不再比你大,或者你不再有孩子
代码
public void heapify(int[] arr, int index, int heapSize) {
int left = 2 * index + 1;
while (left < heapSize) {// 表明此时有孩子节点
// 找出左孩子和右孩子中更大的值,
// 如果右孩子存在且右孩子的值大于左孩子,就返回右孩子的下标,否则返回左孩子的下标
int largest = left + 1 < heapSize && arr[left + 1] > arr[left] ? left + 1 : left;
// 将孩子中更大的那一个和父亲比较,如果比父亲大,则把下标给 largest
largest = arr[largest] > arr[index] ? largest : index;
// 如果孩子节点都没父亲大,则结束比较
if (largest == index) {
return;
}
swap(arr, index, largest);
// 记录 largest ,用于下一次循环比较
index = largest;
left = 2 * index + 1;
}
}
小结
堆的上浮只要与自己的父节点比较,如果比父节点大,就往上窜
堆的下沉是与自己的左、右孩子进行比较,如果比孩子小,就主动下走,让孩子中更大的值上去
最后
时间复杂度
堆的上浮与下沉的时间复杂度取决于完全二叉树的高度,因为每一次比较,我们都会走一个高度,要么往上走(上浮),要么往下走(下沉);而完全二叉树的高度为 logN 级别 (不明白树的高度为什么是 log N 级别的小伙伴可以百度一下唷),因此堆的上浮和下沉的时间复杂度都为 O(logN)
以上就是堆的基本定义以及实现,认识了堆之后,我们就可以很容易明白堆排序的实现原理了,也能逐渐体会到堆的奥妙所在
欢迎来逛逛我的博客 mmimo技术小栈