一种支持删除最大元素和插入元素的数据结构,优先队列的使用和队列(删除最老的元素)以及栈(删除最新的元素)类似,但高效的实现它很有挑战性。
public class | MaxPQ |
---|---|
MaxPQ() 创建一个优先队列 | |
MaxPQ(int max) 创建一个初始容量为max 的优先队列 | |
MaxPQ(Key[] a) 用a[] 中的元素创建一个优先队列 | |
void | insert(Key v) 向优先队列中插入一个元素 |
Key | max() 返回最大元素 |
Key | delMax() 删除并返回最大元素 |
boolean | isEmpty() 返回队列是否为空 |
int | size() 返回优先队列中的元素个数 |
优先队列的各种实现在最坏情况下运行时间的增长数量级
数据结构 | 插入元素与删除最大元素 |
---|---|
有序数组 | N,1 |
无序数组 | 1,N |
堆 | logN,logN |
理想情况 | 1,1 |
可以看出,堆能够很好的实现优先队列,那么什么是堆呢?
数据结构二叉堆能够很好地实现优先队列的基本操作。在二叉堆的数组中,每个元素都要保证大于等于另两个特定位置的元素。相应地,这些位置的元素又至少要大于等于数组中的另两个元素,以此类推。如果我们将所有元素画成一棵二叉树,将每个较大元素和两个较小的元素用边连接就可以很容易看出这种结构。
二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级储存(不使用数组的第一个位置)。(简单起见,在下文中我们将二叉堆简称为堆)在一个堆中,位置k 的结点的父结点的位置为[k],而它的两个子结点的位置则分别为2k 和2k+1。
这样做的好处是我们可以通过计算数组的索引在树中上下移动:从a[k] 向上一层就令k 等于k/2,向下一层则令k 等于2k 或2k+1。一棵大小为N 的完全二叉树的高度为*[lgN]*
我们用长度为N+1 的私有数组pq[] 来表示一个大小为N 的堆,我们不会
使用pq[0], 堆元素放在pq[1] 至pq[N] 中。在排序算法中,我们只通过私有辅助函数less() 和exch() 来访问元素。
堆实现的比较和交换方法如下
private boolean less(int i, int j)
{ return pq[i].compareTo(pq[j]) < 0; }
private void exch(int i, int j)
{ Key t = pq[i]; pq[i] = pq[j]; pq[j] = t; }
在对堆进行操作时,不可避免会打破堆原先的状态,所以需要遍历堆来将堆的状态恢复,这个过程叫做堆的有序化(reheapifying)。
在有序化的过程中我们会遇到两种情况:
首先,我们会学习如何实现这两种辅助操作,然后再用它们实现插入元素和删除最大元素的操作。
如果堆的有序状态因为某个结点变得比它的父结点更大而被打破,那么我们就需要通过交换它和它的父结点来修复堆。交换后,这个结点比它的两个子结点都大(一个是曾经的父结点,另一个比它更小,因为它是曾经父结点的子结点),但这个结点仍然可能比它现在的父结点更大。我们可以一遍遍地用同样的办法恢复秩序,将这个结点不断向上移动直到我们遇到了一个更大的父结点。
只要记住位置k 的结点的父结点的位置是k/2,这个过程实现起来很简单。当一个结点太大的时候它需要浮(swim)到堆的更高层。由下至上的堆有序化的实现代码如下
private void swim(int k)
{
while (k > 1 && less(k/2, k))
{
exch(k/2, k);
k = k/2;
}
}
如果堆的有序状态因为某个结点变得比它的两个子结点或是其中之一更小了而被打破了,那么我们可以通过将它和它的两个子结点中的较大者交换来恢复堆。交换可能会在子结点处继续打破堆的有序状态,因此我们需要不断地用相同的方式将其修复,将结点向下移动直到它的子结点都比它更小或是到达了堆的底部。
由位置为k 的结点的子结点位于2k 和2k+1 可以直接得到对应的代码。当一个结点太小的时候它需要沉(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;
}
}
上面介绍的两个堆有序化的操作:swim() 和sink() 方法是高效实现优先队列API 的基础。因为优先队列是一种支持删除最大元素和插入元素的数据结构。而上浮和下沉可以很好的实现这两种操作。如下图所示
下面是基于堆的优先队列的代码实现:
public class MaxPQ<Key extends Comparable<Key>>
{
private Key[] pq; // 基于堆的完全二叉树
private int N = 0; // 存储于pq[1..N]中,pq[0]没有使用
public MaxPQ(int maxN) // 创建一个初始容量为 maxN的优先队列
{ pq = (Key[]) new Comparable[maxN+1]; }
public boolean isEmpty() // 返回队列是否为空
{ return N == 0; }
public int size() // 返回优先队列中的元素个数
{ return N; }
public void insert(Key v) // 向优先队列中插入一个元素
{
pq[++N] = v;
swim(N);
}
public Key delMax() // 删除并返回最大元素
{
Key max = pq[1]; // 从根结点得到最大元素
exch(1, N--); // 将其和最后一个结点交换
pq[N+1] = null; // 防止对象游离
sink(1); // 恢复堆的有序性
return max;
}
// 辅助方法的实现请见前面的代码框
private boolean less(int i, int j)
private void exch(int i, int j)
private void swim(int k)
private void sink(int k)
}
优先队列由一个基于堆的完全二叉树表示, 存储于数组pq[1…N] 中,pq[0] 没有使用。在insert() 中,我们将N加一并把新元素添加在数组最后,然后用swim() 恢复堆的秩序。在delMax() 中,我们从pq[1] 中得到需要返回的元素,然后将pq[N] 移动到pq[1],将N 减一并用sink() 恢复堆的秩序。同时我们还将不再使用的pq[N+1] 设为null,以便系统回收它所占用的空间。
使用有序或是无序数组的优先队列的初级实现总是需要线性时间来完成插入或者删除最大元素操作,但基于堆的实现则能够保证在对数时间内完成它们。
我们将基于堆的优先队列变成“堆排序”这一新的排序方法,堆排序可以分为两个阶段。在堆的构造阶段中,我们将原始数组重新组织安排进一个堆中;然后在下沉排序阶段,我们从堆中按递减顺序取出所有元素并得到排序结果。
public static void sort(Comparable[] a)
{
int N = a.length;
for(int k = N/2; k >= 1; k--)
{ // 堆的构造
sink(a, k ,N);
}
while(N > 1)
{ // 下沉排序
// 这里我们将堆中的最大元素删除,然后放入堆缩小后数组中空出的位置
// 在下沉排序中一次次销毁堆
exch(a, 1, N--);
sink(a, 1, N);
}
}
堆排序在排序复杂性的研究中有着重要的地位,因为它是我们所知的唯一能够同时最优地利用空间和时间的方法——在最坏的情况下它也能保证使用~ 2NlgN 次比较和恒定的额外空间。当空间十分紧张的时候(例如在嵌入式系统或低成本的移动设备中)它很流行,因为它只用几行就能实现(甚至机器码也是)较好的性能。但现代系统的许多应用很少使用它,因为它无法利用缓存。数组元素很少和相邻的其他元素进行比较,因此缓存未命中的次数要远远高于大多数比较都在相邻元素间进行的算法,如快速排序、归并排序,甚至是希尔排序。
问:
我还是不明白优先队列是做什么用的。为什么我们不直接把元素排序然后再一个个地引用有序数组中的元素?
答:
在某些数据处理的例子里,比如TopM 和Multiway,总数据量太大,无法排序(甚至无法全部装进
内存)。如果你需要从10 亿个元素中选出最大的十个,你真的想把一个10 亿规模的数组排序吗?
但有了优先队列,你就只用一个能存储十个元素的队列即可。在其他的例子中,我们甚至无法同时
获取所有的数据,因此只能先从优先队列中取出并处理一部分,然后再根据结果决定是否向优先队
列中添加更多的数据。