JavaDS-优先级队列(堆)

目录

一、优先级队列

1. 概念

二、优先级队列的模拟实现

1. 堆的概念

2. 堆的存储方式

3. 堆的创建

(1)堆的创建与向下调整

(2)建堆的时间复杂度

4. 堆的插入与删除

(1)堆的插入

(2)堆的删除

5. 用堆模拟实现优先级队列

三、优先级队列的源码

1. PriorityQueue的特性与常用接口介绍

2. PriorityQueue的扩容机制

四、堆的应用


一、优先级队列

1. 概念

前面介绍过,队列是一种先进先出(FIFO)的数据结构,但有些情况下,操作的数据可能带有优先级,一般出队时,可能会需要优先级高的元素先出队列。然后需要满足每次出队的时候都是优先级比较高的元素,那这种结构就需要我们来调整了。

二、优先级队列的模拟实现

优先级队列的底层是堆,而堆实际上是在完全二叉树的基础上进行了调整。

在集合框架中可以了解到PriorityQueue实现了Queue接口,也就是说它本质上应用了队列的方法,而它又是一种特殊的二叉树结构,下面我们一起来了解堆的概念。 

1. 堆的概念

如果有一个关键码的集合K = {k0,k1, k2,…,kn-1},把它的所有元素按完全二叉树的顺序存储方式存储 在一个一维数组中,并满足:Ki <= K2i+1 且 Ki<= K2i+2 (Ki >= K2i+1 且 Ki >= K2i+2) i = 0,1,2…,则称为 小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

2. 堆的存储方式

由堆的概念可见,堆是一棵完全二叉树,因此可以按照层序的规则采用顺序存储的方式来高效存储。

 注意:对于非完全二叉树,则不适合使用顺序方式进行存储,原因是存储时会浪费空间使用null(即空节点),造成空间使用率低。

假设 i 为节点在数组中的下标,则有:

(1)如果i为0,则i表示的节点为根节点,否则i节点的双亲节点为 (i - 1)/2
(2)如果2 * i + 1 小于节点个数,则节点i的左孩子下标为2 * i + 1,否则没有左孩子
(3)如果2 * i + 2 小于节点个数,则节点i的右孩子下标为2 * i + 2,否则没有右孩子 

3. 堆的创建

(1)堆的创建与向下调整

我们要创建一个堆出来,首先我们先假设在一个数组中有如下数据:

{ 27,15,19,18,28,34,65,49,25,37 }

我们将其看成一棵完全二叉树后,其结构如下图所示。

import java.util.Arrays;

public class TestHeap {
    public int[] elem;
    public int usedSize;    // 有效数据个数
    public static final int DEFAULT_SIZE = 10;  // 定义默认大小

    public TestHeap() {
        elem = new int[DEFAULT_SIZE];   // 构造时默认给个容量
    }

    // 把数组中的值给到elem. 注意这里并不算进建堆的时间复杂度.
    public void initElem(int[] arrray) {
        for (int i = 0; i < arrray.length; i++) {
            elem[i] = arrray[i];
            usedSize++;
        }
    }

    // 建一个大根堆,每棵子树向下调整
    public void createHeap() {
        // 已知孩子推导父亲,记最后一个元素下标为i,那么i=数组长度len-1,
        // 所以最后一棵子树的根节点为 (len-1 -1)/2
        for (int parent = (usedSize - 1 - 1) / 2;
             parent >= 0;   // 0下标这棵树也得调整
             parent--) {
            // 统一的调整方案
            shiftDown(parent, usedSize);
            // 要做向下调整的功能,但是需要明确调整的结束位置,即usedSize
            // parent不同,对应的调整结束位置不同,使用usedSize参数在向下调整中进行控制
        }
    }

    /**
     * 向下调整(建大根堆)
     *
     * @param parent 每棵子树的根节点
     * @param len    每棵子树调整的结束位置,不能 > len
     *               时间复杂度O(log n)
     */
    private void shiftDown(int parent, int len) {
        int child = 2 * parent + 1;
        // 1. 必须保证先有左孩子,才能往下调整
        while (child < len) {
            // child + 1 < len 保证先有右孩子, 然后找到左右孩子最大值
            // 注意下面 逻辑运算的先后顺序,条件不能写反,否则可能在10位置出错
            if (child + 1 < len &&
                    elem[child] < elem[child + 1]) {    /*小根堆调整此处符号即可*/
                child++;    // 此时child下标一定是 左右孩子中 较大值 的下标

            }
            if (elem[child] > elem[parent]) {           /*小根堆调整此处符号即可*/
                int tmp = elem[child];
                elem[child] = elem[parent];
                elem[parent] = tmp;
                // 重新更新,当前孩子也是作为一棵子树,往下查找判定
                parent = child;
                child = 2 * parent + 1;
            } else {
                break;  // 符合大根堆不必调整,直接跳出结束调整。
            }
        }
    }
}

