《算法》笔记 6 - 优先队列与堆排序
- 优先队列
- 初级实现
- 二叉堆
- 堆的有序化
- 由下至上的堆有序化
- 由上至下的堆有序化
- 基于堆的优先队列
- 堆排序
优先队列
许多情况下,不一定需要将元素全部排序,而只是需要取得当前元素中的最大或最小元素,然后再收集更多的元素,等需要时再取得最大或最小元素即可。在这种情况下,一个合适的数据结构应该支持两种操作:删除最大元素和插入元素,这种数据类型叫做优先队列。
优先队列的应用场景有很多,典型的如任务调度,优先级最高的任务优先。另外,通过插入一列元素然后一个个删掉其中最小的元素,还可以实现堆排序算法。
初级实现
可以使用有序或无序的数组或链表来实现优先队列。
基于用数组或链表实现的栈,在pop前,先找出最大的元素,并与栈顶元素交换,然后pop弹出的元素便是当前最大的元素了,这是一种无序的实现方式;还可以修改push方法,每次新插入元素都将其按顺序排定到对应的位置,这样pop方法就总是弹出最大的元素,这是有序的实现方式;无序序列是解决这个问题的惰性方法,有序序列则是积极方法,无序实现可以在常数时间内插入元素,有序实现可以在常数时间内删除最大元素,但无序实现在删除最大元素时、有序实现在插入元素时,其最坏情况都需要线性时间来完成,所以这两种优先队列的初级实现都无法兼顾删除最大元素、插入元素这两种操作的的性能。
而接下来基于二叉堆的实现却可以保证两种操作都能在对数时间内完成。
二叉堆
当一颗二叉树的每个结点都大于等于它的两个子结点时,它被称为堆有序,堆有序的二叉树中的最大结点是根节点,从任意结点向下,都能得到一列非递增的元素,从任意节点向上,也能得到一列非递减的元素。
堆有序的二叉树可以用链表来表示,但这样做的话,每个元素都需要三个指针,一个用来指向父节点,两个用来执行子结点。对于完全二叉树来说,只需用数组就可以表示了,将根节点放在数组位置1,它的子结点在位置2,3,下一层子结点又在位置4,5,6,7,这种表示方法便是二叉堆。
二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级存储(不使用数组的位置0)。
在一个二叉堆中,一个节点的子结点的位置分别为2k和2k+1,其父节点的位置为k/2(向下取整)。
堆的有序化
在构造二叉堆的时候,需要遍历堆并按照要求将恢复堆秩序,这个过程称为堆的有序化。在进行堆的有序化时,会遇到两种情况,当某个结点的优先级上升时,需要由下至上地进行堆的有序化,当某个结点的优先级下降时,需要由上至下地进行堆的有序化。
由下至上的堆有序化
如果堆的有序状态因为某个结点变得比它的父结点更大而被打破,就需要通过交换它和它的父结点来修复堆,但这个结点可能仍然比它现在的父结点大,于是便需要一层一层地向上移动,直到遇到更大的父结点。一个结点的父结点在k/2向下取整的位置,由此便可以不断地上浮(swim)了:
private void swim(int k) {
while (k > 1 && less(k / 2, k)) {
exch(k / 2, k);
k = k / 2;
}
}
在上浮到根结点或者遇到更大的父结点时,终止上浮。
由上至下的堆有序化
如果堆的有序状态因为某个结点变得比一个或两个子结点小而被打破,就需要将它和它的一个子结点交换来修复堆,与上浮类似,可能需要一层一层地向下移动,直到遇到更小的子结点,此时就终止下沉(sink):
private void sink(int k) {
while (2 * k <= N) {
int j = 2 * k;
if (j < N && less(j, j + 1)) //与较大的子结点交换
j++;
if (!less(k, j))
break;
exch(k, j);
k = j;
}
}
一个结点的子结点的位置在2k和2k+1处,是与两个子结点中较大的一个交换的。
基于堆的优先队列
基于堆的优先队列的实现代码为:
public class MaxPQ<Key> {
public Key[] pq;
private int N = 0;
public MaxPQ(int maxN) {
pq = (Key[]) new Comparable[maxN + 1];
}
public MaxPQ(){
this(1);
}
public void insert(Key v) {
if (N == pq.length - 1) {
resize(2 * pq.length);
}
pq[++N] = v;
swim(N);
}
public Key delMax() {
Key max = pq[1];
exch(1, N--);
sink(1);
pq[N + 1] = null;
if ((N > 0) && (N == (pq.length - 1) / 4)) {
resize(pq.length / 2);
}
return max;
}
public boolean isEmpty() {
return N == 0;
}
public int size() {
return N;
}
private boolean less(int i, int j) {
return ((Comparable<Key>)pq[i]).compareTo(pq[j]) < 0;
}
private void exch(int i, int j) {
Key t = pq[i];
pq[i] = pq[j];
pq[j] = t;
}
private void swim(int k)
private void sink(int k)
private void resize(int capacity) {
assert capacity > N;
Key[] temp = (Key[]) new Object[capacity];
for (int i = 1; i <= N; i++) {
temp[i] = pq[i];
}
pq = temp;
}
}
在插入元素时,insert()方法将元素插入二叉堆的最底端,然后调用swim()使其上浮到合适的位置;删除最大元素时,delMax()先从根结点拿到最大元素,将它与底层的最后一个元素交换,再调用sink()方法将其下沉到合适的位置。同时加入了可变长数组的特性。
满二叉树的高度为lgN+1,考虑最坏情况下的比较和交换次数,插入元素后数的高度加1,总是与上一层比较,如果一直上浮到根结点,需要lgN+1次比较和交换;删除最大元素时,如果元素从根结点一直下沉到最底层,需要lgN次交换,每次下沉都需要两次比较(两个子节点之间比较出较大者,下沉元素再与较大者比较),所以需要2lgN次比较。
堆排序
堆排序分为两个阶段,在堆的构造阶段,将原始数组有序化为一个堆,然后在下沉阶段,从堆中按递减数序依次取出堆的最大元素得到排序结果。
public class Heap {
public static void sort(Comparable[] pq) {
int N = pq.length;
for (int k = N / 2; k >= 1; k--) { //堆的构造
sink(pq, k, N);
}
while (N > 1) { //下沉排序
exch(pq, 1, N--);
sink(pq, 1, N);
}
}
private static void sink(Comparable[] pq, int k, int n) {
while (2 * k <= n) {
int j = 2 * k;
if (j < n && less(pq, j, j + 1))
j++;
if (!less(pq, k, j))
break;
exch(pq, k, j);
k = j;
}
}
private static boolean less(Comparable[] pq, int i, int j) {
return pq[i - 1].compareTo(pq[j - 1]) < 0;
}
private static void exch(Comparable[] a, int i, int j) {
Comparable swap = a[i - 1];
a[i - 1] = a[j - 1];
a[j - 1] = swap;
}
}
堆的构造
sort方法的第一个循环完成了堆的构造。构造堆时可以选择从左至右遍历数组,用swim()方法使数组的元素依次加入有序的堆中,但如果选择从右至左遍历数组,结合sink()方法只需扫描一半的元素。因为数组的每个位置都已经是一个自堆的根结点了,如果一个结点的两个子结点是子堆,那么在这个结点上调用sink()方法就可以将两个子堆合并为一个堆。大小为1的子结点不需要调用sink(),子结点的位置在2k,2k+1处,所以只需遍历[0..N/2]范围的元素即可。
下沉排序
下沉排序在sort方法的第二个循环完成。将待排序的数组本身作为堆,依次从堆的根结点处取出最大元素,将其与堆最末尾的元素交换,然后堆的大小将1,这样这个最大元素就留在待排序数组了,最终会从右至左,按倒序的方式将数组排序。此处的数组元素是从0开始的,less、exch的索引值需要减1.