如何实现一个堆?堆和优先队列的关系

998 阅读5分钟

​ 优先级队列的下面是堆, 堆的下面物理上是一个数组, 但逻辑上是一个二叉树并且是一颗完全二叉树, 因此要学习优先级队列我们就得先了解什么是堆~

关于堆

什么是堆?

​ 堆就是一个把数组中的元素按照完全二叉树的层序遍历方式排列了一下, 它逻辑上是一个完全二叉树, 但实际上在内存中依旧是按照数组的方式存储的~ 文字可能比较抽象, 见下图~

总结:

  1. 堆逻辑上是一颗完全二叉树
  2. 堆物理上是保存在数组中的
  3. 满足任意节点的值都大于其子树中节点的值,叫做大堆,或大根堆,或最大堆
  4. 反之,则是小堆,或小根堆,或最小堆

  • 我们将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆
  • 使用完全二叉树的目的在于, 为了提高空间的利用率
  • 对于非完全二叉树来说,则不适合使用顺序方式进行存储,因为要想还原二叉树, 就得在数组中存储空节点,这样势必就会导致空间上的浪费~ 从而导致空间利用率比较高低

堆的下标关系

已知双亲(parent)的下标则有:

  • leftChild = 2 * parent + 1
  • rightChild = 2 * parent + 2

已知孩子节点,则可以找到父亲节点下标:

  • parent = (leftChild - 1)/ 2;不区分左右
  • 没有 parent = (rightChild - 1)/ 2 这种说法

堆的向下调整(shiftDown)

向下调整的使用场景:

  • 建堆的时候需要使用向下调整
  • 在删除堆中元素的时候也需要使用向下调整

堆的向下调整具体步骤:

  1. 首先找到数组的最后一个元素, 即从最后一颗子树开始进行向下调整. 要对每一课子树都要进行调整
  2. 通过 parent = (len - 1 - 1) / 2 即可得到父节点的下标, 然后通过 leftChild = 2 * parent + 1rightChild = 2 * parent + 2 找到左右孩子的下标
  3. 然后通过 parent -- 就可以得到下一颗子树的下标, 知道走到下标为 0

调整过程中需要注意的问题:

  • 每棵子树的结束位置如何确定? 不能超过数组的长度 (即 childIndex < array.length)

代码:

/**
 * 向下调整函数的实现
 * @param parent 每棵树的根节点
 * @param len 每棵树的调整的结束位置
 */
private void shiftDown(int parent, int len) {
    int child = (parent * 2) + 1;
    while (child < len) {
        if (child + 1 < len && elem[child] < elem[child + 1]) {
            // 保证当前左右孩子最大值的下标
            child++;
        }
        if (elem[parent] < elem[child]) {
            // 交换
            swap(parent, child);
            parent = child;
            child = parent * 2 + 1;
        } else {
            // 不需要调整, 直接跳出循环
            break;
        }
    }
}

private void swap(int parent, int child) {
    int temp = elem[parent];
    elem[parent] = elem[child];
    elem[child] = temp;
}

堆的向上调整(shiftUp)

向上调整的使用场景:

  • 往堆里插入元素的时候需要向上调整

堆的向上调整具体步骤:

  1. 首先将元素插入到最后一个元素的后面
  2. 然后从最后一个子树开始向上调整, 通过 parent = (len - 1 - 1) / 2 即可得到父节点的下标
  3. 直到 孩子节点的下标 == 0 或 父节点的下标 < 0 调整结束

代码:

/**
 * 向上调整函数的实现
 * @param child 就是数组最后元素的下标
 */
public void shiftUp(int child) {
    int parent = (child - 1) / 2;
    while (parent >= 0) {
        if (elem[parent] < elem[child]) {
            swap(parent,child);
            child = parent;
            parent = (child - 1) / 2;
        } else {
            // 不需要调整, 直接跳出循环
            break;
        }
    }
}

private void swap(int parent, int child) {
    int temp = elem[parent];
    elem[parent] = elem[child];
    elem[child] = temp;
}

实现一个堆

/**
 * @Author XUE_957
 * @Date 2022/6/13 16:56
 * @Version 2022.1.1
 */
public class MyHeap {

    // 堆的存储方式数组
    private int[] elem;
    // 表示有效数据个数
    private int usedSize;

    public MyHeap() {
        // 调用构造方法时,将堆的大小初始化为 10
        this.elem = new int[10];
    }

    public void createMyHeap(int[] array) {
        for (int i = 0; i < array.length; i++) {
            elem[i] = array[i];
            usedSize++;
        }
        // 根据代码 显示的时间复杂度   看起来,应该是O(n*logn)  但是,实际上是O(n)
        for (int parent = (usedSize -1 -1) / 2; parent >= 0 ; parent--) {
            // 调整
            shiftDown(parent,usedSize);
        }
    }

