前言❤️❤️
hello hello💕,这里是洋不写bug~😄,欢迎大家点赞👍👍,关注😍😍,收藏🌹🌹
这篇博客要解析的是堆,堆是一种特殊的完全二叉树,堆的使用、底层构造都比较简单,基本上都不涉及递归,方法也是一看就懂,相比前面的二叉树,堆要简单很多🐵
这个专栏的数据结构是代码都是用Java来写的,JavaSE专栏现在已经全部更新完成,铁汁们复习基础知识时非常推荐使用,可以试一下💪💪💪
🎇个人主页:洋不写bug的博客
🎇所属专栏:数据结构专栏
🎇复习Java基础知识:Java学习之旅,从入门到进阶
🎇铁汁们对于数据结构基础的各种核心知识(不太常用的也有😆),都可以在上面的数据结构专栏学习,专栏正在持续更新中🐵🐵,有问题可以写在评论区或者私信我哦~
1,优先级队列的简介
正常的队列就是“先进先出”,在优先级队列中,出队列的顺序和入队列的顺序是不一样的,把队列中的每个元素,都约定了优先级,优先级高的就先出队列
就比如医院,正常看病就是要挂号、排队,但是急诊送过来的病人情况比较紧急,就需要赶快往前插队
优先级队列的的集合类为PriorityQueue,出队列的方法就是poll(),入队列的方法就是offer(),如果是数字类型,那就是值越小,优先级越高
import java.util.PriorityQueue;
public class Test {
public static void main(String[] args) {
PriorityQueue<Integer> priorityQueue = new PriorityQueue<>();
priorityQueue.offer(2);
priorityQueue.offer(5);
priorityQueue.offer(3);
priorityQueue.offer(1);
priorityQueue.offer(4);
while(!priorityQueue.isEmpty()){
System.out.println(priorityQueue.poll());
}
}
}

如果优先级队列中的元素是String类型,就是按照字典序来排优先级的,这个了解下即可
2,堆的简介
那优先级队列是如何实现的呢,可能有的铁汁会想到:把元素存储到顺序表或者链表中,每次出队列时遍历一遍,把优先级最高的元素给删除掉,但是这样的时间复杂度就是O(N),还是比较高的
优先级队列最经典、最高效的实现方式,就是用堆这个数据结构,堆就是一个完全二叉树
对于完全二叉树来说,可以不使用“孩子表示法”,通过数组的方式来表示这棵树,对这个树进行层序遍历,把遍历的结果依次添加到数组中,如果遇到空结点,就把空结点(null)也添加到数组中,如下图
假设父结点的下标是parent,那左子结点的下标就是2 * parent + 1,右子结点的下标就是2 * parent + 2(实际写代码的时候是不会把null也加入数组中的,)

普通二叉树很少使用这种数组存储法,因为普通二叉树中可能空结点比较多,用这种方法的空间利用率是很低的(完全二叉树的空结点的个数就比较少)
堆有一个重要的特征:任何一个结点的值都是大于(小于)两个子结点的值的(左右子结点谁大谁小无所谓)
- 如果是大于,这个堆就是大根堆/大堆,在整棵树中,根结点的值是最大的
- 如果是小于,这个堆就是小根堆/小堆,在整棵树中,根结点的值是最小的
下图就是一个大堆

对于堆/优先级队列,如果里面存放的是数字/String类型,比较规则就比较明确,如果是对象,那就要约定好按照什么来比较,例如下面的boy类,可以按照身高、颜值、资产等来比较,确定使用比较的属性,就称为key,也就是关键字
class Boy {
String name;
int id;
int balance;
int face;
int height;
int weight;
int rank;
}
3,堆的实现
接下来分析下一个乱序的二叉树是如何调整为堆的
先考虑一个简单的情况,有一个数组,除了[0]元素之外,其他结点都满足小堆的要求,这个数组为[27,15,19,18,28,34,65,49,25,37],树结构如下图,如何调整这个二叉树,让它变成一个小堆

