第 8 章 排序
8.1 排序的基本概念
8.1.1 排序的定义
排序,就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。为了查找方便,通常希望计算机中的表是按关键字有序的。排序的确切定义如下:
输入:n 个记录
输出:输入序列的一个重排
算法的稳定性。若待排序表中有两个元素
注意:对于不稳定的排序算法,只需举出一组关键字的实例,说明它的不稳定性即可。
在排序过程中,根据数据元素是否完全在内存中,可将排序算法分为两类:① 内部排序,是指在排序期间元素全部存放在内存中的排序;② 外部排序,是指在排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内、外存之间移动的排序。
一般情况下,内部排序算法在执行过程中都要进行两种操作:比较和移动。通过比较两个关键字的大小,确定对应元素的前后关系,然后通过移动元素以达到有序。当然,并非所有的内部排序算法都要基于比较操作,事实上,基数排序就不基于比较。
每种排序算法都有各自优缺点,适合在不同环境下使用,就其全面性能而言,很难提出一种被认为是最好算法通常可以将排序算法分为插入排序、交换排序、选择排序、归并排序和基数排序五大类,后面几节会分别进行详细介绍。内部排序算法的性能取决于算法的时间复杂度和空间复杂度,而时间复杂度一般是由比较和移动的次数决定的。
8.2 插入排序
插入排序是一种简单直观的排序方法,其基本思想是每次将一个待排序的记录按其关键字大小插入前面已排好序的子序列,直到全部记录插入完成。由插入排序的思想可以引申出三个重要的排序算法:直接插入排序、折半插入排序和希尔排序。
8.2.1 直接插入排序
根据上面的插入排序思想,不难得出一种最简单也最直接插入排序算法。假设在排序过程中,待排序表
要将元素
1)查找出
2)将
3)将
为了实现对
下面是直接插入排序的代码,其中再次用到了我们前面提到的 “哨兵”(作用相同)。
void InsertSort(ElemType A[], int n){
int i, j;
for(i=2; i<=n; i++) //依次将A[2]~A[n]插入前面已排序序列
if(A[i]<A[i-1]){ //若A[i]关键码小于其前驱,将A[i]插入有序表
A[0] = A[i]; //复制为哨兵,A[0]不存放元素
for(j=i-1; A[0]<A[j]; --j) //从后往前查找待插入位置
A[j+1] = A[j]; //向后挪位
A[j+1] = A[0]; //复制到插入位置
}
}假定初始序列为

直接插入排序算法的性能分析如下:
空间效率:仅使用了常数个辅助单元,因而空间复杂度为
时间效率:在排序过程中,向有序子表中逐个地插入元素的操作进行了 n-1 趟,每趟操作都分为比较关键字和移动元素,而比较次数和移动次数取决于待排序表的初始状态。
在最好情况下,表中元素已经有序,此时每插入一个元素,都只需比较一次而不用移动元素,因而时间复杂度为
最坏情况下,表中元素顺序刚好与排序结果中的元素顺序相反(逆序),总的比较次数达到最大,总移动次数也达到最大。
平均情况下,考虑待排序表中元素是随机的此时可以取上述最好与最坏情况的平均值作为平均情况下的时间复杂度,总的比较次数与总的移动次数均约为
因此,直接插入排序算法的时间复杂度为
稳定性:由于每次插入元素时总是从后向前比较再移动,所以不会出现相同元素相对位置发生变化的情况,即直接插入排序是一个稳定的排序方法。
适用性:直接插入排序算法适用于顺序存储和链式存储的线性表。为链式存储时,可以从前往后查找指定元素的位置。
注意:大部分排序算法都仅适用于顺序存储的线性表。
8.2.2 折半插入排序
从直接插入排序算法中,不难看出每趟插入的过程中都进行两项工作:① 从前面的有序子表中查找出待插入元素应该被插入的位置;② 给插入位置腾出空间,将待插入元素复制到表中的插入位置。注意到该算法中,总是边比较移动元素。下面将比较和移动操作分离即先折半查找出元素的待插入位置,然后统一地移动待插入位置之后的所有元素。当排序表为顺序表时,可以对直接插入排序算法做如下改进:由于是顺序存储的线性表所以查找有序子表时可以用折半查找来实现。确定待插入位置后,就可统一地向后移动元素。算法代码如下:
void InsertSort(ElemType A[], int n){
int i, j, low, high, mid;
for(i=2; i<=n; i++){ //依次将A[2]~A[n]插入前面的已排序序列
A[0] = A[i]; //将A[i]暂存到A[0]
low = 1; high = i-1; //设置折半查找的范围
while(low <= high){ //折半查找(默认递增有序)
mid = (low+high)/2; //取中间点
if(A[mid] > A[0])
high = mid - 1; //查找左半子表
else
low = mid + 1; //查找右半子表
}
for(j=i-1; j>=high+1; --j)
A[j+1] = A[j]; //统一后移元素,空出插入位置
A[high+1] = A[0]; //插入操作
}
}从上述算法中,不难看出折半插入排序仅减少了比较元素次数,约为
8.2.3 希尔排序
从前面的分析可知,直接插入排序算法的时间复杂度为
希尔排序基本思想是:先将待排序表分割成若干形如
希尔排序的过程如下:先取一个小于 n 的步长

