算法数据结构:堆

1,043 阅读9分钟

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)