    /**
     * 向下调整函数的实现
     * @param parent 每棵树的根节点
     * @param len 每棵树的调整的结束位置
     */
    private void shiftDown(int parent, int len) {
        int child = (parent * 2) + 1;
        while (child < len) {
            if (child + 1 < len && elem[child] < elem[child + 1]) {
                // 保证当前左右孩子最大值的下标
                child++;
            }
            if (elem[parent] < elem[child]) {
                // 交换
                swap(parent, child);
                parent = child;
                child = parent * 2 + 1;
            } else {
                // 不需要调整, 直接跳出循环
                break;
            }
        }
    }

    private void swap(int parent, int child) {
        int temp = elem[parent];
        elem[parent] = elem[child];
        elem[child] = temp;
    }

    /**
     * 向上调整函数的实现
     * @param child 就是数组最后元素的下标
     */
    public void shiftUp(int child) {
        int parent = (child - 1) / 2;
        while (parent >= 0) {
            if (elem[parent] < elem[child]) {
                swap(parent,child);
                child = parent;
                parent = (child - 1) / 2;
            } else {
                // 不需要调整, 直接跳出循环
                break;
            }
        }
    }

    public void offer(int val) {
        if (isFull()) {
            // 满了就扩容
            this.elem = Arrays.copyOf(elem, elem.length * 2);
        }
        elem[usedSize] = val;
        // 元素插入完成后, 要进行向上调整
        shiftUp(usedSize);
        usedSize++;
    }

    public int poll() {
        if (isEmpty()) {
            throw new NullPointerException();
        }
        // 让堆顶元素和最后一个元素交换, 然后向下调整, 最后 usedSize--
        int endVal = elem[0];
        swap(0, usedSize);
        usedSize--;
        shiftDown(0, usedSize);
        return endVal;
    }

    public int peek() {
        if (isEmpty()) {
            throw new NullPointerException();
        }
        return elem[0];
    }

    public boolean isEmpty() {
        return usedSize == 0;
    }

    public boolean isFull() {
        return usedSize == elem.length;
    }

}

建堆的时间复杂度分析

对于上图的这颗二叉树来说有:

  • 高度 h = 4

  • 第一层, 有 2^0 = 1 个节点, 调整高度为 3

  • 第二层, 有 2^1 = 2 个节点, 调整高度为 2

  • 第三层, 有 2^2 = 4 个节点, 调整高度为 1

  • 第四层, 有 2^3 = 8 个节点, 调整高度为 0

可以看出, 最后一层的调整高度为 0 不需要调整, 因此 从 h - 1 层开始累加计算, 可推导出:

T(n) = 2 ^ 0 * (h - 1) + 2 ^ 1 * (h - 2) + 2 ^ 2 * (h - 3) + ... + 2 ^ (h - 2) * 1

2T(n) = 2 ^ 1 * (h - 1) + 2 ^ 2 * (h - 2) + 2 ^ 3 * (h - 3) + ... + 2 ^ (h - 2) * 2 + 2 ^ (h - 1) * 1

两项式子错位相减得: T(n) = 2 ^ 1 + 2 ^ 2 + ... + 2 ^ (h - 1) - h

然后使用等比数量求和公式得: T(n) = 2 ^ h - 1 - h

有由总结点的个数和高度的关系得: n = 2 ^ h - 1 h = log(n + 1)

因此, T(n) = n - h = n - log(n + 1)

从而得出建堆的时间复杂度约等于 O(N)

优先级队列

关于PriorityQueue的使用

  1. PriorityQueue中放置的元素必须要能够比较大小,不能插入无法比较大小的对象,否则会抛出 ClassCastException异常(即类型转换异常)
  2. 不能插入null对象,否则会抛出NullPointerException
  3. 没有容量限制,可以插入任意多个元素,其内部可以自动扩容
  4. 插入和删除元素的时间复杂度为 O(logN)
  5. PriorityQueue底层使用了堆这种数据结构
  6. PriorityQueue默认情况下是小堆 (即每次获取到的元素都是最小的元素), 如果需要大堆则需要用户提供比较器

PriorityQueue的三个构造方法

构造器功能介绍
PriorityQueue()创建一个空的优先级队列,默认容量是11
PriorityQueue(intinitialCapacity)创建一个初始容量为initialCapacity的优先级队列,注意:initialCapacity不能小于1,否则会抛IllegalArgumentException异常
PriorityQueue(Collection<?extends E> c)用一个集合来创建优先级队列

PriorityQueue的基本方法说明

函数名功能介绍
booleanoffer(E e)插入元素e,插入成功返回true,如果e对象为空,抛出NullPointerException异常,时间复杂度 ,注意:空间不够时候会进行扩容
E peek()获取优先级最高的元素,如果优先级队列为空,返回null
E poll()移除优先级最高的元素并返回,如果优先级队列为空,返回null
int size()获取有效元素的个数
void clear()清空
booleanisEmpty()检测优先级队列是否为空,空返回true

优先级队列的扩容说明

  • 如果容量小于64时,是按照oldCapacity的2倍方式扩容的
  • 如果容量大于等于64,是按照oldCapacity的1.5倍方式扩容的
  • 如果容量超过MAX_ARRAY_SIZE,按照MAX_ARRAY_SIZE来进行扩容

堆和优先级队列的应用场景 -> TopK问题