希尔排序算法的代码如下:
void ShellSort(ElemType A[], int n){
//A[0]只是暂存单元,不是哨兵,当j<=0时,插入位置已到
for(dk=n/2; dk>=1; dk=dk/2) //步长变化
for(i=dk+1; i<=n; ++i)
if(A[i] < A[i-dk]){ //需将A[i]插入有序增量子表
A[0] = A[i]; //暂存在A[0]
for(j=i-dk; j>0&&A[0]<A[j]; j-=dk)
A[j+dk] = A[j]; //记录后移,查找插入的位置
A[j+dk] = A[0]; //插入
}//if
}希尔排序算法的性能分析如下:
空间效率:仅使用了常数个辅助单元,因而空间复杂度为
时间效率:由于希尔排序的时间复杂度依赖于增量序列的函数,这涉及数学上尚未解决的难题,所以其时间复杂度分析比较困难。当 n 在某个特定范围时,希尔排序的时间复杂度约为
稳定性:当相同关键字的记录被划分到不同的子表时,可能会改变它们之间的相对次序,因此希尔排序是一种不稳定排序方法。例如,图 8.2 中 49 与
适用性:希尔排序算法仅适用于线性表为顺序存储的情况。
8.3 交换排序
所谓交换是指根据序列中两个元素关键字比较结果来对换这两个记录在序列中位置。基于交换的排序算法很多,本书主要介绍冒泡排序和快速排序,其中冒泡排序算法比较简单,一般不会单独考查,通常会重点考查快速排序算法的相关内容。
8.3.1 冒泡排序
冒泡排序基本思想是:从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即
图 8.3 所示为冒泡排序过程,第一趟冒泡时:

冒泡排序算法的代码如下:
void BubbleSort(ELemType A[], int n){
for(i=0; i<n-1; i++){
flag = false; //表示本趟冒泡是否发生交换的标志
for(j=n-1; j>i; j--) //一趟冒泡过程
if(A[j-1] > A[j]){ //若为逆序
swap(A[j-1], A[j]); //交换
flag = true;
}
if(flag == false) //本趟遍历后没有发生交换,说明表已经有序
return
}
}冒泡排序的性能分析如下:
空间效率:仅使用了常数个辅助单元,因而空间复杂度为
时间效率:当初始序列有序时,显然第一趟冒泡后 flag 依然为 false(本趟冒泡没有元素交换),从而直接跳出循环,比较次数为 n-1,移动次数为 0,从而最好情况下的时间复杂度为
从而,最坏情况下的时间复杂度为
稳定性:由于
注意:冒泡排序中所产生的有序子序列一定是全局有序的(不同于直接插入排序),也就是说,有序子序列中的所有元素的关键字一定小于或大于无序子序列中所有元素关键字,这样每趟排序都会将一个元素放置到其最终的位置上。
8.3.2 快速排序
快速排序的基本思想是基于分治法的:在待排序表
一趟快速排序的过程是一个交替搜索和交换的过程下面通过实例来介绍,附设两个指针 i 和 j,初值分别为 low 和 high,取第一个元素 49 为枢轴赋值到变量 pivot。
指针 j 从 high 往前搜索找到第一个小于枢轴的元素 27,将 27 交换到 i 所指位置。

