0
点赞
收藏
分享

微信扫一扫

优先级队列(堆)及Top K问题

刘员外__ 2022-03-12 阅读 107

堆简介:

1. 堆逻辑上是一棵完全二叉树

2. 堆物理上是保存在数组中

3. 满足任意结点的值都大于其子树中结点的值,叫做最大堆;反之,则是最小堆

4.堆有很多存储形式,二叉堆只是其中一种;二叉堆首先是一颗完全二叉树(结构上)

5. 堆的基本作用是,快速找集合中的最值;若是最大堆,堆顶元素就是最大值

存储方式

使用数组保存二叉树结构,方式即将二叉树用 层序遍历方式放入数组中;这种方式的主要用法就是堆的表示

完全二叉树/满二叉树可以使用也建议使用顺序表来存储;但是其他的二叉树不建议使用顺序表(数组);因为会浪费大量的空间来存储空节点(为了区分左右子树);完全二叉树不存在只有右子树没有左子树的情况,不用存储空节点

使用顺序表存储完全二叉树时,节点的索引和节点的关系如下:

1.根节点从0开始编号:

左子树的索引为(k << 1) + 1

右子树的索引为(k << 1) + 2

2.已知孩子(不区分左右)下标,则:

双亲下标 = (child - 1) / 2;

两个关注的小问题:

如何仅用索引就能判断一个节点是否有子树?

2k + 1 < 数组长度

给定一个节点索引为k,如何判断它的父节点是否存在?

在二叉树中只有一个节点没有父节点 - 根节点;只需要看父节点编号是否 > 0 即可;父节点的索引为(k - 1) >> 1;

实现一个堆(代码):

/**
 * 基于整型的最大堆实现
 * 此时根节点从0开始编号,若此时节点编号为k
 * 左子树的编号 = 2K + 1;
 * 右子树的编号 = 2K + 2;
 * 父节点的编号 = (K - 1) / 2;
 * @author zx
 */
public class MaxHeap {
    //使用JDK的动态数组(ArrayList)来存储一个最大堆
    List<Integer> data;

    public MaxHeap(){
        //构造方法的this调用
        this(10);
    }

    /**
     * @param size 初始化堆的大小
     */
    public MaxHeap(int size){
        data = new ArrayList<>(size);
    }

    /**
     * @param arr 将任意数组堆化!!!!!!!思想很重要!!!!!!!!
     */
    public MaxHeap(int[] arr){
        data = new ArrayList<>(arr.length);
        //1.先将arr的所有元素复制到data数组中
        for(int i : arr){
            data.add(i);
        }
        //2.从最后一个非叶子节点开始进行siftDown
        for(int i = parent(data.size() - 1);i >= 0;i--){
            siftDown(i);
        }
    }

    public void add(int val){
        //1.直接向数组末尾添加元素
        data.add(val);
        //2.进行元素的上浮操作
        siftUp(data.size() - 1);
    }

    /**
     * @param k 元素上浮操作
     * 向上调整索引为k的节点,使其仍然满足堆的性质
     */
    private void siftUp(int k) {
        //上浮操作的终止条件:已经走到根节点 || 当前节点值 <= 父节点值(已经处在最终位置)
        //循环的迭代条件:还存在父节点并且当前节点值 > 父节点值
        while(k > 0 && data.get(k) > data.get(parent(k))){
            //交换当前节点和父节点值
            swap(k,parent(k));
            k = parent(k);
        }
    }
    private void swap(int i, int j) {
        int temp = data.get(i);
        data.set(i,data.get(j));
        data.set(j,temp);
    }
    //根据索引得到父节点的索引
    private int parent(int k) {
        return (k - 1) >> 1;
    }

