优先级队列的概念
我们在前面就已经学习过队列,队列是一种先进先出(FIFO)的数据结构,但有些情况下,操作的数据可能带有优先级,一般出队列时,可能需要优先级高的元素先出队列,那么在该场景下,使用队列显然不合适,比如:在手机上玩游戏的时候,如果有来电,那么系统应该优先处理打进来的电话;初中那会班主任排座位时可能会让成绩好的同学先挑座位。
在这种情况下,数据结构应该提供两个最基本的操作,一个是返回最高优先级对象,一个是添加新的对象。这种数据结构就是优先级队列(Priority Queue)。
JDK1.8中的PriorityQueue底层使用了堆这种数据结构,而堆实际就是在完全二叉树的基础上进行了一些调整。
堆的概念
如果有一个关键码的集合K = {k0,k1, k2,…,kn-1},把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:Ki <= K2i+1 且 Ki<= K2i+2 (Ki >= K2i+1 且 Ki >= K2i+2) i = 0,1,2…,则称为 小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
大根堆实例:任意根节点的数值均大于左右孩子的数值
小根堆实例:任意根节点的数值均小于左右孩子的数值
堆的存储方式
堆本质是一颗完全二叉树,因此可以按照层序遍历的顺序采用顺序存储的方式来进行存储,即由一个一维数组进行存储的。
对于非完全二叉树是不适合使用顺序存储的方式进行存储的,因为如果非完全二叉树使用顺序存储的话要想还原回原来的二叉树形态就需要知道空节点的位置,也就意味着需要在一维数组里存放空节点,这就浪费空间,所以采用链式存储会更好一点。
堆的性质
我们要想从一维数组还原二叉树,就必须知道双亲结点与孩子结点的存储关系,不知道大家还是否记得二叉树的第五条性质:
对于具有 n 个结点的完全二叉树,如果按照从上到下从左到右的顺序对所有的结点从0开始编号,则对于序号为 i 的结点有:
若 i = 0,i 为根节点的编号,没有双亲结点
若 i > 0 , 双亲结点的序号为 (i - 1) / 2
若 2 * i + 1 < n , 左孩子序号为 2 * 1 + 1,否则没有左孩子
若 2 * i + 2 < n , 右孩子序号为 2 * 1 + 2,否则没有右孩子
这里也是一样的,根节点就是数组的首元素,即下标为 0 .其他结点按层序序列进行存储 :
这里我们要稍微拓展一下性质五,现在来介绍一些堆的性质:
1.根节点从 0 开始编号
2. 假设双亲结点为 i ,那么左孩子结点为 2 * i + 1,右孩子结点为 2 * i + 2
3. 假设孩子结点为 i,那么其双亲结点为 ( i - 1 ) / 2
4. 假设数组一共有 n 个元素,那么完全二叉树最后一颗子树的双亲结点为 (n - 1 - 1 ) / 2 【要想找到最后一颗子树的双亲结点,我们可以从最后一个结点来推导其双亲结点,最后一个结点为 n - 1,则双亲结点为 (n - 1 - 1 ) / 2】
堆的模拟实现
这里我设计的是小根堆,因为 PriorityQueue 底层就是小根堆。
准备阶段
我们需要一个数组来存储数据,还要一个 useSized 来记录使用了多少的空间。
public class MyHeap {
int[] elem;
int useSized;
public MyHeap() {
elem = new int[10];
}
public void init(int[] arr) {
for (int i = 0; i < arr.length; i++) {
this.elem[i] = arr[i];
}
useSized = arr.length;
}
}
堆的创建
堆的创建我们一般使用向下调整,什么是向下调整呢?就是从根节点向孩子结点走,也是远离根节点的过程,从代码的角度看就是 parent 赋值为 child,child 向下走, 一直到堆调整好:
为什么我们要从最后一颗子树入手?
我们从最后一颗子树开始构建小堆的话,就能保证在循环的过程中,下面的小堆是已经建好的,因此在向下调整的时候,当 parent 本身就 小于 child 就不用交换直接停止循环即可,直接停止循环是因为下面的小堆早已构建好了。
当 parent 赋值为 child 的时候,说明了这是根节点向下调整的思路,然后 child 一直往下走,直到 child 越界就停止循环。
当孩子结点小于双亲结点的时候就交换,如果不满足交换条件,那就应该退出循环,因为说明这已经是小根堆。
注意这里的向下调整需要加多一个参数,这样使用者就可以直接在自己的数组构建堆的排列顺序,而不仅仅局限于在类中构建堆。
public void creatHeap() {
for (int parent = (useSized - 1 - 1) / 2; parent >= 0; parent--) {
shiftDown(elem,parent,this.useSized);
}
}
public void shiftDown(int[] elem,int parent,int useSized) {
int child = 2 * parent + 1; // 获取左孩子
while(child < useSized) {
if(child+1 < useSized && elem[child+1] < elem[child]) { //先判断有没有右孩子,再判断左右孩子谁小
child++;
}
if(elem[parent] > elem[child]) {
//交换
swap(elem,parent,child);
parent = child;
child = 2 * parent + 1;
} else {
break; // 说明本身就是小根堆,直接退出循环
}
}
}
这里再加一个交换代码,因为后面的操作还要继续使用。
为了使用者能在外部将自己的数组变成堆,所以这个交换方法应该有一个数组的形参
private void swap(int[] elem,int i,int j) {
int tmp = elem[i];
elem[i] = elem[j];
elem[j] = tmp;
}
堆的插入
我们将新的数据放在 useSized 下标处,然后开始进行向上调整,构建好小根堆
向上调整其实是双亲结点和孩子结点向根结点靠近的过程,从代码的角度看就是 child 赋值为 parent ,parent 一直向上走的过程。
这里要注意数组如果满了就要进行扩容操作,还有一点如果不满足交换条件就直接终止循环,说明其本身就是一个小根堆。
public void offer(int val) {
if(isFull()) {
grow();
}
elem[useSized] = val;
//向上调整
shirtUp(useSized);
useSized++;
}
public void shirtUp(int child) {
int parent = (child - 1) / 2;
while(parent >= 0) {
if(elem[child] < elem[parent]) {
swap(eles,child,parent);
child = parent;
parent = (child - 1) / 2;
} else {
break;
}
}
}
构建堆的时间复杂度分析
现在计算构建分别在向下调整和向上调整两种情况下的时间复杂度,这里考虑的是从零开始搭建堆,也就是一个初始状态下的数组等待建堆,而不是仅仅插入一个元素的插入操作。
向下调整时间复杂度分析
我们知道每一层的结点个数为 2 ^ (h - 1),h 为高度(根节点所在高度为 1 )。
在向下调整的时候,我们是从最后一颗子树出发,由于时间复杂度采用大O渐进法,所以我们将倒数第二层当作是最后一颗子树的根节点所在处,也就是说倒数第二层我们需要调整的双亲结点有 2 ^ (h - 2) 个。
每一层需要调整的双亲结点个数:
第一层 : 2 ^ 0
第二层: 2 ^ 1
第三层: 2 ^ 2
…
倒数第二层: 2 ^ (h - 2)
最后一层: 0
时间复杂度按最坏的情况计算,就是每一个双亲结点的向下调整,直到 child 越界调整完毕为止,也就是每一个双亲结点 需要向下 交换 的次数 等于 高度,即 ( h - 层数 )
每一层交换次数:
第一层 : 2 ^ 0 * (h - 1)
第二层: 2 ^ 1 * (h - 2)
第三层: 2 ^ 2 * (h - 3)
…
倒数第二层: 2 ^ (h - 2) * 1
最后一层: 0
总次数为 T(n) = 2 ^ 0 * (h - 1) + 2 ^ 1 * (h - 2) + 2 ^ 2 * (h - 3) + … + 2 ^ (h - 2) * 1
使用错位相减法: 2 T(n) = 2 ^ 1 * (h - 1) + 2 ^ 2 * (h - 2) + 2 ^ 3 * (h - 3) + … + 2 ^ (h - 1) * 1
T(n) = 2T(n) - T(n)
= 2 ^ 1 * (h - 1) + 2 ^ 2 * (h - 2) + 2 ^ 3 * (h - 3) + … + 2 ^ (h - 1) * 1 - [ 2 ^ 0 * (h - 1) + 2 ^ 1 * (h - 2) + 2 ^ 2 * (h - 3) + … + 2 ^ (h - 2) * 1 ]
= h - 1 - [ 2^ 1 + 2^ 2 + 2^ 3+… +2 ^ (h-1) ]
= 2^ h - 1 - h
因为 2 ^ h - 1 = n(总结点数) 即 h = log2(n + 1)
T(n) = n - log2(n + 1)
一次函数的增长速率高于对数函数,所以 T(n) ≈ n
向下调整建堆的时间复杂度为O(N)
向上调整时间复杂度分析
向上调整是从最后一层开始进行的,也就意味着每一个结点都参加了这次向上调整
每一层参加向上调整的节点个数:
第一层:2 ^ 0
第二层:2 ^ 1
第三层:2 ^ 2
…
倒数第二层:2 ^ (h - 2)
最后一层:2 ^ (h - 1)
由于是向上调整,最坏情况下,需要交换上面的高度值的次数:
第一层:2 ^ 0 * 0
第二层:2 ^ 1 * 1
第三层:2 ^ 2 * 2
…
倒数第二层:2 ^ (h - 2) * (h - 2)
最后一层:2 ^ (h - 1) * (h - 1)
T(n) = 2 ^ 0 * 0 + 2 ^ 1 * 1 + 2 ^ 2 * 2 + … + 2 ^ (h - 2) * (h - 2) + 2 ^ (h - 1) * (h - 1)
使用错位相减法可得 T(n) = 2 + 2^ h * (h - 2)
因为 h = log2(n + 1)
T(n) = (n + 1)( log2(n + 1) - 2) + 2
向上调整建堆的时间复杂度为O(N * log2(N) )
由于 N 取值为 0,1,2,…
当 N 大于 2 时,log2(N) 恒大于 1
所以从整体趋势来看 N 是小于 N * log2(N)
即向下调整的效率更优于向上调整,所以在构建堆的时候,我们通常采用 向下调整。
堆的删除
堆的删除是指将堆顶的元素删除,但我们不是真删除,而是和堆的最后一个元素进行交换,然后向下调整堆的序列,因为后序的堆排序中我们需要使用到这个删除的思想,所以不能真删除。
只有当堆不为空的时候才可以进行删除操作,所以这里在写一个判空操作。
public int poll() {
if(isEmpty()) {
return -1;
}
int ret = elem[0];
swap(elem,0,useSized-1);
useSized--;
shiftDown(elem,0,useSized);
return ret;
}
public boolean isEmpty() {
return useSized == 0;
}
获取堆顶元素
public int peek() {
if(isEmpty()) {
return -1;
}
return elem[0];
}
堆的模拟实现完整代码
public class MyHeap {
int[] elem;
int useSized;
public MyHeap() {
elem = new int[10];
}
public void init(int[] arr) {
for (int i = 0; i < arr.length; i++) {
this.elem[i] = arr[i];
}
useSized = arr.length;
}
public void creatHeap() {
for (int parent = (useSized - 1 - 1) / 2; parent >= 0; parent--) {
shiftDown(elem,parent,this.useSized);
}
}
private void swap(int[] elem,int i,int j) {
int tmp = elem[i];
elem[i] = elem[j];
elem[j] = tmp;
}
public void shiftDown(int[] elem,int parent,int useSized) {
int child = 2 * parent + 1; // 获取左孩子
while(child < useSized) {
if(child+1 < useSized && elem[child+1] < elem[child]) { //先判断有没有右孩子,再判断左右孩子谁小
child++;
}
if(elem[parent] > elem[child]) {
//交换
swap(elem,parent,child);
parent = child;
child = 2 * parent + 1;
} else {
break; // 说明本身就是小根堆,直接退出循环
}
}
}
public void offer(int val) {
if(isFull()) {
grow();
}
elem[useSized] = val;
//向上调整
shirtUp(useSized);
useSized++;
}
public void shirtUp(int child) {
int parent = (child - 1) / 2;
while(parent >= 0) {
if(elem[child] < elem[parent]) {
swap(elem,child,parent);
child = parent;
parent = (child - 1) / 2;
} else {
break;
}
}
}
private void grow() {
elem = Arrays.copyOf(elem,2 * elem.length);
}
public boolean isFull() {
return useSized == elem.length;
}
public int poll() {
if(isEmpty()) {
return -1;
}
int ret = elem[0];
swap(elem,0,useSized-1);
useSized--;
shiftDown(elem,0,useSized);
return ret;
}
public boolean isEmpty() {
return useSized == 0;
}
public int peek() {
if(isEmpty()) {
return -1;
}
return elem[0];
}
}
PriorityQueue 的使用
Java集合框架中提供了PriorityQueue和PriorityBlockingQueue两种类型的优先级队列,PriorityQueue是线程不安全的,PriorityBlockingQueue是线程安全的,本文主要介绍PriorityQueue。
PriorityQueue 底层是小根堆实现的,要想创建大根堆可以自行添加一个比较器。
使用注意
- 使用时必须导入PriorityQueue所在的包,即:
import java.util.PriorityQueue;
- PriorityQueue中放置的元素必须要能够比较大小,不能插入无法比较大小的对象,否则会抛出
ClassCastException异常 - 不能插入null对象,否则会抛出NullPointerException
- 没有容量限制,可以插入任意多个元素,其内部可以自动扩容
- 插入和删除元素的时间复杂度为 O(log2(N) )
- PriorityQueue底层使用了堆数据结构
- PriorityQueue默认情况下是小堆,即每次获取到的元素都是最小的元素
构造方法和 offer 方法 源码的介绍
这里介绍常见的构造方法:
下面是空的构造方法,可见它调用了另一个包含两个参数的构造方法:
下面是带一个初始容量的方法,方法内部调用了和上面一样的带两个参数的构造方法:
这一个是传入一个比较器的构造方法,内部还是一样调用了带两个参数的构造方法:
下面就是我们提及到的带两个参数的构造方法,这里要重点介绍这个方法:
initialCapacity 是初始容量,comparator 是比较器
当初始容量小于 1 ,说明容量值非法,抛出异常
接下来就是创建一个数组,并且将比较器装好。
现在我们来看一下 offer 方法:
当传入的数值为 null 的时候,就会抛出异常
modCount 是指此优先级队列已被执行的次数
size 是目前优先级队列已经存放多少元素。
当容量已满时就会进行扩容,grow 就是扩容方法,下面是扩容方法的源码:
当容量足够的时候就会进行向上调整,然后 size++
我们来看一下向上调整的源码:
当比较器为空的时候,会调用siftUpUsingComparator 方法:
看到上面的 if 语句,也就是说如果没有提供比较的器的话,传入的参数必须是可比较的对象,也就是没有比较器那就使用自己的 compareTo 方法,如果新加入的对象大于其双亲结点的数值就不需要进行向上调整,也证实了Java集合类提供的优先级队列是 以小根堆为底层的。
当你传入了比较器的时候,优先级队列就会优先使用你的比较器进行比较进而创建你需要的堆,这也说明了使用者可以自己实现大根堆 ~ ~
常见的方法
应用与实践
TopK 问题
题目链接:
https://leetcode.cn/problems/smallest-k-lcci/description/
思路一:先构建 k 个数据的大根堆,然后从 k + 1 开始遍历数据,当遇到比堆顶还小的元素的时候,堆就删除堆顶元素,然后插入新数据构建新堆。
原因:由于我们建立的是大根堆,所以堆顶元素是这 k 个元素的最大值,当从 k +1 开始遍历数组的时候,当找到比堆顶元素小的元素的时候,我们先进行删除操作,然后再进行插入操作,这样等到遍历完成,这 k个元素就是数组 最小的 k 个数。
class IntCmparator implements Comparator<Integer> {
public int compare(Integer o1, Integer o2) {
return o2.compareTo(o1);
}
}
class Solution {
public int[] smallestK(int[] arr, int k) {
int[] ret = new int[k];
if(arr == null || k == 0) {
return ret;
}
PriorityQueue<Integer> queue = new PriorityQueue<>(k,new IntCmparator());
for(int i=0;i<k;i++){
queue.offer(arr[i]);
}
for(int i=k;i<arr.length;i++) {
int top = queue.peek();
if(top > arr[i]) {
queue.poll();
queue.offer(arr[i]);
}
}
for (int i = 0; i < k; i++) {
ret[i] = queue.poll();
}
return ret;
}
}
思路二:将所有数据堆排,建立小根堆,然后一直进行出堆操作,一共需要出 k 次堆,这 k 个元素就是最小的k 个数了。
原因:小根堆的堆顶元素是所有堆中元素最小的,每次出堆的时候,都会进行向下调整建立新的小堆。删除之后堆顶元素还是堆中所有元素的最小值。
class Solution {
public int[] smallestK(int[] arr, int k) {
int[] ret = new int[k];
if(arr == null || k == 0) {
return ret;
}
PriorityQueue<Integer> queue = new PriorityQueue<>();
for(int i=0;i<arr.length;i++) {
queue.offer(arr[i]);
}
for(int i=0;i<k;i++) {
ret[i] = queue.poll();
}
return ret;
}
}
我们来分析一下两个算法的时间复杂度,思路一采用 k 个元素建堆,建堆采用向下调整,所以建堆的时间复杂度为 O(k),然后遍历剩下的所有元素进行删除,插入等操作,这个时间复杂度为 (n - k)* log2(k),那么整体的时间复杂度为 O(k + (n - k)* log2(k) )
思路二采用的是所有元素参与建堆,时间复杂度为O(n),然后进行 k 次删除操作,时间复杂度为 O(k * log2(n) ),那么总体的时间复杂度为 O(n + k * log2(n) )
由此可见采用思路一的算法时间效率更高~~
堆排序
步骤:
1.建堆
升序: 建立大根堆
降序: 建立小根堆
2.利用堆删除思想来进行排序
建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。
解释:这里没有额外开辟空间,而是直接在原数组直接进行排序。
首先删除的操作是堆顶元素和最后一个元素进行交换,然后重新建堆,这样就保证了最后的元素一定是最大的,依次类推,把每次的堆顶元素放到后面,这样就获得了升序的序列;同理可得降序原因。
升序代码:
private void swap(int[] arr,int i,int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
private void creatHeap(int[] arr) {
for (int parent = (arr.length - 1 - 1) / 2 ; parent >= 0 ; parent--) {
siftDown(arr,parent,arr.length);
}
}
private void siftDown(int[] arr, int parent, int size) {
int child = 2 * parent + 1;
while(child < size - 1) {
if(child + 1 < size - 1 && arr[child+1] > arr[child]) {
child++;
}
if(arr[parent] < arr[child]) {
swap(arr,parent,child);
parent = child;
child = 2 * parent + 1;
} else {
break;
}
}
}
public void heapSort(int[] arr) {
if(arr == null || arr.length == 0) {
return;
}
creatHeap(arr); // 建立大堆
int end = arr.length - 1; //获取最后一个元素的下标
//删除
while(end > 0) {
swap(arr,0,end);
siftDown(arr,0,end + 1);
end--;
}
}
降序代码:
private void swap(int[] arr,int i,int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
private void creatHeap(int[] arr) {
for (int parent = (arr.length - 1 - 1) / 2 ; parent >= 0 ; parent--) {
siftDown(arr,parent,arr.length);
}
}
private void siftDown(int[] arr, int parent, int size) {
int child = 2 * parent + 1;
while(child < size - 1) {
if(child + 1 < size - 1 && arr[child+1] < arr[child]) {
child++;
}
if(arr[parent] > arr[child]) {
swap(arr,parent,child);
parent = child;
child = 2 * parent + 1;
} else {
break;
}
}
}
public void heapSort(int[] arr) {
if(arr == null || arr.length == 0) {
return;
}
creatHeap(arr); // 建立小堆
int end = arr.length - 1; //获取最后一个元素的下标
//删除
while(end > 0) {
swap(arr,0,end);
siftDown(arr,0,end + 1);
end--;
}
}
时间复杂度分析:
向下调整建堆时间复杂度为 O(n),一次删除操作时间复杂度为 O( log2(n) ),删除循环时间复杂度为 (n - 1) * log2(n),整体时间复杂度为 n + (n - 1) * log2(n) 即为 O( N * log2 (N) )
由于没有开辟额外的空间,所以空间复杂度为O(1)