堆排序和PriorityQueue源码解析

本节课是《进击的Java新人》第十一周第三课,我们继续讲解与堆有关的知识。

堆排序

使用堆这种数据结构,我们就可以轻松地实现堆排序了。堆排序的本质还是选择排序,也就是每个步骤都从未排序元素中选择值最大的那个元素,将这个过程不断地重复下去,直到所有的值都被选择。而我们知道,对于一个已经规范化的最大堆,堆顶元素是所有元素的最大值。所以,堆排序算法已经呼之欲出了。

每次都从最大堆中取出它的堆顶元素,对堆中的剩余元素,重新进行最大堆的维护,维护后堆顶元素仍然是所有未排序元素中最大的那个。重复以上操作即可完成堆排序。这里要说的是,取出堆顶元素有一个技巧,那就是可以将堆的最后一个元素与堆顶元素互换来实现。这样,每次取出最大值后,堆的大小都会减一,再对新的堆进行规范化操作即可。具体的代码如下所示:

public class HeapSort implements Sorter{
    public void sort(int[] arr) {
        Heap.buildUpHeap(arr);

        for (int i = arr.length - 1; i > 0; i--) {
            int t = arr[0];
            arr[0] = arr[i];
            arr[i] = t;

            Heap.maxHeapify(arr, i, 0);
        }
    }
}

堆排序的过程如图所示:


PriorityQueue版的选择排序

我们看到,前边所介绍的堆,功能是有所限制的,我们写的这些代码几乎都是为堆排序服务的。如果只是这样,这种数据结构就太不通用了。那能不能把堆改造成容器呢?Java中就提供了一种基于堆的容器,这就是PriorityQueue。它的特点是,队列中的元素是有优先级的,优先级最高的元素就排在队列的第一位,当我们从队列中去取的是,得到的是优先级最高的那个元素。这个优先级我们是可以自己定义的,比如值最大的,优先级最高,也可以定义成值最小的,优先级最高,甚至可以指定比较某个具体的对象的某个属性,比如学生的学号,年龄这种的。

我们先来体验一下,使用优先级队列做为辅助的数据结构来做选择排序是怎么样的。代码很简单:

    public void sort(int[] arr) {
        PriorityQueue<Integer> q = new PriorityQueue<>();
        for (int i = 0; i < arr.length; i++) {
            q.add(arr[i]);
        }

        int i = 0;
        while (!q.isEmpty()) {
            arr[i++] = q.poll();
        }
    }

这是典型的选择排序,每次都从优先级队列中找最小的,然后把它放回原来的数组里就可以了。由于PriorityQueue的内部使用了堆,所以这个排序本质仍然是堆排序。如果想从大到小排,只要这样写就可以了:

        PriorityQueue<Integer> q = new PriorityQueue<>(new Comparator<Integer>() {
            @Override
            public int compare(Integer o1, Integer o2) {
                return o2 - o1;
            }
        });

就是说,我们可以自己定义Comparator,自己定义是大的排在前边,还是小的排在前边。我们接下来看一下这个代码。

PriorityQueue源码解析

优先级队列的代码很长,但我们这里只要关注add和poll方法就可以了。其他的,构造函数,removeAt等等方法,请自己学习。其中,构造函数初始化部分与我们之前所讲的堆的自底向上的构建方法是一样的。

先来看add,这个方法只是封装了一下offer方法。那我们继续看offer的实现:

    public boolean offer(E e) {
        if (e == null)
            throw new NullPointerException();
        modCount++;
        int i = size;
        if (i >= queue.length) // 如果容量不足了,就需要扩容
            grow(i + 1);
        size = i + 1;
        if (i == 0)
            queue[0] = e;
        else
            siftUp(i, e);
        return true;
    }

这个也比较简单,看来玄机都在siftUp这个函数里。中间的其他调用过程比较简单,我不讲了,这里重点看一下这个方法:

    private void siftUpComparable(int k, E x) {
        Comparable<? super E> key = (Comparable<? super E>) x;
        while (k > 0) {
            int parent = (k - 1) >>> 1;
            Object e = queue[parent];
            if (key.compareTo((E) e) >= 0)
                break;
            queue[k] = e;
            k = parent;
        }
        queue[k] = key;
    }

