文章目录
- 优先级队列与PriorityQueue
- 堆
- 基本概念和性质
- 建堆
- 堆的插入
- 堆的删除
- 堆的应用
- PriorityQueue
- PriorityQueue的构造方法
- PriorityQueue的常用方法
- PriorityQueue的模拟实现
- 经典TopK问题
优先级队列与PriorityQueue
优先级队列是一种特殊类型的队列,其中元素按照优先级进行排序,最高优先级的元素最先被取出。其概念源于对元素进行优先级排序的需求。与普通队列先进先出(FIFO)的原则不同,优先级队列中的元素根据其优先级值进行排列和取出。在许多场景下,如任务调度、交通信号管理等,优先级队列都发挥着重要作用。
Java中的优先级队列(PriorityQueue)是一种利用 堆 数据结构实现的、按照元素优先级排序的队列,它允许元素以任意顺序插入,但取出时会按照优先级高低进行排序。
堆
基本概念和性质
堆 是一种特殊的完全二叉树,其中每个节点的值总是大于或等于(最大堆)或小于或等于(最小堆)其子节点的值。
准确来说,堆在逻辑上是一棵完全二叉树,物理结构上常用数组表示,存储时按照层序遍历的次序存入数组。
所以,我们先要掌握堆的相关知识。
堆的两个特性:
- 结构性: 用数组表示一棵完全二叉树
- 有序性: 任一结点的关键字是其子树所有节点的最大值(或最小值)。
“最大堆”,也称“大顶堆”(或“大根堆”),简称大堆。特点是:父亲大于等于孩子
“最小堆”,也称“小顶堆”(或“小根堆”),简称小堆。特点是:父亲小于等于孩子
观察图片,最大堆中第二层的7小于第三层的9和11,因为堆只要求父结点大于(或小于)自己的子结点,而不要求大于(或小于)其同层结点的子结点。不要认为第k层的结点都大于(小于)第k+1层!
由于堆的存储方式是数组以及堆性质的特殊性,有以下公式(parent等均表示数组下标,以第一个公式为例,其含义:父结点的下标 = (任一孩子下标 - 1) / 2
):
- parent = (child - 1) / 2
- leftchild = parent * 2 + 1
- rightchild = parent * 2 + 2
第一个公式结果小于0时,代表没有父结点;当第二、三个公式的结果大于数组最后一个元素的下标时,代表没有孩子结点。
练习:
下列关键字序列为堆的是()
A. 60,70,65,50,32,100 B. 100,60,70,50,32,65 C. 32,50,100,70,65,60 D. 70,65,100,32,50,60
解: 由于堆逻辑上是一棵完全二叉树,存储时按照层序遍历顺序,所以直接画图就可以解决问题。不想画图,也可以较容易地观察:A中60>70,所以要建立小根堆,第二层70和65均大于60,但第三层的50显然小于70,不满足堆,排除;B中100>60,所以要建立大根堆,第二层60和70均小于100,第三层的50和32小于其父亲60,65小于其父亲70,满足大根堆,故选择B;C、D同理,可分析得不满足堆得性质。
建堆
给我们一个数组,怎样将其建为堆呢?向下调整算法 是最常用的方法,其更高效、更稳定。
向下调整算法的基本思想是 从根节点开始,逐步将父节点与子节点进行比较和必要的交换,直到整个堆满足堆的性质。
向下调整算法的前提是 左右子树均为堆。
向下调整算法的结果是根结点最终到达能使整棵树满足堆性质的位置
向下调整算法的基本思想中涉及父结点与子结点的交换,其规则为:
- 如果要建立小根堆,则将其与左右孩子中较小的一个作比较,如果比 较小孩子 大(证明较小孩子是父、左右孩子三个结点中最小的),将根结点与较小孩子交换,到达新位置,继续与新的左右孩子的较小值比较,如果比 较小孩子 大,交换,以此类推,直到某次比 较小孩子 小或不存在孩子,结束。
- 如果要建立大根堆,则将其与左右孩子中较大的一个作比较,如果比 较大孩子 小(证明较大孩子是父、左右孩子三个结点中最大的),将根结点与较大孩子交换,到达新位置,继续与新的左右孩子的较大值比较,如果比 较大孩子 小,交换,以此类推,直到某次比 较大孩子 大或不存在孩子,结束。
下图演示了对根结点27进行向下调整算法建小根堆的过程:
但是,我们不能保证所给数组天生满足根结点的左右子树均为堆,即不能从整棵的根结点开始执行向下调整算法。
既然不能从根结点开始向下调整,我们考虑从叶子结点开始执行向下调整,但叶子结点不存在左右孩子,即叶子结点作为根结点的树本身就满足堆,所以我们要从倒数第一个非叶子结点开始。
堆存储在数组里,我们怎么确定倒数第一个非叶子结点的下标呢?
倒数第一个非叶子结点就是倒数第一个结点(即数组最后一个元素)的父结点,根据公式parent = (child - 1) / 2,child传入数组最后一个元素的下标,就能得到向下调整算法开始执行的结点。
对倒数第一个非叶子结点执行向下调整算法后,继续对倒数第二个结点执行,以此类推,直到整棵树的根结点执行完向下调整算法,即可建堆成功!
代码实现:(以建小堆为例)
public void createHeap(int[] array) {
//从倒数第一个非叶子结点开始向下调整
for(int i = (array.length - 1 - 1) / 2; i >= 0; i--) {
//向下调整代码:
int parent = i;
int child = parent * 2 + 1;
while(child < array.length) {
//寻找较小结点
if (child + 1 < array.length && array[child + 1] < array[child]) {
child += 1;
}
//无需再判断child + 1 < array.length
if (array[parent] > array[child]) {
int tmp = array[parent];
array[parent] = array[child];
array[child] = tmp;
parent = child;
child = parent * 2 + 1;
}else {
break;
}
}
}
}
-
每一次向下调整: 进循环前先保存父结点下标,再计算出左孩子的下标(假设左孩子是较小孩子);判断
child
下标是否存在,尝试进入循环;进入循环后,寻找较小孩子,(注意判断条件必须包含child + 1 < array.length
且必须是第一个条件)如果之前认为左孩子是较小孩子的假设不成立,那么就更新child
的值;判断父结点和较小孩子结点的大小,如果父结点小于较小结点,证明父结点是三个结点中最小的,直接跳出循环,否则,交换较小孩子和父结点,更新parent
,接着计算新的父结点的左孩子下标并更新。继续尝试进入循环… -
特别注意当找到较小结点后,判断父结点和较小结点大小时,不需要先判断
child + 1 < array.length
,因为参与调整的结点一定存在孩子,否则不会进入while循环,一定存在则
child
的值是有效值。可能存在调整结点只有左孩子没有右孩子的情况,这时在寻找较小结点时,child
的值不会变化,表示存在的左孩子的下标,调整的结点只要有孩子,就必须与孩子进行比较来维持堆的结构,如果此时判断父结点和较小结点大小时,加上child + 1 < array.length
条件,就会因为调整结点没有右孩子而导致左孩子没有与父结点比较,这导致建堆可能失败。例如非堆序列 [32,50,100,70,65,60] ,如果犯了上述错误,则建堆结果与原序列一致,建堆失败!
堆的插入
当向堆中插入新元素时,先将其存储在数组已有元素后一个位置,然后 向上调整 到合适的位置以保证堆的结构。
下图为向一个大根堆中插入新元素11的向上调整的过程,整个过程要不断与父亲结点比较并不断上调,直到满足堆的性质。
public void offer(int val) {
if(isFull()) {
throw new ElemIsFullException("the elemArray is full!:已满");
}
elem[usedSize] = val;
shiftUp(usedSize);
usedSize++;
}
//向上调整
private void shiftUp(int child) {
int parent = (child - 1) / 2;
while(parent >= 0) {
if(elem[child] < elem[parent]) {
int tmp = elem[child];
elem[child] = elem[parent];
elem[parent] = tmp;
child = parent;
parent = (child - 1) / 2;
}else {
break;
}
}
}
- 此处堆插入的代码实现是模拟实现
PriorityQueue
代码的一部分,掌握插入的逻辑以及向上调整算法。其余的代码会在模拟实现阶段再见。
堆的删除
堆的删除一般指删除堆顶元素,具体操作是:将堆顶元素与最后一个元素交换,然后在认为最后一个元素被删除的情况下向下调整,继续维持堆的结构。
public int poll() {
if(isEmpty()) {
throw new ElemIsEmptyException("elem is empty!:无元素可以删除!");
}
int ret = elem[usedSize-1];
elem[usedSize-1] = elem[0];
elem[0] = ret;
//注意交换后认为元素已被删除,接下来要向下调整维持堆结构
usedSize--;
shiftDown(0, usedSize);
return ret;
}
//向下调整
private void shiftDown(int root, int len) {
int parent = root;
int child = parent * 2 + 1;
while(child < len) {
if (child + 1 < len && elem[child + 1] < elem[child]) {
child += 1;
}
if (elem[parent] > elem[child]) {
int tmp = elem[parent];
elem[parent] = elem[child];
elem[child] = tmp;
parent = child;
child = parent * 2 + 1;
}else {
break;
}
}
}
- 此处堆删除的代码实现是模拟实现
PriorityQueue
代码的一部分,掌握删除的逻辑是先交换头尾再向下调整维持堆结构。其余的代码会在模拟实现阶段再见。
堆的应用
堆最常见的应用除了实现优先级队列外,还有:
- 堆排序
- TopK问题
- 频率统计和数据压缩
- 合并有序小文件
堆排序是十分重要的一种排序方法,后续会在排序章节介绍,TopK问题是本文最后一个问题。
PriorityQueue
在Java的集合框架中,PriorityQueue
是一个基于优先级堆实现的的无界优先级队列。
其基本特性如下:
- 不允许使用
null
元素也不允许插入不可比较的对象(即没有实现Comparable
接口的对象,或者在创建时没有提供Comparator
的情况下)。 - 没有容量限制,可以插入任意多个元素,其内部可以自动扩容
- 默认情况下为小根堆,即每次获取到的元素都是最小元素
更多具体内容如下:
PriorityQueue的构造方法
构造方法 | 说明 |
---|---|
PriorityQueue() | 创建一个空的优先级队列,默认容量为11 |
PriorityQueue(int initialCapacity) | 创建一个初始容量为initalCapacity的优先级队列,但不能指定小于1,否则会抛出异常 |
PriorityQueue(Collection<? extends E> c) | 创建一个包含指定集合元素的 PriorityQueue 优先级队列 |
PriorityQueue(int initialCapacity, Comparator<? super E> comparator) | 创建一个具有指定初始容量和指定比较器的空 PriorityQueue 优先级队列 |
PriorityQueue(Collection<? extends E> c, Comparator<? super E> comparator) | 创建一个包含指定集合元素的 PriorityQueue ,并根据提供的 Comparator 进行排序。 |
PriorityQueue
默认是小顶堆,如果我们需要一个大顶堆,则需要提供一个自定义的Comparator
来实现这一点,该自定义的Comparator
需要反转排序规则,这样就能得到一个大顶堆。
Comparator.reverseOrder()
是 Java 中 Comparator
接口的一个静态方法,它返回了一个实现了 Comparator
接口的 Comparator
实例,这个实例可以对实现了 Comparable
接口的对象集合进行逆序排序。换句话说,它创建了一个比较器,该比较器会将较大的元素视为较低(或“更好”的)优先级,从而与默认的自然顺序(通常是升序)相反。
例如:
//默认小根堆
PriorityQueue<Integer> priorityQueue1 = new PriorityQueue<>();
//指定为大根堆
PriorityQueue<Integer> priorityQueue2 = new PriorityQueue<>(Comparator.reverseOrder());
PriorityQueue的常用方法
方法 | 功能 |
---|---|
boolean offer(E e) | 插入元素,注意插入的元素不能为空且要可比较 |
E peek() | 获取优先级最高的元素(堆顶元素),如果优先级队列为空,返回null |
E poll() | 删除优先级最高的元素(堆顶元素),如果优先级队列为空,返回null |
int size() | 获取有效元素个数 |
void clear() | 清空 |
boolean isEmpty() | 检测优先级队列是否为空 |
PriorityQueue的模拟实现
以int类型为例对关键逻辑进行了实现,同时模拟实现的没有涉及扩容,这与PriorityQueue
不同,我们主要掌握建堆、堆的删除、堆的插入操作。
public class ExceedTheCapacityException extends RuntimeException {
public ExceedTheCapacityException(String message) {
super(message);
}
}
public class ElemIsFullException extends RuntimeException {
public ElemIsFullException(String message) {
super(message);
}
}
public class ElemIsEmptyException extends RuntimeException {
public ElemIsEmptyException(String message) {
super(message);
}
}
public class MyPriorityQueue {
public int[] elem;
public int usedSize;
//默认分配的容量
private static final int DEFAULT_INIT_CAPACITY = 11;
public MyPriorityQueue() {
this(DEFAULT_INIT_CAPACITY);
}
public MyPriorityQueue(int capacity) {
this.elem = new int[capacity];
}
private void initElem(int[] array) {
for (int i = 0; i < array.length; i++) {
elem[i] = array[i];
usedSize++;
}
}
public void createHeap(int[] array) {
if(array.length > elem.length) {
throw new ExceedTheCapacityException("exceed the capacity!:传入的数组超出了容量");
}
initElem(array);
for(int i = (array.length - 1 - 1) / 2; i >= 0; i--) {
shiftDown(i, this.usedSize);
}
}
private void shiftDown(int root, int len) {
int parent = root;
int child = parent * 2 + 1;
while(child < len) {
//寻找较小结点
if (child + 1 < len && elem[child + 1] < elem[child]) {
child += 1;
}
//无需再判断child + 1 < array.length
if (elem[parent] > elem[child]) {
int tmp = elem[parent];
elem[parent] = elem[child];
elem[child] = tmp;
parent = child;
child = parent * 2 + 1;
}else {
break;
}
}
}
public void offer(int val) {
if(isFull()) {
throw new ElemIsFullException("the elemArray is full!:已满");
}
elem[usedSize] = val;
shiftUp(usedSize);
usedSize++;
}
private void shiftUp(int child) {
int parent = (child - 1) / 2;
while(parent >= 0) {
if(elem[child] < elem[parent]) {
int tmp = elem[child];
elem[child] = elem[parent];
elem[parent] = tmp;
child = parent;
parent = (child - 1) / 2;
}else {
break;
}
}
}
public boolean isFull() {
return elem.length == usedSize;
}
public int poll() {
if(isEmpty()) {
throw new ElemIsEmptyException("elem is empty!:无元素可以删除!");
}
int ret = elem[usedSize-1];
elem[usedSize-1] = elem[0];
elem[0] = ret;
usedSize--;
shiftDown(0, usedSize);
return ret;
}
public boolean isEmpty() {
return usedSize == 0;
}
public int peek() {
return elem[0];
}
}
经典TopK问题
TopK问题,即求数据集合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。比如,世界500强,游戏排行榜。
TopK问题一定可以使用排序来解决问题,但是当数据量比较庞大时,排序就很难高效解决问题了。这时,使用堆就能更好的解决问题,具体思想如下:
- 用数据集合中前K个元素来建堆
- 前K个最大的元素,建小堆
- 前K个最小的元素,建大堆
- 用剩余的N-K个元素依次与堆顶元素进行比较,不满足则替换堆顶元素
例如,对于500条数据,寻找前K个最大的数据元素,将集合前K个元素建立小堆,此时堆顶元素就是这K个建堆元素中最小的,也是最有可能被替换的,然后,遍历剩余的N-K个元素,分别与堆顶元素比较,如果比堆顶元素大,说明该元素可以入选当前的前10,替换堆顶元素。我们可以利用PriorityQueue
很好的解决这一类问题,而不需要特意编写堆的插入和删除代码,这是我们学习集合框架的意义。
【例题】
设计一个算法,找出数组中最小的k个数。以任意顺序返回这k个数均可。
class Solution {
public int[] smallestK(int[] arr, int k) {
//补充代码
}
}
利用上述思路即可解决问题:
class Solution {
public int[] smallestK(int[] arr, int k) {
int[] ret = new int[k];
if(k == 0) {
return ret;
}
//“小堆转大堆”
PriorityQueue<Integer> q = new PriorityQueue<>(Comparator.reverseOrder());
//将前k个元素offer
for(int i = 0; i < k; i++) {
q.offer(arr[i]);
}
//遍历剩余元素,与堆顶元素比较,如果比堆顶元素小,则删除现堆顶,插入该元素
for(int i = k; i < arr.length; i++) {
if(arr[i] < q.peek()) {
q.poll();
q.offer(arr[i]);
}
}
//将PriorityQueue中的元素都放入数组中,返回
for(int i = 0; i < k; i++) {
ret[i] = q.poll();
}
return ret;
}
}
原题链接:面试题 17.14. 最小K个数 - 力扣(LeetCode)