针对这种情况,要做的就是“向下调整”
- 先确定当前结点要和左子结点比较还是右子结点比较:
a)如果只有左子树,要比较的就是左子结点
b)如果右子树和左子树都有,要比较的就是左子结点和右子结点(完全二叉树是不可能只有右子树,没有左子树的🐵) - 拿着左右子结点中更小的那个(只有左子树就直接拿左子结点),跟根结点进行比较,如果发现根结点的值大于子结点的,就交换这两个结点的位置
按照这个思路,就是27和15的位置交换一下

这样交换完成后,根结点就满足要求了,但是27、18、28这三个结点构成的子树就不符合要求了,还是按照前面的规则进行调整,交换下27和18的位置

对于27、49、25构成的子树,还是按照这个规则比较,比较后交换27和25的位置,这个二叉树就调整成了一个小堆


这个向下调整使用时有个前提,那就是除了根结点之外,其他的结点都已经符合堆的要求了
如果给一个乱序的完全二叉树(数组),我们是不是就可以找到数组中的最后一个“非叶子结点”A,那A的左结点一定是叶子结点,右子结点可能为空,不为空的话也是叶子结点,对A进行向下调整后,A构成的这个子树就满足堆的要求了
就这样从后往前的进行调整,经过多轮调整后,除了根结点之外,其他的结点就都已经符合堆的要求,对根结点进行向下调整,二叉树就变成堆了
接下来模拟下这个过程,给一个乱序的完全二叉树[9,5,2,7,3,6,8,10],通过调整让它变成小堆,首先找到数组中的最后一个非叶子结点,就是7,在7为根结点的这个子树中,是不需要调整的

在数组中从后往前看,下个叶子结点是2,这个子树也不需要调整

下个叶子结点是5,这个子树需要调整下,将5的位置和3的位置交换下,交换后还要判断换下来的这个结点是否需要再次交换,这里5的左子结点和右子结点都是null,就不用考虑了

最后到了9这个结点,跟左右结点中较小的进行交换,跟2进行交换;交换后,9大于6,再和6这个结点进行交换,至此小堆就构建完成了

构建堆的基本思路:
- 从数组中的最后一个非叶子结点出发,从后往前遍历,针对每个结点,进行向下调整
- 向下调整:就是找出子树中的最小值,和根结点来比较,如果根结点的值更大,就和刚才的子树结点交换,持续往下进行到根结点比子树小,或者根结点没有子树的时候
创建个Heap类,把代码都写到Heap类中,shiftDown方法逻辑如下:
- 向下调整的方法shiftDown,方法中传入数组和要调整的父结点的下标,用 child = 2 * parent + 1来计算左子结点的下标,右子结点的下标是左子结点的下标加1,当然,右子树可能为空,或者左右子树都为空,需要写代码来判断一下
- 比较出左右子树的最小值后,和父结点的值进行比较,如果父结点的更大,就交换位置
- 父结点和子结点交换位置后,更改parent的值,parent = child,再更新child的值,child = 2 * parent + 1,继续进行判断(因为交换后父结点换到了子结点的位置,下面的树可能不符合要求,还是需要调整),重复前面的步骤,进行到parent左右子树都为空,就调整完成了
- parent的左右子树都为空,就是child >= arr.length,代码就写在while循环中,循环条件就是while(child < arr.length),出循环就表示向下调整过程完成了
public static void shiftDown(int[] arr,int index){
int parent = index;
int child = 2 * parent + 1;
while(child < arr.length){
if(child + 1 < arr.length && arr[child] > arr[child + 1]){
child = child + 1;
}
if(arr[parent] <= arr[child]){
break;
}else{
int tmp = arr[parent];
arr[parent] = arr[child];
arr[child] = tmp;
parent = child;
child = 2 * parent + 1;
}
}
}
接下来写一下creatHeap方法,方法逻辑如下:
- 找到数组中的最后一个非叶子结点,然后从这个结点开始,往前遍历数组,遍历时每个结点都调用一下shiftDown方法,进行向下调整,就能得到堆
- 重点就是计算出最后一个非叶子结点lastParent,铁汁们细品下,最后一个非叶子结点的值是不是就是最后一个叶子结点的父结点(如下图),最后一个叶子结点的下标lastLeaf就是arr.length - 1,lastParnt = 2 * lastLeaf + 1,就能推出lastLeaf = (lastParent - 1) / 2