指针 i 从 low 往后搜索找到第一个大于枢轴元素 65,将 65 交换到 j 所指位置。

指针 j 继续往前搜索找到小于枢轴元素 13,将 13 交换到 i 所指位置。

指针 i 继续往后搜索找到大于枢轴的元素 97,将 97 交换到 j 所指位置。

指针 j 继续往前搜索小于枢轴的元素,直至 i ==j。

此时,指针 i(== j)之前的元素均小于 49,指针 i 之后元素均大于等于 49,将 49 放在 i 所指位置即其最终位置,经过一趟划分,将原序列分割成了前后两个子序列。
按照同样的方法对各子序列进行快速排序,若待排序列中只有一个元素,显然已有序。
对算法最好理解方式是手动地模拟一遍这些算法。
假设划分算法已知,记为 Partition(),返回的是上述的 k,注意到
void QUickSort(ElemType A[], int low, int high){
if(low < high){ //递归跳出的条件
//partition()就是划分操作,将表A[low…high]划分为满足上述条件的两个子表
int pivotpos = Partition(A, low, high); //划分
QuickSort(A, low, pivotpos-1); //依次对两个子表进行递归排序
QuickSort(A, pivotpos+1, high);
}
}从上面的代码不难看出快速排序算法的关键在于划分操作,同时快速排序算法的性能也主要取决于划分操作的好坏。从快速排序算法提出至今,已有许多不同的划分操作版本,但考研所考查的快速排序的划分操作基本以严蔚敏的教材《数据结构》为主。假设每次总以当前表中第一个元素作为枢轴来对表进行划分,则将表中比枢轴大的元素向右移动,将比枢轴小的元素向左移动,使得一趟 partition() 操作后,表中的元素被枢轴值一分为二。代码如下:
int Partition(ElemType A[], int low, int high){//一趟划分
ElemType pivot = A[low]; //将当前表中第一个元素设为枢轴,对表进行划分
while(low < high){ //循环跳出条件
while(low<high && A[high]>=pivot) --high;
A[low] = A[high]; //将比枢轴小的元素移动到左端
while(low<high && A[low]<=pivot) ++low;
A[high] = A[low]; //将比枢轴大的元素移动到右端
}
A[low] = pivot; //枢轴元素存放到最终位置
return low; //返回存放枢轴的最终位置
}快速排序算法的性能分析如下:
空间效率:快速排序是递归的,需要借助一个递归工作栈来保存每层递归调用的必要信息,其容量应与递归调用的最大深度一致。最好情况下为
时间效率:快速排序的运行时间与划分是否对称有关,快速排序最坏情况发生在两个区域分别包含 n-1 个元素和 0 个元素时,这种最大限度的不对称性若发生在每层递归上,即对应于初始排序表基本有序或基本逆序时,就得到最坏情况下的时间复杂度为
有很多方法可以提高算法的效率:一种方法是尽量选取一个可以将数据中分的枢轴元素,如从序列的头尾及中间选取三个元素,再取这三个元素的中间值作为最终的枢轴元素;或者随机地从当前表中选取枢轴元素,这样做可使得最坏情况在实际排序中几乎不会发生。
在最理想的状态下,即 Partition() 可能做到最平衡的划分得到两个子问题的大小都不可能大于 n/2,在这种情况下,快速排序的运行速度将大大提升,此时,时间复杂度为
稳定性:在划分算法中若右端区间有两个关键字相同,且均小于基准值记录,则在交换到左端区间后,它们的相对位置会发生变化即快速排序是一种不稳定的排序方法。例如,表
注意:在快速排序算法中,并不产生有序子序列但每趟排序后会将枢轴(基准)元素放到其最终的位置上。
8.4 选择排序
选择排序的基本思想是:每一趟(如第 i 趟)在后面
8.4.1 简单选择排序
根据上面的选择排序的思想,可以很直观地得出简单选择排序算法思想:假设排序表为
简单选择排序算法的代码如下:
void SelectSort(ElemType A[], int n){
for(i=0; i<n-1; i++){ //一共进行n-1趟
min = i; //记录最小元素位置
for(j=i+1; j<n; j++) //在A[i...n-1]中选择最小的元素
if(A[j] < A[min]) min = j; //更新最小元素位置
if(min != i) swap(A[i], A[min]); //封装的swap()函数共移动元素3次
}
}简单选择排序算法的性能分析如下:
空间效率:仅使用常数个辅助单元,故空间效率为
时间效率:从上述伪码中不难看出,在简单选择排序过程中,元素移动的操作次数很少,不会超过
稳定性:在第 i 趟找到最小元素后,和第 i 个元素交换可能会导第 i 个元素与其含有相同关键字元素的相对位置发生改变。例如,表
8.4.2 堆排序
堆的定义如下,n 个关键字序列
①
②
可以将该一维数组视为一棵完全二叉树,满足条件 ① 的堆称为大根堆(大顶堆),大根堆的最大元素存放在根结点且其任一非根结点的值小于等于其双亲结点值。满足条件 ② 的堆称为小根堆(小顶堆),小根堆的定义刚好相反,根结点是最小元素。图 8.4 所示为一个大根堆。

