最详细版图解优先队列(堆)

一、队列与优先队列的区别

  1. 队列是一种FIFO(First-In-First-Out)先进先出的数据结构,对应于生活中的排队的场景,排在前面的人总是先通过,依次进行
  2. 优先队列是特殊的队列,从“优先”一词,可看出有“插队现象”。比如在火车站排队进站时,就会有些比较急的人来插队,他们就在前面先通过验票。优先队列至少含有两种操作的数据结构:insert(插入),即将元素插入到优先队列中(入队);以及deleteMin(删除最小者),它的作用是找出、删除优先队列中的最小的元素(出队)。
优先队列
优先队列

二、优先队列(堆)的特性

  • 优先队列的实现常选用二叉堆在数据结构中,优先队列一般也是指堆

  • 堆的两个性质:

  1. 结构性堆是一颗除底层外被完全填满的二叉树,底层的节点从左到右填入,这样的树叫做完全二叉树。

  2. 堆序性:由于我们想很快找出最小元,则最小元应该在根上,任意节点都小于它的后裔,这就是小顶堆(Min-Heap);如果是查找最大元,则最大元应该在根上,任意节点都要大于它的后裔,这就是大顶堆(Max-heap)。

    结构性:

    完成二叉树
    完成二叉树

通过观察发现,完全二叉树可以直接使用一个数组表示而不需要使用其他数据结构。所以我们只需要传入一个size就可以构建优先队列的结构(元素之间使用compareTo方法进行比较)。

public class PriorityQueue<T extends Comparable<? super T>> 
    public PriorityQueue(int capacity) {
        currentSize = 0;
        array = (T[]) new Comparable[capacity + 1];
    }
}
完全二叉树的数组实现
完全二叉树的数组实现

对于数组中的任意位置 i 的元素,其左儿子在位置 2i 上,则右儿子2i+1 上,父节点在 在 i/2(向下取整)上。通常从数组下标1开始存储,这样的好处在于很方便找到左右、及父节点。如果从0开始,左儿子在2i+1,右儿子在2i+2,父节点在(i-1)/2(向下取整)。

堆序性:

我们这建立最小堆,即对于每一个元素X,X的父亲中的关键字小于(或等于)X中的关键字,根节点除外(它没有父节点)。

堆

如图所示,只有左边是堆,右边红色节点违反堆序性。根据堆序性,只需要常O(1)找到最小元。

三、基本的堆操作

  1. insert(插入)
  • 上滤为了插入元素X,我们在下一个可用的位置建立空穴(否则会破坏结构性,不是完全二叉树)。如果此元素放入空穴不破坏堆序性,则插入完成;否则,将父节点下移到空穴,即空穴向根的方向上冒一步。继续该过程,直到X插入空穴为止。这样的过程称为上滤。
建立空穴
建立空穴
完成插入
完成插入

图中演示了18插入的过程,在下一个可用的位置建立空穴(满足结构性),发现不能直接插入,将父节点移下来,空穴上冒。继续这个过程,直到满足堆序性。这样就实现了元素插入到优先队列(堆)中。

  • java实现上滤
     /**
     * 插入到优先队列,维护堆序性
     *
     * @param x :插入的元素
     */

    public void insert(T x) {
        if (null == x) {
            return;
        }
        //扩容
        if (currentSize == array.length - 1) {
            enlargeArray(array.length * 2 + 1);
        }
        //上滤
        int hole = ++currentSize;
        for (array[0] = x; x.compareTo(array[hole / 2]) < 0; hole /= 2) {
            array[hole] = array[hole / 2];
        }
        array[hole] = x;
    }

    /**
     * 扩容方法
     *
     * @param newSize :扩容后的容量,为原来的2倍+1
     */

    private void enlargeArray(int newSize) {
        T[] old = array;
        array = (T[]) new Comparable[newSize];
        System.arraycopy(old, 0, array, 0, old.length);
    }

可以反复使用交换操作来进行上滤过程,但如果插入X上滤d层,则需要3d次赋值;我们这种方式只需要d+1次赋值。

如果插入的元素是新的最小元从而一直上滤到根处,那么这种插入的时间长达O(logN)。但平均来看,上滤终止得要早。业已证明,执行依次插入平均需要2.607次比较,因此平均insert操作上移元素1.607层。上滤次数只比插入次数少一次。

  1. deleteMin(删除最小元)
  • 下滤:类似于上滤操作。因为我们建立的是最小堆,所以删除最小元,就是将根节点删掉,这样就破坏了结构性。所以我们在根节点处建立空穴,为了满足结构性,堆中最后一个元素X必须移动到合适的位置,如果可以直接放到空穴,则删除完成(一般不可能);否则,将空穴的左右儿子中较小者移到空穴,即空穴下移了一层。继续这样的操作,直到X可以放入到空穴中。这样就可以满足结构性与堆序性。这个过程称为下滤。
删除最小元
删除最小元
完成删除最小元
完成删除最小元

如图所示:在根处建立空穴,将最后一个元素放到空穴,已满足结构性;为满足堆序性,需要将空穴下移到合适的位置。

注意:堆的实现中,经常发生的错误是只有偶数个元素即有一个节点只有一个儿子。所以需要测试右儿子的存在性。

/**
     * 删除最小元
     * 若优先队列为空,抛出UnderflowException
     *
     * @return :返回最小元
     */

    public T deleteMin() {
        if (isEmpty()) {
            throw new UnderflowException();
        }

        T minItem = findMin();
        array[1] = array[currentSize--];
        percolateDown(1);

        return minItem;
    }

     /**
     * 下滤方法
     *
     * @param hole :从数组下标hole1开始下滤
     */

    private void percolateDown(int hole) {
        int child;
        T tmp = array[hole];

        for (; hole * 2 <= currentSize; hole = child) {
            //左儿子
            child = hole * 2;
            //判断右儿子是否存在
            if (child != currentSize &&
                    array[child + 1].compareTo(array[child]) < 0) {
                child++;
            }
            if (array[child].compareTo(tmp) < 0) {
                array[hole] = array[child];
            } else {
                break;
            }
        }
        array[hole] = tmp;
    }

这种操作最坏时间复杂度是O(logN)。平均而言,被放到根处的元素几乎下滤到底层(即来自的那层),所以平均时间复杂度是O(logN)。

四、总结

优先队列常使用二叉堆实现,本篇图解了二叉堆最基本的两个操作:插入及删除最小元。insert以O(1)常数时间执行,deleteMin以O(logN)执行。相信大家看了之后就可以去看java的PriorityQueue源码了。今天只说了二叉堆最基本的操作,还有一些额外操作及分析下次再说。比如,如何证明buildHeap是线性的?以及优先队列的应用等。

声明:图文皆原创,如有转载,请注明出处。如有错误,请帮忙指出,欢迎讨论;若觉得可以,点下推荐支持支持。

posted @ 2019-04-19 22:02  9龙  阅读(7904)  评论(0编辑  收藏  举报