有的铁汁可能会想:如果最后一个结点lastLeaf是右子结点,那lastParent = 2 * lastLeaf + 2,lastLeaf = (lastParent - 2) / 2,那是不是还要写代码判断lastLeaf是左子结点,还是右子结点?
最后一个结点是右子结点,同样可以直接用lastLeaf = (lastParent - 1) / 2来计算,如果最后一个结点是右子结点,那这个结点一定是双数,双数减去1就是单数,单数除以2,结果带有.5,但相除的是两个int类型,小数部分会被约掉,因此结果还是正确的🐵
写代码时不建议写成lastParent = (arr.length - 2)/2,这样代码的可读性就会比较差
public static void createHeap(int[] arr){
int lastLeaf = arr.length - 1;
int lastParent = (lastLeaf - 1) / 2;
for(int i = lastParent;i >= 0;i--){
shiftDown(arr,i);
}
}
在main方法中创建一个乱序的数组,将数组传入createHeap方法,接着打印数组,看下效果,整体代码如下:
import java.util.Arrays;
public class Heap {
public static void shiftDown(int[] arr,int index){
int parent = index;
int child = 2 * parent + 1;
while(child < arr.length){
if(child + 1 < arr.length && arr[child] > arr[child + 1]){
child = child + 1;
}
if(arr[parent] <= arr[child]){
break;
}else{
int tmp = arr[parent];
arr[parent] = arr[child];
arr[child] = tmp;
parent = child;
child = 2 * parent + 1;
}
}
}
public static void createHeap(int[] arr){
int lastLeaf = arr.length - 1;
int lastParent = (lastLeaf - 1) / 2;
for(int i = lastParent;i >= 0;i--){
shiftDown(arr,i);
}
}
public static void main(String[] args) {
int[] array = {1,3,2,6,10,9,8,7,5,4};
createHeap(array);
System.out.println(Arrays.toString(array));
}
}

根据调整后的数组还原下二叉树,发现确实是堆,如下图

对于一个乱序的二叉树(数组),要将其转化为堆,时间复杂度是多少呢?
先分析下shiftDown方法的时间复杂度,循环变量child,每次都是* 2,直到child >= arr.length,结束循环,所以shiftDown方法的时间复杂度就是O(logN)
在createHeap方法中,循环的调用shiftDown方法,铁汁可能会觉得时间复杂度就是O(NlogN),但是在这个for循环中,shiftDown中while的循环次数跟传入的是有很大关系的,当i = lastParent,shiftDown方法中的while循环最多执行一次,当i比较小的时候,shiftDown中的while循环可能要执行多次,但最多也只是执行logN次
for(int i = lastParent;i >= 0;i--){
shiftDown(arr,i);
}
只有i最大时shiftDown的while循环才可能执行logN次,铁汁们细品下,整体的时间复杂度应该是比O(NlogN)要小的,那具体如何计算呢,数学推导如下,这个算是高中数学压轴题的难度(有兴趣的铁汁可以推导一下,如何推导面试是一定不会问到的😄),推导出时间复杂度就是O(N)