堆排序的思路很简单:首先将存放在
堆排序的关键是构造初始堆。n 个结点的完全二叉树,最后一个结点是第
如图 8.5 所示,初始时调整

输出堆顶元素后,将堆的最后一个元素与堆顶元素交换此时堆的性质被破坏,需要向下进行筛选。将 09 和左右孩子的较大者 78 交换,交换后破坏了

下面是建立大根堆算法:
void BuildMaxHeap(ElemType A[], int len){
for(int i = len/2; i>0; i--) //从i=[n/2]~1,反复调整堆
HeadAdjust(A, i, len);
}
void HeadAdjust(ElemType A[], int k, int len){
//函数HeadAdjust将元素k为根的子树进行调整
A[0] = A[k]; //A[0]暂存子树的根结点
for(i=2*k; i<=len; i*=2){ //沿key较大的子结点向下筛选
if(i<len && A[i]<A[i+1])
i++; //取key较大的子结点的下标
if(A[0] >= A[i]) break; //筛选结束
else{
A[k] = A[i]; //将A[i]调整到双亲结点上
k = i; //修改k值,以便继续向下筛选
}
}
A[k] = A[0]; //被筛选结点的值放入最终位置
}调整的时间与树高有关,为
下面是堆排序算法:
void HeapSort(ELemType A[], int len){
BuildMaxHeap(A, len); //初始建堆
for(i=len; i>1; i--){ //n-1趟的交换和建堆过程
Swap(A[i], A[1]); //输出堆顶元素(和堆底元素交换)
HeadAdjust(A, 1, i-1); //调整,把剩余的i-1个元素整理成堆
}
}同时,堆也支持插入操作。对堆进行插入操作时,先将新结点放在堆的末端,再对这个新结点向上执行调整操作。大根堆的插入操作示例如图 8.7 所示。

堆排序适合关键字较多的情况。例如,在 1 亿个数中选出前 100 个最大值?首先使用一个大小为 100 的数组,读入前 100 个数,建立小顶堆,而后依次读入余下的数,若小于堆顶则舍弃,否则用该数取代堆顶并重新调整堆,待数据读取完毕,堆中 100 个数即为所求。
堆排序算法的性能分析如下:
空间效率:仅使用了常数个辅助单元,所以空间复杂度为
时间效率:建堆时间为
稳定性:进行筛选时,有可能把后面相同关键字的元素调整到前面,所以堆排序算法是一种不稳定排序方法。例如,表
8.5 归并排序和基数排序
8.5.1 归并排序
归并排序与上述基于交换、选择等排序的思想不一样,“归并“ 的含义是将两个或两个以上的有序表组合成一个新的有序表。假定待排序表含有 n 个记录,则可将其视为 n 个有序子表,每个子表长度为 1,然后两两归并,得到
图 8.8 所示为 2 路归并排序的一个例子,经过三趟归并后合并成了有序序列。

