数据结构二叉堆能够很好地实现优先队列的基本操作。在二叉堆的数组中,每个元素都要保证大于等于另两个特定位置的元素。相应地,这些位置的元素又至少要大于等于数组中的另两个元素,以此类推。如果我们将所有元素画成一棵二叉树,将每个较大元素和两个较小的元素用边连接就可以很容易看出这种结构。
定义:当一棵二叉树的每个结点都大于等于它的两个子结点时,它被称为堆有序。
相应地,在堆有序的二叉树中,每个结点都小于等于它的父结点(如果有的话)。从任意结点向上,我们都能得到一列非递减的元素;从任意结点向下,我们都能得到一列非递增的元素。特别地:
命题O:根结点是堆有序的二叉树中的最大结点。
证明:根据树的性质归纳可得。
如果我们用指针来表示堆有序的二叉树,那么每个元素都需要三个指针来找到它的上下结点(父结点和两个子结点各需要一个)。如果我们使用完全二叉树,表达就会变得特别方便。要画出这样一棵完全二叉树,可以先定下根结点,然后一层一层地由上向下、从左至右,在每个结点的下方连接两个更小的结点,直至将N个结点全部连接完毕。完全二叉树只用数组而不需要指针就可以表示。具体方法就是将二叉树的结点按照层级顺序放入数组中,根结点在位置1,它的子结点在位置2和3,而子结点的子结点则分别在位置4、5、6和7,以此类推。
定义:二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级储存(不使用数组的第一个位置)。
(简单起见,在下文中我们将二叉堆简称为堆)在一个堆中,位置k的结点的父结点的位置为k/2, 而它的两个子结点的位置则分别为2k和2k+1。 这样在不使用指针的情况下中讨论二叉树时会用到它们)我们也可以通过计算数组的索引在树中上下移动:从a[k]向上一层就令k等于k/2,向下一层则令k等于2k或2k+1。
用数组(堆)实现的完全二叉树的结构是很严格的,但它的灵活性已经足以让我们高效地实现优先队列。用它们我们将能实现对数级别的插入元素和删除最大元素的操作。利用在数组中无需指针即可沿树上下移动的便利和以下性质,算法保证了对数复杂度的性能。
命题P:一棵大小为N的完全二又树的高度为lgN。
证明:通过归纳很容易可以证明这一点,且当N达到2的幂时树的高度会加1。
我们用长度为N+1的私有数组pq[]来表示一个大小为N的堆,我们不会使用Pq[0],堆元素放在pq[1]至Pq[N]中。在排序算法中,我们只通过私有辅助函数less()和exch()来访问元素,但因为所有的元素都在数组pq[]中,我们在后面会使堆实现的比较和交换方法用更加紧凑的实现方式,不再将数组作为参数传递。堆的操作会首先进行一些简单的改动, 打破堆的状态,然后再遍历堆并按照要求将堆的状态恢复。我们称这个过程叫做堆的有序化。
private boolean less(int i, int j){
return pq[i].compareTo([j]) < 0;
}
private void exch(int i, int j){
Key t = pq[i];
pq[i] = pq[j];
pq[j] =t;
}
在有序化的过程中我们会遇到两种情况。当某个结点的优先级上升(或是在堆底加入一个新的元素)时,我们需要由下至上恢复堆的顺序。当某个结点的优先级下降(例如,将根结点替换为一个较小的元素)时,我们需要由上至下恢复堆的顺序。首先,我们会学习如何实现这两种辅助操作,然后再用它们实现插入元素和删除最大元素的操作。
如果堆的有序状态因为某个结点变得比它的两个子结点或是其中之一更小了而被打破了,那么我们可以通过将它和它的两个子结点中的较大者交换来恢复堆。交换可能会在子结点处继续打破堆的有序状态,因此我们需要不断地用相同的方式将其修复,将结点向下移动直到它的子结点都比它更小或是到达了堆的底部。由位置为k的结点的子结点位于2k和2k+1可以直接得到对应的代码。当一个结点太小的时候它需要沉( sink )到堆的更低层。
如果我们把堆想象成一个严密的组织,每个子结点都表示个下属(父结点则表示它的直接上级),那么这些操作就可以得到很有趣的解释。swim(表示一个很有能力的新人加入组织并被逐级提升(将能力不够的上级踩在脚下),直到他遇到了一个更强的领导。sink()则类似于整个社团的领导退休并被外来者取代之后,如果他的下属比他更厉害,他们的角色就会交换,这种交换会持续下去直到他的能力比其下属都强为止。这些理想化的情景在现实生活中可能很罕见,但它们能够帮助你理解堆的这些基本行为。
插入元素。我们将新元素加到数组末尾,增加堆的大小并让这个新元素上浮到合适的位置。
删除最大元素。我们从数组顶端删去最大的元素并将数组的最后-一个元素放到顶端,减小堆的大小并让这个元素下沉到合适的位置。
优先队列API的实现能够保证插入元素和删除最大元素这两个操作的用时由上至下的堆有序化(下沉)的实现和队列的大小仅成对数关系。
prvate void sinkint(){
while (2*k <= N){
int j = 2*k;
if(j < N && less(j,j+1)) j++;
if (!lessk(k,j)) break;
exch(k, j);
k = j;
}
}
public class MaxPQ<key extends Comparable<key>{
private Key[] pq; //基于堆的完全按二叉树
private int N = 0; //存储于pq[1..N]中,Pq[0]没有使用
public MaxPQ(int 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;
swtm(N) ;
}
public Key delMax(){
Key max = pq[1];
exch(1,N--);
pq[N+1] = null;
sink(1); //恢复堆的有序性
return max;
}
//辅助方法的实现请见本节前面的代码框
private boolean less(inti, int j)
private void exch(int i, int j)
private void swim(int k)
}
优先队列由一个基于堆的完全二叉树表示,存储于数组pq[1…N中,pq[0] 没有使用。在我们从pq[1]中得到需要返回的元素,然后将pq[N]移动到pq[1],将N减-并用sinkO恢复堆的秩序。同时我们还将不再使用的pq[N+1]设为null,以便系统回收它所占用的空间。
命题Q:对于一个含有N个元素的基于堆的优先队列,插入元素操作只需不超过(IgN+1 )次比较,删除最大元素的操作需要不超过2lgN次比较。
证明:由命题P可知,两种操作都需要在根结点和堆底之间移动元素,而路径的长度不超过lgN。 对于路径上的每个结点,删除最大元素需要两次比较(除了堆底元素),一次用来找出较大的子结点,一次用来确定该子结点是否需要上浮。
对于需要大量混杂的插人和删除最大元素操作的典型应用来说,命题Q意味着一个重要 的性能突破,使用有序或是无序数组的优先队列的初级实现总是需要线性时间来完成其中一种操作,但基于堆的实现则能够保证在对数时间内完成它们。这种差别使得我们能够解决以前无法解决的问题。
基于用数组表示的完全三叉树构造堆并修改相应的代码并不困难。对于数组中1至N的N个元素,位置k的结点大于等于位于3k-1、3k和3k+1的结点,小于等于位于(k+1)/3的结点。甚至对于给定的d,将其修改为任意的d叉树也并不困难。我们需要在树高( log/N)和在每个结点的d个子结点找到最大者的代价之间找到折中,这取决于实现的细节以及不同操作的预期相对频繁程度。
我们可以添加一个没有参数的构造函数,在insert()中添加将数组长度加倍的代码,在delMax()中添加将数组长度减半的代码,就像在上面的栈那样。这样,算法的用例就无需关注各种队列大小的限制。当优先队列的数组大小可以调整、队列长度可以是任意值时,命题Q指出的对数时间复杂度上限就只是针对一般性的队列长度N而言了。
优先队列存储了用例创建的对象,但同时假设用例代码不会改变它们(改变它们就可能打破堆的有序性)。我们可以将这个假设转化为强制条件,但程序员通常不会这么做,因为增加代码的复杂性会降低性能。
在很多应用中,允许用例引用已经进入优先队列中的元素是有必要的。做到这一点的一种简单方法是给每个元素一个索引。 另外,一种常见的情况是用例已经有了总量为N的多个元素,而且可能还同时使用了多个(平行)数组来存储这些元素的信息。此时,其他无关的用例代码可能已经在使用一个整数索引来引用这些元素了。这些考虑引导我们设计了下表中的API。
public class IndexMinPQ
| |
---|---|
IndexMinPQ(int maxN) | 创建一个最大容量为maxN的优先队列,索引的取值范围为0至maxN-1 |
void insert(int k, Item item) | 插入一个元素,将它和索引k相关联 |
void change(int k, Item item) | 将索引为k的元素设为item |
boolean contains(int k) | 是否存在索引为k的元素 |
public class IndexMinPQ
| |
---|---|
void delete(int k) | 删去索引k及其相关联的元素 |
Item min() | 返回最小元素 |
int minIndex() | 返回最小元素的索引 |
int delMin() | 删除最小元素并返回它的索引 |
boolean i sEmpty() | 优先队列是否为空 |
int size() | 优先队列中的元素数量 |
理解这种数据结构的一个较好方法是将它看成一个能够快速访问其中最小元素的数组。事实上它还要更好——它能够快速访问数组的一个特定子集中的最小元素(指所有被插入的元素)。换句话说,可以将名为pq的IndexMinPQ类优先队列看做数组pq[0…N-1]中的- -部分元素的代表。 将pq.insert(k, item) 看做将k加入这个子集并使pq[k] = item, pq.change(k,item) 则代表令pq[k]=item。 这两种操作没有改变其他操作所依赖的数据结构,其中最重要的就是de]Min() (删除最小元素并返回它的索引)和change() (改变数据结构中的某个元素的索引一即 pq[i]=item)。这些操作在许多应用中都很重要并且依赖于对元素的引用(制)。一般来说, 当堆发生变化时,我们会用下沉(元素减小时)或上浮(元素变大时)操作来恢复堆的有序性。在这些操作中,我们可以用索引查找元素。能够定位堆中的任意元素也使我们能够在API中加入一个delete()操作。
命题Q(续):在一个大小为N的索引优先队列中,插入元素(nsert)、改变优先级(change)、删除(delete)和删除最小元素(remove the minimum)操作所需的比较次数和logN成正比(如下表所示)。
证明:已知堆中所有路径最长即为~IgN,从代码中很容易得到这个结论。
操作 | 比较次数的增长数量级 |
---|---|
insert() | logN |
change() | logN |
contains() | 1 |
delete() | logN |
min() | 1 |
minIndex() | 1 |
delMin() | logN |
下面的用例调用 了IndexMinPQ的代码Multiway解决了多向归并问题:它将多个有序的输人流归并成一一个有序的输出流。许多应用中都会遇到这个问题。输入可能来自于多种科学仪器的输出(按时间排序),或是来自多个音乐或电影网站的信息列表(按名称或艺术家名字排序),或是商业交易(按账号或时间排序),或者其他。如果有足够的空间,你可以把它们简单地读入一个数组并排序,但如果用了优先队列,无论输入有多长你都可以把它们全部读入并排序。
public class Multiway{
public static void merge(In[] streams){
int N = streams .length;
IndexMinPQ<String> Pq = new IndexMinPQ<String>(N);
for(int i = 0;1<N; 1++){
if (!streams[i].isEmpty()){
pq.insert(i, streams[i]. readString());
}
while (!pq. isEmpty()){
int i= pq.delMin();
if (!streams [i]. isEmpty()){
pq. insert(i, streams[i].readString());
}
}
public static void main(String[] args){
Int N =arg.length;
for (int i = 0; 1<N; 1++){
streams[i] = new In(args[i]);
merge(streams);
}
}
}
这段代码调用了IndexMinPQ来将作为命令行参数输人的多行有序字符串归并为一行有序的输出。每个输入流的索引都关联着一个元素( 输人中的下个字符串)。初始化之后,代码进入一个循环,删除并打印出队列中最小的字符串,然后将该输入的下一一个字符串添加为一 个元素。 为了节约,下面将所有的输出排在了一行——实际输出应该是一个字符串一行。