【Java 数据结构】PriorityQueue介绍

news2024/9/21 22:35:09

优先级队列

  • 回顾二叉树
    • 堆是什么
    • 堆的实现
      • 初始化
      • 堆的创建
      • 向下调整建堆复杂度
      • 插入
      • 向上调整建堆复杂度
      • 删除
  • PriorityQueue类介绍
    • PriorityQueue是什么
    • PriorityQueue使用
      • 构造方法
      • 常用方法
    • PriorityQueue源码介绍
    • Top-K问题

回顾二叉树

上一次我们简单的了解了二叉树这个数据结构, 但是在学习过后难免会产生一个问题就是, 二叉树的这个结构到底有什么用呢?

那么接下来我们就来介绍一个集合类 PriorityQueue, 翻译过来就是优先级队列, 它能够将内部的元素进行优先级的排列, 从而使得每一次的出队都能够是优先级最高的元素. 它的底层就是一个使用顺序存储的完全二叉树结构, 而这种结构也有一个新的名字, 它就叫做.

堆是什么

上面我们也说过, 堆是一个顺序存储的完全二叉树, 但是它的特征不仅仅于此, 他还有一些还有其他的性质. 下面是一个关于堆的描述

如果有一个关键码的集合K = { k[0],k[1], k[2],…,k[n-1] }, 把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中, 并满足: K[i] <= K[2i+1] 且 K[i]<= K[2i+2] 或者 K[i] >= K[2i+1] 且 K[i] >= K[2i+2] (其中i = 0, 1, 2, … , n - 1) 则称为小堆或大堆. 其中, 根节点最大的堆叫做最大堆或大根堆, 根节点最小的堆叫做最小堆或小根堆

自然, 这种定义一上来就一堆字母 + 数字, 就不是写给正常人看的, 因此我们使用图片来举例看看堆到底是什么东西.

首先我们来看大根堆, 大根堆顾名思义, 就是根节点是最大的. 那么大是一个相对的概念, 这里的大指的就是比他左右的孩子节点都更大.

那么此时可能有人想出了下面的这个结构

在这里插入图片描述

那么它是一个大根堆吗? 答案是并不是, 因为虽然根节点是大于左右孩子节点的, 但是我们对于左右孩子还有一个要求, 就是左右孩子也得是一个大根堆. 那么经过调整, 上面的这个堆就可以调整成下面这样

在这里插入图片描述

可以看到, 此时自然就形成了, 根节点一定是当前树中最大的节点. 同时, 此时所有的根节点都是符合, 根节点大于左右节点的这个性质的. 但是并不是说, 这个堆它只能这样调整, 只要能够符合大根堆的性质, 那么都是可以的. 并且堆对于左右孩子的顺序, 没有具体要求, 只要它比父亲节点小, 比孩子节点大即可.

那么上面所说的什么 K[i] >= K[2i+1] 且 K[i] >= K[2i+2] 是什么意思呢?

我们说过, 堆是存储在顺序表中的, 通常就是存储在一个数组中, 我们存储的顺序实际上就是和层序遍历的顺序一样, 例如上面的那个堆实际上存储起来应该如下所示

在这里插入图片描述

那么实际上, 上面的 K[i] >= K[2i+1] 且 K[i] >= K[2i+2], 指的就是这个父亲值节点大于孩子节点的值了, 只不过是通过下标的方式来表示的.

例如 i = 1时, 此时 2i + 1 = 3, 2i + 2 = 4, 那么此时 k[i] 就是 25, k[2i + 1]指的就是 12, 而 k[2i + 2] 指的就是 23 了.

同理, 假设我已知了一个孩子节点下标, 那么我同样也是可以获取到父亲节点的下标的. 假设孩子节点是 i, 那么此时父亲节点下标就是就是 (i - 1) / 2. 我们同样通过例子来看.

例如 i = 1的时候, (i - 1) / 2 就会等于 0, 自然就是父亲节点下标. 此时可能有人就要问了, 那如果 i = 2 呢? 0.5 下标是什么意思? 实际上, 我们这里的除法是整型运算, 并不关注其小数部分, 因此这里的 0.5 就是直接看作 0. 因此也是可以定位到父亲节点的.

此时我们就有了三个重要的公式, 对于我们后序书写堆相关实现非常重要

  1. 当 i != 0 时, i 节点的父亲节点为 (i - 1) / 2. i 等于 0代表其为根节点
  2. 当 (2i + 1) < size 时, i 节点的左孩子下标为 2i + 1. 如果(2i + 1) >= size, 则代表没有左孩子
  3. 当 (2i + 2) < size 时, i 节点的右孩子下标为 2i + 2. 如果(2i + 2) >= size, 则代表没有右孩子

上述的所有东西, 对于小根堆也是同理的, 只不过对其的要求变为了根是最小的, 同时要求左右子树也是小根堆. 这里就不再演示了.

此时有一个问题: 为什么要是一棵完全二叉树呢? 我不是一颗完全二叉树行不行呢?

实际上, 如果我们采用顺序存储, 那么就不适合用于表示非完全二叉树, 为什么呢? 假如我现在并不是一棵完全二叉树, 我的中间可能有几个空的, 那么请问此时我要如何在顺序表中表示它呢?

很明显, 只能通过置空的方式, 那么这样的话, 此时我们的顺序表中就可能留有非常多的空位, 因此使用完全二叉树, 是为了能够充分利用空间, 从而实现高效存储的一种选择

在这里插入图片描述