(2)建堆的时间复杂度

考虑最坏情况下堆的时间复杂度。

以满二叉树为例,其节点个数最多。

假设树的高度为h。

以上图为例,第一层要调整的高度为3,第二层为2。

需要建堆,要从最后一棵子树的根节点开始调整,也就意味着最后一层是不存在调整的。也就是说要调整的话是从倒数第二层开始调整,所以这一层要调整的高度为1。

满二叉树,即每个节点都要调整情况下,复杂度最高。

第一层节点个数为 2^0;

第二层节点个数为 2^1;

第三层节点个数为 2^2;

那么需要调整的个数为 2^0 + 2^1 + 2^2 + ... + 2^(h - 2) 个节点,

每个节点总共需要调整的次数为 2^0 * (h-1) + 2^1 * (h-2) + ... + 2^(h - 2) * 1 ,

即 2^0 + 2^1 + ... + 2^(h - 2) + 2^(h - 1) - h ,也就是 2^h - 1 - h(错位相减计算) 。

当有 n 个节点时,高度为 h ,它们总共的节点个数为2^h - 1。于是 h = (log n) + 1,所以2^h = n+1 。所以 O(n) = n - log(n + 1) ,即O(n) 。

4. 堆的插入与删除

(1)堆的插入

    /**
     * 堆的插入
     *
     * @param val
     */
    public void offer(int val) {
        // 扩容
        if (isFull()) {
            elem = Arrays.copyOf(this.elem, 2 * this.elem.length);
        }
        this.elem[usedSize] = val;
        usedSize++;
        // 向上调整
        shiftUp(usedSize - 1);
    }

    private void shiftUp(int child) {
        int parent = (child - 1) / 2;
        while (child > 0) {     // parent >= 0
            if (elem[child] > elem[parent]) {
                int tmp = elem[child];
                elem[child] = elem[parent];
                elem[parent] = tmp;
                // 往上走
                child = parent;
                parent = (child - 1) / 2;
            } else {
                break;
            }
        }
    }

    private boolean isFull() {
        return usedSize == elem.length;
    }

(2)堆的删除

注意:堆的删除一定删除的是堆顶元素,否则删除没有意义。具体如下:
1. 将堆顶元素对堆中最后一个元素交换
2. 将堆中有效数据个数减少一个
3. 对堆顶元素进行向下调整

    public int pop() {
        if (isEmpty()) {
            return -1;
        }
        //将堆顶元素与最后一个元素进行交换
        int tmp = elem[0];
        elem[0] = elem[usedSize - 1];
        elem[usedSize - 1] = tmp;
        //删除堆中最后一个元素
        usedSize--;
        //保证仍然是大根堆
        shiftDown(0, usedSize);
        return tmp;
    }

    public boolean isEmpty() {
        return usedSize == 0;
    }

5. 用堆模拟实现优先级队列

public class TestHeap {

    //...
    //代码见上文

    public int peek() {
        if (isEmpty()) {
            return -1;
        }
        return elem[0];
    }
}

三、优先级队列的源码

PriorityQueue是一个非常重要的集合,所以接下来我们一起来了解它的源码构造。

首先,我们可以在编译器中实例化出PriorityQueue对象:

在集合框架中可以了解到PriorityQueue实现了Queue接口,并且会重写Queue接口中的方法。

可以自行从下表中选择方法对创建好的priorityQueue对象进行测试。

函数名功能介绍
boolean offer(E e)插入元素e,插入成功返回true,如果e对象为空,抛出NullPointerException异常,时间复杂度O(logN),注意:空间不够时候会进行扩容
E peek()获取优先级最高的元素,如果优先级队列为空,返回null
E poll()移除优先级最高的元素并返回,如果优先级队列为空,返回null
int size()获取有效元素的个数

void clear()

清空
boolean isEmpty()检测优先级队列是否为空,空返回true

以上是部分方法, 不是全部.

1. PriorityQueue的特性与常用接口介绍

Java集合框架中提供了PriorityQueuePriorityBlockingQueue两种类型的优先级队列,PriorityQueue是线程不安全的,PriorityBlockingQueue是线程安全的,也就是说在以后多线程的运行环境下,会优先使用PriorityBlockingQueue。

 关于PriorityQueue的使用需要注意:

1. 使用时必须导包:

import java.util.PriorityQueue;

2. PriorityQueue中放置的元素必须能够比较大小,不能插入无法比较大小的对象,否则会抛出ClassCastException异常。

import java.util.PriorityQueue;

class Student{
    public int age;