Merge() 的功能是将前后相邻的两个有序表归并为一个有序表。设两段有序表
ElemType *B = (ElemType *)malloc((n+1)*sizeof(ElemType));//辅助数组B
void Merge(ElemType A[], int low, int mid, int high){
//表A的两段A[low...high]和A[mid+1...high]各自有序,将它们合并成一个有序表
for(int k = low; k<high; k++)
B[k] = A[k]; //将A中所有元素复制到B中
for(i=low, j=mid+1,k=i; i<=mid&&j<=high;k++){
if(B[i] <= B[j]) //比较B的左右两段中的元素
A[k] = B[i++]; //将较小值复制到A中
else
A[k] = B[j++];
}//for
while(i <= mid) A[k++] = B[i++]; //若第一个表未检测完,复制
while(j <= high) A[k++] = B[j++]; //若第二个表未检测完,复制
}注意:上面的代码中,最后两个 while 循环只有一个会执行。
一趟归并排序的操作是,调用
递归形式的 2 路归并排序算法是基于分治的,其过程如下。
分解:将含有 n 个元素待排序分成各含 n/2 个元素的子表,采用 2 路归并排序算法对两个子表递归地进行排序。
合并:合并两个已排序子表得到排序结果。
void MergeSort(ElemType A[], int low, int high){
if(low < high){
int mid = (low+high)/2; //从中间划分两个子序列
MergeSort(A, low, mid); //对左侧子序列进行递归排序
MergeSort(A, mid+1, high); //对右侧子序列进行递归排序
Merge(A, low, mid, high); //归并
}
}2 路归并排序算法的性能分析如下:
空间效率:Merge() 操作中,辅助空间刚好为 n 个单元,所以算法的空间复杂度为
时间效率:每趟归并的时间复杂度为
稳定性:由于 Merge() 操作不会改变相同关键字记录的相对次序,所以 2 路归并排序算法是一种稳定的排序方法。
注意:一般而言,对于 N 个元素进行 k 路归并排序时,排序的趟数 m 满足
8.5.2 基数排序
基数排序是一种很特别的排序方法,它不基于比较和移动进行排序,而基于关键字各位的大小进行排序。基数排序是一种借助多关键字排序的思想对单逻辑关键字进行排序方法。
假设长度为 n 的线性表中每个结点
为实现多关键字排序通常有两种方法:第一种是**最高位优先(MSD)法,按关键字位权重递减依次逐层划分成若干更小的子序列,最后将所有子序列依次连接成一个有序序列。第二种是最低位优先(LSD)**法,按关键字权重递增依次进行排序,最后形成一个有序序列。
下面描述以 r 为基数的最低位优先基数排序过程,在排序过程中,使用 r 个队列
对
分配:开始时把
收集:把
通常采用链式基数排序,假设对如下 10 个记录进行排序:

每个关键字是 1000 以下的正整数,基数 r = 10,在排序过程中需要借助 10 个链队列,每个关键字由 3 位子关键字构成

第二趟分配用次低位子关键字

第三趟分配用最高位子关键字

