优先级队列(堆)
队列是一种先进先出(FIFO)的数据结构,优先级队列(Priority Queue)是一种特殊的队列数据结构,其中每个元素都有一个与之关联的优先级。在优先级队列中,元素按照优先级的顺序进行排列,具有较高优先级的元素会被先出队。
注意,优先级队列并不保证在所有时间点上都以完全有序的方式维护元素。它只保证在删除最大/最小元素时能够返回具有最高/最低优先级的元素。
一、Java中的PriorityQueue
PriorityQueue底层使用了堆这种数据结构,而堆实际就是在完全二叉树的基础上进行了一些调整。
为了更好理解优先级队列,下面介绍一下堆这种数据结构
二、堆
1.堆的概念及存储方式
堆(Heap)是一种特殊的树状数据结构,它满足以下两个性质:
1、堆是一个完全二叉树(Complete Binary Tree):除了最后一层外,其它层都是满的,并且最后一层的节点都靠左对齐。
2、堆中每个节点的值都大于等于(或小于等于)其子节点的值,这被称为堆的堆序性(Heap
Property)。如果每个节点的值都大于等于其子节点的值,则称为最大堆(Max Heap),反之则称为最小堆(Min Heap)。
在堆中,根节点的值是最大(或最小)的元素。因此,堆常常用于实现优先级队列和排序算法,如堆排序。
堆可以通过数组来实现,其中数组的每个元素表示堆中的一个节点。具体地,假设堆的根节点存储在索引位置 0 处,则对于任意一个索引 i,其父节点的索引为 (i-1)/2,左子节点的索引为 2i+1,右子节点的索引为 2i+2。
2.堆的创建
例如:对于一个数组{27,15,19,18,28,34,65,49,25,37},建大根堆的步骤如下:
1.对于每个根结点向下调整,这里的根节点是从有孩子节点的最后一个根节点开始选取,而每个子节点的根节点索引为 (i - 1) / 2,即最后一个根节点的下标为 (array.length - 1 - 1) / 2
2.向下调整的过程,对于每个父节点,去比较父节点和父节点的最大子节点,如果前者小,就交换;反之,由于每个父节点的子树都是一个最大堆,因此可以直接break
注意:每调整一个父结点,被交换的子节点作为父节点的这个子树还要重新调整
堆的属性:
public int[] elem;
public int usedSize;
public TestHeap(int[] array) {
this.elem = Arrays.copyOf(array, array.length);
this.usedSize = array.length;
}
堆的创建(根据数组创建):
//创建堆
public void createBigHeap() {
//处理所有子树的父节点(向下调整)
for (int parent = (usedSize - 1 - 1) / 2; parent >= 0; parent--) {
siftDown(parent, usedSize);
}
}
/* 向下调整:
* 参数一:要调整的父节点
* 参数二:调整的结束位置
* */
private void siftDown(int parent, int end) {
int child = 2 * parent + 1;
//每调整一个父结点,被交换的子节点作为父节点的这个子树还要重新调整
//child < end:表示当前parent还有左孩子
while (child < end) {
//有右孩子的前提下,找出最大的孩子
if (child + 1 < usedSize && elem[child] < elem[child + 1]) {
child++;
}
//此时child指向最大的孩子,如果这个值大于父结点,就交换
if (elem[parent] < elem[child]) {
swap(parent, child);
//此时被交换的孩子成为新的父结点,继续向下调整
parent = child;
child = 2 * parent + 1;
} else {
break;
}
}
}
private void swap(int i, int j) {
int tmp = elem[i];
elem[i] = elem[j];
elem[j] = tmp;
}
3.堆的插入与删除
堆的插入总共需要两个步骤:
1. 先将元素放入到底层空间中(注意:空间不够时需要扩容)
2. 将最后新插入的节点向上调整,直到满足堆的性质
向上调整:
当前插入节点与其父节点比较,
如果小于,则不需要调整;
反之,交换该节点和父节点的位置,并再以该节点为子节点,直到调整到堆顶
//堆的插入(堆底元素)
public void offer(int val) {
if (isFull()) {
//扩容
elem = Arrays.copyOf(elem, usedSize * 2);
}
//插入
elem[usedSize] = val;
usedSize++;
//向上调整
siftUp(usedSize - 1);
}
public boolean isFull() {
return usedSize == elem.length;
}
//向上调整
private void siftUp(int child) {
int parent = (child - 1) / 2;
while (child > 0) {
if (elem[parent] < elem[child]) {
swap(child, parent);
child = parent;
parent = (child - 1) / 2;
} else {
break;
}
}
}
三、堆排序
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
1. 建堆
升序:建大堆
降序:建小堆
2. 利用堆删除思想来进行排序
建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。
一句话概括:堆顶元素和堆底元素交换,堆顶再向下调整
建大堆时,堆顶元素都是最大值,调整到堆底,就保证堆底是最大值,然后对堆顶向下调整,以此反复直到遍历完数组,完成排序
//堆排序
public void heapSort() {
int end = usedSize - 1;
//堆顶(最大值)和堆底交换
while (end > 0) {
swap(0, end);
siftDown(0, end - 1);
end--;
}
}
四、PriorityQueue的实际应用-TOP-K问题
- 讲完了PriorityQueue的底层堆,再使用这个工具类就很清晰了,下面是其实际应用
TOP-K问题:即求数据集合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
1. 用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆
2. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
Top-k问题-最小K个数
先给上代码:
class Solution {
public int[] smallestK(int[] arr, int k) {
if (k == 0) {
return new int[0];
}
//top-k
//1.建k个元素大的大顶堆
PriorityQueue<Integer> pq = new PriorityQueue<>((o1, o2) -> o2 - o1);
for (int i = 0; i < k; i++) {
pq.offer(arr[i]);
}
//2.遍历数组剩余元素,找更小值更新堆
for (int i = k; i < arr.length; i++) {
//如果碰见比堆顶元素小,移除堆顶
if (arr[i] < pq.peek()) {
pq.poll();
pq.offer(arr[i]);
}
}
//3.返回堆里的所有元素
int[] result = new int[k];
for (int i = 0; i < k; i++) {
result[i] = pq.poll();
}
return result;
}
}
这里的注意点是,PriorityQueue这个工具类默认创建的是最小堆,而在这题中我们所需要的是最大堆,而PriorityQueue有一个构造方法是这样的:
现在我们就能知道,通过传入Comparator这个比较器的实现类对象,就能够指定PriorityQueue的排序规则。
- 简单介绍一下Comparator:
Comparator接口是Java中的一个接口,位于java.util包下。它定义了一种用于比较两个对象的规则。
Comparator接口中只有一个方法compare(),因此它也是一个函数式接口(只有一个方法需要重写),它用于比较两个对象的大小关系。
通常我们在一个类只需要使用一次时,会使用匿名内部类的方式,匿名内部类又能使用lambda表达式简化,这是JavaSE的内容。因此就由定义一个新的实现类简化成了这行代码。
PriorityQueue<Integer> pq = new PriorityQueue<>((o1, o2) -> o2 - o1);