    public Student(int age) {
        this.age = age;
    }
}
public class Test {
    public static void main(String[] args) {
        PriorityQueue<Student> priorityQueue = new PriorityQueue<>();
        priorityQueue.offer(new Student(10));
        priorityQueue.offer(new Student(5));
    }
}

运行结果:

可以看到两个对象无法比较大小,并且在报错中读到Heap.Student cannot be cast to java.lang.Comparable,也就是无法转化成Comparable对象,那么我们可以重写Comparable中的compareTo方法。 

class Student implements Comparable<Student>{
    public int age;

    public Student(int age) {
        this.age = age;
    }

    @Override
    public int compareTo(Student o) {
        return this.age - o.age;
    }
}

在代码中Debug进行调试:

在调试中可以看到priorityQueue引用所指的对象,0位置为5,1位置为10。说明compareTo方法被用到了,那么compareTo是在哪里被用到的? 我们分析源码。


首先在main中的三行代码的第一行new了priorityQueue对象,那么实例化这个对象就得调用它的构造方法,在构造方法源码中可以看到:调用了带两个参数的构造方法

第一个参数是一个成员,默认初始容量是11。(在源码中往上看可以看到这个默认容量是11)

查看this,看到第一个参数我们传了11,第二个是null,

 往下走代码看到queue,进一步看queue,可以看到queue是一个Object类型的数组。

也就相当于是new了一个11大小的Object类型的数组。

接着comparator是null,进一步看是比较器的引用,没有实例化对象。

也就是说在调用PriorityQueue构造方法的时候没有给comparator赋值,只是给数组进行了初始化长度为11。

所以综上,当没有传入比较器的时候,相当于只是对queue数组进行了实例化


于是我们接着看main中的offer。

我们第一次插入的时候是new Student(10),首先它会判断放进来的是不是空,如果是空就会抛一个空指针异常,此时我们不是空就继续往下。

可以知道size为0,此时0小于数组长度,不需要扩容,继续往下。

size = 0 + 1 = 1,这是 i 是为0,然后会执行 queue[0] = e;,把e放进0下标位置。

也就是说将new Student(10)放进数组的第一个位置。

走完if语句之后,直接return true; ,相当于在第一次过程当中我们没有产生比较。

而到第二次,仍然是上方offer代码,new Student(5),int i = size = 1,1 小于数组长度,没有扩容,size = 1 + 1 = 2,i 此时不为0,执行siftUp(i, e);,此时问题就来到了siftUp。

comparator为空,siftUpComparable向上调整。如果它不为空就执行siftUpUsingComparator。

所以如果传了比较器,即不为空,就会优先使用比较器比较。

那么此时k为1,x为Student(5)。

首先定义了一个key把x强转为key的类型,而后k为1大于0,定义parent为(1-1)/2为0,定义e为在queue的0位置的元素,然后是key调用这个对象的compareTo把e传进去,那么上文中重写了compareTo:

class Student implements Comparable<Student>{
    //..
    //详见上文

    @Override
    public int compareTo(Student o) {
        return this.age - o.age;
    }
}

key调用compareTo,key就是this.age,o.age就是e,5小于10,if结束,把e里面的值放到k下标(queue[k] = e),然后k = parent = 0,循环终止,最后将k中的值放入0位置(queue[k] = key)。

所以,综上分析,key没有进入循环,实际上是两对象进行了交换

以上便是小根堆的源码逻辑,那么如果要把小根堆变成大根堆就需要自行提供比较器。

在看构造方法源码时可以看到PriorityQueue的构造方法中是含有带一个参数的构造方法和带两个参数的构造方法的,也都有比较器。

 所以我们也可以自己实现一个比较器。

import java.util.Comparator;

class IntCmp implements Comparator<Integer> {
    @Override
    public int compare(Integer o1, Integer o2) {
        //return o2 - o1;
        return o2.compareTo(o1);
    }
}

然后再main中实例化对象时采用含比较器的构造方法。

PriorityQueue<Integer> priorityQueue = new PriorityQueue<>(new IntCmp());

于是按照正常的想法先看构造方法的源码。

 进而看this,可以知道在第二个参数传的不是null了。

所以,当在offer的时候也是一样的,调用siftUp,

 而此时comparator不为空调用siftUpUsingComparator,

可以发现实际上和siftUpComparable的代码逻辑完全一样,只不过是方法名字不一样而已,所以将小根堆转化为大根堆就变得尤为简单,改变compareTo的符号即可。

综上所述可以总结如下:

1. 当没有传数组容量的时候,默认是11

2. 当没有传入比较器的时候,它必须是可比较的

3. 优先使用的是比较器来比较。

2. PriorityQueue的扩容机制

在offer源码的grow中可以看到PriorityQueue的扩容机制。

 注:集合中的grow一般都指的是扩容方法。