4,优先级队列的实现
优先级队列的核心操作就是入队列、出队列、返回堆顶元素,返回堆顶元素很简单,直接返回arr[0]即可,重点是入队列、出队列时二叉树如何调整
先分析入队列,入队列就是把一个元素添加到数组末尾,作为二叉树的最后一个叶子结点,入队列如何调整二叉树呢?有的铁汁可能会想:可以直接调用堆的createHeap方法
但是这里铁汁们细品下,就这一个子树不符合堆的性质,需要调整,其他子树都是符合的,但是createHeap方法会判断依次判断每个非叶子结点跟其左右子树是否符合要求,效率就比较低
入队列新的元素时,就可以采用“向上调整“,假设是小堆,步骤如下:
- 从最后一个元素的位置出发,找到该元素的父结点,父结点的下标就是(child - 1)/2,比较父结点和子结点的大小,如果父结点更小,说明新加入的元素没有破坏堆,就不需要进行调整了,如果父结点大,就交换两个元素
- 如果在第1步中交换元素了,更新child的值为parent,接着再用(child - 1)/2重新计算parent,继续第1步的比较,当元素到达根结点或者元素的值大于其父结点的值时,向上调整就完成了
例如这样一个小堆,加入10这个结点,加到叶子结点,跟父结点比较大小,交换10和28的位置,

交换10和28的位置后,再次进行判断,发现子结点的值还是小于父结点,还需要交换18和10的位置

交换18和10的位置后,再次判断,父结点的值还是大于子结点,还需要交换10和15的位置

交换10和15的位置后,就得到了小堆

代码如下,这个代码还是写到Heap类中
public static void shiftUp(int[] arr,int index){
int child = index;
int parent = (child - 1) / 2;
while(child > 0){
if(arr[parent] > arr[child]){
int tmp = arr[parent];
arr[parent] = arr[child];
arr[child] = tmp;
child = parent;
parent = (child - 1) / 2;
}else{
break;
}
}
}
接着我们分析下出队列,出队列就是把arr[0]元素删除,如果是一个有序的顺序表的话,直接把后面元素往前移动即可,但是“堆”就不能这么搞了
这时候就可以转化下思路,删除队首元素不好删除,删除队尾元素是很简单的,直接删掉即可,那我们就可以把根结点和最后一个结点进行交换,接着删除最后一个结点的位置,这时候树中只有根结点是不符合堆的性质的,从根结点出发,进行以此向下调整即可
分析清楚后,就创建一个MyPriorityQueue类,模拟实现下我们自己的优先级队列,代码逻辑如下:
- 在队列中固定长度的数组,通过size这个成员变量来记录数组的长度
- 在offer方法中加入判断,判断size是否等于arr.length,确定数组是否满了,满了的话可以进行自动扩容,这里我么就直接return
- offer方法就是调用Heap类中的shiftUp方法,poll方法就是调用Heap类中的shiftDown方法,注意,前面写的shiftDown是一直进行到child < arr.length,现在再传入一个size参数,一直进行到child < size
- 堆中的createHeap方法,创建优先级队列也是用不到的,就可以删除掉,Heap类和Mypriority类代码如下:
package package12;
public class Heap {
public static void shiftDown(int[] arr,int size,int index){
int parent = index;
int child = 2 * parent + 1;
while(child < size){
if(child + 1 < size&& arr[child] > arr[child + 1]){
child = child + 1;
}
if(arr[parent] <= arr[child]){
break;
}else{
int tmp = arr[parent];
arr[parent] = arr[child];
arr[child] = tmp;
parent = child;
child = 2 * parent + 1;
}
}
}
public static void shiftUp(int[] arr,int index){
int child = index;
int parent = (child - 1) / 2;
while(child > 0){
if(arr[parent] > arr[child]){
int tmp = arr[parent];
arr[parent] = arr[child];
arr[child] = tmp;
child = parent;
parent = (child - 1) / 2;
}else{
break;
}
}
}
}
package package12;
public class MyPriorityQueue {
private int[] array;
private int size = 0;
public MyPriorityQueue() {
array = new int[1000];
}
public void offer(int value){
if(size == array.length){
return;
}
array[size] = value;
Heap.shiftUp(array,size);
size++;
}
public Integer poll(){
if(size == 0){
return null;
}
int tmp = array[0];
array[0] = array[size - 1];
array[size - 1] = tmp;
size--;
Heap.shiftDown(array,size,0);
return tmp;
}
public Integer peek(){
if(size == 0){
return null;
}
return array[0];
}
}
接着在main方法中测试下MyPriorityQueue
package package12;
import java.util.PriorityQueue;
public class Test {
public static void main(String[] args) {
MyPriorityQueue myPriorityQueue = new MyPriorityQueue();
myPriorityQueue.offer(4);
myPriorityQueue.offer(3);
myPriorityQueue.offer(1);
myPriorityQueue.offer(2);
System.out.println(myPriorityQueue.peek());
System.out.println(myPriorityQueue.poll());
System.out.println(myPriorityQueue.poll());
System.out.println(myPriorityQueue.poll());
System.out.println(myPriorityQueue.poll());
}
}