我们看到,x 的序号是 k,而 k 的取值是 size + 1(这是由offer方法传进来的),也就是说新加进来的那个 x 是放到队列的最后一位的。那接下来就比较 x(当然,已经赋值给key这个变量了) 与它的 parent 的大小,它的 parent 其实就是 e。如果 key 比 e 大,那就说明已经满足了最小堆父亲比两个孩子都大的性质,这是因为原来的queue已经是一个最小堆了,不管 key 是 e 的左孩子还是右孩子,它的兄弟结点一定会比父结点大,如果 key 也比 e 大了,那么这个最小堆的调整就做完了,所以就可以直接停下了。否则呢,就把父亲换下来,自己再去与祖父结点比较。看上去比较简单,图我就不画了,大家自己弄个数组,自己画一下。

再来看一下,从头上取元素的方法。我们可以自己想一下,poll的实现,必然是从堆顶,也就是queue[0]那个位置取出元素,但这样一来,queue[0]就空出来,我们必须把这个空补上,这个有点难办了,如果是左孩子比较小,那么我们把左孩子放到这个空格里以后,堆就不再是一个完全二叉树了——空位并没有消失,只是往后移了。

这里有一个重要的技巧:把最后一个元素换到头上,然后使它沉下去。我第一次学堆的时候,真的是击节赞叹,觉得这也太巧妙了。其实,编程的乐趣就在这里。很多看上去不太好搞的问题,那种巧妙的转化,举重若轻,真是令人心旷神怡。

好了,我们看代码:

    public E poll() {
        if (size == 0)
            return null;
        int s = --size;
        modCount++;
        E result = (E) queue[0];
        E x = (E) queue[s];
        queue[s] = null;
        if (s != 0)
            siftDown(0, x);
        return result;
    }

很简单,把最后一个元素放到queue[0]里,重新调整堆,把queue[0]返回出去。而顶上这个元素的左子树和右子树都是规范的最小堆,这就变成了我们上节课所讲的第一个问题了。如果你认真学了昨天的内容,接下来的内容就可以不用看了。

    @SuppressWarnings("unchecked")
    private void siftDownComparable(int k, E x) {
        Comparable<? super E> key = (Comparable<? super E>)x;
        int half = size >>> 1;        // loop while a non-leaf
        while (k < half) {
            int child = (k << 1) + 1; // assume left child is least
            Object c = queue[child];
            int right = child + 1;
            if (right < size &&
                ((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
                c = queue[child = right];
            if (key.compareTo((E) c) <= 0)
                break;
            queue[k] = c;
            k = child;
        }
        queue[k] = key;
    }

和我们昨天的程序并无二致。都是找到父亲,左孩子,右孩子这爷仨中的最小值,将其与父结点交换。当然如果父结点本身就已经是最小值了,那就直接break,调整就完成了。

好了。今天的讲解就到这里了。

作业:

1. 假如,有一个这样的结构:

class Student {
    public int age;
    public String name;
}

使用PriorityQueue针对这种结构,按age域进行排序。

2. 使用一个最大堆和一个最小堆找出一个数列的中位数。这个数列的长度是未定的,随时都有会新的数加进来,要求你设计的系统必须随时可以给出当前时刻的中位数。

比如 1, 3, 4, 5, 2这5个数字的中位数是3,如果再加一个6,那么中位数就有两个,3和4。说白了,中位数就是数列排好序以后,位于中间的那个数字(当然,如果数列长度是偶数,就是中间的两个数字。)

3. 有一个有序数组,长度为 n,将其打乱,但是每个数字离开原来的位置的距离不会超过m,其中,m 远小于 n。请问,能否设计一个方法,使得数组重新有序,并且时间复杂度为O((log m) *n)?

注意哦,时间复杂度相比起本周第一课的课后作业变了哦。

上一节课:数据结构:堆

下一节课:算法设计(二):分治

目录:课程目录

编辑于 2017-03-20

文章被以下专栏收录