堆的实现

初始化

初始化就是创建存储的数组, 以及一些其他的信息, 例如 size 之类的. 还有书写一些构造方法和用于打印的方法, 这些都是基本操作, 就不细说了.

public class MyPriorityQueue {
    private int[] elemData;
    private int size;

    public MyPriorityQueue() {}

    public MyPriorityQueue(int[] arr) {
        elemData = arr;
        size = arr.length;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("[");
        for (int i = 0; i < size; i++) {
            sb.append(elemData[i]);
            if(i != size - 1){
                sb.append(", ");
            }
        }
        sb.append("]");
        return sb.toString();
    }
}

堆的创建

假设我们现在有了一个数组, 那么我们如何将其变为一个堆呢? 下面我们来看一个创建大根堆的过程. 设初始数组为1 5 3 8 7 6, 二叉树结构如下所示

在这里插入图片描述

那么此时我们会想到, 一个堆它的要求是左右子树也是一个堆. 那么我们就从最后一棵子树开始调整是否可行呢? 那么我们假设就按照这个思路来进行调整, 来创建出一个大根堆

在这里插入图片描述

通过这个模拟的步骤, 我们可以得出一个大体思路及一些注意事项:

  1. 我们最开始是从最后一个父亲节点开始, 依次往前进行向下调整
  2. 与子树交换后, 需要继续向被调整的子树向下调整, 查看下面的堆结构是否被改变.

那么分析出了思路后, 我们可以发现其中有一个关键的步骤就是向下调整的这个步骤. 那么我们就先来看这一部分应该如何操作.

首先我们肯定就需要一个父亲节点, 我们假设使用 parent 来标记它, 它用于告诉我们你调整的是什么位置的树. 其次就是我们上面图片例子中提到的, 找到左右孩子的最小值, 此时我们使用 child 来标记它. 但是此时又有三种情况

  1. 没有孩子
  2. 只有一个孩子
  3. 有两个孩子

那么如何看这个节点有没有孩子呢? 实际上就需要回到刚开始我们介绍的三个公式

  1. 当 i != 0 时, i 节点的父亲节点为 (i - 1) / 2. i 等于 0代表其为根节点
  2. 当 (2i + 1) < size 时, i 节点的左孩子下标为 2i + 1. 如果(2i + 1) >= size, 则代表没有左孩子
  3. 当 (2i + 2) < size 时, i 节点的右孩子下标为 2i + 2. 如果(2i + 2) >= size, 则代表没有右孩子

可以看到, 其中说到 2i + 1>= size 的时候就没有左孩子了, 而 2i + 1 是什么呢? 是通过父亲节点下标计算出的孩子节点下标. 因此换句话说, 当左孩子节点的下标大于等于 size 的时候, 就是没有左孩子的了.

对于右孩子, 实际上也是同理的, 但是我们这里还可以得出一个更好的办法.

首先试想, 对于一个节点, 它如果有一个孩子, 那么这个孩子是不是一定就是我们的左孩子. 因为我们规定二叉树是一棵完全二叉树, 因此是不可能出现先有右孩子后有左孩子的情况的.

其次, 既然是一个顺序存储的完全二叉树, 那么既然知道了左孩子的下标, 那么右孩子的下标不就是, 左孩子的下标 + 1 吗? 如图所示

在这里插入图片描述

那么此时, 我们就可以非常简单的确定孩子情况. 我们就直接计算左孩子的下标, 令其为 child. 如果左孩子都不存在(child <= size), 那么则证明一定没有孩子节点. 同时如果左孩子存在(child > size), 我们也无需使用公式重复计算右孩子节点, 此时(child + 1)即是右孩子下标, 我们可以用其来检测是否含有右孩子节点.

那么有了孩子的信息, 接下来就来到了, 找左右孩子最大值环节, 这个还是非常简单的, 有的话就比较, 没有就默认左孩子即可. 然后就是和父亲节点进行比较, 来判断是否进行交换, 当然此时也是有两个情况的:

  1. 父亲节点很大, 大孩子都比不过, 此时可以保证满足大根堆, 我们自然就无需交换, 并且也不需要向下调整
  2. 父亲节点比更大的孩子节点小, 此时交换, 同时向交换的这个孩子节点处, 向下调整.

此时就剩下了一个细节问题: 我们的这个向下调整, 什么时候结束呢?

很明显, 当当前节点都没有孩子的时候, 自然也就没有了调整的必要, 此时向下调整就可以结束了. 也就是child < size 的时候, 代码就可以结束了.

有了思路, 此时我们就可以写出这个向下调整的代码了.

// 向下调整
// 参数为要调整的父节点下标和堆大小, 因为堆的大小并不等于数组的长度, 因此需要传入
public void shiftDown(int parent, int size) {
    // 计算左孩子下标, 标记为child
    int child = parent * 2 + 1;

    // 当child < size 时, 说明左孩子, 继续向下调整
    while(child < size){
        // 确认有没有右孩子, 有就比较获取最大值
        if(child + 1 < size && elemData[child] < elemData[child + 1]){
            child = child + 1;
        }

        // 此时child指向最大的孩子
        // 比较child和parent
        if(elemData[child] > elemData[parent]){
            // child > parent, 此时需要交换

            // 交换
            int tmp = elemData[child];
            elemData[child] = elemData[parent];
            elemData[parent] = tmp;

            // 此时需要向下调整, 变换parent和child下标即可
            // 让parent去交换的孩子下标
            parent = child;
            // 计算新的parent的左孩子下标
            child = parent * 2 + 1;

        }else{
            // child < parent, 此时不需要调整, 直接跳出循环
            break;
        }
    }
}

