前面介绍过队列, 队列是一种先进先出 (FIFO) 的数据结构 ,但有些情况下, 操作的数据可能带有优先级,一般出队 列时,可能需要优先级高的元素先出队列 ,该中场景下,使用队列显然不合适,比如:在手机上玩游戏的时候,如果有来电,那么系统应该优先处理打进来的电话;初中那会班主任排座位时可能会让成绩好的同学先挑座位。
在这种情况下, 数据结构应该提供两个最基本的操作,一个是返回最高优先级对象,一个是添加新的对象 。这种数 据结构就是优先级队列 (Priority Queue) 。
如果有一个 关键码的集合 K = {k0 , k1 , k2 , … , kn-1} ,把它的所有元素 按完全二叉树的顺序存储方式存储 在一 个一维数组中 ,并满足: Ki <= K2i+1 且 Ki<= K2i+2 (Ki >= K2i+1 且 Ki >= K2i+2) i = 0 , 1 , 2… ,则 称为 小堆 ( 或大堆) 。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。下面是一棵大根堆为啥这里让 ki 和 k2i+1 ,k2i+2进行比较呢?
通过观察父节点和子节点的下标关系可以发现,左孩子下标=父节点下标*2+1,右孩子下标=父节点下标*2+2
左右孩子有关系吗?
左右孩子之间没有任何关系
为啥堆要用数组来存储?其他类型的二叉树没有用数组?
堆总是一棵完全二叉树,用数组来存储每一个结点的值,空间利用率比较高
用数组来存储其他二叉树,会有很多的空间浪费
用下面的一张图就可以看明白了
按照将数组转化为堆的方法,把上面的集合转化为完全二叉树
肯定不满足堆的定义,该怎么转化呢?
现在演示调节把18为根节点的子树转化成堆的过程
1. 先比较18的两个子节点,发现49>25
2. 比较18和19,发现18<49,把18和49交换
同理,交换37和28
这样是不是就发现以18和28为根节点的子树已经变成大根堆了?
那么问题来了?要求将整棵树变成大根堆,该怎么做呢?
现在演示把15为根节点的子树转化成堆的过程
1. 先比较15的两个子节点,发现49>28
2. 比较15和49,发现49>15,把15和49交换
只交换15和49并不能把这棵子树变成大根堆,针对15这个结点还要重复刚才的两步
这样上图红圈内的子树就变成大根堆了.
可以得到一个规律:如果子树是大根堆,只需要将根节点不断向下调整,就可以把整棵树变成大根堆
那么什么时候根节点可以不用下沉了呢?
根节点没有孩子/根节点>max(子节点)
1. 判断根节点有没有孩子很简单,根据父子节点的下标规律,如果2*ki
2. 如果根节点>max(子节点),而子节点所在的子树又是大根堆,这时就可以不用向下调整了
下面给出实现代码
public void shiftDown(int parent,int end) { int child=parent*2+1; while(child
elem[parent]) {//比较父节点和子节点中较大的那一个,如果父节点<子节点,交换,否则退出循环 int tmp=elem[child]; elem[child]=elem[parent]; elem[parent]=tmp; parent=child;//父节点接着向下调整 child=parent*2+1; } else { break; } } }
使用向下调整有个前提,那就是左右子树必须是大根堆.所以如果想把整棵树都变成大根堆,需要先调整所有子树,该怎么控制顺序呢?
根据父子节点之间的下标关系,可以发现最小的子树其根节点的下标为(usedSize-1-1)/2
第一个"-1"获取最后一个元素的下标,第二个"-1"是因为父节点下标==(子节点下标-1)/2
所以可以从i=(usedSize-1-1)/2开始,向前迭代,就可以实现先调整子树再调整整棵树的顺序了
下面给出建堆的代码
public class MyHeap { int[] elem=new int[10];//用来存放堆的数组 int usedSize;//记录插入的结点个数 public MyHeap(int[] array) { for(int i=0;i
>1;i>=0;i--) { shiftDown(i,usedSize); } } } 下图是建堆的最终结果
很容易得出,每个节点向下调整的时间复杂度为h-当前层数(h是树的高度)
总结上面的规律,可以得出将整棵树转化成堆的时间复杂度
用式2-式1,再用等比数列求和公式就可以得到时间复杂度为
可以得到建堆的时间复杂度就是O(n)
如果想在这个堆中插入一个元素100,需要改变整棵树的结构吗?
下面来演示一下
1. 100和父节点37比较,发现比37大,交换100和37
2. 100继续和父节点49比较,发现比49大,交换49和100
3. 100和65比较,发现比65大,交换65和100
可以发现,插入一个元素时,只影响该元素到根节点路径上的结点
那么什么时候插入的元素可以不用继续向上调整了呢?
该元素已经是根节点/该元素<父节点
1. 判断当前插入元素是不是已经变成了根节点很简单,只需要看下标是不是为0就可以了
2. 如果这个元素<父节点,父节点所在的树本身又是个最大堆,即父节点<祖父节点,所以不需要继续向上调整了
下面给出插入代码
public void insert(int val){ if(usedSize==elem.length) {//数组长度不够时扩容 elem= Arrays.copyOf(elem,elem.length*2); } elem[usedSize]=val; shiftUp(usedSize); usedSize++; } public void shiftUp(int child) { int parent=(child-1)>>1; while(child>0){//如果当前结点是根节点,不需要接着向上调整了 if(elem[child]>elem[parent]) { int tmp=elem[child]; elem[child]=elem[parent]; elem[parent]=tmp; child=parent; parent=(child-1)>>1; } else {//子节点小于父节点,不需要接着调整了 break; } } }
来看一下插入的结果
实际上,也可以使用向上调整算法建堆,有两种办法
1. 使用insert方法将数组元素一个个插入
2. 如果是在原本的数组上进行改动,让i=1开始向后遍历,直到最后一个节点
为啥这次是i从前往后遍历呢捏?
因为向上调整的前提是父节点所在的树本身是有序的
下面给出方法2的代码实现
public MyHeap(int[] array) { for(int i=0;i
但是向上调整方法主要是用来插入元素的,用它建堆的时间复杂度要比向下调整大很多
下面来计算一下
向上调整方法本身的时间复杂度也为O(h),h表示这棵树的高度
就可以的得到时间复杂度为O(nlogn)
堆的删除是指弹出堆顶元素,如果我们直接弹出
很不幸,这个堆要重建了,时间复杂度是O(n)~~
为了降低复杂度,智者(不知道是谁,可以去问度娘)想到了一个办法,交换堆顶元素和最后一个元素,如下图
聪明的你们肯定想到了,这时候只需要将元素28向下调整就可以了
下面给出实现的代码
public int poll() { if(usedSize==0) { throw new HeapEmptyException("队列为空"); } int tmp=elem[0]; elem[0]=elem[--usedSize]; elem[usedSize]=tmp; shiftDown(0,usedSize); return tmp; }
PriorityQueue是一个普通类,其继承关系如下图
使用PriorityQueue时需要注意以下几点:1.使用PriorityQueue时插入的元素必须是可比较的(可比较是指实现了Comparable接口或者有比较器),否则会抛出ClassCastException异常2.不能插入null对象,否则会抛出NullPointerException异常
3. PriorityQueue的最大容量为 Integer.MAX_VALUE,可以任意插入多个元素
4.插入和删除元素的时间复杂度为O(logN)
5.PriorityQueue默认建小根堆
老规矩,在介绍每个类的方法时, 首当其冲就是它的构造方法
下面我们先来演示用集合构造优先级队列的方法(因为前几个比较难啃)
public static void main(String[] args) { List
list=new ArrayList<>(); list.add(9); list.add(15); list.add(1); PriorityQueue priorityQueue=new PriorityQueue<>(list); while(!priorityQueue.isEmpty()){ System.out.println(priorityQueue.poll()); } } 可以得到如下输出结果
可以验证第五条性质--PriorityQueue默认建小堆
下面我们看看前四个构造方法的实现,其实前三个构造方法都是来摸鱼的~~
看到这里,肯定有同学要问:这个comparator究竟是个嘛玩意?莫急莫急,且看下文~
在Java中,对于基本类型,可以直接比较大小(boolean除外,它只能比较==/!=)
public static void main(String[] args) {
int a = 10;
int b = 20;
System.out.println(a > b);
System.out.println(a < b);
System.out.println(a == b);
char c1 = 'A';
char c2 = 'B';
System.out.println(c1 > c2);
System.out.println(c1 < c2);
System.out.println(c1 == c2);
boolean b1 = true;
boolean b2 = false;
System.out.println(b1 == b2);
System.out.println(b1 != b2);
}
那么对象该怎么比较呢?
有些同学肯定会发现,直接使用"=="比较对象时,编译器并不报错
class Person{
String name;
int age;
}
public class Test {
public static void main(String[] args) {
Person A=new Person();
Person B=new Person();
System.out.println(A==B);//比较A和B是否指向同一对象
System.out.println(A!=B);//比较A和B是否指向不同对象
}
}
注意: "=="两端是对象引用时,会直接比较两个引用的地址,即是否指向同一对象;
同样, "!="两端是对象的引用时,会返回两个引用指向的对象是否不一样
如果两个人的名字相同,年龄相等, 我们就认为他们是同一个人,该怎么实现这个比较逻辑呢?
答: 重写equals方法 (这个方法不需要我们自己去实现,alt+insert让idea来就好了)
class Person{
String name;
int age;
@Override
public boolean equals(Object o) {
if (this == o) return true;//如果两个引用指向同一个对象,返回true
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && Objects.equals(name, person.name);比较A的name,age是否和B完全相同
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
public class Test {
public static void main(String[] args) {
Person A=new Person();
Person B=new Person();
System.out.println(A.equals(B));
}
}
使用equals方法可以比较两个对象是否相同,但是该如何比较两个对象的大小呢?
Java为我们提供了两个接口来比较对象的大小
Comparble是JDK提供的泛型的比较接口类,其源码如下
public interface Comparable < E > {// 返回值 :// < 0: 表示 this 指向的对象小于 o 指向的对象// == 0: 表示 this 指向的对象等于 o 指向的对象// > 0: 表示 this 指向的对象大于 o 指向的对象int compareTo ( E o );}
现在演示按照年龄大小比较 两个Person
class Person implements Comparable
{ String name; int age; public Person(String name, int age) { this.name = name; this.age = age; } @Override public int compareTo(Person o) { return this.age-o.age; } @Override public String toString() { return "Person{" + "name='" + name + '\'' + ", age=" + age + '}'; } } public class Test { public static void main(String[] args) { PriorityQueue priorityQueue=new PriorityQueue<>(); Person a=new Person("张三",12); Person b=new Person("赵四",20); Person c=new Person("王五",4); priorityQueue.offer(a); priorityQueue.offer(b); priorityQueue.offer(c); while(!priorityQueue.isEmpty()){ System.out.println(priorityQueue.poll()); } } } 这样就可以按照年龄构造一个小根堆了,下面是验证的输出结果
肯定有同学会问,如果想建大堆咋办?
可以用你的脚指头想一下,只要把this.age-o.age调换一下顺序就好啦
为了避免凑字数的流言,只给出compareTo的代码,其他的代码不变
public int compareTo(Person o) { return o.age-this.age; }
再运行一次,就会发现变成大堆了
Comparable接口虽然实现了对象的比较,现在又有一个问题?如果有一天还要求按名字给这些Person排序呢?
Comparable接口只能实现一种排序逻辑,所以这时候就需要第二位主角上场了~~
比较器Comparator
//定义比较器,按照name比较Person对象 class NameCmp implements Comparator
{ @Override public int compare(Person o1, Person o2) { return o1.name.compareTo(o2.name);//用这种写法会创建小根堆,如果创建大根堆,调换O1和O2位置即可 } } public class Test { public static void main(String[] args) { NameCmp nameCmp=new NameCmp(); Queue queue=new PriorityQueue<>(nameCmp); Person a=new Person("张三",12); Person b=new Person("赵四",20); Person c=new Person("王五",4); queue.offer(a); queue.offer(b); queue.offer(c); while(!queue.isEmpty()){ System.out.println(queue.poll()); } } }
equals | 重写父类Object的方法,只能用于比较对象是否相等 |
ComparaTo | 定义在被比较类的内部,侵入性比较强,只能有一种实现逻辑 |
compare | 重新定义一个用于比较的类,侵入性弱,可以实现多个比较逻辑 |
PriorityQueue实现了Queue接口,所以Queue的方法也同样适用于PriorityQueue
方法名
|
功能 |
boolean offer(E e)
|
插入元素 e ,插入成功返回 true ,如果 e 对象为空,抛出 NullPointerException 异常
|
E peek()
|
获取优先级最高的元素,如果优先级队列为空,返回 null
|
E poll()
|
移除优先级最高的元素并返回,如果优先级队列为空,返回 null
|
int size()
|
获取有效元素的个数
|
void clear()
|
清空队列 |
boolean isEmpty()
|
检查队列是否为空,为空返回true,否则返回false |
只知道这些方法肯定是不够的,下面分析一下源码,来看看PriorityQueue内部的实现吧
首先来看offer方法
现在用{27,11}建堆,假设已经插入了27,下面演示插入11的过程
仔细看就会发现,这里siftUp和我们上文向上调整的方法的实现是一样的,
下面来研究poll方法
同样拉出来{11,27,29}这仨兄弟,现在要弹出堆顶元素
下面在谈谈PriorityQueue的扩容
解决这类方法有三个思路:1. 从小到大排序,然后输出前k个元素一般情况下,这种方法的时间复杂度是O(n*logn)class Solution { public int[] smallestK(int[] arr, int k) { int[] result=new int[k]; Arrays.sort(arr); for(int i=0;i
是不是看着很简洁,但是可千万不要摆到台面上,这属于暴力求解了
2. 建立一个小根堆,依次弹出k个堆顶元素来计算一下这个方法的时间复杂度:建堆的时间复杂度是O(n)每次弹出栈顶元素,共弹出k次,时间复杂度是O(k*logn),所以总的时间复杂度是O(n+k*logn)class Solution { public int[] smallestK(int[] arr, int k) { int[] result=new int[k]; PriorityQueue
pq=new PriorityQueue<>(); for(int i=0;i 0){ result[i++]=pq.poll(); } return result; } }
下面来介绍PriorityQueue的正确使用以arr={27,12,3,1,5,87},k=3为例来计算一下时间复杂度:
建k个元素的堆时间复杂度为O(k),插入后面n-k个元素时,最坏情况下时间复杂度为O(2*(n-k)*logk),这里的2计算的是弹出堆顶元素时的时间复杂度,因为不能直接将peek和arr[i]交换,否则会破坏这个堆的结构
class Cmp implements Comparator
{ public int compare(Integer o1,Integer o2){ return o2-o1; } } class Solution { public int[] smallestK(int[] arr, int k) { int[] result=new int[k]; if(k==0) return result; PriorityQueue pq=new PriorityQueue<>(k,new Cmp()); for(int i=0;i
堆这种数据结构还可以用来排序,关于排序下篇文章会讲到
......end