1、什么是堆(Heap)
堆是一种特殊的树,需要满足下面两点要求:
❝❞
- 堆是一个完全二叉树
- 堆中每一个结点的值都必须大于等于(或小于等于)其子树中每个结点的值
「第一点」,堆必须是一个完全二叉树。完全二叉树又是什么呢?它是除了最后一层,其它层的结点个数都是满的,最后一层的结点都是靠左排列。
「第二点」,堆中的每个结点的值必须大于等于(或者小于等于)其子树中每个结点的值。实际上,我们还可以换一种说法,堆中每个结点的值都大于等于(或者小于等于)其左右子结点的值。
对于每个结点的值都大于等于子树中每个结点值的堆,我们叫作「大顶堆」。对于每个结点的值都小于等于子树中每个结点值的堆,我们叫作「小顶堆」。
其中第 1 个和第 2 个是大顶堆,第 3 个是小顶堆,第 4 个不是堆。除此之外,从图中还可以看出来,对于同一组数据,我们可以构建多种不同形态的堆。
2、如何实现一个堆
要实现一个堆,我们先要知道,堆都支持哪些操作以及如何存储一个堆。
完全二叉树,比较适合用数组来存储。用数组来实现完全二叉树是非常节省空间的。因为我们不需要存储左右子结点的指针,单纯地通过数组的小标,就可以找到一个结点的左右子结点和父节点。通常我们会见到两种实现方法:第一种是数组下标从 1 开始的,第二种是从 0 开始的。
2.1、下标从 1 开始
结构如下图所示
从图中可以看出,数组下标为 i 的结点的左子结点的下标是i << 1,右子结点的下标是i << 1 | 1,父结点就是下标为i >> 1。
❝❞
- 左:i << 1 = i * 2
- 右:i << 1 | 1 = i * 2 + 1
- 父:i >> 1 = i / 2
- 叶子结点:n / 2 + 1
2.2、下标从 0 开始
从图中可以看出,数组下标为 i 的结点的左子结点的下标是2 * i + 1,右子结点的下标是2 * i + 2,父结点就是下标为(i - 1) / 2。
❝❞
- 左:i * 2 + 1
- 右:i * 2 + 2
- 父:(i - 1) / 2
- 叶子结点:n / 2
3、堆的操作
堆的几个核心的操作,分别是往堆中插入一个元素,和删除堆顶元素。
3.1、往堆中插入一个元素
往堆中插入一个元素后,我们需要继续满足堆的特性。
通常我们会把新插入的元素放到堆的最后,看是不是还符合堆的特性?如果不符合,我们需要对堆进行调整,让其重新满足堆的特性,这个过程叫作「堆化(heapify)」。
堆化实际上有两种,从下往上和从上往下。新插入一个元素堆化的过程就是从下往上的堆化的过程。
堆化其实很简单,就是顺着结点所在的路径,向上或向下对比,然后交换。过程如下图所示:
从图中可以看出,让新插入结点与父节点对比大小;如果不满足子结点小于等于父节点,我们就交换两个结点。一直重复这个过程,知道父节点之间满足上面说的两个条件。
3.2、删除堆顶元素
从堆的定义的第二条中,任何结点的只都大于等于(或小于等于)子结点的值,我们可以发现,堆顶元素存储的就是堆中数据的最大值或者最小值。
删除堆顶元素,我们通常是这同样做的,不直接删除,而是把最后一个结点放到堆顶,数组长度减 1。然后利用同样的父子结点对比方法,对于不满足父子结点大小关系的,交换两个子结点;并重复进行此过程,知道父子结点之间满足大小关系为止。这就是从上往下堆化的过程。过程如下:
从上图可以看出,因为移除的是最后一个元素,而在堆化的过程中,都是交换操作,不会出现树中的「空洞」(如下图所示),所以这种方法堆化之后的结果,肯定满足完全二叉树的特性。
堆的具体代码实现如下:
public class MaxHeap {
private final int[] heap;
private final int limit;
private int heapSize;
private final int startIndex;
public MaxHeap(int limit) {
this.limit = limit + 1;
heapSize = 1;
startIndex = 1;
heap = new int[this.limit];
}
public boolean isEmpty() {
return heapSize == startIndex;
}
public boolean isFull() {
return heapSize == limit;
}
public void push(int value) {
if (heapSize == limit) {
throw new RuntimeException("heap is full");
}
heap[heapSize] = value;
heapInsert(heap, heapSize++);
}
public int pop() {
int cur = heap[startIndex];
swap(heap, startIndex, --heapSize);
heapify(heap, startIndex, heapSize);
return cur;
}
/**
* 自下而上
*
* @param arr
* @param index
*/
private void heapInsert(int[] arr, int index) {
int parent = parentIndex(index);
while (parent > 0 && arr[index] > arr[parentIndex(index)]) {
parent = parentIndex(index);
swap(arr, index, parent);
index = parent;
}
}
/**
* 自上而下
*
* @param arr
* @param index
* @param heapSize
*/
private void heapify(int[] arr, int index, int heapSize) {
int left = leftIndex(index);
while (left < heapSize) {
int right = rightIndex(index);
// 获取左右子结点中最大值
int largest = right < heapSize && arr[right] > arr[left] ? right : left;
// 比较左右子结点最大值和 index 位置的值大小,取最大值的索引
largest = arr[largest] > arr[index] ? largest : index;
// 如果左右孩子都没有自己的大,则跳出循环
if (largest == index) {
break;
}
// 交换 index 和 largest 位置的数据
swap(arr, largest, index);
index = largest;
// 进入下一层
left = leftIndex(index);
}
}
public int leftIndex(int index) {
return index << 1;
}
public int rightIndex(int index) {
return index << 1 | 1;
}
public int parentIndex(int index) {
return index >> 1;
}
private void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
public static void main(String[] args) {
MaxHeap maxHeap = new MaxHeap(7);
maxHeap.push(11);
maxHeap.push(7);
maxHeap.push(1);
maxHeap.push(9);
maxHeap.push(9);
maxHeap.push(13);
maxHeap.push(17);
System.out.println(maxHeap.pop());
System.out.println(maxHeap.pop());
System.out.println(maxHeap.pop());
System.out.println(maxHeap.pop());
System.out.println(maxHeap.pop());
System.out.println(maxHeap.pop());
System.out.println(maxHeap.pop());
}
}
一个包含 n 个结点的完全二叉树,树的高度不会超过 log2 n。堆化的过程就是顺着结点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是 O(n∗log n)。插入数据和删除堆顶元素的主要逻辑就是堆化,所以,往插入一个元素和删除堆顶元素的时间复杂度都是 O(n∗log n)。
4、小结
堆是一种完全二叉树,它最大的特性是:每个节点的值都大于等于(或小于等于)其子树节点的值。因此,堆被分成了两类,「大顶堆」和「小顶堆」。
堆中比较重要的两个操作是插入一个数据和删除堆顶元素,这两个操作都要用到堆化。插入一个数据的时候,我们把新插入的数据放到数组的最后,然后从下往上堆化;删除堆顶数据的时候,我们把数组中的最后一个元素放到堆顶,然后从上往下堆化。这两个操作时间复杂度都是 O(n∗log n)