此时可能有人要问了: 我学二叉树递归写的有点多了, 现在看啥都想递归, 我总觉得这个代码可以使用递归来实现, 那么是否可行呢?

答案是当然可以, 思路也是大差不差的, 只不过写法是递归而已, 下面是递归代码

// 保证方法的统一性, 封装一个方法用于第一次调用递归方法
public void shiftDown2(int parent, int size) {
    shiftDownHelper(parent, parent * 2 + 1, size);
}

// 递归向下调整
public void shiftDownHelper(int parent, int child, int size) {
    // child 大于 size, 此时没有孩子, 直接返回
    if(child >= size){
        return;
    }

    // 找到最大的孩子
    if(child + 1 < size && elemData[child] < elemData[child + 1]){
        child = child + 1;
    }

    // 比较child和parent
    if(elemData[child] > elemData[parent]){
        // child > parent, 此时需要交换和向下调整
        // 交换
        int tmp = elemData[child];
        elemData[child] = elemData[parent];
        elemData[parent] = tmp;

        // 向下调整child
        shiftDownHelper(child, child * 2 + 1, size);
    }
    
    // 此时则证明, child < parent, 不需要调整, 直接返回
}

回到我们最初的要求, 即创建一个堆. 创建堆的方法我们刚开始在模拟后也说了, 就是从最后一个父亲节点开始, 依次往前进行向下调整

那么此时代码也就非常好写了, 直接一个循环, 从最后一个父亲开始往前调用向下调整即可

public void createHeap() {
    for (int i = size / 2 - 1; i >= 0; i--){
        shiftDown(i, size);
    }
}

那么假如说我们现在想要创建一个小根堆, 那么就把其中比较的代码修改一下, 改成找小的孩子节点, 如果父亲更小不交换即可.

向下调整建堆复杂度

那么此时有一个问题: 这个建堆的时间复杂度, 是多少呢?

此时可能有人想了, 这个向下调整, 假设最坏应该是一个logN, 因为完全二叉树的高度应该是log(N + 1). 同时总共有 N 个节点, 那么向下调整 N 次, 自然就是 N*logN了.

但是事实真的如此吗? 下面我们来进行一个计算. 同时为了简化计算, 我们这里采用一个满二叉树来计算时间复杂度.

在这里插入图片描述

首先如果要计算建堆的复杂度, 实际上就是计算调整的总次数, 那么调整的次数的综合, 应该就是每一个节点的最大调整次数相加.

那么每一个节点的最大调整次数又是多少呢? 应该就是(h - 节点所在的层数), 因为假设最坏情况, 从当前层调整到最底层, 此时每一次调整都是调整一层, 那么调整次数就是(h - 节点所在层数).

例如我有一个节点在第一层, 那么此时最坏情况就是调整到最下面, 假设高度为 3 , 那么此时调整次数自然就是(3 - 1) = 2.

既然调整的次数与层数有关, 那么也就是说我们每一层的节点都要分开计算它们的调整次数, 那每一层的节点数又是多少呢?

实际上, 对于一个满二叉树, 每一层的节点还是非常好算的, 因为实际上就是一个以 2 为底的指数函数变化. 当 h = 1的时候, 实际上就是 2 ^ 0, 只有一个节点, 同理 h = 2时, 节点数为 2 ^ 1 = 2.

此时我们就可以得出每一层的节点数为 2 ^ (h - 1).

有了每一层的节点数, 和每一层节点要调整的最大次数, 此时就可以计算出每一层节点的调整次数

公式: x 层所有节点需要调整的最大次数 = x 层节点数 * x 层单个节点最大调整数

设其为 F(x), 则 F(x) = [2 ^ (x - 1)] * (h - x), h 为树的高度

例: 第一层: x = 1, F(1) = (2 ^ 0) * (h - 1)

第二层: x = 2, F(2) = (2 ^ 1) * (h - 2)

第 h - 1层, x = h - 1, F(h - 1) = 2 ^ (h - 2) * 1

设调整次数的和为 T(h), 那么把上面的东西相加就可以得到

T(h) = F(1) + F(2) + … + F(h - 1)

​ = (2 ^ 0) * (h - 1) + (2 ^ 1) * (h - 2) + … + 2 ^ (h - 2) * 1

那么如何计算出这个 T(h) 呢? 实际上如果高中对于数列这一块比较熟悉的话, 就可以看出这种一个等比数列 * 等差数列的经典题目, 是可以通过错位相减法来计算的. 具体错位相减如何操作我们这里不详细介绍, 我们这里只介绍如何把当下的 T(h) 算出来

令 2T(h) - T(h)

得T(h) = (1 - h) + 2 ^ 1 + 2 ^ 2 + … + 2 ^ (h - 1)

下面是一个参考图, 表示如何进行 2T(h) - T(h) 的计算, 由于是纯数学计算, 因此采用手写的方式可能更加易懂

在这里插入图片描述

那么此时我们知道 h 代表的是树的高度, 那么如何将其转换为节点数相关节点数 n 的呢?