    /**
     * @return 取出当前最大堆的最大值
     */
    public int extractMax(){
        //取值一定注意判空
        if(data.isEmpty()){
            throw new NoSuchElementException("heap is empty!cannot extract!");
        }
        int max = data.get(0);
        //1.将数组末尾元素顶到堆顶
        int lastVal = data.get(data.size() - 1);
        data.set(0,lastVal);
        //2.将数组末尾的元素删除
        data.remove(data.size() - 1);
        //3.进行元素的下沉操作
        siftDown(0);
        return max;
    }

    /**
     * @param k 元素的下沉操作
     */
    private void siftDown(int k) {
        //还存在子树
        while(leftChild(k) < data.size()){
            int j = leftChild(k);
            //判断一下是否有右子树
            if(j + 1 < data.size() && data.get(j + 1) > data.get(j)){
                //此时右树存在且大于左树的值
                j = j + 1;
            }
            //此时j就对应左右子树的最大值
            //和当前节点k去比较
            if(data.get(k) >= data.get(j)){
                //下沉结束
                break;
            }else{
                swap(k,j);
                k = j;
            }
        }
    }
    /**
     * @return 根据索引得到父节点的索引
     */
    private int leftChild(int k) {
        return (k - 1) >> 1;
    }

    public boolean isEmpty(){
        return data.size() == 0;
    }

    public int peekMax(){
        if(isEmpty()){
            throw new NoSuchElementException("heap is empty! cannot peek");
        }
        return data.get(0);
    }
}

思想延深:

堆化思想

将任意的数组调整为堆的结构

1.任意数组都可以看成一个完全二叉树

2.将这N个元素逐步调用add方法插入到一个新堆中就得到一个最大堆

注:堆化方法的时间复杂度是Nlog(N)。

上面自己实现堆中的【MaxHeap(int[] arr)】 

1.任意数组都可以看做一颗完全二叉树

2.从当前这个完全二叉树的最后一个非叶子节点开始,进行元素下沉操作即可调整为堆

如何找到最后一个非叶子节点?

    //根据索引得到父节点的索引
    private int parent(int k) {
        return (k - 1) >> 1;
    }

当K = arr.length - 1时,最后一个非叶子节点为:(arr.length - 1 - 1) / 2

最后一个叶子节点的父节点 => 最后一个叶子节点(数组的最后一个元素:arr.length - 1)

从小问题逐步往上走,不断去调整子树,将子树不断变为大树的过程中,就将整颗树调整为堆

时间复杂度:O(N)

堆的应用:优先级队列

概念

在很多应用中,我们通常需要按照优先级情况对待处理对象进行处理,比如首先处理优先级最高的对象,然后处理次

高的对象。最简单的一个例子就是,在手机上玩游戏的时候,如果有来电,那么系统应该优先处理打进来的电话。

在这种情况下,我们的数据结构应该提供两个最基本的操作, 一个是返回最高优先级对象,一个是添加新的对象。 这种数据结构就是优先级队列(PriorityQueue)

内部原理

优先级队列:看起来是个队列,底层是基于堆的实现

按照元素优先级的大小动态顺序出队

处理的元素个数是动态变化的,有进有出,不像排序处理的集合元素个数是固定的。

操作系统的进程调度来说,底层就维护了一个优先级队列。

优先级队列的实现方式有很多,但最常见的是使用堆来构建。

JDK中优先级队列

