前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >算法原理系列:优先队列

算法原理系列:优先队列

作者头像
用户1147447
发布2019-05-26 09:47:53
4350
发布2019-05-26 09:47:53
举报
文章被收录于专栏:机器学习入门机器学习入门

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://cloud.tencent.com/developer/article/1434772

算法原理系列:优先队列

第一次总结这种动态的数据结构,一如既往,看了大量的教程,上网搜优先队列原理,能出来一大堆,但不知道为什么怎么这么多人搞不清楚原理和实现的区别?非要把实现讲成原理,今天就说说自己对优先队列的看法吧。

缘由

顾名思义,优先队列是对队列的一种改进,队列为先进先出的一种数据结构,而优先队列则保持一条性质:

在队头的原始始终保持优先级最高。

优先级最高,我们可以用一个数来衡量,所以简单的想法就是时刻把持队首的key元素最小(最小堆),或者key元素最大(最大堆)。

好了,开始构建优先队列的结构吧,现假定给你一堆数:

代码语言:javascript
复制
nums = [78, 82, 75, 35, 71, 23, 41, 42, 58, 8]

目标:

每次输出最小值,并从nums中删除该元素,直到nums的大小为0。

很简单,最navie的做法,每次遍历一遍数组,找出最小值,输出,并且置为INF,防止下次继续遍历,代码如下:

代码语言:javascript
复制
    public static void main(String[] args) {
        int[] test = RandomUtil.randomSet(1, 100, 10);
        solve(test);
    }

    static final int INF = 1 << 30;
    private static void solve(int[] nums){
        for (int i = 0; i < nums.length; i++){
            int index = -1, min = INF;
            for (int j = 0; j < nums.length; j++){
                if (nums[j] != INF && nums[j] < min){
                    index = j;
                    min = nums[j];
                }
            }
            System.out.println(min);
            nums[index] = INF;
        }
    }

最简单的做法,但时间复杂度爆表,依次输出n个元素时,时间复杂度为O(n2)O(n^2),找最小值操作也需要O(n)O(n),此处有几个非常别捏的地方,该算法无法解决数组的动态扩展,而且把使用过的元素变为INF,所以我们需要设计一种动态扩展的数据结构来存储不断变动的nums,于是有了优先队列的数据结构。

优先队列API如下:

代码语言:javascript
复制
public class PriorityQueue<Key extends Comparable<Key>> {

    Key[] array;
    int N = 0; //记录插入元素个数
    int SIZE; //当N>SIZE时,可以动态扩展

    @SuppressWarnings("unchecked")
    public PriorityQueue(int SIZE){
        this.SIZE = SIZE;
        array = (Key[]) new Comparable[SIZE];
    }   

    public void offer(Key key){
    }

    public Key poll(){
        return null;
    }

    public Key peek(){
        return null;
    }

    public boolean isEmpty(){
        return false;
    }
}

有了这样的API,我们就可以把nums中的元素全部offer进去,而当想要输出最小值时,直接poll出来即可,非常方便,想用就用,所以改进的做法如下:

代码语言:javascript
复制
public static void main(String[] args) {
        int[] test = RandomUtil.randomSet(1, 100, 10);
        PriorityQueue<Integer> queue = new PriorityQueue<>(10);
        for (int i = 0; i < test.length; i++){
            queue.offer(test[i]);
        }
        while (!queue.isEmpty())
            System.out.println(queue.poll());
    }

同样能够最大不断输出最小值,且支持动态加入元素,是不是高级很多。所以该问题就转变成了设计优先队列的API了。

API设计

开始吧,在《算法》书中介绍了初级优先队列的实现,一种是基于stack操作的无序惰性算法,核心思想是,把所有的元素压入栈中,不管它们的顺序,只有当我需要最小值时,用O(n)O(n)的算法实现求最小,输出。惰性源于尽量满足插入常数级,不到迫不得已不去使用O(n)O(n)的操作。

另外一种IDEA是在插入时就保持元素的有序,这样在取的操作,我们可以简单的移动一个指针来不断输出最小值,所以为了【维持插入的有序】操作,有了时间复杂度为O(n)O(n)的插入算法,而在取最小时,可以O(1)O(1)。

这是书中提到的两个初级实现,可以说它们没有什么特别的地方,想到也是理所当然的事,接下来就是实现细节的事了,我直接给出数组有序的版本(未实现动态扩展,感兴趣的可以自己实现下)。