那么就是通过二叉树的高度计算公式 h = log(n + 1) 即可. 这个公式是需要向上取整的, 具体这个公式是如何得到的呢? 我们是可以通过满二叉树的节点总数和高度关系来推的. 满二叉树的每一层节点数可以看作是 2 ^ (h - 1). 设总结点数为 n, 高度为 H. 那么 n = 2 ^ 0 + 2 ^ 1 + ... + 2 ^ (H - 1). 实际上就是等比数列求和, 然后可以得到 n = (2 ^ H) - 1. 随后把 1 放到 n 处, 变为 n + 1 = 2 ^ H. 最后两边取对数即可得到 H = log(n + 1)

最后我们将其带入, 就可以得到T(n) = n - log(n + 1), 最后使用大 O 渐进表示, 就可以得出, 这个复杂度是O(n) 的

总而言之, 根据上面的推导, 我们就可以得出, 建堆的时间复杂度是 O(n) 的.

插入

实现了堆的创建, 接下来就是堆的插入操作了. 首先我们既然是要插入一个元素到堆里, 那么自然我们就不能让这个插入的元素, 破坏了我们堆的结构.

那么我应该如何不让这个元素破坏堆的结构呢? 直接插入到一个符合条件的位置是否可以呢?

虽然我们可以选择直接将这个元素插入到一个符合要求的位置, 但是这样做实际上是比较麻烦的. 首先, 即使不关注要完全二叉树的这个条件, 这个看似非常好写的做法也有一定的难度.

假如我们要大根堆, 直接找一个比这个插入数大的子树放进去就行吗? 不一定, 我们假如它两边都有子树, 并且值都比插入值小呢? 那么此时我们就需要去转移下面的这些子树的位置. 或许在链式结构中比较好实现, 但是在顺序结构中, 这种做法无疑效率是极低并且难操作的. 并且我们还是忽略了完全二叉树的条件, 如果加上了这个条件, 那么则更加难以直视.

此时我们就可以参考我们建堆的思路, 我们建堆是通过一次一次的调整得到的. 那么既然我们插入了一个节点, 那我们对这个节点进行调整, 是否可行呢?

但是在哪之前, 我们还需要解决一个问题, 我们暂定调整是可以成功的, 那么此时这个新的节点应该放在哪呢?

那么既然调整可以成功, 我们自然就可以将其放在一个对于先前的堆影响最小的地方, 也就是最后一个位置. 首先它不会破坏完全二叉树的结构, 同时它也不会影响到我们前面建好的堆.

那么现在确定要放最后了, 那么如何进行调整呢?

实际上我们就可以参考之前写的向下调整, 假设当前节点下标我们就可以找到当前新节点的父亲节点, 然后进行比较. 看其是否小于父亲(以大根堆为例). 如果符合, 则证明插入后, 没有破坏这一棵子树的堆结构, 那么自然上面的堆结构也就没有影响. 可以看到, 这个就是我们选择的插入位置的优势之处, 尽可能的缩小了影响范围, 只影响到了该调整的区域, 其他区域不用变动.

但是假如大于了父亲, 那么此时就需要进行交换, 同时继续向上, 直到把所处的树转换为大根堆为止.

下面是一个例子

在这里插入图片描述

当然, 我们也可以假设 8 上面还有节点 500, 并且它的另一棵子树也是符合要求的. 那么此时 12 小于 500, 符合子节点小于父节点, 同时此时右子树经过调整后也是一棵符合要求的大根堆, 那么此时整棵树依旧是一个符合要求的大根堆.

在这里插入图片描述

此时这个调整的过程, 由于其是一个向上的过程, 也就被称作为是向上调整.

总结一下上面的思路, 分为以下几步:

  1. 把节点放入到最后一个位置, 标记为 child
  2. 将 child 位置的节点进行向上调整
  3. 向上调整过程:
    • 找到当前孩子的父亲节点, 比较
    • 如果孩子节点大于父亲节点, 则交换, 并且继续向上调整
    • 如果孩子节点小于父亲节点, 则向上调整直接结束
    • 如果孩子节点走到了最顶层的根节点, 此时整个树都被调整完毕, 循环结束(循环条件)

其实向上调整的思路和向下调整还是非常类似的, 只不过方向反过来了而已.

public void offer(int val){
    // 如果容量不足, 扩容
    if (size == elemData.length) {
        elemData = Arrays.copyOf(elemData, size * 2);
    }
    // 往最后一个位置放入元素
    elemData[size] = val;
    // 对这个位置进行向上调整
    shiftUp(size);
    // 不要忘记调整大小
    size++;
}

private void shiftUp(int child) {
    // 获取父节点下标
    int parent = (child - 1) / 2;
	// 向上调整
    while(child > 0){
        if (elemData[child] > elemData[parent]) {
            // child > parent, 此时需要交换

            // 交换
            int tmp = elemData[child];
            elemData[child] = elemData[parent];
            elemData[parent] = tmp;

            // 调整child
            child = parent;
            parent = (child - 1) / 2;
        } else {
            // child < parent, 此时不需要调整, 直接跳出循环
            break;
        }
    }
}

向上调整建堆复杂度

这里学习了另外一个调整堆的方式, 叫做向上调整, 那么此时可能有人就要问了: 我能不能通过向上调整, 来实现堆的创建呢?

答案是, 当然可以, 但是通过向上调整的方式去创建堆, 它的复杂度相较于向下调整会更高. 从简单的角度来理解, 我们的向下调整, 在树的高度为 h 的时候, 只需要调整[1, h - 1]层部分的节点. 而对于向上调整来说, 则是需要调整[2, h]部分的节点

