【数据结构与算法分析——C语言描述】第七章:排序
标签(空格分隔):【数据结构与算法】
第七章:排序
7.1 预备知识
- 我们描述的算法是可以互换的。每个算法都将接受到一个含有元素的数组和一个包含元素个数的整数。
- 假设 是传递到我们的排序例程中的元素的个数。按照 C语言的规定,数据从位置 0 处开始。
- 假设 和 运算符存在,它们可以用于将相容的序放到输入中。除赋值运算符号外,这两种运算时仅有的允许对输入数据进行的操作。在这些条件下的排序叫做基于比较的排序(comparsion-based sorting).
7.2 插入排序
7.2.1 算法
- 插入排序(insertion sort):插入排序由
趟(pass)排序组成。对于 P = 1 趟到 P = N - 1 趟,插入排序保证从位置 0 到 位置 P 上的元素为已排序状态。插入排序便是利用了这样一个事实。
如下例:输入数据为 34, 8, 64, 51, 32, 21. 有 9 个逆序 (34,8), (34,32), (34,21), (64,51), (64,32), (64,21), (51,32), (51,21), (32,21).
void InsertionSort( ElementType A[], int N){
int j, P;
ElementType Tmp;
for( P = 1; P < N; P++){
Tmp = A[P];
for( j = P; j > 0 && A[j-1] > Tmp; j--)
A[j] = A[j-1];
A[j] = Tmp;
}
}
7.2.2 插入排序的分析
插入排序的时间复杂度为
7.3 一些简单排序算法的下界
逆序(inversion):指数组中具有性质 且 的序偶 .
对于插入排序算法而言,假设输入数据中存在逆序对个数为 ,那么时间复杂度为 .
定理 1:
N 个互异数的数组的平均逆序数是 .
证明:对于任意的数的表 ,考虑其反序表 . 上例中的反序表是 21, 32, 51, 64, 8, 34. 考虑该表中任意两个数的序偶 (x, y) 且 y > x. 显然,恰是 和 中的一个,该序偶对应一个逆序。在表 和它的反序表 中这样的序偶的总个数为 . 因此,平均逆序数为 .定理 2:
通过交换相邻元素进行排序的任何算法平均需要 时间。
证明:N 个互异数的数组的平均逆序数是 ,每次交换只减少一个逆序,因此需要 次交换。
7.4 希尔排序
7.4.1 算法
- 希尔排序(Shellsort):它是冲破二次时间屏障的第一批算法之一。又称为 缩小增量排序(diminishing increment sort) ,它通过比较相距一定间隔的元素来工作,各趟比较所用的距离随着算法的进行而减小,直到只比较相邻怨怒是的最后一趟排序为止。
- 增量序列(increment sequence):希尔排序使用的一个序列, .
-
排序 (
) :对于任意一个
都有
.
Shell建议的序列: 和 . - 希尔排序重要的一个性质:
一个 排序后的文件将保持 排序性。
举个例子:
//使用希尔增量的希尔排序
void ShellSort( ElementType A[], int N){
int i, j ,Increment;
ElementType Tmp;
for( Increment = N / 2; Increment > 0; Increment /= 2){
for( i = Increment; i < N; i++){
Tmp = A[i];
for( j = i; j >= Increment; j -= Increment)
if( Tmp < A[ j - Increment])
A[j] = A[ j - Increment];
else
break;
A[j] = Tmp;
}
}
}
7.4.2 希尔排序的最坏情形分析
- 定理1:
使用希尔增量时希尔排序的最坏运行时间为 .
证明: - 首先构造一个坏情形来证明下界,我们选择
是 2 的幂,这使得除最后一个增量为 1 外所有的增量都是偶数。现在,我们给出一个数组 InputData 作为输入,它的偶数位置上有 N/2 个同是最大的数,在奇数位置上有 N/2 个同为最小的数。由于除最后一个增量外的增量都是偶数,因此,当我们进行最后一趟排序之前, N/2 个最大的元素依旧处在偶数位置上, N/2 个最小的元素依旧处在奇数位置上。于是,在最后一趟排序开始之前的第 i 个最小的数 ( i <= N/2 ) 在位置 2i - 1 处。将第 i 个元素恢复到其正确的位置上就需要在数组中移动 i - 1 个间隔。这样,仅仅将 N/2 个最小元素放置到正确的位置上需要
的时间。如下例:
证明时间上界为 ,带有增量 的一趟排序由 个关于 个元素插入排序而成的。由于插入排序是二次的,因此一趟排序总的开销是 . 对所有各趟排序求和便可以得到总的时间复杂度为 . 这些增量形成一个几何级数,其公比为 2 ,而该级数中最大项是 ,因此,我们得到上界 .
原命题的得证。Hibbard增量: , 相邻的增量之间没有公因子.
定理1:
使用Hibbard增量时希尔排序的最坏运行时间为 .
证明略.Sedegewick增量:其中最好的序列是 $ 9 * 4^i
- 9 * 2^i + 1 4^i - 3 * 2^i + 1 $ ,最好的序列是 { 1, 5, 19, 41, 109, …}
7.5 堆排序
7.5.1 算法
- 堆排序(heapsort)
建立 个元素的二叉堆,花费的时间为 .然后我们执行 次 DeleteMin 操作。按照顺序,最小的元素先离开堆。通过将这些元素记录到第二个数组中再将数组拷贝回来,我们得到了 个元素的排序。由于每个 DeleteMin 操作花费的时间为 ,因此总的运行时间便是 .
这个算法的主要问题在于它额外使用了一个数组。因此存储需求增加了一倍。在某些例子中这可能是个问题。注意,附加的数组消耗的时间为 , 这影响并不显著。问题在于空间。 - 如何避免这个问题?
我们直到这样一个事实,在每次 DeleteMin 之后,堆便虽小了 1 . 因此,位于堆最后的一个单元正好可以放置刚刚被删去的元素。
假设我们由一个堆,它含有 6 个元素。 第一次 DeleteMin 产生 .现在该堆只剩下五个元素,因此我们可以把 放到位置 6 上。下一次 DeleteMin 产生 ,现在堆只剩下四个元素,我们可以把 放到位置 5 处。使用这种策略,在最后一次 DeleteMin 操作后,该数组便以递减的顺序包含这些元素。 - (max)堆
如果我们想要这些元素排成更典型的递增顺序,那么我们可以改变序的特性使得父节点的关键字的值大于儿子节点的关键字的值。这便是(max)堆.
for example, 输入序列 31, 41, 59, 26, 53, 58, 97.第一步先以线性时间建立一个堆。如下图:
然后,通过将堆中最后一个元素和第一个元素交换,缩减堆的大小并进行下滤,来执行 次 DeleteMax 操作。如下图,为第一次执行 DeleteMax 之后的堆。
从图中可以看出来,堆中的最后一个元素是 31 ; 堆数组中放置 97 的一部分从技术上而言已经不在属于该堆。此后 5 次
DeleteMax 操作之后,该堆实际上只有一个元素,而堆数组中留下的元素呈现出来的将是最后的排序。
注意:不想二叉堆,当时数据是在数组下从下标 1 处开始的,而此处堆排序的数组包含位置 0 处的数据。因此,这时的程序与二叉堆的代码稍微有所不同。
#define LeftChild(i) ( 2 * i + 1)
//注意下标从 0 开始 因此 +1
void PercDown( ElementType A[], int i, int N){
int Child;
ElementType Tmp;
for( Tmp = A[i]; LeftChild(i) < N; i = Child){
Child = LeftChild;
if( Child != N - 1 && A[Child + 1] > A[Child])
Child++;
if( Tmp < A[Child])
A[i] = A[Child];
else
break;
}
A[i] = Tmp;
}
void Heapsort( ElementType A[], , int N){
int i;
//BuildHeap
for( i = N / 2; i >= 0; i--)
PercDown( A, i, N);
//DeleteMax
for( i = N - 1; i > 0; i--){
Swap( &A[ 0], &A[ i]);
PercDown( A, 0, i);
}
}
7.5.2 堆排序的分析
- 定理:对
个互异项的随机排序进行堆排序,所用的平均次数为
.
证明略
7.6 归并排序
7.6.1 算法
- 归并排序(mergesort):以
最坏运行时间运行,而所使用的比较次数几乎是最优的。它是一个递归算法很好的实例。
该算法的基本操作和合并两个已经排序好的表。 过程
因为这两个表是已经排序好的,所以若将输出放到第三个表中时则该算法可以通过对输入数据一趟排序来完成。
基本的合并算法是取两个输入数组 A 和 B,一个输出数组 C ,以及三个计数器 Aptr,Bptr,Cptr.它们初始置于对应数组的开始端。A[Aptr] 和 B[Bptr] 中的较小者将被拷贝到 C 中的下一个位置,相关的计数器则向前推进一步。当两个输入表有一个用完的时候,另一个剩余部分拷贝到 C 中。
举个例子:
合并两个已经排序好的表的时间很明显是线性的,因为最多进行了 次比较,其中 是元素的总的个数。
void MSort( ElementType A[], ElementType TmpArry[], int Left, int Right){
int Center;
if( Left < Right){
Center = (Left + Right) / 2;
MSort( A, TmpArry, Left, Center);
MSort( A, TmpArry, Center + 1, Right);
Merge( A, TmpArry, Left, Center + 1, Right);
}
}
void Mergesort( ElementType A[], int N){
ElementType *TmpArry;
TmpArry = malloc( N * sizeof( ElementType));
if( TmpArry != NULL){
MSort( A, TmpArry, 0, N - 1);
free( TmpArry);
}
else
FatalError(" NO SPACE FOR TMP ARRAY !!");
}
void Merge( ElementType A[], ElementType TmpArry[], int Lpos, int Rpos, int RightEnd){
int i, LeftEnd, NumElements, TmpPos;
LeftEnd = Rpos - 1;
TmpPos = Lpos;
NumElements = RightEnd - Lpos + 1;
while( Lpos <= LeftEnd && Rpos < RightEnd)
if( A[Lpos] <= A[Rpos])
TmpArry[ TmpPos] = A[Lpos++];
else
TmpArry[ TmpPos] = A[Rpos++];
while( Lpos <= LeftEnd)
TmpArry[ TmpPos] = A[Lpos++];
while( Rpos <= RightEnd)
TmpArry[ TmpPos] = A[Rpos++];
for( i = 0; i < NumElements; i++, RightEnd--)
A[RightEnd] = TmpArry[RightEnd];
}
7.6.2 归并算法的分析
考虑到归并算法是一种递归算法,我们必须给运行时间写出一个递归关系。
- 假设
是 2 的幂,从而我们总是可以将它分裂成均为偶数的两个部分。对于
,归并排序所用的时间是常数,我们将它标记为 1 .否则,对
个数归并排序时间等于完成两个大小为
的递归排序所需要的时间再加上合并的时间,它是线性的。下列方程给出准确的表示:
这是一个标准的递归方程。求解方法很多,例如 叠缩(telescoping)求和,等等。这里不再赘述。 - 最终得到:
7.7 快速排序
- 快速排序(quicksort):在实践中最快的排序算法,它的平均运行时间是 .它采用了非常精炼和高度优化的内部循环。它最坏的性能为 , 但是稍加努力就可以避免。它像归并排序一样也是一种递归算法。
快速排序的四个步骤:
- 如果数组 中的元素的个数是 0 或者 1 ,则返回;
- 取 中任意一个元素 , 称之为枢纽元(pivot);
- 将 ( 中的其他元素 ) 分成两个不相交的集合 和 .
- 返回
之后继续
.
由于对那些等于枢纽元的元素的处理,步骤 3 处的分割描述不唯一。
for example:
问题:同样分割成两个子问题,为什么快速排序比归并排序要快?答案是:第三步骤中的分割选择的位置适当并且十分有效,它的高效弥补了大小不等的队规调用的缺点甚至还有所超出。
7.7.1 选择枢纽元
虽然选择任何一个元素当枢纽元都可以完成任务,但是有些选择显然更优。
一种错误的方法
不要使用以下两种方法- 通常,一些初学者会选择第一个元素充当枢纽元。
在输入是随机的情况下,这么做是可以接受的。
但是如果输入是预排序或者反序,那么这样的枢纽元会造成一个劣质的分割,因为所有的元素不是被划入 和 . 更有甚者,这种情况可能发生在所有的递归调用中。实际上,如果第一个元素作为枢纽元并且输入时预先排序的,那么排序需要的时间是二次的,可是实际上什么事情也没干,这是相当尴尬的场景。然而,预先排序的输入是一种非常常见的情况。
因此选择第一个元素当枢纽元确实很糟糕,应当放弃这种想法。 - 选择前两个互异的关键字中的较大者作为枢纽元,这和只选择第一个元素作为枢纽元有相同的害处。
- 通常,一些初学者会选择第一个元素充当枢纽元。
一种安全的做法
随机选取枢纽元。一般而言这是很安全的,除非随机数生成器有问题,因为随机的枢纽元不可能总在接连不断地产生劣质的分割。另一方面,随机数的生成一般而言是非常昂贵得到,根本无法减少算法其余部分的平均时间。三数中值分割法(Median-of-Three Partitioning)
一组 个数的中止是第 个最大的数。枢纽元最好的选择是选择数组的中值。不幸的是,这很难算出,并且明显减慢了快排的速度。这样的中值的估计量可以通过随机选择三个元素并使用它们的中值作为枢纽元得到。事实上,随机性并没有多大的帮助。因此,一般的做法是使用左端、右端和中心位置上的三个元素的中值作为枢纽元。例子,输入{8, 1, 4, 9, 6, 3, 5, 2, 7, 0},其枢纽元便是数值 6 .
7.7.2 分割策略
暂时假设所有的元素互异,依旧以上述例子为例程,输入{8, 1, 4, 9, 6, 3, 5, 2, 7, 0},枢纽元为 6 .
- 第一步通过将枢纽元和最后的元素交换使得枢纽元离开将要被分割的位置的数据段。得到下图
其中, 从第一个元素开始而 从倒数第二个元素开始。 - 第二步:在分割阶段,最重要的事是把较小的元素移动到左边,较大的元素移动到数组的右边,当然,大与小是相对于枢纽元来说的。
当 在 的左边时,我们将 向右移动,移过那些小于枢纽元的元素,并将 左移,移动过那些大于枢纽元的元素。当 和 停止时, 指向一个大元素而 指向一个小元素。 如果 在 的左边,那么这两个元素互换,其效果是把一个大元素移向右边而把一个小元素移动向左边。在上述例子中,我们有以下情况。
然后交换,重复该过程直到 和 彼此交错为止.
此时, 和 已经交错,因此不再交换。
最后一步:将枢纽元与 所指向的元素交换。在这一步骤中,当枢纽元与 所指向的元素交换时,我们直到位置 的每一个元素都必然是小于枢纽元的元素。类似我们知道位置 的元素都是大于枢纽元的元素。
细节问题:如果元素不是互异的,我们不得不考虑如何处理那些数值上等于枢纽元的关键字。问题在于当 遇到第一题个等于枢纽元的关键字时,是否应当停止?直观的来看, 应当做相同的工作,否则便会出现偏向某一方的情况。例如,如果 停止而 不停止,那么所有等于枢纽元的关键字将被分到 中。
为了搞清楚应该怎么办,我们考虑数组中所有的关键字都相等的情况。- 如果 和 都停止,那么在相等的元素之间将会有很多次的交换,看似没什么意义。但是其正面效果则是 和 将在中间交错,因此在枢纽元被替代时候,这种分割建立了两个几乎相等的子数组。此时的运行时间为 .
- 如果 和 都不停止,那么就应该有相应的程序防止 和 越出数组的界限,不进行交换的操作。虽然这样做似乎不错,但是正确的实现方法却是把枢纽元交换到 最后到过的位置,这个位置是倒数第二个位置(或者是最后一个位置),这样的做法会产生两个非常不均衡的子数组。如果所有的关键字都是相同的,那么运行时间将会是 , 对于预排序的输入而言,这相当于什么都没干,效果与使用第一个数组元素作为枢纽元相同。
- 于是我们发现,进行不必要的交换建立两个均衡的子数组要比蛮干冒险得到两个不均衡的子数组要好。因此,如果 和 遇到等于枢纽元的关键字,那么我们就让 和 都停止。对于这种输入,实际上是不花费二次时间的四种可能性中唯一的一种可能。
7.7.3 小数组
对于很小的数组 ,快速排序并不如插入排序号,不仅如此,因为快速排序是递归实现的,所以类似的情形经常发生。解决方法是对于小数组不递归地使用快速排序,而代之以使用插入排序等。
7.7.4 实际的快速排序的例程
//驱动程序
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[Center];
}
#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;
}
//跳出for循环时,i 与 j 交错
Swap( &A[i], &A[Right - 1])
Qsort( A, Left, i - 1);
}
else
InsertionSort( A + Left,Right - Left + 1)
}
7.7.5 快速排序分析
- 最坏情况
得到 - 最好情况
正好处于中间,得到 - 平均情况分析
这是最困难的一部分,对于平均情况,我们假定对于 ,每一个文件的大小都是等可能的,因此每个大小均有概率 . 这个假设对于我们这里的枢纽元的选择和分割方法实际上是合理的,不过,对于某些其他情况并不合理。那些不包含子文件随机性的分割方法不能使用这种分析方法。
原式转换为
解得
7.7.6 选择的线性期望时间算法
可以通过修改快速排序来解决选择问题(selection problem)
- 快速选择(quickselect):查找集合
中第
个最小元的算法。
- 如果数组 ,那么 , 并将唯一的元素作为答案返回。如果使用小数组的截止(cutoff)方法且 ,则将 排序并返回第 个最小元。
- 选择一个枢纽元 .
- 将 ( 中的其他元素 ) 分成两个不相交的集合 和 .
- 如果
,那么第
个最小元必定处于
中,在这种情况下,返回 quickselect(
). 如果
,那么枢纽元必定就是第
个元素,将它作为答案返回。否则,第
个最小元就处于
中,它是
中第
个最小元,我们需要调用并返回quickselect(
).
通过上述分析知道,快速选择只是用了一次递归调用而不是两次,最坏的情形和快速排序的相同,就是 .
#define Cutoff (3)
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] < Pviot) {}
while( A[--j] > Pviot) {}
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);
}
7.8 大型数据结构的排序
关于排序的全部讨论,我们是建立在假设被排序的元素是一些简单的整数。但是,在生活中,我们常常需要通过某个关键字对大型数据结构进行排序,例如工资名单的记录、税务信息的组成等等。
对于所有的算法而言,基本的操作就是交换,不过这里交换两个数组可能是非常昂贵的操作,因此结构实际上可能会很大。在这种情况下,实际的解法是让输入数组包含有只想结构的指针,我们通过比较指针指向的关键字,并在必要的时候进行交换指针来实现排序。这样的操作我们称之为间接排序(indirect sorting).
7.9 排序的一般下界
虽然我们得到了一些
的排序算法,但是,我们不清楚是否可以做的更好。
在这一节中,我们将会证明,然和使用比较的算法在最坏的情形下需要
次比较( 从而有
的运行时间 ),因此归并排序的堆排序在一个常数因子的范围内是最优的。
该证明可以进一步证明即使在最坏的情况下,只用到比较的人已排徐算法都需要进行
比较,这意味着快速排序在相差一个常数因子的情况下平均是最优的。
7.9.1 决策树
- 决策树(decision tree):一棵二叉树,每个节点表示在元素之间的一组可能的排序,它与已经进行比较的已知。比较的结构是树的边。用来证明下界的抽象概念。
如上图,该决策树表示了将三个元素 a b c 排序的一种算法。这种算法的初始状态处于根处(我们可以互换地使用术语 “状态” 和 “节点”),没有进行比较,因此所有的顺序都是合法的。这个特定的算法进行的第一次比较是 a 与 b . 两种比较的结果导致了两种可能的状态。如果 a < b , 那么只有三种可能性被保留,不同的算法可能有不同的决策树。若 a > c , 那么算法进入状态 5 .由于只存在一种顺序,因此算法可以停止并报告它已经完成了排序。若 a < c , 则算法尚不可中止,因为存在两种可能的顺序,此时无法确定那种顺序是正确的,在这种情况下,算法还将再需要一次比较。
凡是只是用比较排序的每一种算法都可以使用决策树表示。当然,只有输入数据是非常少的情况画出决策树才是可能的。由排序算法所使用的比较次数等于最深的树叶的深度。在我们的例子中,该算法再最坏的情况下进行了三次比较,所使用的比较的平均次数等于树叶的平均深度。由于决策树很大,因此必然存在一些长的路径。为了证明下届,需要证明某些基本的树的性质。引理 1 :
令 是深度为 的二叉树,则 最多有 个树叶。
证明是显而易见的,我们是用数学归纳法。 时,最多有一个树叶,基准情况为真。 时,存在一个根,它不可能是树叶,其左子树和右子树中的每一个深度最多是 . 由归纳假设,每一棵子数最多有 个树叶,因此总数最多有 个树叶。则原命题得证。引理 2 :
具有 片树叶的二叉树的深度至少是 .定理 1 :
只使用元素间比较的任何排序算法再最坏的情况下至少需要 次比较。
证明:对 个元素进行排序的决策树必然有 个树叶,依据引理 2 可以得证。定理 2 :
只使用元素间比较的任何排序算法都需要 进行 次比较.
证明:依据定理 1 我们知道,需要 次比较。
以上这种类型的下界论断,当用于证明最坏情形的结果的时候,有时称为信息-理论(information-theoretci)下界。一般定理说的是,如果存在 种不同的情况需要去纷纷,而问题是 YES/NO 的形式,那么通过任何算法求解该问题在某种情况下总需要 个问题。对于任何基于比较的排序算法的平均运行时间,证明类似的结果是可能的。
7.10 桶式排序
一个非常重要的重要的事情,在某些特殊情况下以线性时间进行排序是有可能的。
- 桶式排序(bucket sort):为了使桶式排序能够正常工作,必须要有一些额外的信息。输入数据 必须是小于 的正整数组成。如果是这种情况,那么算法很简单:使用一个大小为 称为 的数组,它被初始化全部为 0 ,于是 便有了 个单元(或者称为 桶),这些桶的初始化原为空。当读到 时, . 输入数据读入后,直接打印 数组。该算法用时为
- 这个算法看似打破了 排序算法的一般下界 ,但是实际上并没有,因为它使用了比简单比较更为强大的操作。通过使适当的桶增值,算法在单位时间上实质上执行了一个 M-路 比较。这类与可扩散列上的策略相似。
- 该算法的提出了用于证明下界模型的合理性问题。这个模型实际上是一个强模型,因为通常的排序算法并不能对于它可能预见到的输入类型做出假设,但是必须仅仅基于排序信息做出一些策略。
7.11 外部排序
在上述讨论的排序算法,我们都是假定输入数据可以装入内存。但是,在一些应用中,输入的数据量过于庞大而无法一次性装入内存中,因此我们要引入外部排序算法(external sorting).
7.11.1 为什么需要新的算法
大部分内部排序算法都用到内存可直接寻址的事实,
例如
- 希尔排序使用一个时间单位比较元素
和
.
- 堆排序用一个事件单位比较元素
和
.
- 使用三数中值分割法的快速排序在常数个时间单位比较
.
但是,一旦输入数据处于磁带上,那么所有的这些操作就是去了它们的效率,因此磁带上的元素只能被顺序访问。即时数据在一张磁盘上,由于转动磁盘和移动磁头所需要的延迟,仍然存在实际上的效率损失。
为了解外部排序究竟有多慢,可以建立一个大的随机文件,但是不能太大以至于装不进内存。将该文件读入并用一种有效的算法将它排序。将该输入数据进行排序花费的时间与将其读入花费的时间相比无足轻重,尽管排序是 操作而读入数据花费 时间。
7.11.2 外部排序模型
各式各样的海量储存装置使得外部排序比内部排序对设备的依赖性要严重的多。我们考虑一些算法在磁带上工作,而磁带可能是最受限制的储存媒体。由于访问磁带上一个元素需要把磁带转动到正确的位置,因此磁带必须要有两个方向上连续的顺序才能够被有效地访问。
我们将假设至少有三个磁带驱动器进行排序工作。我们需要两个驱动器执行有效的排序,而第三个驱动器进行简化工作。但是如果只有一个磁带驱动器可用,那么我们不得不说,任何排序都需要 次操作.
7.11.3 简单算法
基本的外部排序算法使用Merge例程。
- 假设我们有四盘磁带,
它们是两盘输入磁带和两盘输出磁带。假设数据最初位于
上,并设内存一次可以容纳和排序
个记录。一种自然的做法是第一部从输入磁带一次读入
个记录,在内部将这些记录排序,然后再把这些排过序的记录交替地卸载
上。我们将把每组排序过的记录叫做 顺串(run) 。做完这些之后,我们倒回所有的磁带。
作为例题,假设我们的输入是 {81, 94, 11, 96, 12, 35, 17, 99, 28, 58, 41, 75, 15 }.
那么有
81 | 94 | 11 | 96 | 12 | 35 | 17 | 99 | 28 | 58 | 41 | 75 | 15 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
如果 ,那么在顺串构造之后,磁盘将包含下列数据
11 81 94 | 17 28 99 | 15 | |
12 35 96 | 41 58 75 |
现在 包含一组顺串,我们将每个磁带的第一个顺串取出来并获奖二者合并,把它们合并到 上,得到一个二倍长的顺串。
- 然后,我们再从每盘磁带下去取出下一个顺串,合并,并将结果写到 上。继续这个过程,交替使用 和 ,直到 和 为空,或者剩下一个顺串。对于后者,我们把剩下的顺串拷贝到适当的顺串上。将四个磁盘倒回,并重复相同的步骤,这一次用两盘 a 磁带作为输入,两盘 b 磁盘作为输出,得到一些 的顺串,我们继续这个过程直到得到长为 的一个顺串。
11 12 35 81 94 96 | 15 | |
---|---|---|
17 28 41 58 75 99 | ||
11 12 17 28 35 41 58 75 81 94 96 99 | |
15 |
11 12 15 17 28 35 41 58 75 81 94 96 99 | |
---|---|
- 该算法需要 趟工作,外加一趟构造初始的顺串。例如,如果我们有 1000 万个记录,每个记录 128 个字节, 并由 4 兆字节的内存,那么第一趟建立 320 个顺串。此时我们还需要 9 趟才能完成排序。
7.11.4 多路合并
如果我们有额外的磁带,那么我们可以减少将输入数据排序所需要的趟数,通过将基本的 2-路 合并扩充为 k-路 合并就能做到这一点。
- 两个顺串的合并操作通过将每一个输入磁带转到每一个顺串的开头来完成的,然后,找到较小的元素,把它放到输出磁带上,并将相应的输入磁带向前推进。如果有 k 盘输入 磁带,那么这种方法将以相同的方式工作,唯一的求别在于,它发现 k 个元素中最小的元素的过程稍微复杂。我们可以通过使用优先队列的方法找到最小元。为了得到下一个写到磁带上的元素,我们进行一次 DeleteMin 操作。将相应的磁带向前推进,如果在输入磁带上的顺串尚且没有完成,那么我们将新元素插入到优先队列中。
作为例题,假设我们的输入是 {81, 94, 11, 96, 12, 35, 17, 99, 28, 58, 41, 75, 15 }.
那么有,
11 81 94 | 41 58 75 | |
12 35 96 | 15 | |
17 28 99 |
- 随后,我们需要进行两趟 3-路合并来完成该排序。
11 12 17 28 35 58 94 96 99 | ||
---|---|---|
15 41 58 75 | ||
11 12 15 17 28 35 41 58 75 81 94 96 99 | ||
---|---|---|
- 在初始顺串构造阶段之后,使用 k-路合并所需要的趟数为 ,因为每趟的顺串达到 k 倍大小。
7.11.5多相合并
上一节讨论的 k-路 合并方法需要使用 2k 盘磁带,这对于某些应用非常不方便。只使用 k + 1 盘磁带也有可能完成排序工作。
- 举例说明,如何只用三盘磁带来完成 2-路合并。
假设有三盘磁带 ,在 上有一个输入文件,它将产生 34 个顺串。 - 一种选择是在 和 的每一个磁盘中放入 17 个顺串。由于所有的顺串都在一盘磁带上,因此我们现在必须把其中的一些顺串放到 上来进行另外一次的合并。执行合并的逻辑方式是将前 8 个顺串从 中拷贝到 并进行合并。这样的效果是对于我们所作的每一趟合并有附加了额外的半躺工作。
另外一种选择是将 34 个顺串不均衡的分成了两份。假设我们把 21 个顺串放到 上 13 个顺串放到 上。然后,在 用完之前将 13 个顺串合并到 上。此时,我们可以倒回磁带 和 , 然后将具有 13 个顺串的 和 8 个顺串的 合并到 上。此时,我们合并 8 个顺串直到 用完为止,这样,在 上将留下 5 个顺串而在 上则有 8 个顺串。 然后我们再合并 和 等等。如下图
顺串最初的分配有很大的关系。例如,若 22 个顺串放置到 上,12 个放到 上,则第一趟合并后哦我们得到 上的 12 个顺串 和 上的 10 个顺串。再另外一次合并之后, 上有 10 个顺串而 上只有 2 个顺串。此时,进展的速度慢了下来,因为 用完之前我们只能合并两组顺串。这时 有 8 个顺串而 有两个顺串。同样,我们只能合并量组顺串,结果 有 6 个顺串且 有 2 个顺串。在经过三趟合并之后, 还有两个顺串而其余磁带已经没有任何内容。我们必须将一个顺串拷贝到另一个磁盘上,然后结束合并。
7.11.6 替换选择
最后我们需要考虑的是顺串的构造。迄今为止我们已经用到的策略是所谓的最简可能:读入尽可能多的记录并将它们排序,然后把结果写到磁盘上。这看起来像是可能的最佳处理,知道实现只要一个记录被写道输出磁带上,它使用的内存就可以被另外的记录使用。如果输入才带上的下一个记录比我们刚刚输入的记录大,那么它就可以放到这个顺串中。
利用这个想法,我们可以给出一个产生顺串的算法,我们称之为替换原则(repalcement selection).
替换原则
开始, 个记录被读入内存中并被放到一个优先队列中,我们执行一次 DeleteMin , 把最小的记录写道输出磁带上,然后再从输入磁带读入下一个记录。如果它比刚刚写出的记录大,那么我们可以把它加到优先队列中,否则,不能把它放入当前的顺串。由于优先队列少了一个元素,因此我们可以把新元素存入到优先队列的死区(dead space)中,直到顺串完成构建,而该新元素用于下一个顺串。将一个元素存入死区的做法类似于在堆排序中的做法。我们继续这样的步骤直到优先队列的大小为零,此时该顺串构建完成。我们使用死区中的所有元素通过建立一个新的优先队列开始构建一个新的顺串。我的微信公众号