堆--->优先级队列

1. 优先级队列的概念

前面介绍过队列, 队列是一种先进先出 (FIFO) 的数据结构 ,但有些情况下, 操作的数据可能带有优先级,一般出队 列时,可能需要优先级高的元素先出队列 ,该中场景下,使用队列显然不合适,比如:在手机上玩游戏的时候,如果有来电,那么系统应该优先处理打进来的电话;初中那会班主任排座位时可能会让成绩好的同学先挑座位。
在这种情况下, 数据结构应该提供两个最基本的操作,一个是返回最高优先级对象,一个是添加新的对象 。这种数 据结构就是优先级队列 (Priority Queue)

 2. 堆

JDK1.8 中的 PriorityQueue 底层使用了堆这种数据结构 ,而堆实际就是在完全二叉树的基础上进行了一些调整。

2.1 堆的概念

如果有一个 关键码的集合 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

左右孩子有关系吗?

左右孩子之间没有任何关系

为啥堆要用数组来存储?其他类型的二叉树没有用数组?

堆总是一棵完全二叉树,用数组来存储每一个结点的值,空间利用率比较高

用数组来存储其他二叉树,会有很多的空间浪费

用下面的一张图就可以看明白了

2.2 堆的创建

下面给一个集合 { 27,15,19,18,28,34,65,49,25,37 } ,如何将其创建成堆呢?
下面以创建大根堆为例

2.2.1 堆向下调整

按照将数组转化为堆的方法,把上面的集合转化为完全二叉树

如下图

肯定不满足堆的定义,该怎么转化呢?

现在演示调节把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<n,就说明根节点还有子节点

2. 如果根节点>max(子节点),而子节点所在的子树又是大根堆,这时就可以不用向下调整了

下面给出实现代码

public void shiftDown(int parent,int end) {

        int child=parent*2+1;
        while(child<end) {
            if(child+1<end&&elem[child]<elem[child+1]) {//比较左右结点的大小
                child=child+1;//把child换成最大的结点
            }
            if(elem[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<array.length;i++){
            elem[i]=array[i];
            usedSize++;
        }
        for(int i=(usedSize-1-1)>>1;i>=0;i--) {
            shiftDown(i,usedSize);
        }
    }

}

下图是建堆的最终结果

2.2.2 建堆的时间复杂度

 很容易得出,每个节点向下调整的时间复杂度为h-当前层数(h是树的高度)

 总结上面的规律,可以得出将整棵树转化成堆的时间复杂度

 用式2-式1,再用等比数列求和公式就可以得到时间复杂度为

可以得到建堆的时间复杂度就是O(n)

 2.2.3 堆的插入&向上调整

如果想在这个堆中插入一个元素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<array.length;i++){
            elem[i]=array[i];
            usedSize++;
        }

        for(int i=1;i<usedSize;i++){
            shiftUp(i);
        }
    }

但是向上调整方法主要是用来插入元素的,用它建堆的时间复杂度要比向下调整大很多

下面来计算一下

向上调整方法本身的时间复杂度也为O(h),h表示这棵树的高度

 

 同样使用刚才的方法(自个用笔算吧)

就可以的得到时间复杂度为O(nlogn)

2.2.4 堆的删除

堆的删除是指弹出堆顶元素,如果我们直接弹出

 很不幸,这个堆要重建了,时间复杂度是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;
    }

3. PriorityQueue 

3.1 PriorityQueue的框架

PriorityQueue是一个普通类,其继承关系如下图

Java 集合框架中提供了 PriorityQueue PriorityBlockingQueue 两种类型的优先级队列, PriorityQueue 是线 程不安全的, PriorityBlockingQueue 是线程安全的.本文主要讲解PriorityQueue.
使用PriorityQueue时需要注意以下几点:
1.使用PriorityQueue时插入的元素必须是可比较的(可比较是指实现了Comparable接口或者有比较器),否则会抛出ClassCastException异常

2.不能插入null对象,否则会抛出NullPointerException异常

 

3. PriorityQueue的最大容量为 Integer.MAX_VALUE,可以任意插入多个元素