基数排序算法的性能分析如下。
空间效率:一趟排序需要的辅助存储空间为 r(r 个队列:r 个队头指针和 r 个队尾指针),但以后的排序中会重复使用这些队列,所以基数排序的空间复杂度为
时间效率:基数排序需要进行 d 趟分配和收集,一趟分配需要
稳定性:对于基数排序算法而言,很重要一点就是按位排序时必须是稳定的。因此,这也保证了基数排序稳定性。
8.6 各种内部排序算法的比较及应用
8.6.1 内部排序算法比较
前面讨论的排序算法很多,对各算法的比较是考研中必考的内容。一般基于三个因素进度对比:时空复杂度、算法的稳定性、算法的过程特征。
从时间复杂度看:简单选择排序、直接插入排序和冒泡排序平均情况下的时间复杂度都为
从空间复杂度看:简单选择排序、插入排序、冒泡排序、希尔排序和堆排序都仅需要借助常数个辅助空间。快速排序在空间上只使用一个小的辅助栈,用于实现递归,平均情况下大小为
从稳定性看:插入排序、冒泡排序、归并排序和基数排序是稳定的排序方法,而简单选择排序、快速排序、希尔排序和堆排序都是不稳定排序方法对于排序方法的稳定性,读者应能从算法本身的原理上去理解,而不应拘泥于死记硬背。
从过程特征看:采用不同排序算法,在一次循环或几次循环后排序结果可能是不同的,考研题中经常出现给出一个待排序的初始序列和已经部分排序的序列问其采用何种排序算法。这就要对各类排序算法的过程特征十分熟悉,如冒泡排序和堆排序在每趟处理后都能产生当前的最大值或最小值,而快速排序一趟处理就能确定一个元素的最终位置等。
表 8.1 列出了各种排序算法的时空复杂度和稳定性情况,其中空间复杂度仅列举了平均情况复杂度,由于希尔排序的时间复杂度依赖于增量函数,所以无法准确给出其时间复杂度。
| 算法的种类 | 时间复杂度 | 空间复杂度 | 是否稳定 | ||
| 最好情况 | 平均情况 | 最坏情况 | |||
| 直接插入排序 | O(n) | O(n2) | O(n2) | O(1) | 是 |
| 冒泡排序 | O(n) | O(n2) | O(n2) | O(1) | 是 |
| 简单选择排序 | O(n2) | O(n2) | O(n2) | O(1) | 否 |
| 希尔排序 | O(1) | 否 | |||
| 快速排序 | O(nlog2n) | O(nlog2n) | O(n2) | O(log2n) | 否 |
| 堆排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(1) | 否 |
| 2 路归并排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(n) | 是 |
| 基数排序 | O(d(n+r)) | O(d(n+r)) | O(d(n+r)) | O(r) | 是 |
8.6.2 内部排序算法的应用
通常情况下,对排序算法的比较和应用考虑以下情况。
1)选取排序方法需要考虑的因素
① 待排序的元素数目 n。
② 元素本身信息量的大小。
③ 关键字的结构及其分布情况。
④ 稳定性的要求。
⑤ 语言工具的条件,存储结构及辅助空间的大小等。
2)排序算法小结
① 若 n 较小,可采用直接插入排序或简单选择排序。由于直接插入排序所需的记录移动次数较简单选择排序的多,因而当记录本身信息量较大时,用简单选择排序较好。
② 若文件的初始状态已按关键字基本有序,则选用直接插入或冒泡排序为宜。
③ 若 n 较大,则应采用时间复杂度为
④ 在基于比较的排序方法中,每次比较两个关键字的大小之后,仅出现两种可能的转移,因此可以用一棵二叉树来描述比较判定过程,由此可以证明:当文件的 n 个关键字随机分布时,任何借助于 “比较” 的排序算法,至少需要
⑤ 若 n 很大,记录的关键字位数较少且可以分解时,采用基数排序较好。
⑥ 当记录本身信息量较大时,为避免耗费大量时间移动记录,可用链表作为存储结构。
8.7 外部排序
外部排序可能会考查相关概念、方法和排序过程,外部排序的算法比较复杂,不会在算法设计上进行考查。本节的主要内容有:
① 外部排序指待排序文件较大,内存一次放不下,需存放在外存的文件的排序。
② 为减少平衡归并中外存读写次数所采取的方法:增大归并路数和减少归并段个数。
③ 利用败者树增大归并路数。
④ 利用置换-选择排序增大归并段长度来减少归并段个数。
⑤ 由长度不等的归并段,进行多路平衡归并,需要构造最佳归并树。
8.7.1 外部排序的基本概念
前面介绍过的排序方法都是在内存中进行的(称为内部排序)。而在许多应用中,经常需要对大文件进行排序,因为文件中的记录很多、信息量庞大,无法将整个文件复制进内存中进行排序。因此,需要将待排序的记录存储在外存上,排序时再把数据一部分一部分地调入内存进行排序,在排序过程中需要多次进行内存和外存之间的交换。这种排序方法就称为外部排序。
8.7.2 外部排序的方法
文件通常是按块存储在磁盘上的,操作系统也是按块对磁盘上的信息进行读写的。因为磁盘读/写的机械动作所需的时间远远超过内存运算的时间(相比而言可以忽略不计),因此在外部排序过程中的时间代价主要考虑访问磁盘的次数,即 I/O 次数。
外部排序通常采用归并排序法。它包括两个相对独立的阶段:① 根据内存缓冲区大小,将外存上的文件分成若干长度为
例如,一个含有 2000 个记录的文件,每个磁盘块可容纳 125 个记录,首先通过 8 次内部排序得到 8 个初始归并段 R1~R8,每个段都含 250 个记录。然后对该文件做如图 8.13 所示的两两归并,直至得到一个有序文件。

