数据结构之优先队列

作者: Ice_spring | 来源:发表于2019-08-07 14:24 被阅读5次

    优先队列PriorityQueue

    在数据结构中,普通的队列是先进先出,但有时我们可能并不想有这么固定的规矩,我们希望能有一个带优先级的队列。考虑在现实生活中,一些服务排队窗口会写着“军人依法优先”;送进医院的患者,即便是按顺序到达的,生病更加严重的往往优先级也会更高;还有操作系统中的作业调度也和优先级有关......
    于是我们能不能改进队列?使得队列是有一定优先级的,这样能让一些事物和任务的处理变的更加灵活。当然是可以的,最基本的我们可以基于线性结构来实现,考虑基于线性结构的时间复杂度:

    结构\操作 入队 出队
    普通线性结构 O(1) O(n)
    顺序线性结构 O(n) O(1)

    普通线性结构实现的优先队列出队时间复杂度是O(n),因为出队要拿出最优先的元素,也就是相对最大的元素(注意:大小是相对的,我们可以指定比较规则),从而要扫描一遍整个数组选出最大的取出才行。而对于顺序线性结构的入队操作,入队后可能破坏了原来的有序性,从而要调整当前顺序。
    可以看到使用线性结构总有时间复杂度是O(n)的操作,还有没有更好的实现方式呢,当然是有的,这就要来聊一聊堆Heap


    堆严格意义上来说又叫二叉堆(Binary Heap),因为它的结构是一颗完全二叉树,堆一般分为最大堆和最小堆。

    最大堆:父亲节点的值大于孩子节点的值
    最小堆:父亲节点的值小于孩子节点的值

    由于是完全二叉树,节点的索引之间有着一定的关系,故我们可以使用数组来存储二叉堆,具体如下:

    Heap

    如果从索引为0开始存储,则父亲和孩子节点的索引关系如下:
    left(i)=2i+1\\right(i)=2i+2\\parent(i)=[\frac{i-1}{2}]

    下面来基于数组实现我们的最大堆,首先是数组类的基本操作:

    '''Array.java'''
    //本数组具有动态能力,用户不用担心数组空间不够的问题
    public class Array<E> {//array盛放的数据类型是E类,这是为了数组的泛型
    
        private E[] data;
        private int size;//size指向第一个非空位置
        public Array(int capacity){
            //构造函数,传入容量capacity构造Array
            data = (E[])new Object[capacity];
            size = 0;
        }
    
        public Array(){
            //无参数构造函数
            this(10);//capacity是系统识别的语义
        }
        //服务于最大堆的构造函数
        public Array(E arr[]){
            data = (E[])new Object[arr.length];
            for(int i=0;i<arr.length;i++)
                data[i] = arr[i];
            size = arr.length;
        }
        public int getSize(){
            return size;
        }
    
        public int getCapacity(){
            return data.length;
        }
    
        public boolean isEmpty(){
            return size==0;
        }
        public void addLast(E e){
            add(size,e);//与上面等价
        }
        public void addFirst(E e){
            add(0,e);
        }
        public void add(int index, E e){//向索引为index位置插入e
            if(size == data.length)
                resize(2 * data.length);//空间满了则扩容为之前二倍
            if(index < 0 || index > size)
                throw new IllegalArgumentException("add failed,required index > 0 && < size!");
            for(int i = size-1;i>=index;i--)
                data[i+1]=data[i];
            data[index]=e;
            size++;
        }
    
        E get(int index){//获取index索引位置的元素
            if(index < 0 || index > size)
                throw new IllegalArgumentException("get failed, index is illegal!");
            return data[index];
        }
        void set(int index,E e){//修改index索引位置的元素为e
            if(index < 0 || index > size)
                throw new IllegalArgumentException("set failed, index is illegal!");
            data[index] = e;
        }
    
        public boolean contains(E e){//查找数组中是否含有e
            for(int i=0;i<size;i++) {
                if (data[i].equals(e))
                    return true;
            }
            return false;
        }
    
        public int find(E e){//查找元素e所在的索引,若不存在元素e,返回-1
            for(int i=0;i<size;i++) {
                if (data[i].equals(e))
                    return i;
            }
            return -1;
        }
    
        public E remove(int index) {//删除索引为index的元素,返回该元素
            if (index < 0 || index > size)
                throw new IllegalArgumentException("Remove failed, index is illegal!");
            E ret = data[index];
            for (int i = index + 1; i < size; i++)
                data[i - 1] = data[i];
            size--;
            data[size] = null;//销毁size位置的空间,本句不是必须
            //loitering object并不等于内存泄漏,不过最好手动释放这种对象的空间
    
            if(size == data.length/2)//为防止复杂度震荡,可改为除以4
                //if(size == data.length/4&&data.length/2!=0)
                resize(data.length/2);//数组中元素个数减小到一定之前1/2时进行容量缩小
            return ret;
        }
    
        public E removeFirst(){//删除第一个元素返回其值
            return remove(0);
        }
        public E removeLast(){//删除最后一个元素返回其值
            return remove(size-1);
        }
    
        public void removeElement(E e){//从数组中删除指定元素e
            int index = find(e);
            if(index!=-1)
                remove(index);
        }
        //服务于最大堆的交换元素函数
        public void swap(int i,int j){
            if(i < 0 || i >= size || j < 0 || j >= size)
                throw new IllegalArgumentException("index is illegal!");
            E t = data[i];
            data[i] = data[j];
            data[j] = t;
        }
        @Override//覆盖父类方法提示,便于微小错误的修改
        public String toString(){
            StringBuilder res = new StringBuilder();
            res.append(String.format("Array:size = %d , capacity = %d\n",size,data.length));
            res.append('[');
            for(int i = 0;i<size;i++){
                res.append(data[i]);
                if(i!=size-1)
                    res.append(", ") ;
            }
            res.append(']');
            return res.toString();
        }
    
        private void resize(int newCapacity){//改变容量函数
            //扩容或缩容只在内部由类自己完成,不能由外部用户操作,对外部透明
            E[] newData= (E[])new Object[newCapacity];
            for(int i=0;i<size;i++)
                newData[i] = data[i];
            data = newData;
        }
    }
    

    由于数组类的操作比较简单,没有太多要说明的。接着基于数组的操作构建我们的最大堆类:

    public class MaxHeap<E extends Comparable<E>> {
        private Array<E> data;
        public MaxHeap(int capacity){
            data = new Array<>(capacity);
        }
        public MaxHeap(){
            data = new Array<>();
        }
    
        //将任意数组整理为堆的形状heapify,O(n)
        //实现为构造函数
        public MaxHeap(E arr[]){
            data = new Array<>(arr);
            for(int i=parent(arr.length-1); i >= 0; i --)//最后一个节点的父亲节点即是倒数第一个非叶子节点
                siftDown(i);
        }
        public int size(){
            return data.getSize();
        }
        public boolean isEmpty(){
            return data.isEmpty();
        }
        //辅助函数
        private int parent(int index){
            //返回完全二叉树的数组表示中,一个索引所表示的元素的父亲节点的索引
            if(index == 0)
                throw new IllegalArgumentException("root has no parent!");
            return (index - 1) / 2;
        }
        private int leftChild(int index){
            //返回索引的左孩子的索引
            return index * 2 + 1;
        }
        private int rightChild(int index){
            return index * 2 + 2;
        }
    
        //添加操作,堆中元素(Sift Up)上浮过程,只需和父亲节点一直比较即可
        public void add(E e){
            data.addLast(e);
            siftUp(data.getSize() - 1);
        }
        private void siftUp(int k){
            //元素上浮过程
            while(k > 0 && data.get(parent(k)).compareTo(data.get(k)) < 0){
                data.swap(k,parent(k));
                k = parent(k);
            }
        }
        //看堆中最大元素
        public E findMax(){
            if(data.getSize()==0)
                throw new IllegalArgumentException("Heap is empty!");
            return data.get(0);
        }
        //取出堆顶元素,把两个堆合成一个堆比较复杂,
        // 于是只需把最后一个元素拿到堆顶,接着元素下沉(Sift Down)过程即可
        public E extractMax(){
            E ret = findMax();
            data.swap(0,data.getSize()-1);
            data.removeLast();
            siftDown(0);
            return ret;
        }
        private void siftDown(int k){
            //和两个孩子比较,如果比自己大,则交换
            while(leftChild(k) < data.getSize()){//若>则是k的左孩子越界,终止循环
                int j = leftChild(k);
                if(j+1 < data.getSize() && data.get(j+1).compareTo(data.get(j)) > 0){//说明当前节点有右孩子
                    j = rightChild(k);//如果右孩子大,则j存储右孩子节点索引,j++也可以
                    //无论如何此时data[j]中是左右孩子中最大值,j是它们孩子中最大值的索引
                }
                if(data.get(k).compareTo(data.get(j)) >= 0)
                    break;//没有违反堆的性质
                data.swap(k,j);
                k = j;
            }
        }
        //取出堆中最大的元素,放入新元素e
        public E replace(E e){
            E ret = findMax();
            data.set(0,e);
            siftDown(0);
            return ret;
        }
    }
    

    关于最大堆类需要说明的是replace操作和heapify操作,其中的replace操作实现的功能是取出最大元素(堆顶元素)再放入一个新元素,这个操作的实现可以考虑两种方式:

    • 1、先进行extractMax,再执行add操作,这样的时间复杂度是两次O(logn);
    • 2、将新元素代替堆顶元素后执行SiftDown操作,时间复杂度是O(logn)。

    我们使用的是第二种,SiftDown是元素下沉过程,它的作用就是调整当前堆结构使其符合最大堆结构。
    heapify操作同样可以考虑两种实现:

    • 1、将n个元素逐个插入一个空堆,时间复杂度是O(nlogn);
    • 2、将数组看成一棵完全二叉树,从最后一个非叶子节点开始执行SiftDown操作,时间复杂度是O(n)。

    我们使用的同样是第二种。

    基于堆的优先队列

    现在我们就可以用最大堆来完成一个优先队列了,同样,优先队列的基本操作和普通队列的操作是相同的,包括入队出队等,为此我们实现相应的队列接口:

    '''Queue.java'''
    public interface Queue<E> {
        int getSize();
        boolean isEmpty();
        E dequeue();
        E getFront();
        void enqueue(E e);
    }
    
    '''PriorityQueue.java'''
    public class PriorityQueue<E extends Comparable<E>> implements Queue<E>{
        private MaxHeap<E> maxHeap;
        public PriorityQueue(){
            //构造函数
            maxHeap = new MaxHeap<>();
        }
        @Override
        public int getSize(){
            return maxHeap.size();
        }
        @Override
        public boolean isEmpty(){
            return maxHeap.isEmpty();
        }
        @Override
        public E getFront(){
            return maxHeap.findMax();
        }
        @Override
        public void enqueue(E e){
            maxHeap.add(e);
        }
        @Override
        public E dequeue(){
            return maxHeap.extractMax();
        }
    }
    

    可以看到,在有了堆这种底层数据结构后,优先队列代码非常简洁明了。

    优先队列的应用——LeetCode347

    来看一道LeetCode上的题目:

    LeetCode347

    如果单纯用一个数组进行循环,统计各个数字的频率,这样做虽然可行,但时间复杂度会超过O(nlogn),于是考虑使用优先队列,在这个优先队列中,我们要维护频次最大的k个元素:

    • 1、依次入优先队列,不过入队的是元素及其频次的映射,所以要用到TreeMap,直到入队k个不同元素;
    • 2、入队k个不同的元素后,每新来一个则和这k个最大元素中频次最小的那个进行比较,如果比较结果是大于则换出这个元素;
    • 3、直至数组中所有元素遍历完成,此时优先队列中存储的就是前k个频次最高元素,打印输出即可。

    上述伪算法描述中,要每次取出k个元素中最小的元素,所以可以使用最小堆,我们实现的是最大堆,难道还要再实现一个最小堆?其实不用的,所谓的大小是相对的,我们是比较规则的制定者,只需更改比较规则即可,下面给出Solution:

    import java.util.LinkedList;
    import java.util.List;
    import java.util.TreeMap;
    //n log k 的复杂度
    //使用自己的优先队列
    class Solution {
        private class Freq implements Comparable<Freq>{
            public int e, freq;
            public Freq(int e, int freq){
                this.e = e;
                this.freq = freq;
            }
            @Override
            public int compareTo(Freq another){
                //重新定义优先级
                if(this.freq < another.freq)
                    return 1;//默认情况compareTo < 时返回 -1;这里我们自己定义了优先级,从而将最大堆相当于转变为最小堆
                else if(this.freq > another.freq)
                    return -1;
                else
                    return 0;
            }
        }
        public List<Integer> topKFrequent(int[] nums, int k) {
            TreeMap<Integer, Integer> map = new TreeMap<>();
            for(int num:nums){
                if(map.containsKey(num))
                    map.put(num,map.get(num)+1);
                else
                    map.put(num,1);
            }
            PriorityQueue<Freq> pq = new PriorityQueue<>();
            for(int key: map.keySet()){
                //遍历键
                if(pq.getSize() < k)//没有够k个入优先队列元素,则继续入队
                    pq.enqueue(new Freq(key, map.get(key)));
                else {
                    if (map.get(key) > pq.getFront().freq) {
                        //够k个新来的则要比较
                        pq.dequeue();//出队
                        pq.enqueue(new Freq(key, map.get(key)));
                    }
                }
            }
            LinkedList<Integer> res = new LinkedList<>();
            while(!pq.isEmpty())
                res.add(pq.dequeue().e);
            return res;
        }
    }
    

    将它用到的类封装为内部类提交,获得通过!
    当然我们也可以使用Java中提供的PriorityQueue,从而简化代码:

    import java.util.*;
    import java.util.PriorityQueue;
    import java.util.Comparator;
    public class S5 {
        public List<Integer> topKFrequent(int[] nums, int k) {
            TreeMap<Integer, Integer> map = new TreeMap<>();
            for(int num:nums){
                if(map.containsKey(num))
                    map.put(num,map.get(num)+1);
                else
                    map.put(num,1);
            }
            PriorityQueue<Integer> pq = new PriorityQueue<>((a,b) -> map.get(a) - map.get(b));
            for(int key: map.keySet()){
                if(pq.size() < k)//没有够k个入优先队列元素,则继续入队
                    pq.add(key);
                else {
                    if (map.get(key) > map.get(pq.peek())) {
                        //够k个新来的则要比较
                        pq.poll();//出队
                        pq.add(key);
                    }
                }
            }
            LinkedList<Integer> res = new LinkedList<>();
            while(!pq.isEmpty())
                res.add(pq.poll());
            return res;
        }
    }
    

    Java中的PriorityQueue可传入比较器定义我们的比较原则,此时代码已简化很多,提交,获得通过!

    通过

    相关文章

      网友评论

        本文标题:数据结构之优先队列

        本文链接:https://www.haomeiwen.com/subject/crexdctx.html