数据结构之优先级队列 - 超详细的教程,手把手教你认识并运用优先级队列

目录

1. 优先级队列概念

2. 优先级队列的模拟实现

2.1 堆的概念

2.2 堆的存储方式

2.3 堆的创建

2.4 堆的插入与删除

3.常用接口介绍

3.1 PriorityQueue 的特性

3.2 PriorityQueue常用接口介绍

4. 堆的应用

4.1 PriorityQueue 的实现

4.2 堆排序

4.3 Top-k问题


1. 优先级队列概念

队列是一种先进先出的结构,但是有些时候,要操作的数据带有优先级,一般出队时,优先级较高的元素先出队,这种数据结构就叫做优先级队列。

比如:你在打音游的时候,你的朋友给你打了个电话,这种时候,就应该优先处理电话,然后再来继续打音游,此时,电话就是优先级较高的。

在这种情况下,数据结构应该提供两个最基本的操作,一个是返回优先级最高的对象,一个是添加新的对象。这种数据结构就是优先级队列(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…,则称为 小堆(或 大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

堆的性质:

  • 堆中某个节点的值总是不大于或不小于其父节点的值。
  • 堆总是一棵完全二叉树。

如图:

2.2 堆的存储方式

因为堆是一棵完全二叉树,所以可以采用层序遍历的方式来高效存储数据

注意:对于非完全二叉树,则不适合使用顺序方式进行存储,因为为了能够还原二叉树,空间中必须要存储空节点,就会导致空间利用率比较低。

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

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

2.3 堆的创建

如何将集合 {19,  37 , 28 , 54 , 76 , 88 , 12 , 49 , 90 , 65} 中的数据,创建成一个堆呢?

以大根堆为例:

先将它化为完全二叉树的形式:

然后遍历每一棵树的根节点,进行调整,先从最后一棵子树的根节点 p 开始调整

整个过程就是先得到左右孩子的最大值下标,然后与根节点比较,如果没根节点大,说明这棵子树就是大根堆,直接返回,如果大于根节点,就需要与根节点进行交换,因为进行了交换,所以不能保证交换后还是大根堆,需要再次进行判断交换后的子树是否满足大根堆。

因为这个过程 p 和 c 是往下走的,所以这个过程也叫做向下调整。

没有左右孩子,就说明左右孩子的下标是大于数组最大下标的 。

过程:

根据如上流程,我们就可以模拟实现大根堆了,首先创建一个 MyBigHeap 类,因为 堆是用数组来存储数据的,所以还需要一个数组的成员变量,还需要一个计数器来记录堆的大小。

再顺便把构造方法给写了,然后再写个初始化堆的方法。然后根据上面的流程来写代码:

public class MyBigHeap {
    //因为堆实际上是用层序遍历的方式存储在顺序表中的
    //所以我们需要一个数组
    private int[] elem;
    private int usedSize;

    // 生成构造方法
    public MyBigHeap() {
        elem = new int[10];
    }

    // 初始化
    public void initHeap(int[] arr) {
        for (int i = 0; i < arr.length; i++) {
            elem[i] = arr[i];
            usedSize++;
        }
    }

    
    public void createHeap() {
        
    }

    
}

先来写创建堆的方法,因为前面已经分析过了,这里我就不再赘述。

    //创建大根堆的前提是先初始化数组
    //得从最后一棵子树开始调整 => 已知,数组最后一个元素的下标,求他的父亲节点
    //每一次调整,都是从根节点 往下 调整(向下调整)
    public void createHeap() {
        for (int parent = (usedSize - 1 - 1) / 2; parent >= 0; parent--) {
            shiftDown(parent, usedSize);
        }
    }

    private void shiftDown(int parent, int usedSize) {
        int child = 2 * parent + 1;// 左孩子
        // 进入循环说明有左孩子
        while (child < usedSize) {
            if (child + 1 < usedSize && elem[child] < elem[child + 1]) {
                //存在右孩子并且右孩子比左孩子要大
                child++;
            }
            //来到这里,child 一定是左右孩子的最大值
            //然后和 parent 比较,进行调整
            if (elem[parent] < elem[child]) {
                swap(parent, child);
                //还要再看看交换完的是否符合子树的大根堆规则
                //所以parent要向下更新成child,再进行比较调整
                parent = child;
                child = 2 * parent + 1;
            } else {
                //根比左右孩子大
                break;
            }
        }
    }

    private void swap(int parent, int child) {
        int tmp = elem[parent];
        elem[parent] = elem[child];
        elem[child] = tmp;
    }

写完之后来测试下看看。

public class Test {
    public static void main(String[] args) {
        MyBigHeap heap = new MyBigHeap();
        int[] arr = {19, 37, 28, 54, 76, 88, 12, 49, 90, 65};
        heap.initHeap(arr);
        heap.createHeap();
        System.out.println();
    }
}

没啥问题。写完之后我们再来看看 createHeap 的时间复杂度。

因此,建堆的时间复杂度为 O(n)。

2.4 堆的插入与删除

接下来,我们来实现堆的插入和删除方法。

首先来实现插入方法,这个很简单,就是在数组后面插入一个新的元素,插入完成之后,需要判断是否还满足大根堆,如果根小于新插入的元素,就需要交换,交换完之后,还需要继续往上看看,交换后的子树是否满足大根堆,满足大根堆就直接返回,就这样循环,直到 c = 0,换无可换为止。

p 和 c 是向上走的,所以这个过程又被称为向上调整

向上调整创建堆比向下调整创建堆的复杂度要高。

因为是从最后一层开始的,而最后一层的节点数又很多。向下调整的最后一层不用调整。

    public void offer(int val) {
        // 首先判断堆满没满
        if (usedSize == elem.length) {
            elem = Arrays.copyOf(elem, elem.length * 2);
        }
        elem[usedSize] = val;
        shiftUp(usedSize);
        usedSize++;
    }

    private void shiftUp(int child) {
        int parent = (child - 1) / 2;
        while (child > 0) {
            //child等于 0 的时候说明循环结束,此时parent < 0
            if (elem[parent] < elem[child]) {
                swap(parent, child);
                //让child向上更新成parent,看看新的堆是否符合大根堆要求
                child = parent;
                parent = (child - 1) / 2;
            } else {
                break;

            }
        }
    }

写完之后来测试一下看看。

没问题。

接下来可以写删除了,因为是队列,所以肯定是删除堆顶元素。

我们采用比较简单的方式,替罪羊删除法。

就是将最后一个节点与堆顶节点交换,然后再进行向下调整即可。

    public int poll() {
        if (usedSize == 0) {
            return Integer.MIN_VALUE;
        }
        int ret = elem[0];
        // 1. 让堆顶元素与最后一个元素交换
        swap(0, usedSize - 1);
        usedSize--;
        
        // 2. 向下调整 0 下标的元素
        shiftDown(0, usedSize);
        return ret;
    }

写完之后,我们来测试一下看看。

没啥问题。

向上调整的时间复杂度为 O(nlogn)

向下调整的时间复杂度为 O(n)

1.下列关键字序列为堆的是:() 
A: 100,60,70,50,32,65    B: 60,70,65,50,32,100     C: 65,100,70,32,50,60 
D: 70,65,100,32,50,60    E: 32,50,100,70,65,60     F: 50,100,70,65,60,32

2.已知小根堆为8,15,10,21,34,16,12,删除关键字8之后需重建堆,在此过程中,关键字之间的比较次数是() 
     A: 1            B: 2              C: 3               D: 4 

3.最小堆[0,3,2,5,7,4,6,8],在删除堆顶元素0之后,其结果是() 
     A: [3,2,5,7,4,6,8]           B: [2,3,5,7,4,6,8] 

     C: [2,3,4,5,7,8,6]           D: [2,3,4,5,6,7,8]

答案: ACC

3.常用接口介绍

3.1 PriorityQueue 的特性

Java集合框架中提供了 PriorityQueue 和 PriorityBlockingQueue 两种类型的优先级队列, PriorityQueue 是线程不安全的,PriorityBlockingQueue 是线程安全的.

使用 PriorityQueue 的注意事项:

1.  使用时必须导入 PriorityQueue 所在的包,即:

import java.util.PriorityQueue;

2. PriorityQueue 中放置的元素必须要能够比较大小,不能插入无法比较大小的对象,否则会抛出 ClassCastException异常
3. 不能插入 null 对象,否则会抛出 NullPointerException
4. 没有容量限制,可以插入任意多个元素,其内部可以自动扩容
5. 插入和删除元素的时间复杂度为O(logn)
6. PriorityQueue 底层使用了堆数据结构
7. PriorityQueue 默认情况下是小堆---即每次获取到的元素都是最小的元素

3.2 PriorityQueue常用接口介绍

1. 优先级队列的构造

2. 插入/删除/获取优先级最高的元素

4. 堆的应用

4.1 PriorityQueue 的实现

用堆作为底层结构封装优先级队列.

import java.util.Arrays;

public class MyBigHeap {
    //因为堆实际上是用层序遍历的方式存储在顺序表中的
    //所以我们需要一个数组
    private int[] elem;
    private int usedSize;

    // 生成构造方法
    public MyBigHeap() {
        elem = new int[10];
    }

    // 初始化
    public void initHeap(int[] arr) {
        for (int i = 0; i < arr.length; i++) {
            elem[i] = arr[i];
            usedSize++;
        }
    }

    //创建大根堆的前提是先初始化数组
    //得从最后一棵子树开始调整 => 已知,数组最后一个元素的下标,求他的父亲节点
    //每一次调整,都是从根节点 往下 调整(向下调整)
    public void createHeap() {
        for (int parent = (usedSize - 1 - 1) / 2; parent >= 0; parent--) {
            shiftDown(parent, usedSize);
        }
    }


    public void heapSort() {
        int end = usedSize - 1;
        while (end > 0) {
            swap(0, end);
            shiftDown(0, end);
            end--;
        }
    }

    private void shiftDown(int parent, int usedSize) {
        int child = 2 * parent + 1;// 左孩子
        // 进入循环说明有左孩子
        while (child < usedSize) {
            if (child + 1 < usedSize && elem[child] < elem[child + 1]) {
                //存在右孩子并且右孩子比左孩子要大
                child++;
            }
            //来到这里,child 一定是左右孩子的最大值
            //然后和 parent 比较,进行调整
            if (elem[parent] < elem[child]) {
                swap(parent, child);
                //还要再看看交换完的是否符合子树的大根堆规则
                //所以parent要向下更新成child,再进行比较调整
                parent = child;
                child = 2 * parent + 1;
            } else {
                //根比左右孩子大
                break;
            }
        }
    }

    private void swap(int parent, int child) {
        int tmp = elem[parent];
        elem[parent] = elem[child];
        elem[child] = tmp;
    }

    public void offer(int val) {
        // 首先判断堆满没满
        if (usedSize == elem.length) {
            elem = Arrays.copyOf(elem, elem.length * 2);
        }
        elem[usedSize] = val;
        shiftUp(usedSize);
        usedSize++;
    }

    private void shiftUp(int child) {
        int parent = (child - 1) / 2;
        while (child > 0) {
            //child等于 0 的时候说明循环结束,此时parent < 0
            if (elem[parent] < elem[child]) {
                swap(parent, child);
                //让child向上更新成parent,看看新的堆是否符合大根堆要求
                child = parent;
                parent = (child - 1) / 2;
            } else {
                break;

            }
        }
    }

    public int poll() {
        if (usedSize == 0) {
            return Integer.MIN_VALUE;
        }
        int ret = elem[0];
        // 1. 让堆顶元素与最后一个元素交换
        swap(0, usedSize - 1);
        usedSize--;

        // 2. 向下调整 0 下标的元素
        shiftDown(0, usedSize);
        return ret;
    }

}

4.2 堆排序

堆排序即利用堆的思想来进行排序,总共分为两个步骤:

1. 建堆

  • 升序,建立大堆
  • 降序,建立小堆

2. 利用堆删除思想来进行排序

建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。

堆排序思想:(升序),先建立出一个大根堆来,然后将第一个元素arr[0]与最后一个元素arr[end]互换,end--,再向下调整,直到 end = 0 才停止。(跟删除的逻辑一样,可以理解为将所有元素删完,堆就有序了)

//创建大根堆的前提是先初始化数组
    //得从最后一棵子树开始调整 => 已知,数组最后一个元素的下标,求他的父亲节点
    //每一次调整,都是从根节点 往下 调整(向下调整)
    public void createHeap() {
        for (int parent = (usedSize - 1 - 1) / 2; parent >= 0; parent--) {
            shiftDown(parent, usedSize);
        }
    }


    public void heapSort() {
        int end = usedSize - 1;
        while (end > 0) {
            swap(0, end);
            shiftDown(0, end);
            end--;
        }
    }

    private void shiftDown(int parent, int usedSize) {
        int child = 2 * parent + 1;// 左孩子
        // 进入循环说明有左孩子
        while (child < usedSize) {
            if (child + 1 < usedSize && elem[child] < elem[child + 1]) {
                //存在右孩子并且右孩子比左孩子要大
                child++;
            }
            //来到这里,child 一定是左右孩子的最大值
            //然后和 parent 比较,进行调整
            if (elem[parent] < elem[child]) {
                swap(parent, child);
                //还要再看看交换完的是否符合子树的大根堆规则
                //所以parent要向下更新成child,再进行比较调整
                parent = child;
                child = 2 * parent + 1;
            } else {
                //根比左右孩子大
                break;
            }
        }
    }

    private void swap(int parent, int child) {
        int tmp = elem[parent];
        elem[parent] = elem[child];
        elem[child] = tmp;
    }

写完之后来测试下看看。

没问题。

4.3 Top-k问题

TOP-K问题:即求数据集合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。

最先想到的方法就是排序,但是当数据量非常大时,排序效率就比较低。

有一个较优方法:

如果是求前 k 个大的数据,我们可以建立一个容量为 k 的小根堆,然后遍历数组,先查看堆中元素是否小于 k,如果小于 k,那就直接放入堆中,如果大于 k,就可以让堆顶元素和当前元素比较,如果堆顶元素小于当前元素,说明堆顶元素一定不是前 k 大的,则弹出堆顶元素,将当前元素加入堆中。当遍历完数组之后,堆中存储的元素一定是前 k 大的元素。

如果是求前 k 小的数据,则建立大根堆,做法和上面类似,就是堆顶元素和当前元素比较时,如果堆顶元素大于当前元素,则说明堆顶元素一定不是前 k 小元素,弹出堆顶元素,将当前元素入堆即可。

总结:求前 k 大,就建立小根堆,就前 k 小,就建立大根堆。

习题:面试题 17.14. 最小K个数

就按我刚刚说的做即可。

代码实现:

class Solution {
    public int[] smallestK(int[] arr, int k) {
        int[] ret = new int[k];
        if (arr == null || k <= 0) return ret;
        // 因为是求前 k 小,所以要建立大根堆
        // 默认为小根堆,要传构造器建立大根堆
        PriorityQueue<Integer> queue = new PriorityQueue<>(new IntCmp());
        for (int i = 0; i < arr.length; i++) {
            if (i < k) {
                // 入堆
                queue.offer(arr[i]);
            } else {
                // top < arr[i]
                if (arr[i] < queue.peek()) {
                    queue.poll();
                    queue.offer(arr[i]);
                }
            }
        }
        int j = k - 1;
        while (!queue.isEmpty()) {
            ret[j] = queue.poll();
            j--;
        }
        return ret;
    }
}

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

}

  • 65
    点赞
  • 48
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值