5,比较规则的制定
如果优先级队列中的元素是对象,也可以把对象的某个属性作为比较标准进行排序,这个属性也被称为关键字
想要给优先级队列指定比较规则,就需要先指定比较器,也就是实现Comparable和Comparator接口(这两个接口的使用在JavaSE专栏中详细解析过,想要复习的铁汁可以看下🐵)
- 如果队列中插入的是类的对象,就可以直接让这个类实现Comparable接口;
- -如果队列中插入的是int或者String这种,就可以实现Comparator来完成上述比较规则
例如优先级队列中插入Integer元素时,默认是小堆,如果想要改一下比较规则,值大的元素靠前,就需要创建一个比较器类IntComparator,实现Comparator接口
重写compare方法,返回o2 - o1,compare方法是这样,如果返回值小于0,就说明o1排在o2前面,返回o2 - o1,就是o1大于o2的时候,o1排在前面(这部分内容铁汁们不想记也可以,写完在main方法中测试下,看是大堆还是小堆🐵)
package package12;
import java.util.Comparator;
public class IntComparator implements Comparator<Integer> {
@Override
public int compare(Integer o1, Integer o2) {
return o2 - o1;
}
}
在main方法中测试一下,确实就变成了大堆
package package12;
import java.util.PriorityQueue;
public class Test {
public static void main(String[] args) {
PriorityQueue<Integer> priorityQueue = new PriorityQueue<>(new IntComparator());
priorityQueue.offer(1);
priorityQueue.offer(3);
priorityQueue.offer(2);
priorityQueue.offer(4);
System.out.println(priorityQueue.poll());
System.out.println(priorityQueue.poll());
System.out.println(priorityQueue.poll());
System.out.println(priorityQueue.poll());
}
}
使用Comparator时,还可以不创建比较器类,直接使用匿名内部类(在JavaSE专栏最后一篇博客的lambda表达式部分有解析,感兴趣的铁汁可以看下)
package package12;
import java.util.Comparator;
import java.util.PriorityQueue;
public class Test {
public static void main(String[] args) {
PriorityQueue<Integer> priorityQueue = new PriorityQueue<>(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2 - o1;
}
});
priorityQueue.offer(1);
priorityQueue.offer(3);
priorityQueue.offer(2);
priorityQueue.offer(4);
System.out.println(priorityQueue.poll());
System.out.println(priorityQueue.poll());
System.out.println(priorityQueue.poll());
System.out.println(priorityQueue.poll());
}
}
创建个Student类,在队列中加入一些Student对象,如果不指定比较规则,那出队列时就会报错
package package12;
public class Student {
public int id;
public String name;
public int score;
public Student(int id, String name, int score) {
this.id = id;
this.name = name;
this.score = score;
}
@Override
public String toString() {
return "Student{" +
"id=" + id +
", name='" + name + '\'' +
", score=" + score +
'}';
}
}
package package12;
import java.util.PriorityQueue;
public class Test {
public static void main(String[] args) {
PriorityQueue<Student> priorityQueue = new PriorityQueue<>();
priorityQueue.offer(new Student(1,"helly",99));
priorityQueue.offer(new Student(2,"burt",77));
priorityQueue.offer(new Student(3,"tom",88));
priorityQueue.offer(new Student(4,"david",100));
System.out.println(priorityQueue.poll());
System.out.println(priorityQueue.poll());
System.out.println(priorityQueue.poll());
System.out.println(priorityQueue.poll());
}
}

