优先级队列
1. 优先级队列
1.1 概念
队列是一种先进先出的数据结构,但有些情况下,操作的数据可能带有优先级,一般出队列时,可能需要优先级高的元素出队列。该场景中,使用队列显然不合适,比如:在手机上玩游戏时,如果有电话打进来,那么系统应该先处理队列。
在这种情况下,我们的数据结构应该提供两个最基本的操作,一个是返回最高优先级对象,一个是添加新的对象。这种数据结构就是优先级队列。
1.2 常用接口介绍
1.2.1 PriorityQueue的特性
关于PriorityQueue的使用要注意:
- 使用时必须导入PriorityQueue所在的包,即:
import java.util.PriorityQueue;
- PriorityQueue中放置的元素必须要能够比较大小,不能插入无法比较大小的对象,否则会抛出ClassCastExpection异常。
- 不能插入null对象,否则会抛出NullPointerExpection。
- 没有容量限制,可以插入任意多个元素,其内部可以自动扩容。
- 插入和删除元素的时间复杂度为 O ( l o g 2 N ) O(log_2N) O(log2N).
- PriorityQueue底层使用了堆数据结构,(后面介绍)。
- PriorityQueue默认情况下是小堆-----即每次获取到的元素都是最小的元素。
1.2.2 PriorityQueue常用接口介绍
- 优先级队列的构造
此处只列出了几种构造方式,其他的方式可以参考帮助文档。
构造器 | 功能介绍 |
---|---|
PriorityQueue() | 创建一个空的优先级队列 |
PriorityQueue(int initialCapacity) | 创建一个初始容量为initialCapacity 的优先级队列。注意:initialCapacity 不能小于1,否则会抛出ILLegalArgumentException 异常 |
PriorityQueue(Collection<?extends E>c) | 用一个集合来创建优先级队列 |
public class Priority {
static void TestPriorityQueue(){
//创建一个空的优先级队列
PriorityQueue<Integer> p1=new PriorityQueue<>();//默认容量是11
//创建一个空的优先级队列,底层的容量为initialCapacity
PriorityQueue<Integer> p2=new PriorityQueue<>(100);
ArrayList<Integer> list=new ArrayList<>();
list.add(4);
list.add(3);
list.add(2);
list.add(1);
PriorityQueue<Integer> p3=new PriorityQueue<>(list);
System.out.println(p3.size());
System.out.println(p3.peek());
}
public static void main(String[] args) {
TestPriorityQueue();
}
}
注意:默认情况下,PriorityQueue队列是小堆,如果需要大堆,需要用户提供比较器。
- 用户自己提供比较器
public class Card {
String rank;
String suit;
public Card(String rank,String suit){
this.rank=rank;
this.suit=suit;
}
}
public class CardCmp implements Comparator<Card>{
@Override
public int compare(Card o1, Card o2) {
o1.rank.compareTo(o2.rank) ;
return 0;
}
}
验证:PriorityQueue中放置的元素必须能够比较大小,否则就会抛出ClassCastExpection
public static void method3(){
PriorityQueue<Card> p=new PriorityQueue<>(new CardCmp());
p.offer(new Card("A","♠"));
p.offer(new Card("K","♠"));
}
- 默认情况下是小堆,如何创建大堆呢?----起始元素是最大的
public static void method4(){
PriorityQueue<Integer> p=new PriorityQueue<>(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2-o1;//o2-o1是大堆, o1-o2是小堆
}
});
p.offer(5);
p.offer(1);
p.offer(4);
p.offer(2);
p.offer(3);
}
插入/删除/获取优先级最高的元素
函数名 | 功能介绍 |
---|---|
boolean offer(E e) | 插入元素e,插入成功返回true,如果e对象为空,抛出NullPointerException异常,时间复杂度 O ( l o g 2 N ) O(log_2N) O(log2N),注意:空间不够时会进行扩容 |
E peek() | 获取优先级最高的元素,如果优先级队列为空,返回null |
E poll() | 移除优先级最高的元素并返回,如果优先级队列为空,返回null |
int size() | 获取有效元素的个数 |
void clear() | 清空 |
boolean isEmpty() | 检测优先级队列是否为空,空的话返回true |
public static void method2(){
PriorityQueue<Integer> p=new PriorityQueue<>();
p.offer(5);
p.offer(1);
p.offer(4);
p.offer(2);
p.offer(3);
System.out.println(p.size());
p.offer(null);//空指针异常
System.out.println(p.peek());//获取堆顶元素----即优先级最大或者最小的元素
p.poll();
p.poll();
p.poll();
System.out.println(p.peek());
p.clear();
if(p.isEmpty()){
System.out.println("p is empty");
}else{
System.out.println("p is not empty");
}
}
下面是PriorityQueue的扩容方式:
public class Test {
private static final int MAX_ARRAY_SIZE=Integer.MAX_VALUE-8;
private void grow(int minCapacity){
int oldCapacity= queue.length;
int newCapacity=oldCapacity+((oldCapacity<64)?(oldCapacity+2):(oldCapacity>>1));
if(newCapacity-MAX_ARRAY_SIZE>0){
newCapacity=hugeCapacity(minCapacity);
queue= Arrays.copyOf(queue,newCapacity);
}
}
private static int hugeCapacity(int minCapacity){
if(minCapacity<0){
throw new OutOfMemoryError();
return (minCapacity>MAX_ARRAY_SIZE)?Integer.MAX_VALUE:MAX_ARRAY_SIZE;
}
}
}
优先级队列的扩容说明:
- 如果容量小于64时,是按照oldCapacity的2倍方式扩容的。
- 如果容量大于64时,是按照oldCapacity的1.5倍方式扩容的。
- 如果容量超过MAX_ARRAY_SIZE,按照MAX_ARRAY_SIZE来进行扩容。
1.3 优先级队列的应用
top-k:最大或者最小的前k个数据。
top-k问题:最小的k个数
class Solution {
public int[] smallestK(int[] arr, int k) {
if(arr==null){
return new int[0];
}
//用数组中的所有元素构造一个小堆
PriorityQueue<Integer> p=new PriorityQueue<>();
for(int i=0;i<arr.length;++i){
p.offer(arr[i]);
}
//获取堆中前k个元素
int[] ret =new int[k];
for(int i=0;i<k;++i){
ret[i]=p.poll();
}
return ret;
}
}
2. 优先级队列的模拟实现
PriorityQueue底层使用了堆的数据结构,而堆实际就是在完全二叉树的基础之上进行了一些元素的调整。
2.1 堆的概念
如果有一个关键码的集合K={
k
0
,
k
1
,
k
−
2
,
.
.
.
,
k
n
−
1
k_0,k_1,k-2,...,k_n-1
k0,k1,k−2,...,kn−1},把它的所有元素按完全二叉树的顺序存储方式存储。在一个一维数组中,并满足:Ki<=K2i+2(Ki>=K2i+2)i=0,1,2…,则称为小堆 (或大堆)。将根结点最大的堆叫最大堆或大根堆,根结点最小的堆叫做最小堆或小根堆。
堆的性质:
- 堆中某个结点的值总是不大于或不小于其父结点的值;
- 堆中总是一棵完全二叉树;
2.2 堆的存储方式
从堆的概念可知,堆是一棵完全二叉树,因此可以用层序的规则采用顺序的方式来高校存储。
注意:对于非完全二叉树,不适合使用顺序方式进行存储。
原因:为了能够还原二叉树,空间中必须要存储空结点,就会导致空间利用率比较低。
将元素存储到数组中后,可以根据二叉树部分的性质5对树进行还原。假设i为结点在数组中的下标,则有:
- 如果i为0,则i表示的结点为根结点,否则i结点的双亲结点为(i-1)/2
- 如果2i+1小于结点个数,则结点i的左孩子下标为2i+1,否则没有左孩子
- 如果2i+2小于结点个数,则结点i的右孩子下标为2i+2,否则没有右孩子
2.3 堆的创建
2.3.1 堆向下调整
对于集合{27,15,19,18,28,34,65,49,25,37}中的数据,如何将其创建成堆呢?
观察上图发现:根结点的左右子树已经完全满足堆的性质,因此只需要将根结点向下调整即可。
向下调整过程:
- 让parent标记需要调整的结点,child标记parent的左孩子。
- 如果parent的左孩子存在,即child<size,则进行以下操作,知道parent的左孩子不存在
- parent右孩子是否存在,存在找到左右孩子中最小的孩子,让child标记
- 将parent和child比较,如果parent比child大,则交换两个,parent中的大元素向下移动,可能导致子树不满足性质,因此需要继续向下调整
// 功能:调整以parent为根的二叉树
// 前提:必须要保证parent的左右子树已经满足堆的特性
// 时间复杂度:O(logN)
private void shiftDown(int parent){
// 默认让child先标记左孩子---因为:parent可能有左没有右
int child = parent*2 + 1;
// while循环条件可以保证:parent的左孩子一定存在
// 但是不能保证parent的右孩子是否存在
while(child < size){
// 1. 找到左右孩子中较小的孩子
if(child+1 < size && array[child+1] < array[child]){
child += 1;
}
// 2. 较小的孩子已经找到了
// 检测双亲和孩子间是否满足堆的特性
if(array[parent] > array[child]){
swap(parent, child);
// 大的双亲往下走了,可能会导致子树又不满足堆的特性
// 因此需要继续往下调整
parent = child;
child = parent*2 + 1;
}else{
// 以parent为根的二叉树已经是堆了
return;
}
}
}
注意:在调整以parent为根的二叉树时,必须要满足parent的左子树和右子树已经是堆了才可以向下调整。
时间复杂度:从根结点一直比到叶子结点,比较的次数为完全二叉树的高度,即
O
(
l
o
g
2
N
)
O(log_2N)
O(log2N)
2.3.2 堆的创建
那么对于普通的序列{1,5,3,8,7,6},即根结点的左右子树不满足堆的特性,又该如何调整呢?
public MyPriorityQueue(Integer[] arr){
// 1. 将arr中的元素拷贝到数组中
array = new Integer[arr.length];
for(int i = 0; i < arr.length; ++i){
array[i] = arr[i];
}
size = arr.length;
// 2. 找当前完全二叉树中倒数第一个叶子节点
// 注意:倒数第一个叶子节点刚好是最后一个节点的双亲
// 最后一个节点的编号size-1 倒数第一个非叶子节点的下标为(size-1-1)/2
int lastLeafParent = (size-2)/2;
// 3. 从倒数第一个叶子节点位置开始,一直到根节点的位置,使用向下调整
for(int root = lastLeafParent; root >= 0; root--){
shiftDown(root);
}
}
2.3.3 建堆的时间复杂度
因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明。
假设树的高度为h:
注意:叶子结点不需要调整,因为是从倒数第一个非叶子结点开始调整的。
第1层,
2
0
2^0
20个结点,需要向下移动h-1层;
第2层,
2
1
2^1
21个结点,需要向下移动h-2层;
第3层,
2
2
2^2
22个结点,需要向下移动h-3层;
第4层,
2
3
2^3
23个结点,需要向下移动h-4层;
…
第h-1层,
2
h
−
2
2^{h-2}
2h−2个结点,需要向下移动1层;
则需要移动结点总的移动步数:
T
(
n
)
=
2
0
∗
(
h
−
1
)
+
2
1
∗
(
h
−
2
)
+
2
2
∗
(
h
−
3
)
+
2
3
∗
(
h
−
4
)
+
.
.
.
+
2
h
−
3
∗
(
2
)
+
2
h
−
2
∗
(
1
)
T(n)=2^0*(h-1)+2^1*(h-2)+2^2*(h-3)+2^3*(h-4)+...+2^{h-3}*(2)+2^{h-2}*(1)
T(n)=20∗(h−1)+21∗(h−2)+22∗(h−3)+23∗(h−4)+...+2h−3∗(2)+2h−2∗(1) ------------ ①
2
T
(
n
)
=
2
1
∗
(
h
−
1
)
+
2
2
∗
(
h
−
2
)
+
2
3
∗
(
h
−
3
)
+
2
4
∗
(
h
−
4
)
+
.
.
.
+
2
h
−
2
∗
(
2
)
+
2
h
−
1
∗
(
1
)
2T(n)=2^1*(h-1)+2^2*(h-2)+2^3*(h-3)+2^4*(h-4)+...+2^{h-2}*(2)+2^{h-1}*(1)
2T(n)=21∗(h−1)+22∗(h−2)+23∗(h−3)+24∗(h−4)+...+2h−2∗(2)+2h−1∗(1) -------------②
②-①错位相减:
T
(
n
)
=
1
−
h
+
2
1
+
2
2
+
2
3
+
2
4
+
.
.
.
+
2
h
−
2
+
2
h
−
1
T(n)=1-h+2^1+2^2+2^3+2^4+...+2^{h-2}+2^{h-1}
T(n)=1−h+21+22+23+24+...+2h−2+2h−1
=
2
0
+
2
1
+
2
2
+
2
3
+
2
4
+
.
.
.
+
2
h
−
2
+
2
h
−
1
−
h
=2^0+2^1+2^2+2^3+2^4+...+2^{h-2}+2^{h-1}-h
=20+21+22+23+24+...+2h−2+2h−1−h
=
2
h
−
1
−
h
=2^h-1-h
=2h−1−h
又因为n=
2
h
−
1
2^h-1
2h−1,
h
=
l
o
g
2
(
n
+
1
)
h=log_2(n+1)
h=log2(n+1)
T
(
n
)
=
n
−
l
o
g
2
(
n
+
1
)
≈
n
T(n)=n-log_2(n+1)≈n
T(n)=n−log2(n+1)≈n
2.4 堆的插入与删除
2.4.1 堆的插入
堆的插入总共需要两个步骤:
- 先将元素放入到底层空间中(注意:空间不够时需要扩容)
- 将最后新插入的结点向上调整,直到满足堆的性质
private void shiftUp(int child){
//找到child的双亲
int parent = (child-1)/2;
while(child != 0){
if(array[child] < array[parent]){
swap(child, parent);
child = parent;
parent = (child-1)/2;
}else{
return;
}
}
}
2.4.2 堆的删除
注意:堆的删除一定是堆顶元素。
- 将堆顶元素与最后一个元素交换
- 将堆中有效元素个数减少一个
- 将堆顶元素向下调整
2.5 用堆模拟实现优先级队列
public class MyPriorityQueue {
Integer[] array;
int size; // 有效元素的个数
boolean offer(Integer e){
if(e == null){
throw new NullPointerException("插入时候元素为null");
}
ensureCapacity();
array[size++] = e;
// 注意:当新元素插入之后,可能会破坏堆的性质---需要向上调整
shiftUp(size-1);
return true;
}
// 将堆顶的元素删除掉
public Integer poll(){
if(isEmpty()){
return null;
}
Integer ret = array[0];
// 1. 将堆顶元素与堆中最后一个元素交换
swap(0, size-1);
// 2. 将堆中有效元素个数减少一个
size--; // size -= 1;
// 3. 将堆顶元素往下调整到合适位置
shiftDown(0);
return ret;
}
public int size(){
return size;
}
public boolean isEmpty(){
return size == 0;
}
public void clear(){
size = 0;
}
public int peek(){
return array[0];
}
}
3. 堆的应用
3.1 PriorityQueue的实现
用堆作为底层结构封装优先级队列
3.2 堆排序
堆排序即用堆的思想来进行排序,总共分为两个步骤:
- 建堆
升序:建大堆
降序:建小堆 - 利用堆删除思想来进行排序
public static void swap(int [] array,int left,int right){
int temp=array[right];
array[right]=array[left];
array[left]=array[right];
}
public static void shiftDown(int[] array,int size, int parent){
int child =parent*2+1;
while(child<size){
//找左右孩子中较大的孩子
if(child+1<size&&array[child+1]>array[child]){
child+=1;
}
//双亲小于较大的孩子
if(array[parent]<array[child]){
swap(array,parent,child);
}
}
}
//假设:升序
public static void heapSort(int[] array){
//1.建堆---升序 建大堆 降序 建小堆
for(int root=(array.length-2)>>1;root>=0;root--){
shiftDown(array,array.length,root);
}
//2.利用堆删除的思想来排序---向下调整
int end=array.length-1;//用end标记最后一个元素
while(end!=0){
swap(array,0,end);
shiftDown(array,end,0);
end--;
}
}
3.3 Top-k问题
Top-k问题:求数据集合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
对于top-k问题,能想到的最简单的方式就是排序,但是如果数据量非常大,排序就不太可取了。最佳的方式是用堆来解决。基本思路如下:
- 用数据集合中前K个元素来建堆
前K个最大的元素则建小堆
前K个最小的元素则建大堆 - 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
将剩余N-K个元素依次与堆顶元素比较之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。