今天开始学会二叉堆

581 阅读5分钟

Date:2021/02/17

小小的我,依然想要和世界碰撞


什么是堆

堆(Heap),也称为二叉堆,是一棵具有如下性质的完全二叉树

  • 任意父节点的值都大于或等于子节点的值,这种堆称为大顶堆
  • 任意父节点的值都小于或等于子节点的值,这种堆称为小顶堆

堆的这种性质决定了最大堆的堆顶元素(根节点)的值是整个堆中最大的,最小堆的堆顶元素的值是整个堆中最小的。利用这种性质,堆常用于堆排序、优先队列等。

堆的表示

虽然说堆是一颗完全二叉树,但是我们常用数组来表示堆,这却决于完全二次树的特性:n(n0)n(n\ge0)各节点的完全二叉树从上到下,从左到右从00开始编号,对于第i(0i<n)i(0\le i<n)各节点:

  • i==0i==0,则为根节点
  • i>0i>0,则父节点的编号为floor((i1)/2)floor((i-1)/2)
  • 如果2i+1<n2i+1<n,则左孩子的编号为2i+12i+1,否则无左孩子
  • 如果2i+2<n2i+2<n,则右孩子的编号为2i+22i+2,否则无右孩子

所以一棵完全二叉树可以映射为一个数组,数组长度为节点的个数,上面的编号也就是数组的下标,如下图所示。

编号的顺序和数组的存储其实就是层次遍历的结果。

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,然后对堆顶节点进行下沉操作。

下沉过程之前已经提到了。

创建堆

创建一个堆,就是将一棵普通的完全二叉树的所有非叶子节点依次(按照数组下标从大到小的顺序)进行下沉操作。

关于最后一个非叶子节点有如下性质:

  • 对于一棵有nn个节点的完全二叉树,最后一个非叶子节点的索引为(n2)/2(n-2)/2

将下面的一棵完全二叉树转为大顶堆,需要对非叶子节点(红色节点)从后往前依次进行下沉操作,

首先节点21和节点14已经满足了大顶堆的性质,不需要进行任何操作,

然后对节点2进行下沉操作,

然后对节点10进行下沉操作,二叉堆构建完成。

复杂度分析

堆的插入实际上是单个节点的上浮操作,堆的删除实际上是单个节点的下沉操作,所以堆的插入和删除的时间复杂度都是O(logn)

而构建一个堆是对非叶子节点都进行下沉操作,但是时间复杂度不是简单的O(nlogn),而是**O(n)**,这是因为每一层的非叶子节点最多下沉的层数是不一样的,对于一棵有nn个节点的完全二叉树,其高度可以认为是h=lognh=logn

  • 00层有11个节点,最多需要下沉h01h-0-1

  • ii层有2i2^i个节点,最多需要下沉hi1h-i-1

  • 因此将每个节点的时间相加有:S=20(h1)+21(h2)++2h1(0)S=2^0*(h-1)+2^1*(h-2)+\cdot\cdot\cdot+2^{h-1}*(0)

利用错位相减法求该数列的和,即可得到时间复杂度为O(n)