代码如下:

代码语言:javascript
复制
/**
 * 
 * @author DemonSong
 * 
 * 实现基于插入排序的优先队列
 * 
 * 插入元素   O(n)
 * 删除元素   O(n)
 *
 * @param <T>
 */
public class PriorityQueue<T extends Comparable<T>> {


    T[] array;
    int N = 0;
    int SIZE;

    @SuppressWarnings("unchecked")
    public PriorityQueue(int SIZE){
        this.SIZE = SIZE;
        array = (T[]) new Comparable[SIZE];
    }

    public void offer(T key){
        if (N == 0){
            array[N++] = key;
            return;
        }
        int i = 0;
        while (i < N && key.compareTo(array[i]) > 0) i++;
        if (i == N){
            array[N++] = key;
            return;
        }
        rightShift(i);
        array[i] = key;
    }

    private void rightShift(int insert){
        N++;
        for (int i = N-1; i >= insert+1; --i){
            array[i] = array[i-1];
        }
    }


    public T poll(){
        if (this.isEmpty()) return null;
        T ele = array[0];
        leftShift();
        return ele;
    }

    private void leftShift(){
        for (int i = 1; i < N; i++){
            array[i-1] = array[i]; 
        }
        array[N-1] = null;
        N--;
    }

    public T peek(){
        return array[0];
    }

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

    @Override
    public String toString() {
        if (this.isEmpty())
            return "[]";

        StringBuilder sb = new StringBuilder();
        sb.append('[');
        for (int i = 0; i < N; i++){
            sb.append(array[i]+", ");
        }
        String res = sb.toString().substring(0, sb.length()-2);
        return res + "]";
    }

    public static void main(String[] args) {
        int[] test = RandomUtil.randomSet(1, 100, 10);
        PriorityQueue<Integer> queue = new PriorityQueue<>(10);
        for (int i = 0; i < test.length; i++){
            queue.offer(test[i]);
        }
        while (!queue.isEmpty())
            System.out.println(queue.poll());
    }
}

对代码感兴趣的可以研究下细节,注意,为了实现简单,我的poll操作还是O(n)O(n),并不是达不到O(1)O(1).

堆原理

怎么说呢,上述初级实现中有一个array数组,它的结构相当低级,这也是为什么offer操作是O(n)O(n)的时间复杂度,假设你把nums一堆数offer进去,该数组在计算机看来是这样子的:

代码语言:javascript
复制
nums = [12, 16, 37, 41, 51, 55, 56, 74, 77, 84]

queue的视角:
12 -> 16 -> 37 -> 41 -> 51 -> 55 -> 56 -> 74 -> 77 ->84

可以想象,这个关系是有多么的强,我只需要开头的最小值,结果你却维护了整个数组的有序,而为了维护整个数组的有序,我又是rightShift,又是比较的,这耗费了多少操作?

所以归根结底的原因在于,取最小的操作不值得维护整体的有序性,比如,我们换个视角来看问题,如下:

代码语言:javascript
复制
堆的视角:

12 -> 16 -> 41 -> 74
   -> 37 -> 51 -> 77
         -> 55 -> 84
         -> 56

该结构相当零活,在第一层的一定是最小的,而第二层的元素一定比第三层元素小,符合这种结构的解唯一么?如下:

12 -> 37 -> 41 -> 74
   -> 16 -> 55 -> 77
         -> 51 -> 84
         -> 56

同样符合,是吧?

在这里,我们可以得到一个有趣的猜想,一种数据结构出现的结果越不“唯一”,维护该结构所需要的消耗越小。

那么现在问题来了,动态加入一个元素后,如下:

代码语言:javascript
复制
queue.offer(19);

12 -> 37 -> 41 -> 74
   -> 16 -> 55 -> 77
         -> 51 -> 84
         -> 56

怎么加,加哪里,该如何维护?想想,如果维护的是一个树结构,假设从根结点开始插入该元素,因为19>12,所以必然放入下一层,但放入37还是16这个结点下?无所谓,你可以放入任何一个结点的子树中,关键来了!!!这就少维护了一半的关系!每次对半坎,所以说树结构高级的原因就在于,有些操作在判断时,都会去掉一半元素,这就好比原本规模大小为nn的问题,一下子递归为n2\frac{n}{2}的子问题,那自然而然递归深度只有log2n\log_2 n了,是吧?

所以加入一个元素如下:

代码语言:javascript
复制
12 -> 19(37) -> 41 -> 74
   -> 16     -> 55 -> 77
             -> 51 -> 84
             -> 56

这是堆原理的另一个关键步骤,我们知道了19比37来的小,所以它不可能再进入下一层,那37怎么办?第二层已经容不下它了,所以它必须下一层找出入。这里需要注意一个堆的性质,16结点的子树和37结点的子树是不相关的,它们各自维护一层层的关系,所以37没必要和16去判断。

ok,到这堆的原理已经讲完了,真正的两个精髓被我们找到了。

  • 维护一个相对较弱的层级结构而非很强关系的有序结构,前者的维护成本要小很多。
  • 维护插入元素时,随机挑选某个子结点进行沉降,遇到比它大的结点,把该结点的元素代入到下一层,直到叶子结点。

好了,接下来的优先队列的实现都是细节问题了,比如为什么用数组去维护二叉堆?

答:数组就不能维护二叉堆么?队首元素从下标1开始,所以当某个父结点为k时,左子结点为2k,右子结点为2k+1。同样的,已知子结点k,父结点为k/2。

为什么插入是从数组尾部开始?

答:我也想从头部开始插啊,但头部开插,我要写个随机算法来随机选择某个子结点?而且即使实现了,你这叶子结点的生长性也太随机了吧?随机的结果必然导致父子结点的k求法失效。

尾插的好处是什么?

答:每次都是严格的扩展完全二叉堆,这还不够好么?所以刚才是沉降操作,反过来自然有了上浮操作。

删除了头元素该怎么办?

答:现在是不是就可以头插了?毕竟没有元素在头,这个位置必须有元素可以替代,为了保证二叉堆的严格性,那肯定也是从最后一个元素取嘛,ok了,优先队列的设计完毕了。然后,再看看图吧,这些操作本来就是自然而然产生的。

上浮操作:

下沉操作:

再来看看具体实现吧,建议亲自实现一遍,敲的时候就不要抄了,脑子过一遍,印象深刻。代码如下:

代码语言:javascript
复制
import java.util.Comparator;
import com.daimens.algorithm.utils.RandomUtil;

public class PriorityQueue<Key extends Comparable<Key>> {

    Key[] array;
    int N = 0;
    int SIZE;
    private Comparator<? super Key> comparator;

    public PriorityQueue(int SIZE){
        this.SIZE = SIZE;
        array = (Key[]) new Comparable[SIZE+1];
    }

    public PriorityQueue(int SIZE, Comparator<? super Key> comparator){
        this(SIZE);
        this.comparator = comparator;
    }

    public void offer(Key key){
        array[++N] = key;
        swin(N);
    }


    private void swin(int k){
        while (k > 1 && less(k, k / 2)){
            swap(k, k / 2);
            k = k / 2;
        }
    }

    private boolean less(int i, int j){
        if (comparator != null) 
            return comparator.compare(array[i], array[j]) < 0;
        else 
            return array[i].compareTo(array[j]) < 0;
    }

    private void swap(int i, int j){
        Key tmp = array[i];
        array[i] = array[j];
        array[j] = tmp;
    }

    public Key poll(){
        Key key= array[1];
        swap(1, N);
        array[N] = null;
        N--;
        sink(1);
        return key;
    }

    private void sink(int k){
        while (2*k <= N){
            int j = 2 * k;
            if (j < N && less(j+1, j)) j++;
            if(!less(j, k)) break;
            swap(k, j);
            k = j;
        }
    }

    public Key peek(){
        return array[1];
    }

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

    @Override
    public String toString() {
        if (this.isEmpty())
            return "[]";

        StringBuilder sb = new StringBuilder();
        sb.append('[');
        for (int i = 1; i <= N; i++){
            sb.append(array[i]+", ");
        }
        String res = sb.toString().substring(0, sb.length()-2);
        return res + "]";
    }

    public static void main(String[] args) {
        int[] test = RandomUtil.randomSet(1, 100, 10);
        PriorityQueue<Integer> queue = new PriorityQueue<>(10, (a, b) -> (b-a));
        for (int i = 0; i < test.length; i++){
            queue.offer(test[i]);
        }
        while (!queue.isEmpty()){
            System.out.println(queue.poll());
        }
    }
}
本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2017年05月25日,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 算法原理系列:优先队列
    • 缘由
      • API设计
        • 堆原理
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档