算法原理系列:优先队列

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

缘由

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

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

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

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

nums = [78, 82, 75, 35, 71, 23, 41, 42, 58, 8]

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

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

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),此处有几个非常别捏的地方,该算法无法解决数组的动态扩展,而且把使用过的元素变为INF,所以我们需要设计一种动态扩展的数据结构来存储不断变动的nums,于是有了优先队列的数据结构。

优先队列API如下:

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出来即可,非常方便,想用就用,所以改进的做法如下:

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)的操作。

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

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

代码如下:

/**
 * 
 * @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(1).

堆原理

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

nums = [12, 16, 37, 41, 51, 55, 56, 74, 77, 84]

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

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

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

堆的视角:

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

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

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

同样符合,是吧?

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

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

queue.offer(19);

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

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

所以加入一个元素如下:

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了,优先队列的设计完毕了。然后,再看看图吧,这些操作本来就是自然而然产生的。

上浮操作:

算法原理系列:优先队列_结点

下沉操作:

算法原理系列:优先队列_数组_02

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

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());
        }
    }
}