4.插入和删除元素的时间复杂度为O(logN)

5.PriorityQueue默认建小根堆

3.2 PriorityQueue的构造方法 

老规矩,在介绍每个类的方法时, 首当其冲就是它的构造方法

构造体功能
PriorityQueue()
创建一个无参的队列,默认容量是11(这个11跟上面的图片有关,)
PriorityQueue(int
initialCapacity)
创建一个队列,默认容量是initialCapacity
PriorityQueue(Comparator<? super E> comparator)创建一个容量为11的队列,元素比较逻辑按照comparator比较器
PriorityQueue(int initialCapacity,Comparator<? super E> comparator)
创建一个容量为initialCapacity的队列,比较逻辑按照comparator
PriorityQueue(Collection<?
extends E> c)
用一个集合来创建一个队列

 

 下面我们先来演示用集合构造优先级队列的方法(因为前几个比较难啃

 public static void main(String[] args) {
        List<Integer> list=new ArrayList<>();
        list.add(9);
        list.add(15);
        list.add(1);
        PriorityQueue<Integer>priorityQueue=new PriorityQueue<>(list);
        while(!priorityQueue.isEmpty()){
            System.out.println(priorityQueue.poll());
        }
    }

可以得到如下输出结果

 可以验证第五条性质--PriorityQueue默认建小堆

 下面我们看看前四个构造方法的实现,其实前三个构造方法都是来摸鱼的~~

 看到这里,肯定有同学要问:这个comparator究竟是个嘛玩意?莫急莫急,且看下文~

3.3 对象的比较

3.3.1 基本类型的比较

在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);
}

3.3.2 比较对象相等

那么对象该怎么比较呢?

有些同学肯定会发现,直接使用"=="比较对象时,编译器并不报错

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方法可以比较两个对象是否相同,但是该如何比较两个对象的大小呢?

 3.3.3 比较对象大小

Java为我们提供了两个接口来比较对象的大小

ComparbleJDK提供的泛型的比较接口类,其源码如下

public interface Comparable < E > {
// 返回值 :
// < 0: 表示 this 指向的对象小于 o 指向的对象
// == 0: 表示 this 指向的对象等于 o 指向的对象
// > 0: 表示 this 指向的对象大于 o 指向的对象
int compareTo ( E o );
}

现在演示按照年龄大小比较 两个Person

class Person implements Comparable <Person> {
    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<Person> 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<Person> {

    @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<Person> 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());
        }
        
        
    }
}

3.3.4 三种方法的比较

equals重写父类Object的方法,只能用于比较对象是否相等
ComparaTo定义在被比较类的内部,侵入性比较强,只能有一种实现逻辑
compare重新定义一个用于比较的类,侵入性弱,可以实现多个比较逻辑

3.4  PriorityQueue的常用方法

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的扩容

 

 

3.5 PriorityQueue的应用

3.5.1 TopK问题

TOP-K 问题:即求数据集合中前 K 个最大的元素或者最小的元素,一般情况下数据量都比较大
比如:专业前 10 名、世界 500 强、富豪榜、游戏中前 100 的活跃玩家等。
解决这类方法有三个思路:
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<k;i++){
            result[i]=arr[i];
        }
        return result;
    }
}

是不是看着很简洁,但是可千万不要摆到台面上,这属于暴力求解了

 

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<Integer> pq=new PriorityQueue<>();
        for(int i=0;i<arr.length;i++) {
            pq.offer(arr[i]);
        }
        int i=0;
        while(k-->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<Integer> {
    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<Integer> pq=new PriorityQueue<>(k,new Cmp());
        for(int i=0;i<k;i++) {
            pq.offer(arr[i]);
        }
        for(int i=k;i<arr.length;i++){
            if(arr[i]<pq.peek()){
                pq.poll();
                pq.offer(arr[i]);
            }
        }
        int i=0;
        while(!pq.isEmpty()){
            result[i++]=pq.poll();
        }
        
        return result;
    }
}

堆这种数据结构还可以用来排序,关于排序下篇文章会讲到

 ......end

 

  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不 会敲代码

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值