优先级队列
- 回顾二叉树
- 堆
- 堆是什么
- 堆的实现
- 初始化
- 堆的创建
- 向下调整建堆复杂度
- 插入
- 向上调整建堆复杂度
- 删除
- 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. 因此也是可以定位到父亲节点的.
此时我们就有了三个重要的公式, 对于我们后序书写堆相关实现非常重要
- 当 i != 0 时, i 节点的父亲节点为 (i - 1) / 2. i 等于 0代表其为根节点
- 当 (2i + 1) < size 时, i 节点的左孩子下标为 2i + 1. 如果(2i + 1) >= size, 则代表没有左孩子
- 当 (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
, 二叉树结构如下所示
那么此时我们会想到, 一个堆它的要求是左右子树也是一个堆. 那么我们就从最后一棵子树开始调整是否可行呢? 那么我们假设就按照这个思路来进行调整, 来创建出一个大根堆
通过这个模拟的步骤, 我们可以得出一个大体思路及一些注意事项:
- 我们最开始是从最后一个父亲节点开始, 依次往前进行向下调整
- 与子树交换后, 需要继续向被调整的子树向下调整, 查看下面的堆结构是否被改变.
那么分析出了思路后, 我们可以发现其中有一个关键的步骤就是向下调整的这个步骤. 那么我们就先来看这一部分应该如何操作.
首先我们肯定就需要一个父亲节点, 我们假设使用 parent 来标记它, 它用于告诉我们你调整的是什么位置的树. 其次就是我们上面图片例子中提到的, 找到左右孩子的最小值, 此时我们使用 child 来标记它. 但是此时又有三种情况
- 没有孩子
- 只有一个孩子
- 有两个孩子
那么如何看这个节点有没有孩子呢? 实际上就需要回到刚开始我们介绍的三个公式
- 当 i != 0 时, i 节点的父亲节点为 (i - 1) / 2. i 等于 0代表其为根节点
- 当 (2i + 1) < size 时, i 节点的左孩子下标为 2i + 1. 如果(2i + 1) >= size, 则代表没有左孩子
- 当 (2i + 2) < size 时, i 节点的右孩子下标为 2i + 2. 如果(2i + 2) >= size, 则代表没有右孩子
可以看到, 其中说到 2i + 1>= size 的时候就没有左孩子了, 而 2i + 1 是什么呢? 是通过父亲节点下标计算出的孩子节点下标. 因此换句话说, 当左孩子节点的下标大于等于 size 的时候, 就是没有左孩子的了.
对于右孩子, 实际上也是同理的, 但是我们这里还可以得出一个更好的办法.
首先试想, 对于一个节点, 它如果有一个孩子, 那么这个孩子是不是一定就是我们的左孩子. 因为我们规定二叉树是一棵完全二叉树, 因此是不可能出现先有右孩子后有左孩子的情况的.
其次, 既然是一个顺序存储的完全二叉树, 那么既然知道了左孩子的下标, 那么右孩子的下标不就是, 左孩子的下标 + 1 吗? 如图所示
那么此时, 我们就可以非常简单的确定孩子情况. 我们就直接计算左孩子的下标, 令其为 child. 如果左孩子都不存在(child <= size), 那么则证明一定没有孩子节点. 同时如果左孩子存在(child > size), 我们也无需使用公式重复计算右孩子节点, 此时(child + 1)即是右孩子下标, 我们可以用其来检测是否含有右孩子节点.
那么有了孩子的信息, 接下来就来到了, 找左右孩子最大值环节, 这个还是非常简单的, 有的话就比较, 没有就默认左孩子即可. 然后就是和父亲节点进行比较, 来判断是否进行交换, 当然此时也是有两个情况的:
- 父亲节点很大, 大孩子都比不过, 此时可以保证满足大根堆, 我们自然就无需交换, 并且也不需要向下调整
- 父亲节点比更大的孩子节点小, 此时交换, 同时向交换的这个孩子节点处, 向下调整.
此时就剩下了一个细节问题: 我们的这个向下调整, 什么时候结束呢?
很明显, 当当前节点都没有孩子的时候, 自然也就没有了调整的必要, 此时向下调整就可以结束了. 也就是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, 符合子节点小于父节点, 同时此时右子树经过调整后也是一棵符合要求的大根堆, 那么此时整棵树依旧是一个符合要求的大根堆.
此时这个调整的过程, 由于其是一个向上的过程, 也就被称作为是向上调整.
总结一下上面的思路, 分为以下几步:
- 把节点放入到最后一个位置, 标记为 child
- 将 child 位置的节点进行向上调整
- 向上调整过程:
- 找到当前孩子的父亲节点, 比较
- 如果孩子节点大于父亲节点, 则交换, 并且继续向上调整
- 如果孩子节点小于父亲节点, 则向上调整直接结束
- 如果孩子节点走到了最顶层的根节点, 此时整个树都被调整完毕, 循环结束(循环条件)
其实向上调整的思路和向下调整还是非常类似的, 只不过方向反过来了而已.
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源码介绍
接下来我们就去源码中寻找一下两个问题:
-
PriorityQueu默认的大小是多少?
-
它扩容的机制是什么?
根据我们的经验, 大概率这个默认容量, 是一个常量放在最上面的, 因此我们可以直接通过搜索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 个元素.
此时很明显, 有两个非常简单的思路:
- 排序, 返回前 k 个
- 把所有元素放入堆中, 返回前 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 个最小的元素, 但是其却是一个大根堆.
这个特性, 主要就是由于堆提供的信息决定的. 因为大根堆能够提供的主要信息就是堆中最大的元素, 这个信息就基本上只能用于去保证堆中的信息是整体中最小的. 因为只要我的最大的元素都比其他所有的元素小, 那么这个堆里的所有元素, 就一定比其他的所有元素小. 这个思想, 在未来我们实现堆排序的时候也能用到.