在前面分析了二叉搜索树
、红黑树
等众多树结构
,今天博主给大家换个口味,深入分析一下堆
的实现原理与维护规则。(堆
其实与二叉树
有点相似)
一、堆
的概述
1、什么是堆
堆
,在日常生活中是一个量词,比如:一堆木头
,下面这张图就是一堆木头
,大家注意它的摆放规则,成金字塔型
(上窄下宽)。
数据结构中的堆
,与上面的结构类似,不过借助了二叉树
的结构。肯定会有小伙伴来一句,woc,这不就是二叉树
么。。。其实你也可以暂时这么理解,看完博客你就会发现还是有些区别。
2、堆
的划分
根据堆
中元素摆放顺序的不同,可分为两种堆结构,小顶堆
、大顶堆
。
小顶堆
:对于堆中的任意节点A
,如果它存在子节点B、C,则 节点A
的值 ≤ min{子节点B
的值, 子节点C
的值}
大顶堆
:对于堆中的任意节点A
,如果它存在子节点B、C,则 节点A
的值 ≥ max{子节点B
的值, 子节点C
的值}
堆只对父节点
的值与子节点
的值有大小限定,但是对于节点
的左、右子节点
的值相对关系没有限定(二叉搜索树
的特征才是 左子节点
< 父
< 右子节点
)。
3、堆
的作用
讲了半天,也画了好几张图,辣么堆
有什么作用呢?
根据前面小顶堆
、大顶堆
的定义,我们知道小顶堆
的堆顶
存放的是堆中最小
的元素,大顶堆
的堆顶
存放的堆中最大
的元素。而这正是堆
的作用,可能会有小伙伴一脸鄙夷,就这找最值的功能都要特意设计一个数据结构
来实现?
那来一道面试题,给你10亿
个数,如何在最短的时间里找出最大的10
个? (我觉得如果没做出这道题,辣么只能说了解堆的定义,但是不理解、不会运用堆
,边看博客,边思考吧,文末附答案)
二、堆
的底层实现
堆
的实现一般使用数组
,而不是二叉树
什么的。从形态来看,堆不就是棵二叉树
么,为啥不用二叉树
而用数组
呢?主要原因是为了随机访问
(通过下标访问元素
)。本篇博客将只讨论使用数组
实现的堆
,如果你偏要用二叉树
,可以自己实现一个。
以数组
实现堆
不仅带来了随机访问
(通过下标访问元素
),其实还有两个很重要的规律
。
- 下标为
index
的左、右孩子的下标分别是index * 2 + 1
、(index + 1) * 2
- 堆中有子节点的节点最大下标为
size / 2 - 1
(注意:size
为堆的大小,不是数组的大小)
三、堆
的维护
首先说明一下,堆
一般对外展示的只有堆顶
,也就是说堆
的插入
、移除
,在外界看来都是在堆顶
操作。(下面的维护都是基于小顶堆
,大顶堆
的维护操作是类似的。)
1、扩容
如果数组
没有剩余空位置,此时需要对数组
进行扩容
。由于基于数组
实现的堆
只有数组
这个结构,堆
只是通过数组下标
在逻辑
上存在,所以只要将数组
中的元素按原来的顺序复制到一个更长的数组
即可。
2、插入
元素(上浮)
对于插入操作,我们首先将元素放到下一个空闲
的位置,然后对插入的元素进行上浮
操作。
插入元素上浮伪代码如下:
// data是堆数组,size是堆的大小(并不是data数组的长度),element是待插入的节点
void insert(int data[], int &size, int element) {
// 假设data有空位置插入,将待插入元素直接放到堆尾的下一个空位置
int insertIndex = size++;
data[insertIndex] = element;
// 上浮插入节点
while (insertIndex > 0) {
// 求出父节点的下标(根据父节点下标能求出左右子节点下标,那么根据子节点下标同样也能求出父节点下标)
int parentIndex = (insertIndex - 1) / 2;
// 如果插入的元素大于父节点
if (data[insertIndex] > data[parentIndex]) {
// 上浮完毕
break;
}
// 否则插入的元素小于父节点元素的值,交换
swap(data[insertIndex], data[parentIndex]);
// 更新插入元素的下标,继续上浮
insertIndex = parentIndex;
}
}
3、删除
(堆顶)元素(下沉)
插入元素是将尾端
元素上浮
,而删除
堆顶元素,是将堆尾
元素放入堆顶,然后将该元素下沉
。
删除元素下沉伪代码如下:
// data是堆数组,size是堆的大小(并不是data数组的长度)
void deleteTop(int data[], int &size) {
if (size == 0) {
// 堆为空,无需进行删除操作
return;
}
// 将堆尾替换堆顶
data[0] = data[--size];
// 记录需要下沉节点的下标,寻找最大的有子节点的下标(前说过这是一条规律)
int index = 0, lastHaveChildIndex = size / 2 - 1;
// 只要当data[index]存在子节点才有下沉的必要
while (index >= lastHaveChildIndex) {
// 右子节点必定有左子节点(左子节点的公式: 父节点下标 * 2 + 1)
// minChildIndex用于记录左、右子节点更小的下标
int minChildIndex = index * 2 + 1, rightChildIndex;
if ((rightChildIndex = index * 2 + 2) < size && data[minChildIndex] > data[rightChildIndex]) {
// 如果index存在右子节点,并且右子节点的值比左子节点的值小,更新minChildIndex
minChildIndex = rightChildIndex;
}
// 如果下沉节点data[index]比左右子节点最小值都大,停止下沉
if (data[index] >= data[minChildIndex]){
break;
}
// 否则与左、右子节点较小则交换,并且更新index,继续下沉
swap(data[index], data[minChildIndex]);
index = minChildIndex;
}
}
四、总结
堆中插入
元素,直接放入数组的下一个空位置
,然后上浮
插入元素,即可完成堆的调整;删除堆顶
元素,将堆尾
的元素替换堆顶
,再对堆顶的元素进行下沉,即可完成堆的调整。(再次强调一下,基于数组
实现的堆
,只有数组
这个结构,堆
只是根据数组下标
的依赖关系在逻辑
上存在。如果你将堆的实现修改为二叉树
,那么堆
才是真正存在。)
现在提下前面提出的面试题的答案,给你10亿
个数,如何在最短的时间里找出最大的10
个?
我们只要构建一个大小为10的小顶堆
,前期取10个数直接插入堆中,然后对剩下的10亿-10
个数,每此取出一个都与堆顶
(堆中最小值)比较,如果比堆顶
大,则替换堆顶,接着调整堆(下沉堆顶元素
即可),最终堆中的元素就是10亿
个数中最大的10
个数。(如果答案都没看懂,再看一遍博客吧,有点走马观花哦)