Date:2021/02/17
小小的我,依然想要和世界碰撞
什么是堆
堆(Heap),也称为二叉堆,是一棵具有如下性质的完全二叉树:
- 任意父节点的值都大于或等于子节点的值,这种堆称为大顶堆。
- 任意父节点的值都小于或等于子节点的值,这种堆称为小顶堆。
堆的这种性质决定了最大堆的堆顶元素(根节点)的值是整个堆中最大的,最小堆的堆顶元素的值是整个堆中最小的。利用这种性质,堆常用于堆排序、优先队列等。
堆的表示
虽然说堆是一颗完全二叉树,但是我们常用数组来表示堆,这却决于完全二次树的特性:各节点的完全二叉树从上到下,从左到右从开始编号,对于第各节点:
- ,则为根节点
- ,则父节点的编号为
- 如果,则左孩子的编号为,否则无左孩子
- 如果,则右孩子的编号为,否则无右孩子
所以一棵完全二叉树可以映射为一个数组,数组长度为节点的个数,上面的编号也就是数组的下标,如下图所示。
编号的顺序和数组的存储其实就是层次遍历的结果。
Java可以直接使用ArrayList数组:
public class BinaryHeap<E extends Comparable<E>>{
private ArrayList<E> elements;
// ...
}
堆的自调整
堆的添加、删除等操作可能会破坏堆的性质,为了维持堆的特性,有两种操作,分别是上浮和下沉。
以大顶堆为例。
上浮
上浮操作的本质就是对不满足堆特性的节点,向上寻找合适的节点与之替换。比如下图中的大顶堆的最后一个节点20比父节点大,不满足大顶堆的性质,所以要对该节点进行上浮操作。
首先将20与其父节点9进行比较,发现20比9大,所以将20和9进行交换,
继续将20和父节点10进行比较,发现20比10大,所以将20和10进行交换,
在和父节点21比较,此时20比21小,已经满足了堆的大顶堆的特性,所以节点20的上浮操作就完成了,得到了一个大顶堆。
实际上,在上浮的过程中,不需要每次都进行交换,可以先将上浮节点的值保存,然后需要交换时,直接将父节点的值覆盖子节点,等到上浮结束后,再用之前保存的上浮节点值覆盖交换的最后位置。
上浮操作的实现如下:
/**
* 堆节点上浮操作
*
* @param index 上浮节点的索引
*/
protected void upAdjust(int index) {
if (index < 0 || index >= elements.size()) {
throw new IndexOutOfBoundsException("堆节点索引越界!");
}
int parent = parent(index);
E temp = this.elements.get(index);
while (parent >= 0 && this.elements.get(parent).compareTo(temp) < 0) {
this.elements.set(index, this.elements.get(parent));
index = parent;
parent = parent(index);
}
this.elements.set(index, temp);
}
下沉
下沉操作和上浮操作类似,下沉是向下寻找合适的节点与之替换。下沉操作需要将父节点和左右孩子的较大值(小顶堆就是较小值)进行比较和替换,这一点与上浮不同。
比如下图中的根节点9不满足大顶堆的性质,需要下沉操作,
节点9的左右孩子中左孩子更大,将9和左孩子20比较,左孩子大,进行交换,
节点9的左右孩子中左孩子10更大,将9和左孩子10比较,左孩子大,进行交换,
节点9只有左孩子,但是左孩子此时比节点9小,满足大顶堆的性质,所以节点9的下沉操作完成。
和上浮一样,实际上不需要真正的交换。
下沉操作的实现如下:
/**
* 堆节点下沉操作
*
* @param index 下沉节点的索引
*/
protected void downAdjust(int index) {
if (index < 0 || index >= elements.size()) {
throw new IndexOutOfBoundsException("堆节点索引越界!");
}
E temp = this.elements.get(index);
int childIndex = leftChildren(index);
while (childIndex < this.elements.size()) {
// 进入循环说明有左孩子
if (childIndex + 1 < this.elements.size() && this.elements.get(childIndex).compareTo(this.elements.get(childIndex + 1)) < 0) {
//进入if说明右右孩子并且右孩子比左孩子大
// 将childIndex指向右孩子
childIndex++;
}
if (temp.compareTo(this.elements.get(childIndex)) > 0) {
break;
}
this.elements.set(index, this.elements.get(childIndex));
index = childIndex;
childIndex = leftChildren(index);
}
this.elements.set(index, temp);
}
堆的操作
添加元素
为了保证堆始终是完全二叉树,所以只能将元素添加到最后,也就是数组的最后一个位置。添加元素分为两步:
-
将新元素添加到最后(保证是完全二叉树)
-
对新元素进行上浮操作(保证堆的大小关系)
删除元素
删除元素是指删除堆顶元素,也就是删除最大值或最小值。删除元素的步骤为:
- 用最后一个元素替换堆顶元素
- 删除最后一个元素(最后一个元素是叶子节点,直接删除即可)
- 对堆顶元素进行下沉操作
下图中的最大堆,要删除堆顶元素21,用最后一个节点9替换堆顶元素,并删除节点9,然后对堆顶节点进行下沉操作。
下沉过程之前已经提到了。
创建堆
创建一个堆,就是将一棵普通的完全二叉树的所有非叶子节点依次(按照数组下标从大到小的顺序)进行下沉操作。
关于最后一个非叶子节点有如下性质:
- 对于一棵有个节点的完全二叉树,最后一个非叶子节点的索引为。
将下面的一棵完全二叉树转为大顶堆,需要对非叶子节点(红色节点)从后往前依次进行下沉操作,
首先节点21和节点14已经满足了大顶堆的性质,不需要进行任何操作,
然后对节点2进行下沉操作,
然后对节点10进行下沉操作,二叉堆构建完成。
复杂度分析
堆的插入实际上是单个节点的上浮操作,堆的删除实际上是单个节点的下沉操作,所以堆的插入和删除的时间复杂度都是O(logn)。
而构建一个堆是对非叶子节点都进行下沉操作,但是时间复杂度不是简单的O(nlogn),而是**O(n)**,这是因为每一层的非叶子节点最多下沉的层数是不一样的,对于一棵有个节点的完全二叉树,其高度可以认为是:
-
第层有个节点,最多需要下沉层
-
第层有个节点,最多需要下沉层
-
因此将每个节点的时间相加有:
利用错位相减法求该数列的和,即可得到时间复杂度为O(n)。