把内存工作区等分为 3 个缓冲区,如图 8.12 所示,其中的两个为输入缓冲区,一个为输出缓冲区。首先,从两个输入归并段 R1 和 R2 中分别读入一个块,放在输入缓冲区 1 和输入缓冲区 2 中。然后,在内存中进行 2 路归并,归并后的对象顺序存放在输出缓冲区中。若输出缓冲区中对象存满,则将其顺序写到输出归并段(R1')中,再清空输出缓冲区,继续存放归并后的对象。若某个输入缓冲区中的对象取空,则从对应的输入归并段中再读取下一块,继续参加归并。如此继续,直到两个输入归并段中对象全部读入内存并都归并完成为止。当 R1 和 R2 归并完后,再归并 R3 和 R4、R5 和 R6、最后归并 R7 和 R8,这是一趟归并。再把上趟的结果 R1' 和 R2‘、R3’ 和 R4' 两两归并,这又是一趟归并。最后把 R1'' 和 R2'' 两个归并段归并结果得到最终的有序文件,一共进行了 3 趟归并。
在外部排序中实现两两归并时,由于不可能将两个有序段及归并结果段同时存放在内存中,因此需要不停地将数据读出写入磁盘,而这会耗费大量时间。一般情况下:
外部排序的总时间 = 内部排序所需的时间 + 外存信息读写的时间 + 内部归并所需的时间
显然,外存信息读写的时间远大于内部排序和内部归并的时间,因此应着力减少 I/O 次数。由于外存信息读/写是以 “磁盘块” 为单位的,可知每一趟归并需进行 16 次 “读” 和 16 次 “写”,3 趟归并加上内部排序时所需进行的读/写,使得总共需进行 32 × 3 + 32 = 128 次读写。
若改用 4 路归并排序,则只需 2 趟归并,外部排序时的总读、写次数便减至 32 × 2 + 32 =96。因此,增大归并路数,可减少归并趟数,进而减少总的磁盘 I/O 次数,如图 8.14 所示。

一般地,对 r 个初始归并段,做 k 路平衡归并,归并树可用严格 k 叉树(即只有度为 k 与度为 0 的结点的 k 叉树)来表示。第一趟可将 r 个初始归并段归并为
8.7.3 多路平衡归并与败者树
上节讨论过,增加归并路数 k 能减少归并趟数
式中,
为了使内部归并不受
如图 8.15(a) 所示,b3 与 b4 比较,b4 是败者,将段号 4 写入父结点

因为
可见,使用败者树后,内部归并的比较次数与
值得说明的是,归并路数
8.7.4 置换选择排序(生成初始归并段)
从 8.7.2 节的讨论可知,减少初始归并段个数 r 也可以减少归并趟数
设初始待排文件为 FI,初始归并段输出文件为 FO,内存工作区为 WA,FO 和 WA 初始状态为空,WA 可容纳
1)从 FI 输入
2)从 WA 中选出其中关键字取最小值记录,记为 MINIMAX 记录。
3)将 MINIMAX 记录输出到 FO 中去。
4)若 FI 不空,则从 FI 输入下一个记录到 WA 中。
5)从 WA 中所有关键字比 MINIMAX 记录的关键字大的记录中选出最小关键字记录,作为新的 MINIMAX 记录。
6)重复 3)~ 5),直至在 WA 中选不出新的 MINIMAX 记录为止,由此得到一个初始归并段,输出一个归并段结束标志到 FO 中去。
7)重复 2)~ 6),直至 WA 为空。由此得到全部初始归并段。
设待排文件
| 输出文件 FO | 工作区 WA | 输入文件 FI |
|---|---|---|
| — | — | 17,21,05,44,10,12,56,32,29 |
| — | 17 21 05 | 44,10,12,56,32,29 |
| 05 | 17 21 44 | 10,12,56,32,29 |
| 05 17 | 10 21 44 | 12,56,32,29 |
| 05 17 21 | 10 1244 | 56,32,29 |
| 05 17 21 44 | 10 12 56 | 32,29 |
| 05 17 21 44 56 | 10 12 32 | 29 |
| 05 17 21 44 56 # | 10 12 32 | 29 |
| 10 | 29 12 32 | — |
| 10 12 | 29 32 | — |
| 10 12 29 | 32 | — |
| 10 12 29 32 | — | — |
| 10 12 29 32 | — | — |
上述算法,在 WA 中选择 MINIMAX 记录的过程需利用败者树来实现。
8.7.5 最佳归并树
文件经过置换选择排序后,得到的是长度不等的初始归并段。下面讨论如何组织长度不等的初始归并段的归并顺序,使得 I/O 次数最少?假设由置换选择得到 9 个初始归并段,其长度(记录数)依次为 9,30,12,18,3,17,2,6,24。现做 3 路平衡归并,其归并树如图 8.16 所示。