异常原因是“类转化异常”,对于优先级队列来说,如果指定了Comparator,就按照Comparator的规则来比较,没有指定Comparator,就尝试把元素按照Comparable的规则来比较,但是这里都没有指定,就报错了
- 让Student类实现Comparable接口,重写compareTo方法,compareTo方法只传入一个参数,compareTo方法就是比较this和参数o,也就是比较自己和别人
- compareTo方法就是返回负数的话,this排在前面,小堆写法就是this.score - o .score(也没必要背,用的时候试一下就行了)
package package12;
public class Student implements Comparable<Student>{
public int id;
public String name;
public int score;
public Student(int id, String name, int score) {
this.id = id;
this.name = name;
this.score = score;
}
@Override
public String toString() {
return "Student{" +
"id=" + id +
", name='" + name + '\'' +
", score=" + score +
'}';
}
@Override
public int compareTo(Student o) {
return this.score - o.score;
}
}
接着在main方法中验证下,就是按照分数来排序的

那什么时候用Comparator,什么时候用Comparable呢?
使用Comparable:
- 要比较的类是自己写的,随时可以修改这个类
- 针对这个类的比较规则是唯一的
使用Comparator:
- 不要求这个类是自己写的,可能没办法直接修改这个类
- 比较规则可能有多种,就可以创建多个比较器类,制定不同的比较规则
6,最小k个数
这个题目可以使用优先级队列,先把数组的所有元素都入队列,再出队列k次即可
class Solution {
public int[] smallestK(int[] arr, int k) {
int[] smallestK = new int[k];
PriorityQueue<Integer> priorityQueue = new PriorityQueue<>();
for(int num : arr){
priorityQueue.offer(num);
}
for(int i = 0;i < k;i++){
smallestK[i] = priorityQueue.poll();
}
return smallestK;
}
}
这个题目还可以在空间上进行优化,普通解法是创建了一个长度为N的小堆,就可以优化为创建长度为k的大堆,具体步骤如下:
- 遍历数组,把前k个元素,加到堆中,因为是大堆,堆顶元素是堆中的最大值
- 从第k + 1个元素开始,比较下该元素与堆顶元素的大小,如果该元素小于堆顶元素,就把堆顶元素删除掉,该元素插入到堆中;如果该元素大于堆顶元素,那就不用管,继续往后遍历数组即可
- 遍历完数组后,这个大堆中存储的就是最小的k个元素
class Solution {
public int[] smallestK(int[] arr, int k) {
int[] smallestK = new int[k];
if(arr.length == 0 || k == 0){
return smallestK;
}
PriorityQueue<Integer> priorityQueue = new PriorityQueue<>(new Comparator<Integer>(){
public int compare(Integer o1,Integer o2){
return o2 - o1;
}
});
for(int i = 0;i < k;i++){
priorityQueue.offer(arr[i]);
}
for(int i = k;i < arr.length;i++){
if(arr[i] < priorityQueue.peek()){
priorityQueue.poll();
priorityQueue.offer(arr[i]);
}
}
for(int i = 0;i < k;i++){
smallestK[i] = priorityQueue.poll();
}
return smallestK;
}
}
可能优化写法在力扣上提交后,消耗的内存空间更多了,力扣中内存空间受到评测系统采样、JVM 对象开销、服务器波动,仅供参考,如果数据很多,一定是优化写法消耗的空间更少
而且在面试中面试官问出这道题,肯定不仅仅是为了考察我们是否知道优先级的基础概念的,想考察的是我们如何去优化
结语
- 堆的使用和底层实现都还是比较简单的,代码逻辑都不算复杂,重点就是制定比较规则的时候对于Comparator接口或Comparbale接口的实现
- 堆在插入元素后,重新排序的时间复杂度还是非常低的,为O(logN)
以上就是今天的所有内容啦~完结撒花~🥳🎉🎉



347



被折叠的 条评论
为什么被折叠?