假设要调整的是一棵满二叉树, 那么此时虽然就差了一层, 但是节点的数量却可能是爆炸性增长的. 例如我的 h - 1 层是 2000 个节点, 那么下一层就是 4000. 层数越高, 那么这个增长的数值就会越来越大. 因此由于建堆的复杂度 = 节点数 * 节点的调整次数. 即使节点调整次数不变, 节点数增加了, 复杂度自然就会增加.

假如上面的简单说明没有听懂, 也没有关系. 下面我们就来再次通过简单的计算, 来看看如果采用向上调整来建堆, 它的时间复杂度是多少. 由于上面已经介绍过基本知识, 因此这里就直接通过手绘的方式来计算

在这里插入图片描述

删除

堆的删除, 实际上就是将它最高优先的元素, 实际上就是堆顶的元素. 那么我们是要直接删堆顶元素吗?

这样删除依旧是非常麻烦的, 为什么呢? 如果我们直接删除堆顶元素, 则意味着此时左右子树需要有一个元素上位, 那么此时就可能导致完全二叉树的结构遭到破坏, 而维护完全二叉树的成本又比较高.

因此我们需要尽可能的不要破坏完全二叉树结构的一种方法来实现删除操作, 那么此时就可以参考我们刚刚的插入操作.

插入操作是先将元素放到最后一个位置, 然后进行向上调整. 那我删除此时就可以尝试存储堆顶元素, 然后把最后一个元素放到堆顶, 然后对其进行向下调整即可. 这样就避免了最麻烦的问题, 就是不可能修改完全二叉树的结构. 同时也仅仅需要维护最高的堆, 剩余的堆没有被影响到.

那么如果上面的向下调整的代码没有问题, 我们这里的删除代码则是非常好写的

public int poll() {
    if (size == 0) {
        throw new NoSuchElementException("堆为空");
    }

    // 获取第一个元素
    int ret = elemData[0];
    // 将最后一个元素放到第一个位置
    elemData[0] = elemData[size - 1];
    // 这里要在调整前, 提前改变size, 否则会调整到无效的元素
    size--;
    shiftDown(0, size);
    return ret;
}

现在我们已经基本上实现完了这个堆, 那么接下来就可以正式学习我们 Java 中的优先级队列了.

PriorityQueue类介绍

PriorityQueue是什么

优先级队列在最开始的时候也说过, 它的底层是一个堆, 能够将内部的元素进行优先级的排列, 从而使得每一次的出队都能够是优先级最高的元素. 同时, 它实现的是 Queue 接口的操作, 因此使用方法是和 Queue 类似的.

在这里插入图片描述

同时, 由于它和其他的集合类一样, 支持泛型, 因此我们往里面装的东西必须是一个类型. 但又由于它需要对内部的元素进行优先级排列, 换句话说就是要对内部的元素进行比较, 因此传入的类型要么需要能够直接比较, 要么需要我们自己实现它们的比较逻辑.

那此时就需要回忆一个基础知识了, 如何实现对象之间的比较呢?

实际上这主要有两种方法, 一个是通过实现 Comparable 接口, 来重写里面的compareTo()方法来实现比较. 另一个方法则是通过创建一个比较器, 然后通过比较器来进行比较, 那如果要创建比较器, 则需要让对应的比较器类实现 Comparator 接口.

PriorityQueue使用

构造方法

这些集合类的常用构造方法都是非常类似的, 我们简单了解即可

方法名, 参数作用
PriorityQueue()初始化一个PriorityQueue
PriorityQueue(int initialCapacity)根据提供的int类型构造对应大小的PriorityQueue
PriorityQueue(Collection<? extends E> c)根据提供的集合类型构造PriorityQueue

上面我们说到了, 给优先级队列提供的对象, 一定是要能够比较的对象, 那么如果我就是不给它能比较的对象, 那么会发生什么呢? 我们来测试一下

我们创建一个学生类, 并且创建两个对象传入进去

class Student {
    public String name;
    public int age;
    public Student(){}

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }
    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}
public class Main {
    public static void main(String[] args) {
        PriorityQueue priorityQueue = new PriorityQueue<>();
        priorityQueue.offer(new Student("张三", 18));
        priorityQueue.offer(new Student("李四", 19));
        System.out.println(priorityQueue.poll());
    }
}

可以看到, 尝试运行后直接抛出了异常, 说学生类必须要能够被转换到 Comparable. 实际上就是要求我们我们实现这个 Comparable 接口

在这里插入图片描述

那我们现在就去实现一下 Comparable接口, 并且按照年龄来比较.

class Student implements Comparable<Student>{
    public String name;
    public int age;
    public Student(){}

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }

    @Override
    public int compareTo(Student o) {
        return this.age - o.age;
    }
}

再次重新运行, 发现成功推出了一个元素

在这里插入图片描述

此时可能有人要问了, 我每一次写这个 Comparable接口, 老是弄不明白什么时候是谁更大. 此时, 你就可以去看看 Comparable 的注释

在这里插入图片描述

它说的是返回一个负的代表当前对象小于传入对象, 返回零代表相等, 返回正的代表大于传入对象.

或者还有一种更加简单的办法, 我们直接用 Integer 这样的类, 试一试就知道了.

public class Main {
    public static void main(String[] args) {
        Integer num1 = 123;
        Integer num2 = 123123123;
        System.out.println(num1.compareTo(num2));
    }
}

在这里插入图片描述

可以看到, 返回了 -1, 此时我们就知道了, 如果调用方法的对象小于传入的对象就应该返回负的. 其他的同理