在图 8.16 中,各叶结点表示一个初始归并段,上面的权值表示该归并段的长度,叶结点到根的路径长度表示其参加归并的趟数,各非叶结点代表归并成的新归并段,根结点表示最终生成的归并段。树的带权路径长度 WPL 为归并过程中的总读记录数,故 I/O 次数 = 2 × WPL = 484。
显然,归并方案不同,所得归并树亦不同,树的带权路径长度(I/O 次数)亦不同。为了优化归并树的 WPL,可将第 4 章中哈夫曼树的思想推广到 m 叉树的情形,在归并树中,让记录数少的初始归并段最先归并,记录数多的初始归并段最晚归并,就可以建立总的 I/O 次数最少的最佳归并树。上述 9 个初始归并段可构成一棵如图 8.17 所示的归并树,按此树进行归并,仅需对外存进行 446 次读/写,这棵归并树便称为最佳归并树。

在图 8.17 中的哈夫曼树是一棵严格 3 叉树,即树中只有度为 3 或 0 的结点。若只有 8 个初始归并段,如上例中少了一个长度为 30 的归并段。若在设计归并方案时,缺额的归并段留在最后,即除最后一次做 2 路归并外,其他各次归并仍是 3 路归并,此归并方案的外存读/写次数为 386。显然,这不是最佳方案。
正确的做法是:若初始归并段不足以构成一棵严格

如何判定添加虚段的数目?
设度为 0 的结点有
若
(%为取余运算),则说明这 个叶结点(初始归并段)正好可以构造 叉树归并树。此时,内结点有 个。若
,则说明对于这 个叶结点,其中有 u 个多余,不能包含在 k 叉归并树中。为构造包含所有 个初始归并段的 叉归并树,应在原有 个内结点的基础上再增加 1 个内结点。它在归并树中代替了一个叶结点的位置,被代替的叶结点加上刚才多出的 u 个叶结点,即再加上 k-u-1 个空归并段,就可以建立归并树。
以图 8.18 为例,用 8 个归并段构成 3 叉树,