优先级队列的扩容说明:
如果容量小于64时,是按照oldCapacity的2倍方式扩容的
如果容量大于等于64,是按照oldCapacity的1.5倍方式扩容的
如果容量超过MAX_ARRAY_SIZE,按照MAX_ARRAY_SIZE来进行扩容

四、堆的应用

1. 最小k个数。OJ链接

有10W个数据, 要找到前K个最大/最小的数据.

本题可以直接排序然后选择前k个元素到数组中并返回。

class Solution {
    public int[] smallestK(int[] arr, int k) {
        Arrays.sort(arr);
        int[] tmp = new int[k];
        for(int i = 0; i < k; i++){
            tmp[i] = arr[i];
        }
        return tmp;
    }
}

但在面试中该解法对但是并不合适,当需求数据非常多的时候,内存就可能不足。

class Solution {
    //时间复杂度 O(N+KlogN)
    public int[] smallestK(int[] arr, int k) {
        //1. 建立一个小根堆
        PriorityQueue<Integer> minheap = new PriorityQueue<>();
        //2. 取出数组中的每个元素,存到小根堆中
        for (int i = 0; i < arr.length; i++) {
            minheap.offer(arr[i]);
        }
        //3. 弹出k个元素,存到数组中,返回数组
        int[] tmp = new int[k];
        for (int i = 0; i < k; i++) {
            tmp[i] = minheap.poll();
        }
        return tmp;
    }
}

那如果要求再降低时间复杂度呢?此时它就成了一个很典型的TOPK问题。

首先,将这组数据当中的前K个元素建立为大根堆,然后从K+1开始,每次和堆顶元素进行比较,如果 i 下标元素小于堆顶元素则出堆。

这个算法与上一个的区别在于没有整体建堆,并且其中第二步操作使得时间复杂度降为:K + (N - K) * logK 即 N * logK。

class Solution {
    public int[] smallestK(int[] arr, int k) {
        if(arr == null || k == 0){
            return new int[0];
        }
       //1. 建立一个大根堆
        PriorityQueue<Integer> minHeap = new PriorityQueue<>(k, new Comparator<Integer>() {
            @Override
            public int compare(Integer o1, Integer o2) {
                return o2.compareTo(o1);
            }
        });

        //2. 调整元素
        for (int i = 0; i < arr.length; i++) {
            if (minHeap.size() < k) {
                minHeap.offer(arr[i]);
            } else {
                //当前数组元素为arr[i]
                int val = minHeap.peek();
                if (val > arr[i]) {
                    //弹出大的
                    minHeap.poll();
                    //放进小的
                    minHeap.offer(arr[i]);
                }
            }
        }
        //3. 弹出k个元素,存到数组中,返回数组
        int[] tmp = new int[k];
        for (int i = 0; i < k; i++) {
            tmp[i] = minHeap.poll();
        }
        return tmp;
    }
}

接下来我们可以再升华一下这个题目,如果要求第K小的如何求解?

首先前两步和上题一样,将这组数据当中的前K个元素建立为大根堆,然后从K+1开始,每次和堆顶元素进行比较,如果 i 下标元素小于堆顶元素则出堆,最后直接弹出堆顶元素,即为第K小的值。

注:最后这个大根堆里面,是前K个最小的元素,堆顶元素是这K个里面最大的,同时也是第K小的。

        if (arr == null || k == 0) {
            return new int[0];
        }
        //1. 建立一个大根堆
        PriorityQueue<Integer> minHeap = new PriorityQueue<>(new Comparator<Integer>() {
            @Override
            public int compare(Integer o1, Integer o2) {
                return o2.compareTo(o1);
            }
        });
        //2.
        for (int i = 0; i < k; i++) {
            minHeap.offer(arr[i]);
        }
        for (int i = k; i < arr.length; i++) {
            int val = minHeap.peek();
            if (val > arr[i]) {
                minHeap.poll();
                minHeap.offer(arr[i]);
            }
        }
        //3. 弹出k个元素,存到数组中,返回数组
        int[] tmp = new int[k];
        for (int i = 0; i < k; i++) {
            tmp[i] = minHeap.poll();
        }
        return tmp;

五、堆排序

堆排序即利用堆的思想来进行排序,当需要进行升序排序时,需要通过建大根堆来实现,小根堆则无法实现,原因是小根堆在每次弹出后元素放置方向,其次空间复杂度会非常大,所以只能通过建大根堆来实现。

    public void heapSort() {
        int end = usedSize - 1;
        while (end > 0) {
            int tmp = elem[0];
            elem[0] = elem[end];
            elem[end] = tmp;
            shiftDown(0, end);
            end--;
        }
    }
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值