正如第6章提到的,优先队列可以用于花费时间的排序。基于该想法的算法叫作堆排序(heapsort),它给出我们至今所见到的最佳的大O运行时间。然而,在实践中它却慢于使用Sedgewick增量序列的希尔排序。
回忆在第6章建立个元素的二叉堆的基本方法,此时花费时间。然后我们执行次DeleteMin操作。按照顺序,最小的元素先离开该堆。通过将这些元素记录到第二个数组然后再将数组拷贝回来,我们得到个元素的排序。由于每个DeleteMin花费时间,因此总的运行时间是。
该算法的主要问题在于它使用了一个附加的数组。因此,存储需求增加一倍。在某些
现在我们把注意力转到归并排序(mergesort)。归并排序以最坏情形运行时间运行,而所使用的比较次数几乎是最优的。它是递归算法一个很好的实例。
这个算法中基本的操作是合并两个已排序的表。因为这两个表是已排序的,所以若将输出放到第三个表中,则该算法可以通过对输入数据一趟排序来完成。基本的合并算法是取两个输入数组A和B,一个输出数组C,以及三个计数器Aptr、Bptr、Cptr,它们初始置于对应数组的开始端。A[Aptr]和B[Bptr]中的较小者被拷贝到C中的下一个位置,相关的计数器向前推进一步。当两个输入表有一个用完的时候,则将另一个表中的剩余部分拷贝到C中。合并例程工作的例子见下面各图。
合并两个已排序的表的时间显然是线性的,因为最多进行了次比较,其中是元素的总数。为了看清这一点,注意每次比较都是把一个元素加到C中,但最后的比较例外(它至少添加两个元素)。
因此,归并排序算法很容易描述。如果,那么只有一个元素需要排序,答案是显然的。否则,递归地将前半部分数据和后半部分数据各自归并排序,得到排序后的两部分数据,然后使用上面描述的合并算法再将这两部分合并到一起。例如,欲将八元素数组24,
13, 26,1,2,27,38,15排序,我们递归地将前四个数据和后四个数据分别排序,得到
1, 13,24,26,2,15,27,38。然后,将这两部分合并。最后得到1,2,13,15,24,
26,27,38。该算法是经典的分治(divide-and-conquer)策略,它将问题分成一些小的问题然后递归求解,而治的阶段则将分的阶段解得的各个答案修补到一起。分治是递归非常有力的用法,我们将会多次遇到。
归并排序的一种实现方法在图7-9中给出。这个称为Mergesort的过程正是递归例程MSort的一个驱动程序。
Merge例程是精妙的。如果对Merge的每个递归调用均局部声明一个临时数组,那么在任意时刻就可能有logN个临时数组处于活动期,这对于小内存的机器是致命的。另一方面,如果Merge例程动态分配并释放最小量临时内存,那么由ma1loc占用的时间会很多。严密测试指出,由于Merge位于MSort的最后一行,因此在任意时刻只需要一个临时数组活动,而且可以使用该临时数组的任意部分。我们将使用与输入数组A相同的部分,这就达到本节末尾描述的改进。图7-10实现了这个Merge例程。
void MSort(ElementType A[], ElementType TmpArray[], int Left, int Right)
{
int Center;
if (Left < Right)
{
Center = (Left + Right) / 2;
MSort(A, TmpArray, Left, Center);
MSort(A, TmpArray, Center + 1, Right);
Merge(A, TmpArray, Left, Center + 1, Right);
}
}
void MergeSort(ElementType A[], int N)
{
ElementType *TmpArray;
TmpArray = malloc(N * sizeof(ElementType));
if (TmpArray != NULL)
{
MSort(A, TmpArray, 0, N - 1);
free(TmpArray);
}
else
FatalError("No space for tmp array!!!");
}
void Merge(ElementType A[], ElementType TmpArray[], int Lpos, int Rpos, int RightEnd)
{
int i, LeftEnd, NumElements, TmPos;
LeftEnd = Rpos - 1;
TmPos = Lpos;
NumElements = RightEnd - Lpos + 1;
while (Lpos <= LeftEnd && Rpos <= RightEnd)
if (A[Lpos] <= A[Rpos])
TmpArray[TmPos++] = A[Lpos++];
else
TmpArray[TmPos++] = A[Rpos++];
while (Lpos <= LeftEnd)
TmpArray[TmPos++] = A[Lpos++];
while (Rpos <= RightEnd)
TmpArray[TmPos++] = A[Rpos++];
for (i = 0; i < NumElements; i++, RightEnd--)
A[RightEnd] = TmpArray[RightEnd];
}
归并排序的分析
归并排序是用于分析递归例程方法的经典实例:必须给运行时间写出一个递归关系。
假设是2的幂,从而我们总可以将它分成均为偶数的两部分。对于,归并排序所用时间是常数,我们将记为1。否则,对个数归并排序的用时等于完成两个大小为的递归排序所用的时间再加上合并的时间,它是线性的。下述方程给出准确的表示:
这是一个标准的递归关系,它可以用多种方法求解。我们将介绍两种方法。第一种方法是用去除递归关系的两边,你很快就会发现这么做的理由。相除后得到
该方程对任意的(其是2的幂)都是成立的,我们还可以写成
和
将所有这些方程相加,也就是说,将等号左边的所有各项相加并使结果等于右边所有各项的和。项出现在等号两边,可以消去。事实上,出现在两边的项均被消去,我们称之为叠缩(telescoping)求和。在所有的加法完成之后,最后的结果为
这是因为所有其余的项都被消去了而方程的个数是个,故而将各方程末尾的1相加起来得到。再将两边同乘以,我们得到最后的答案
注意,假如我们在求解开始时不是通除以,那么两边的和也就不可能叠缩。这就是为什么要通除以。
另一种方法是在右边连续地代入递归关系。我们得到
由于可以将N/2代入上面的方程中
因此得到
再将代入上面的等式中,我们看到
因此有
按这种方式继续下去,得到
利用,我们得到
选择使用哪种方法是风格问题。第一种方法偏重于一些琐碎的工作,把它写到一张标准的的纸上可能更好,这样会少出些数学错误,不过需要用到一定的经验。第二种方法更偏重于使用蛮力进行计算。
回忆我们已经假设。分析可以更加精细以处理不是2的幂的情形(通常出现的就是这样的情形)。事实上,答案几乎是一样的。
虽然归并排序的运行时间是,但是它很难用于主存排序,主要问题在于合并两个排序的表需要线性附加内存,在整个算法中还要花费将数据拷贝到临时数组再拷贝回来这样一些附加的工作,其结果是严重放慢了排序的速度。这种拷贝可以通过在递归交替层次时审慎地转换A和mpArray的角色来得到避免。归并排序的一种变形也可以非递归地实现(见练习7.14),但即使这样,对于重要的内部排序应用而言,人们还是选择快速排序,我们将在下一节描述这种算法。不过,本章稍后就会看到,合并例程是大多数外部排序算法的基石。
顾名思义,快速排序(quicksort)是在实践中最快的已知排序算法,它的平均运行时间是。该算法之所以特别快,主要是由于非常精练且高度优化的内部循环。它的最坏情形的性能为,但稍加努力就可避免这种情形。虽然多年来快速排序算法被认为是理论上高度优化而在实践中却不可能正确编程的一种算法,但是如今该算法简单易懂而且不难证明。像归并排序一样,快速排序也是一种分治的递归算法。将数组排序的基本算法由下列简单的四步组成:
1.如果中元素个数是0或1,则返回。
2.取中任意元素,称之为枢纽元(pivot)。
3.将(中其余元素)分成两个不相交的集合:和
4.返回{quicksort()后,继而,继而quicksort()}。
由于对那些等于枢纽元的元素的处理,第3步分割的描述不是唯一的,因此这就成了一个设计上的决策。一部分好的实现方法是将这种情形尽可能有效地处理。直观地看,我们希望把等于枢纽元的大约一半的关键字分到,中,而另外的一半分到。中,很像我们希望二叉查找树保持平衡一样。
图7-11解释快速排序对一个数集的做法。这里的枢纽元(随机地)选为65,集合中其余元素分成两个更小的集合。递归地将较小的数的集合排序得到0,13,26,31,43,57(递归法则3),较大的数的集合类似处理,此时整个集合的排序很容易得到。
应该清楚该算法是成立的,但是不清楚的是为什么它比归并排序快。如同归并排序那样,快速排序递归地解决两个子问题并需要线性的附加工作(第3步),不过,与归并排序不同,这两个子问题并不保证具有相等的大小,这是个潜在的隐患。快速排序更快的原因在于,第3步的分割实际上是在适当的位置进行并且非常有效,它的高效大大弥补了大小不等的递归调用的缺憾。
迄今为止,对该算法的描述尚缺少许多细节,我们现在就来补充这些细节。实现第2步和第3步有许多方法,这里介绍的方法是大量分析和经验研究的结果,它代表实现快速排序的非常有效的方法,哪怕是对该方法最微小的偏差都可能引起意想不到的不良结果。
虽然上面描述的算法无论选择哪个元素作为枢纽元都能完成排序工作,但是有些选择显然更优。
一种错误的方法
没有经过充分考虑的常见选择是将第一个元素用作枢纽元。如果输入是随机的,那么这是可以接受的,但是如果输入是预排序的或是反序的,那么这样的枢纽元就产生一个劣质的分割,因为所有的元素不是都被划入,就是都被划入。更有甚者,这种情况可能发生在所有的递归调用中。实际上,如果第一个元素用作枢纽元而且输入是预先排序的,那么快速排序花费的时间将是二次的,可是实际上却根本没干什么事,这是相当尴尬的。然而,预排序的输入(或具有一大段预排序数据的输入)是相当常见的,因此,使用第一个元素作为枢纽元绝对是糟糕的主意,应该立即放弃这种想法。另一种想法是选取前两个互异的关键字中的较大者作为枢纽元,而这和只选取第一个元素作为枢纽元具有相同的害处。不要使用这两种选取枢纽元的策略。
一种安全的做法
一种安全的方针是随机选取枢纽元。一般来说这种策略非常安全,除非随机数生成器有问题(这并不罕见),因为随机的枢纽元不可能总是接连不断地产生劣质的分割。另一方面,随机数的生成一般是昂贵的,根本减少不了算法其余部分的平均运行时间。
三数中值分割法(Median-of-Three Partitioning)
一组个数的中值是第个最大的数。枢纽元的最好选择是数组的中值。不幸的是,这很难算出,且会明显减慢快速排序的速度。这样的中值的估计量可以通过随机选取三个元素并用它们的中值作为枢纽元而得到。事实上,随机性并没有多大的帮助,因此一般的做法是使用左端、右端和中心位置上的三个元素的中值作为枢纽元。例如,输入为8,1,4,9,6,3,5,2,7,0,它的左边元素是8,右边元素是0,中心位置([(Left+Right)/2])上的元素是6。于是枢纽元是。显然使用三数中值分割法消除了预排序输入的坏情形(在这种情形下,这些分割都是一样的).并且减少了快速排序大约5%的运行时间。
有几种分割策略用于实践,但此处描述的分割方法能够给出好的结果。我们将会看到,
它很容易做错或效率较低,不过使用一种已知的方法却是安全的做法。该方法的第一步是
通过将枢纽元与最后的元素交换使得枢纽元离开要被分割的数据段。从第一个元素开始而
从倒数第二个元素开始。如果最初的输入与前面一样,那么下面的图表示当前的状态。
我们暂时假设所有的元素互异,后面将着重考虑出现重复元素时应该怎么办。作为一种限制性的情形,如果所有的元素都相同,那么我们的算法必须做相应的工作。可是奇怪的是,此时算法却特别容易出错。
在分割阶段要做的就是把所有小元素移到数组的左边而把所有大元素移到数组的右边。当然,“小”和“大”是相对于枢纽元而言的。
当在的左边时,我们将右移,移过那些小于枢纽元的元素,并将左移,移过那些大于枢纽元的元素。当和停止时,指向一个大元素而指向一个小元素。如果在的左边,那么将这两个元素互换,其效果是把一个大元素移向右边而把一个小元素移向左边。在上面的例子中,不移动,而滑过一个位置,情况如下图所示。
然后我们交换由和指向的元素,重复该过程直到和彼此交错为止。
此时,和已经交错,故不再交换。分割的最后一步是将枢纽元与所指向的元素交换。
在最后一步,当枢纽元与所指向的元素交换时,我们知道在位置的每一个元素都必然是小元素,这是因为或者位置包含一个从它开始移动的小元素,或者位置上原来的大元素在交换期间被置换了。类似的论断指出,在位置上的元素必然都是大元素。
我们必须考虑的一个重要细节是如何处理那些等于枢纽元的关键字。问题在于,当遇到一个等于枢纽元的关键字时是否应该停止,以及当遇到一个等于枢纽元的关键字时是否应该停止。直观地看,和应该做相同的工作,否则分割将出现偏向一方的倾向。例如,如果停止而不停,那么所有等于枢纽元的关键字都将被分到中。
为了搞清楚怎么办更好,我们考虑数组中所有的关键字都相等的情况。如果和都停止,那么在相等的元素间将有很多次交换。虽然这似乎没有什么意义,但是其正面的效果则是和将在中间交错,因此当枢纽元被替代时,这种分割建立了两个几乎相等的子数组。归并排序分析告诉我们,此时总的运行时间为。
如果和都不停止,那么就应该有相应的程序防止和越出数组的界限,不执行交换操作。虽然这样似乎不错,但是正确的实现方法却是把枢纽元交换到i最后到过的位置,这个位置是倒数第二个位置(或最后的位置,这依赖于精确的实现方法)。这样的做法将会产生两个非常不均衡的子数组。如果所有的关键字都是相同的,那么运行时间是的。对于预排序的输入而言,其效果与使用第一个元素作为枢纽元相同。它花费的时间是二次的,可是却什么事也没干!
进行不必要的交换建立两个均衡的子数组要比蛮干冒险得到两个不均衡的子数组好。因此,如果和遇到等于枢纽元的关键字,那么我们就让和都停止。对于这种输入,这实际上是不花费二次时间的四种可能性中唯一的一种可能。
初看起来,过多考虑具有相同元素的数组似乎有些愚蠢。难道有人偏要对5000个相同的元素排序吗?为什么?我们记得,快速排序是递归的。设有100000个元素,其中有5000个是相同的。最后,快速排序将对这5000个元素进行递归调用。此时,真正重要的在于确保这5000个相同的元素能够被有效地排序。
对于很小的数组(),快速排序不如插入排序好。不仅如此,因为快速排序是递归的,所以这样的情形还经常发生。通常的解决方法是对于小的数组不是递归地使用快速排序,而是使用诸如插入排序这样对小数组有效的排序算法。使用这种策略实际上可以节省大约15%(相对于自始至终使用快速排序时)的运行时间。一种好的截止范围(cutoffrange)是,虽然在5到20之间任意截止范围都有可能产生类似的结果。这种做法也避免了一些有害的特殊情形,如取三个元素的中值而实际上却只有一个或两个元素的情况。
快速排序的驱动程序见图7-12。
这种例程的一般形式将是传递数组以及被排序数组的范围Left(左端)和Right(右端)。要处理的第一个例程是枢纽元的选取。选取枢纽元最容易的方法是对A[Left]、A[Right]、A[Center]适当地排序。这种方法还有额外的好处,即该三元素中的最小者被分在A[Left],而这正是分割阶段应该将它放到的位置。三元素中的最大者被分在A[Right],这也是正确的位置,因为它大
于枢纽元。因此,我们可以把枢纽元放到A[Right-1]并在分割阶段将i和j初始化到Left+1和Right-2。因为A[Left]比枢纽元小,所以将它用作j的警戒标记,这是另一个好处。因此,我们不必担心j越界。由于i将停在那些等于枢纽元的关键字处,故将枢纽元存储在A[Right-1],将提供一个警戒标记。图7-13中的程序进行三数中值分割,它具有所描述的所有附加的作用。似乎使用实际上不对A[Left]、A[Right]、A[center]排序的方法计算枢纽元只不过效率稍微降低一些,但是很奇怪,这将产生坏结果(见练习7.38)。
图7-14的程序是快速排序真正的核心。它包括分割和递归调用。这里有几件事值得注
意。第3行将i和j初始化为比它们的正确值大1,使得不存在需要考虑的特殊情况。此处的初始化依赖于三数中值分割法有一些附加作用的事实。如果按照简单的枢纽元策略使用该程序而不进行修正,那么这个程序是不能正确运行的,原因在于i和j开始于错误的位置而不再存在j的警戒标志。
第8行的Swap为了速度上的考虑有时显式写出。为使算法速度快,需要迫使编译器以直接插入的方式编译这些代码。为此,许多编译器都自动这么做,但对于不这么做的编译器,差别可能很明显。
i = Left + 1;
j = Right - 2;
for ( ; ;)
{
while (A[i] < Pivot) i++;
while (A[j] > Pivot) j--;
if (i < j)
Swap(&A[i], &A[j]);
else
break;
}
最后,从第5行和第6行可看出为什么快速排序这么快。算法的内部循环由一个增1/减1运算(它很快)、一个测试以及一个转移组成。该算法没有像归并排序中那样的额外技巧,不过,这个程序仍然出奇复杂。令人感兴趣的是将第3~9行用图7-15中列出的语句代替,这是不能正确运行的,因为若A[i]=A[j]=pivot,则会产生一个无限循环。
void QuickSort(ElementType A[], int N)
{
Qsort(A, 0, N - 1);
}
ElementType Median3(ElementType A[], int Left, int Right)
{
int Center = (Left + Right) / 2;
if (A[Left] > A[Center])
Swap(&A[Left], &A[Center]);
if (A[Left] > A[Right])
Swap(&A[Left], &A[Right]);
if (A[Center] > A[Right])
Swap(&A[Center], &A[Right]);
Swap(&A[Center], &A[Right - 1]);
return A[Right - 1];
}
#define Cutoff (3)
void Qsort(ElementType A[], int Left, int Right)
{
int i, j;
ElementType Pivot;
if (Left + Cutoff <= Right)
{
Pivot = Median3(A, Left, Right);
i = Left;
j = Right - 1;
for ( ; ;)
{
while (A[++i] < Pivot) {}
while (A[--j] > Pivot) {}
if (i < j)
Swap(&A[i], &A[j]);
else
break;
}
Swap(&A[i], &A[Right - 1]);
Qsort(A, Left, i - 1);
Qsort(A, i + 1, Right);
}
else
InsertionSort(A + Left, Right - Left + 1);
}
可以修改快速排序以解决选择问题(selection problem),这种问题我们在第1章和第6章已经看到。当时,通过使用优先队列,我们能够以时间找到第个最大(最小)元。对于查找中值的特殊情况,它给出一个算法。
由于我们能够以时间给数组排序,因此可以期望为选择问题得到一个更好的时间界。我们介绍的查找集合中第个最小元的算法几乎与快速排序相同。事实上,其前三步是一样的。我们将把这种算法叫作快速选择(quickselect)。令为中元素的个数。快速选择的步骤如下:
1.如果,那么,并将中的元素作为答案返回。如果使用小数组的截止(cutoff)方法且,则将排序并返回第个最小元。
2.选取一个枢纽元。
3.将集合分割成和,就像我们在快速排序中所做的那样。
4.如果,那么第个最小元必然在中。在这种情况下,返回quickselect(,)。如果,那么枢纽元就是第个最小元,我们将它作为答案返回。否则,这第个最小元就在中,它是中的第()个最小元。我们进行一次递归调用并返回quickselect(,)。
与快速排序相比,快速选择只做了一次递归调用而不是两次。快速选择的最坏情形和快速排序的相同,也是。直观看来,这是因为快速排序的最坏情形发生在和有一个是空的时候,于是,快速选择也就不是真的节省一次递归调用。不过,平均运行时间是。
快速选择的实现甚至比抽象的描述还要简单,其程序见图7-16。当算法终止时,第个最小元就在位置上。这破坏了原来的排序,如果不希望这样,那么需要做一份拷贝。
void Qselect(ElementType A[], int k, int Left, int Right)
{
int i, j;
ElementType Pivot;
if (Left + Cutoff <= Right)
{
Pivot = Median3(A, Left, Right);
i = Left;
j = Right - 1;
for ( ; ;)
{
while (A[++i] < Pivot) {}
while (A[--j] > Pivot) {}
if (i < j)
Swap(&A[i], &A[j]);
else
break;
}
Swap(&A[i], &A[Right - 1]);
if (k <= i)
Qselect(A, k, Left, i - 1);
else if (k > i + 1)
Qselect(A, k, i + 1, Right);
}
else
InsertionSort(A + Left, Right - Left + 1);
}