public class PriorityQueue<E> extends AbstractQueue<E>
    implements java.io.Serializable {

PriorityQueue类在Java1.5中引入并作为 Java Collections Framework 的一部分。PriorityQueue是基于优先堆的一个无界队列,这个优先队列中的元素可以默认自然排序或者通过提供Comparator(比较器)在队列实例化时的排序。优先队列的头是基于自然排序或者Comparator排序的最小元素。如果有多个对象拥有同样的排序,那么就可能随机地取其中任意一个。当我们获取队列时,返回队列的头对象。优先队列不允许空值,而且不支持non-comparable(不可比较)的对象,比如用户自定义的类。优先队列要求使用Java Comparable和Comparator接口给对象排序,并且在排序时会按照优先级处理其中的元素。

优先队列的大小是不受限制的,但在创建时可以指定初始大小。当我们向优先队列增加元素的时候,队列大小会自动增加。

PriorityQueue是非线程安全的,所以Java提供了PriorityBlockingQueue(实现BlockingQueue接口)用于Java多线程环境。

PriorityQueue对元素采用的是堆排序,头是按指定排序方式的最小元素。堆排序只能保证根是最大(最小),整个堆并不是有序的。方法iterator()中提供的迭代器可能只是对整个数组的依次遍历。也就只能保证数组的第一个元素是最小的。

插入方法(offer()、poll()、remove() 、add() 方法)时间复杂度为O(log(n)) ;remove(Object) 和 contains(Object) 时间复杂度为O(n);检索方法(peek、element 和 size)时间复杂度为常量。

JDK中的优先级队列默认是最小堆的实现。队首元素就是当前队列中最小值。

使用Comparator将最小堆改造为最大堆使用

        Queue<Student> queue = new PriorityQueue<>(new StudentComDesc());
        //匿名内部类:创建一个Comparator接口的子类,这个子类只使用一次
        Queue<Student> queue = new PriorityQueue<>(new Comparator<Student>() {
            @Override
            public int compare(Student o1, Student o2) {
                return o2.getAge() - o1.getAge();
            }
        });
        //lambda表达式写法
//        Queue<Student> queue = new PriorityQueue<>((o1,o2) -> o2.getAge() - o1.getAge());
        Student stu1 = new Student("铭哥",40);
        Student stu2 = new Student("龙哥",20);
        Student stu3 = new Student("蛋哥",18);
        queue.offer(stu1);
        queue.offer(stu2);
        queue.offer(stu3);
        while (!queue.isEmpty()) {
            System.out.println(queue.poll());
        }

  Comparator接口的优点: 在有时需要升序,有时有需要降序的场景中,如果使用Comparable会不得不多次修改compareTo()方法,但使用Comparator就不需要,根据不同的需求配置不同的比较器即可。

class StudentCom implements Comparator<Student> {
    @Override
    public int compare(Student o1, Student o2) {
        return o1.getAge() - o2.getAge();
    }
}
class StudentComDesc implements Comparator<Student> {
    @Override
    public int compare(Student o1, Student o2) {
        return o2.getAge() - o1.getAge();
    }
}
class Student {
    private String name;
    private int age;
    public int getAge() {
        return age;
    }
    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }
    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

堆的应用:Top K问题

做此类题的套路:

最小或最大的K个*** => 都是优先级队列(堆的应用)

Top K问题的时间复杂度为:nlogK

另外的思路就是排序:nlogn

取大用小,取小用大 :

找最小的K个数,构造最大堆

找最大的K个数,构造最小堆

面试题 17.14. 最小K个数----力扣

思路分析:

找到最小的K个数,构造只有K个元素的最大堆,然后扫面这个数组,当把数组完全扫描完毕之后,最大堆中就存放了最小的K个元素

扫描到某个元素时,发现堆顶元素大于当前扫描元素

最大堆性质:堆顶元素是最大值,要找最小的,若扫描的元素 > 堆顶元素 => 大于堆中的所有值,这个值一定不是要找的。

当扫描的元素比堆顶元素小就堆顶出队,换入一个更小的值!!像是一个不断打擂的过程。

把大的PK掉,不断换上更小的值。

    /**
     * @return 最大堆
     * 时间复杂度:O(NlogK)
     * 因为 K << n   ==>>   logk < logn
     * logK:堆中的元素个数
     */
    public int[] smallestK(int[] arr, int k) {
        //最小的k个数,取小用大
        if(arr.length == 0 || k == 0){
            return new int[0];
        }
        int[] res = new int[k];
        //构造一个最大堆,JDK默认是最小堆,使用比较器改造为最大堆
        //要把最小堆改为最大堆,就是告诉编译器值越大,让编译器看来它反而小
        Queue<Integer> queue = new PriorityQueue<>(new Comparator<Integer>() {
            @Override
            public int compare(Integer o1, Integer o2) {
                return o2 - o1;
            }
        });
        for(int i = 0;i < arr.length;i++){
            if(queue.size() < k){
                queue.offer(arr[i]);
            }else{
                //当前判断扫描元素和堆顶元素的关系
                //若扫描元素 > 堆顶元素 > 堆中的所有值,这个值一定不是要找的数
                int peek = queue.peek();
                if(arr[i] > peek){
                    continue;
                }else{
                    //此时当前元素 < 堆顶元素,将堆顶元素出队,将较小值i入队
                    queue.poll();
                    queue.offer(arr[i]);
                }
            }
        }
        //此时最大堆中就保存了最小的k个数
        for(int j = 0;j < k;j++){
            res[j] = queue.poll();
        }
        return res;
    }

方法二:排序

    /**
     * @return 排序法
     * 时间复杂度:O(nlogn)
     */
    public int[] smallestK2(int[] arr, int k) {
        int[] res = new int[k];
        Arrays.sort(arr);
        for(int i = 0;i < k;i++){
            res[i] = arr[i];
        }
        return res;
    }

373. 查找和最小的 K 对数字
 

347. 前 K 个高频元素 

拜托,面试别再问我TopK了!!!_架构师之路-CSDN博客

堆的应用:堆排序

面试时,就记住这个代码!!!!!!!!!!!!!!!!!!!!
    /**
     * @param arr 堆排序
     * 时间复杂度 O(nlogn)
     */
    public static void heapSort(int[] arr) {
        // 1.将无序序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆
        // 从最后一个非叶子节点开始进行siftDown操作
        for (int i = (arr.length - 1 - 1) / 2; i >= 0; i--) {
            siftDown(arr,i,arr.length);
        }
        // 此时arr就被我调整为大顶堆
        // 2.将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;**********
        // 3.重新调整未调整节点,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤。
        for (int i = arr.length - 1; i > 0; i--) {//O(N)
            // arr[0] 堆顶元素,就是当前堆的最大值
            swap(arr,0,i);
            siftDown(arr,0,i);//O(logn)
        }
    }
    /**
     * 元素下沉操作
     * 调整索引为i的节点不断下沉,直到到达最终位置:
     * a.到达了叶子节点 2i + 1 >= size
     * b.当前节点值 > 左右子树的最大值(下沉到最终位置)
     ** 时间复杂度分析:
     *  时间复杂度为 O(logn)
     * @param arr
     * @param i 当前要下沉的索引
     * @param length 数组长度
     */
    private static void siftDown(int[] arr, int i, int length) {
        //还存在子树(条件a)
        while (2 * i + 1 < length) {
            int j = (i * 2) + 1;
            //判断一下是否有右子树
            if (j + 1 < length && arr[j + 1] > arr[j]) {
                //此时右树存在且大于左树的值
                j = j + 1;
            }
            // j就是左右子树的最大值
            if (arr[i] > arr[j]) {//条件(b)
                // 下沉结束
                break;
            }else {
                //循环结束后,就已经将树的最大值,放在了最顶
                swap(arr,i,j);
                i = j;
            }
        }
    }
    private static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

堆排序中建堆过程时间复杂度 O(n) 怎么来的?

假如有N个节点,那么高度为H=logN,最后一层每个父节点最多只需要下调1次,倒数第二层最多只需要下调2次,顶点最多需要下调H次,而最后一层父节点共有2^(H-1)个,倒数第二层公有2^(H-2),顶点只有1(2^0)个,所以总共的时间复杂度为s = 1 * 2^(H-1) + 2 * 2^(H-2) + ... + (H-1) * 2^1 + H * 2^0;将H代入后s= 2N - 2 - log2(N),近似的时间复杂度就是O(N)。

经评论提醒,H=log2(N) + 1

举报

相关推荐

0 条评论