那我们上面说过, 实现对象的比较, 既可以通过 Comparable 接口, 也可以通过创建一个比较器. 那此时有人就要问了: 我实现了比较器, 也和原Student没什么关系啊, 这个优先级队列又怎么知道我创建了比较器呢? 我又要怎么把比较器给他呢?

实际上这就涉及到了优先级队列一个较为特殊的构造方法, 如下所示

方法名, 参数作用
PriorityQueue(Comparator<? super E> comparator)根据传入的比较器, 初始化一个PriorityQueue

可以看到, 这个构造方法就支持你把比较器传入进去, 那么此时它自然也就会知道要怎么比较你的元素了. 下面就是一个通过比较器来传入学生类的例子

// 实现比较器
class StudentComparator implements Comparator<Student> {
    @Override
    public int compare(Student o1, Student o2) {
        return o1.age - o2.age;
    }
}

class Student{
    public String name;
    public int age;
    public Student(){}

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

public class Main {
    public static void main(String[] args) {
        // 创建比较器
        Comparator<Student> comparator = new StudentComparator();
        // 传入比较器, 创建优先级队列
        PriorityQueue priorityQueue = new PriorityQueue<>(comparator);
        priorityQueue.offer(new Student("张三", 18));
        priorityQueue.offer(new Student("李四", 19));
        System.out.println(priorityQueue.poll());
    }
}

同理, 如果搞不清楚比较器如何表示大小关系, 可以进入源码查看注释.

在这里插入图片描述


从上面我们也可以看出, 默认情况下 PriorityQueue 是一个小根堆. 那么如果我们希望把它变为一个大堆, 那么我们就需要通过手动传入比较器的方法来定义.

下面是一个用 Integer 的大堆创建例子, 这里直接采用了匿名内部类来传入参数.

public class Main {
    public static void main(String[] args) {

        // 创建一个大根堆
        PriorityQueue<Integer> bigHeap = new PriorityQueue<>(new Comparator<Integer>() {
            @Override
            public int compare(Integer o1, Integer o2) {
                // 默认比较是 o1 - o2, 这样当 o1 大于 o2的时候就会返回正数
                // 但是为了得到大根堆, 这里反着来, 返回 o2 - o1
                return o2 - o1;
            }
        });

        // 添加一些元素
        bigHeap.add(5);
        bigHeap.add(1);
        bigHeap.add(10);
        bigHeap.add(3);
        // 打印堆顶元素
        System.out.println(bigHeap.peek());
    }
}

此时查看堆顶就是 10

在这里插入图片描述

常用方法

优先级队列里面的方法, 实际上就和之前我们使用过的队列的方法是一样的

返回值, 方法名, 参数说明
boolean offer(E e)插入元素e, 如果e对象为空抛出异常
E peek()获取优先级最高的元素, 如果优先级队列为空, 返回null
E poll()移除优先级最高的元素并返回, 如果优先级队列为空, 返回null
int size()获取有效元素的个数
void clear()清空
boolean isEmpty()检测优先级队列是否为空

由于我们上面实现过这些功能的核心部分, 因此这些功能我们就不详细介绍了.

PriorityQueue源码介绍

接下来我们就去源码中寻找一下两个问题:

  1. PriorityQueu默认的大小是多少?

  2. 它扩容的机制是什么?

根据我们的经验, 大概率这个默认容量, 是一个常量放在最上面的, 因此我们可以直接通过搜索DEFAULT来看看能不能找到. 可以发现, 这个还是很简单的. 初始容量就是11

在这里插入图片描述

那么接下来就是另外一个问题, 扩容是如何做的. 那要找到扩容的代码, 实际上我们就需要去添加元素的方法, 也就是offer()中去找.

可以看到, 在插入位置等于数组长度的时候, 就会触发grow(), 也就是扩容方法

在这里插入图片描述

实际上这个扩容方法还是比我们之前看过的ArrayList的源码简单很多的, 我们直接看英文注释都可以看明白.

private void grow(int minCapacity) {
    int oldCapacity = queue.length;
    // Double size if small; else grow by 50%
    // 如果小于64, 就是2倍, 如果大于64那么1.5倍扩容
    int newCapacity = oldCapacity + ((oldCapacity < 64) ?
                                     (oldCapacity + 2) :
                                     (oldCapacity >> 1));
    // overflow-conscious code
    // 溢出检测
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // 复制扩容
    queue = Arrays.copyOf(queue, newCapacity);
}

Top-K问题

接下来我们来借助堆来解决一个问题, 这个问题叫做Top-K问题. Top-K问题的要求也非常简单: 假设我现在有 n 个数据, 此时要求返回数组中最小的 k 个元素.

此时很明显, 有两个非常简单的思路:

  1. 排序, 返回前 k 个
  2. 把所有元素放入堆中, 返回前 k 个.

虽然这两个思路确实解决了问题, 但是这两个思路都不是非常的好, 一个是其不适用于数据量非常大的情况, 假设数据量非常大, 内存中都装不下这些数据, 那你又如何对这些数据进行处理从而得到前 k 个元素呢? 另一个点则是, 效率不高, 我们要将所有的数据进行排序, 或者是将所有的数据放到堆中进行逐个调整, 效率都是比较低的.

那么接下来我们不妨想一下, 如果只是要最小的 k 个元素, 我们有任何必要去维护所有的数据的大小信息吗? 就好比你从 50 个苹果里面, 选出最大的 3 个, 我们没有必要去知道另外 47 个的大小关系.

接下来我们依旧是以 50 个苹果找 3 个为例子, 假设我现在随机给你 3 个苹果, 并且我只让你拿着三个苹果, 要你找出 50 个苹果里面最大的 3 个, 你会如何找?

很明显, 此时就有一个很简单的思路, 我们每一次都把 3 个苹果里面最小的拿出来, 去和其他的苹果比, 如果有更大的, 就把这个最小的换了.那么按照这样的方式, 当我们看完了所有的苹果后, 我们就可以保证我们手上的苹果一定是 50 个里面最大的了. 因为交换到了最后, 我们最小的苹果, 都是经过我们交换后得出的比剩下47个更大的大果.

实际上这个思路的核心就是, 维护在我们看过的苹果中, 我们手上的苹果是最大的, 因此每一次比较都是拿手上的最小的比.

那么回到这一题, 我们依旧是只维护 k 个数字, 由于这个题目要求的是最小的 k 个数字, 那么我们比较的时候就是需要这 k 个数中的最大的(就是和我们提出的苹果例子相反的). 那么既然是要 k 个数里面最大的数, 自然我们就可以想到, 创建一个大小为 k 的大根堆, 并且进行维护.

维护的过程为: 遍历剩下的数字, 如果堆顶元素(k 个数中最大的)比当前遍历的数字更小, 那么把当前遍历的数字放入堆中, 并且重新调整, 随后重复上述步骤即可.

那么这个思路的代码还是比较简单的, 我们直接看代码即可

class Solution {
    public int[] smallestK(int[] arr, int k) {
        // 应付测试用例: 如果 k 为 0 返回空数组
        if(k == 0) return new int[0];
        // 创建大小为 k 的大根堆
        PriorityQueue<Integer> bigHeap = new PriorityQueue<>((o1, o2)-> o2 - o1);

        // 放入前 k 个元素
        for(int i = 0; i < k; i++){
            bigHeap.offer(arr[i]);
        }

        // 遍历剩下的元素并且维护堆
        for(int i = k; i < arr.length; i++){
            // 如果堆顶元素大于当前元素, 出堆, 放入当前元素
            if(bigHeap.peek() > arr[i]){
                bigHeap.poll();
                bigHeap.offer(arr[i]);
            }
        }

        // 把堆中元素放入数组中返回
        int[] ret = new int[k];
        for(int i = 0; i < k; i++){
            ret[i] = bigHeap.poll();
        }
        return ret;
    }
}

从这一题我们可以看出, 堆本身的性质, 与其实际的作用是相反的. 我们这一题的堆, 是用于存储前 K 个最小的元素, 但是其却是一个大根堆.

这个特性, 主要就是由于堆提供的信息决定的. 因为大根堆能够提供的主要信息就是堆中最大的元素, 这个信息就基本上只能用于去保证堆中的信息是整体中最小的. 因为只要我的最大的元素都比其他所有的元素小, 那么这个堆里的所有元素, 就一定比其他的所有元素小. 这个思想, 在未来我们实现堆排序的时候也能用到.

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2057853.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

每天五分钟深度学习框架pytorch:神经网络工具箱nn的介绍

本文重点 我们前面一章学习了自动求导,这很有用,但是在实际使用中我们基本不会使用,因为这个技术过于底层,我们接下来将学习pytorch中的nn模块,它是构建于autograd之上的神经网络模块,也就是说我们使用pytorch封装好的神经网络层,它自动会具有求导的功能,也就是说这部…

夏晖WMS是什么?夏晖WMS怎么与金蝶云星空进行集成?

在数字化浪潮席卷全球的今天&#xff0c;企业对于业务流程的高效管理和数据集成的需求愈发迫切。夏晖WMS作为一款领先的仓库管理系统&#xff0c;与金蝶云星空ERP的集成成为了众多企业提升管理效率的关键环节。 夏晖WMS是什么? 夏晖WMS是一款由夏晖物流&#xff08;上海&…

Golang | Leetcode Golang题解之第355题设计推特

题目&#xff1a; 题解&#xff1a; type Twitter struct {Tweets []intUserTweets map[int][]intFollows map[int][]intIsFollowMy map[int]bool }/** Initialize your data structure here. */ func Constructor() Twitter {// 每一次实例化的时候&#xff0c;都重新分配一次…

C语言 | Leetcode C语言题解之第354题俄罗斯套娃信封问题

题目&#xff1a; 题解&#xff1a; int cmp(int** a, int** b) {return (*a)[0] (*b)[0] ? (*b)[1] - (*a)[1] : (*a)[0] - (*b)[0]; }int maxEnvelopes(int** envelopes, int envelopesSize, int* envelopesColSize) {if (envelopesSize 0) {return 0;}qsort(envelopes, …

宜佰丰超市进销存管理系统

你好呀&#xff0c;我是计算机学姐码农小野&#xff01;如果有相关需求&#xff0c;可以私信联系我。 开发语言&#xff1a; Java 数据库&#xff1a; MySQL 技术&#xff1a; JavaMysql 工具&#xff1a; IDEA/Eclipse、Navicat、Maven 系统展示 首页 管理员功能模块…

接口测试及常用接口测试工具(postman/jmeter)详解

&#x1f345; 点击文末小卡片 &#xff0c;免费获取软件测试全套资料&#xff0c;资料在手&#xff0c;涨薪更快 首先&#xff0c;什么是接口呢&#xff1f; 接口一般来说有两种&#xff0c;一种是程序内部的接口&#xff0c;一种是系统对外的接口。 系统对外的接口&#x…

【Alibaba Cola 状态机】重点解析以及实践案例

【Alibaba Cola 状态机】重点解析以及实践案例 1. 状态模式 状态模式是一种行为型设计模式&#xff0c;允许对象在内部状态改变时改变其行为&#xff0c;简单地讲就是&#xff0c;一个拥有状态的context对象&#xff0c;在不同状态下&#xff0c;其行为会发生改变。看起来是改…

Spring项目:文字花园(四)

一.实现登录 传统思路: • 登陆⻚⾯把⽤⼾名密码提交给服务器. • 服务器端验证⽤⼾名密码是否正确, 并返回校验结果给后端 • 如果密码正确, 则在服务器端创建 Session . 通过 Cookie 把 sessionId 返回给浏览器. 问题: 集群环境下⽆法直接使⽤Session. 原因分析: 我们开…

渐变纹理的使用

1、渐变纹理的使用 通过单张纹理和凹凸纹理相&#xff0c;我们知道图片中存储的数据不仅仅可以是颜色数据&#xff0c;还可以是高度、法线数据。 理论上来说&#xff0c;图片中存储的数据我们可以自定义规则&#xff0c;我们可以往图片中存储任何满足 我们需求的数据用于渲染。…

原神4.8版本抽到角色和重点培养数据表

<!DOCTYPE html> <html lang"zh-cn"> <head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"><title>原神4.8版本抽到角色和重点培养数据表</title…

vue-element-admin——<keep-alive>不符合预期缓存的原因

vue-element-admin——<keep-alive>不符合预期缓存的原因 本文章&#xff0c;以现在中后台开发用的非常多的开源项目vue-element-admin为案例。首先&#xff0c;列出官方文档与缓存<keep-alive>相关的链接&#xff08;请认真阅读&#xff0c;出现缓存<keep-ali…

MSR配置

公钥私钥 网页上提供的脚本安装客户端??去掉跳板机 history | grep azcopy 44 azcopy 47 azcopy cp --recursive --log-level NONE --overwrite true https://singularitywor9084471172.blob.core.windows.net/yifanyang/thinking.py\?sv\2023-01-03\&st\2024-…

机器学习:逻辑回归实现下采样和过采样

1、概述 逻辑回归本身是一种分类算法&#xff0c;它并不涉及下采样或过采样操作。然而&#xff0c;在处理不平衡数据集时&#xff0c;这些技术经常被用来改善模型的性能。下采样和过采样是两种常用的处理不平衡数据集的方法。 2、下采样 1、概念 下采样是通过减少数量较多的类…

【学习笔记】Day 19

一、进度概述 1、机器学习常识1-11&#xff0c;以及相关代码复现 二、详情 1、不确定性 所谓不确定性, 是指我们在进行预测的时候, 不能够保证 100% 的准确。而机器学习&#xff0c;比的就是谁 “猜的更准”。 不确定性&#xff0c;可能由信息不足、信息模糊等原因产…

编写开放接口与思考

编写开放接口与思考 一、情景描述&#xff1a; 当一个项目开发一定程度时&#xff0c;会有跟合作厂商对接共同开发的情况&#xff0c;那么如果合作厂商想要使用你项目中的某个接口&#xff0c;你该如何把接口暴露给他们&#xff1f; 二、实现方式分析 1、因为现在接口大部分…

超融合/分布式 IT 架构有哪些常见故障类型?如何针对性解决和预防?

本文刊于《中国金融电脑》2024 年第 7 期。 作者&#xff1a;SmartX 金融团队 以超融合为代表的分布式 IT 基础架构凭借其高性能、高可靠和灵活的扩展能力&#xff0c;在满足大规模、高并发、低延迟业务需求等方面展现出显著优势&#xff0c;成为众多金融机构构建 IT 基础设施…

Nginx: 体系化知识点梳理

概述 我们需要对 Nginx 要有体系化的一个认识对 Nginx 自身来说&#xff0c;它是作为一个中间件的&#xff0c;只要是中间件&#xff0c;它必然会涉及到前端和后端对于 Nginx 来说&#xff0c;它是需要协调整个前后端的一个组件那对于中间件来&#xff0c;我们要理解整个外部系…

Python 设置Excel工作表页边距、纸张大小/方向、打印区域、缩放比例

在使用Excel进行数据分析或报告制作时&#xff0c;页面设置是确保最终输出效果专业、美观的关键步骤。合理的页面设置不仅能够优化打印效果&#xff0c;还能提升数据的可读性。本文将详细介绍如何使用Python操作Excel中的各项页面设置功能。 目录 Python 设置Excel工作表页边…

Hexo通过GitHub设置自定义域名

本身GitHub也是支持自定义域名的&#xff0c;本次教程将讲解如何使用GitHub自带的自定义域名解析。 1. GitHub设置 1.1 登录GitHub账号 登录GitHub账号&#xff0c;找到名称为 用户名.github.io的仓库&#xff0c;并点击进入。 1.2 进入Settings页面 点击如图的Settings按…

Mysql的相关编程基础知识

一. 配置MySQL 首先下载mysql-5.0.96-winx64&#xff0c;安装过程如下图所示。 1.安装MySQL 5.0 ​ ​ 2.选择手动配置、服务类型、通用多功能型和安装路径 ​ 3.设置数据库访问量连接数为15、端口为3306&#xff08;代码中设置URL用到&#xff09;、编码方式为utf-8 ​ 4.设…