目录
- 7.1 排序的基本概念
- 7.1.1 排序的定义
- 7.2 插入排序
- 7.2.1 直接插入排序
- 7.2.2 折半插入排序
- 7.2.3 希尔排序
- 7.3 交换排序
- 7.3.1 冒泡排序
- 7.3.2 快速排序
- 7.4 选择排序
- 7.4.1 简单选择排序
- 7.4.2 堆排序
- 7.5 归并排序和基数排序
- 7.5.1 归并排序
- 7.5.2 基数排序
- 7.6 各种内部排序算法的比较及应用
- 7.6.1 内部排序算法的比较
- 7.6.2 内部排序算法的应用
- 7.7 外部排序
- 7.7.1 外部排序的基本概念
- 7.7.2 外部排序的方法
- 7.7.3 多路平衡归并与败者树
- 7.7.4 置换-选择排序(生成初始归并段)
- 7.7.5 最佳归并树
7.1 排序的基本概念
7.1.1 排序的定义
排序:就是重新排列表中的元素,使表中的元素满足按关键字递增或递减的过程。
为了査找方便,通常要求计算机中的表是按关键字有序的。
排序的确切定义如下:
输入:n 个记录 \(R_1,R_2,\cdots,R_n\),对应的关键字为 \(k_1,k_2,\cdots,k_n\)
输出:输入序列的一个重排 \(R‘_1,R‘_2,\cdots,R‘_n\),使得有 \(k‘_1\le k‘_2\le \cdots \le k‘_n\)(其中“\(\le\)”可以换成其他的比较大小的符号)。
算法的稳定性:如果待排序表中有两个元素 \(R_i\)、\(R_j\),其对应的关键字 \(key_i=key_j\),且在排序前 \(R_i\) 在 \(R_j\) 前面,如果使用某一排序算法排序后,\(R_i\) 仍然在 \(R_j\) 的前面,则称这个排序算法是稳定的;否则称排序算法是不稳定的。
需要注意的是,算法是否具有稳定性并不能衡量一个算法的优劣,它主要是对算法的性质进行描述。
注意:
对于不稳定的排序算法,只需举出一组关键字的实例,说明它的不稳定性即可。
在排序的过程中,根据数据元素是否完全在内存中,可将排序算法分为两类:
内部排序是指在排序期间元素全部存放在内存中的排序;
外部排序是指在排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内、外存之间移动的排序。
一般情况下,内部排序算法在执行过程中都要进行两种操作:比较和移动。
通过比较两个关键字,确定对应的元素的前后关系,然后通过移动元素以达到有序。
当然,并不是所有的内部排序算法都要基于比较操作,事实上,基数排序就不是基于比较的。
内部排序算法的性能取决于算法的时间复杂度和空间复杂度,而时间复杂度一般是由比较和移动的次数来决定的。
7.2 插入排序
插入排序是一种简单直观的排序方法,其基本思想在于每次将一个待排序的记录,按其关键字大小插入到前面己经排好序的子序列中,直到全部记录插入完成。
由插入排序的思想可以引申出三个重要的排序算法:直接插入排序、折半插入排序和希尔排序。下面将依次进行讲解。
7.2.1 直接插入排序
没有加以特殊说明. 通常都是指递增有序序列
从上面的插入排序思想中,不难得出一种最简单也最直观的直接插入排序算法。
假设在排序过程中,待排序表 \(L[1\cdots n]\) 在某次排序过程中的某一时刻状态如下:
有序序列 \(L[1\cdots i-1]\) 丨 \(L(i)\) 丨 无序序列 \(L[1+1\cdots n]\) |
---|
为了实现将元素 \(L(i)\) 插入到己有序的子序列 \(L[1\cdots i-1]\) 中,我们需要执行以下操作(为避免混淆,下面用“\(L[]\)”表示一个表,而用“\(L()\)”表示一个元素):
- 査找出 \(L(i)\) 在 \(L[1\cdots i-1]\) 中的插入位置 k。
- 将 \(L[k\cdots i-1]\) 中所有元素全部后移一个位置。
- 将 \(L(i)\) 复制到 \(L(k)\)。
为了实现对 \(L[1\cdots n]\) 的排序,可以将 \(L(2)\sim L(n)\) 依次插入到前面己排好序的子序列中,初始假定 \(L[1]\) 是一个己排好序的子序列。
上述操作执行 n-1 次就能得到一个有序的表。
插入排序在实现上通常采用就地排序(空间复杂度为 \(\mathcal{O}(1)\)),因而在从后向前的比较过程中,要反复把已排序元素逐步向后挪位,为新元素提供插入空间。
下面是直接插入排序的代码,再次用到了我们前面提到的“哨兵”(作用相同)。
void InsertSort(ElemType A[], int n){
int i, j;
for(i=2; i<=n; i++) //依次将 A[2]...A[n] 插入到前面己排序序列
if(A[i].key < A[i-1].key) { //若 A[i] 的关键码小于其前驱,需将A[i]插入有序表
A[0] = A[i]; //复制为哨兵,A[0] 不存放元素
for(j=i-1; A[0].key<A[j].key;--j) //从后往前査找待插入位置
A[j+1] = A[j]; //向后挪位
A[j+1] = A[0]; //复制到插入位置
}
}
直接插入排序算法的性能分析如下:
- 空间效率:仅使用了常数个辅助单元,因而空间复杂度为 \(\mathcal{O}(1)\)。
- 时间效率:在排序过程中,向有序子表中逐个地插入元素的操作进行了 n-1 趟,每趟操作都分为比较关键字和移动元素,而比较次数和移动次数取决于待排序表的初始状态
- 在最好情况下,表中元素已经有序,此时每插入一个元素,都只箱比较一次而不用移动元素,因而时间复杂度为 \(\mathcal{O}(n)\)。
- 在最坏情况下,表中元素顺序刚好与排序结果中元素顺序相反(逆序)时,总的比较次数达到最大,为 \(\sum_{i=2}^n i\),总的移动次数也达到最大,为 \(\sum_{i=2}^n (i+1)\)
- 平均情况下,考虑待排序表中元素是随机的,此时可以取上述最好与最坏情况的平均值作为平均情况下的时间复杂度,总的比较次数与总的移动次数均约为 \(n^2/4\)。
由此,直接插入排序算法的时间复杂度为 \(\mathcal{O}(n^2)\)。
虽然折半插入排序算法的时间复杂度也有 \(\mathcal{O}(n^2)\),但对于数据量比较小的排序表,折半插入排序往往能表现出很好的性能。
- 稳定性:由于每次插入元素时总是从后向前先比较再移动,所以不会出现相同元素相对位置
发生变化的情况,即直接插入排序是一个稳定的排序方法。
- 适用性:直接插入排序算法适用于顺序存储和链式存储的线性表。
当为链式存储时,可以从前往后査找指定元素的位置。
注意:大部分排序算法都仅适用于顺序存储的线性表。
7.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].key > A[0].key)
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]; //插入操作
}
}
从上述算法中,不难看出折半插入排序仅仅是减少了比较元素的次数,约为 \(\mathcal{O}(n\log_2{n})\),该比较次数与待排序表的初始状态无关,仅取决于表中的元素个数 n;
而元素的移动次数没有改变,它依赖于待排序表的初始状态。
因此,折半插入排序的时间复杂度仍为 \(\mathcal{O}(n^2)\)。
折半插入排序是一个稳定的排序方法。
7.2.3 希尔排序
从前面的讲解不难看出,直接插入排序算法适用于基本有序的排序表和数据量不大的排序表。
基于这两点,1959 年 D.L.Shell 提出了希尔排序,又称为缩小增量排序。
希尔排序的基本思想是:先将待排序表分割成若干个形如 L[i, i+d, i+2d,…i+kd]的 “特殊”
子表,分别进行直接插入排序,当整个表中元素已呈 “基本有序” 时,再对全体记录进行一次直
接插入排序。希尔排序的排序过程如下:
先取一个小于 n 的步长 \(d_1\),把表中全部记录分成 \(d_1\) 个组,所有距离为 \(d_1\) 的倍数的记录放在同一个组中,在各组中进行直接插入排序;
然后取第二个步长 \(d_2\lt d_1\),重复上述过程,直到所取到的 \(d_t=1\),即所有记录己放在同一组中,再进行直接插入排序,由于此时己经具有较好的局部有序性,故可以很快得到最终结果。
到目前为止,尚未求得一个最好的增量序列,希尔提出的方法 \(d_1=n/2\),\(d_{i+1}=\lfloor d_i/2\rfloor\),并且最后一个增量等于 1。
希尔排序算法如下:
void ShellSort(ElemType A[], int n) {
//对顺序表作希尔插入排序,本算法和直接插入排序相比,作了以下修改:
//1.前后记录位罝的增置是 dk, 不是 1
//2.r[0]只是暂存单元. 不是哨兵,当 j<=0 时,插入位置己到
for(dk=n/2; dk>=1; dk=dk/2) //步长变化
for(i=dk+1; i<=n; ++i)
if(A[i].key<A[i-dk].key){ //需将 A[i] 插入有序增子表
A[0] = A[i]; //暂存在 A[0]
for(j=i-dk; j>0 && A[0].key<A[j].key; j-=dk)
A[j+dk] = A[j]; //记录后移,査找插入的位罝
A[j+dk] = A[0]; / /插入
}//if
}
希尔排序算法的性能分析如下:
- 空间效率:仅使用了常数个辅助单元,因而空间复杂度为 \(\mathcal{O}(1)\)。
- 时间效率:由于希尔排序的时间复杂度依赖于增量序列的函数,这涉及数学上尚未解决的难题,所以其时间复杂度分析比较困难。
当 n 在某个特定范围时,希尔排序的时间复杂度约为 \(\mathcal{O}(N^1.3)\)。
在最坏情况下希尔排序的时间复杂度为 \(\mathcal{O}(n^2)\)。
- 稳定性:当相同关键字的记录被划分到不同的子表时,可能会改变它们之间的相对次序,因此,希尔排序是一个不稳定的排序方法。
例如,表 \(L=\{3,2,2\}\),经过一趟排序后,\(L=\{2,2,3\}\),最终排序序列也是 \(L=\{2,2,3\}\),显然,2 与 2 的相对次序己经发生了变化。
- 适用性:希尔排序算法仅适用于当线性表为顺序存储的情况。
7.3 交换排序
所谓交换,就是根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。
基于交换的排序算法有很多,考研主要要求掌握冒泡排序和快速排序。
其中冒泡排序算法比较简单,一般不会单独考査,重点是考査快速排序算法的相关内容。
7.3.1 冒泡排序
冒泡排序算法的基本思想是:
假设待排序表长为 n,从后往前(或从前往后)两两比较相邻元素的值,
若为逆序(即 \(A[i-1]\gt A[i]\)),则交换它们,直到序列比较完。
我们称它为一趟冒泡,结果将最小的元素交换到待排序列的第一个位置(关键字最小的元素如气泡一般逐渐往上“漂浮”直至“水面”,这就是冒泡排序名字的由来)。
下一趟冒泡时,前一趟确定的最小元素不再参与比较,待排序列减少一个元素,每趟冒泡的结果把序列中的最小元素放到了序列的最终位置,……,这样最多做 n-1 趟冒泡就能把所有元素排好序。
void BubbleSort(ElemType A[], int n){
//用冒泡排序法将序列 A 中的元素按从小到大排列
for(i=0; i<n-1; i++) {
flag = false; //表示本趟冒泡是否发生交换的标志
for(j=n-1; j>i; j--) //一趟冒泡过程
if(A[j-1].key > A[j].key) { // 若为逆序
swap(A[j-1], A[j]); // 交换
flag=true;
}
if(flag==false)
return; //本趟遍历后没有发生交换,说明表已经有序
}
}
冒泡排序算法的性能分析如下:
- 空间效率:仅使用了常数个辅助单元,因而空间复杂度为 \(\mathcal{O}(1)\)。
- 时间效率:
- 当初始序列有序时,显然第一趟冒泡后 flag 依然为 false(本趟冒泡没有元素交换),从而S接跳出循环,比较次数为 \(n-1\),移动次数为 0, 从而最好情况下的时间复杂度为 \(\mathcal{O}(n)\);
- 当初始序列逆序时,需要进行 n-1 趟排序,第 i 趟排序要进行 n-i 次关键字的比较,而且每次比较都必须移动元素 3 次来交换元素位置。
这种情况下,比较次数 \(\sum_{i=1}^{n-1} (n-i)=\frac{n(n-1)}{2}\);移动次数 \(\sum_{i=1}^{n-1} 3(n-i)=\frac{3n(n-1)}{2}\)
从而,最坏情况下时间复杂度为 \(\mathcal{O}(n^2)\)。其平均时间复杂度也为 \(\mathcal{O}(n^2)\)。
- 稳定性:由于当 \(i\gt j\) 且 \(A[i].key=A[j].key\) 时,不会交换两个元素,从而冒泡排序是一个稳定的排序方法。
注意:
冒泡排序中所产生的有序子序列一定是全局有序的(不同于直接插入排序),也就是说,
有序子序列中的所有元素的关键字一定小于或大于无序子序列中所有元素的关键字,
这样每一趟排序都会将一个元素放置到其最终的位置上。
7.3.2 快速排序
快速排序是对冒泡排序的一种改进,其基本思想是基于分治法的:在待排序表 \(L[1\cdots n]\) 中任取一个元素 pivot 为基准,通过一趟排序将待排序表划分为独立的两部分 \(L[1\cdots k-1\) 和 \(L[k+1\cdots n]\),使得 \(L[1\cdots k-1]\) 中所有元素小于 pivot,\(L[k+1\cdots n]\) 中所有元素大于或等于 pivot,则 pivot 放在了其最终位置 \(L(k)\) 上,这个过程称作一趟快速排序。
而后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。
首先假设划分算法已知,记为 Partition()
,返回的是上述中的 k,注意到 L(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; //返回存放枢轴的最终位置
}
对算法的最好的理解方式就是手动地模拟一遍这些算法。
快速排序算法的性能分析如下:
- 空间效率:由于快速排序是递归的,需要借助一个递归工作栈来保存每一层递归调用的必要
信息,其容量应与递归调用的最大深度一致。
最好情况下,为 \(\lceil \log_2(n+1)\rceil\);
最坏情况下,因为要进行 \(n-1\) 次递归调用,所以栈的深度为 \(\mathcal{O}(n)\);
平均情况下,栈的深度为 \(\mathcal{O}(\log_2{n})\)。
因而空间复杂度在最坏情况下为 \(\mathcal{O}(n)\),平均情况下为 \(\mathcal{O}(\log_2{n})\)。
- 时间效率:快速排序的运行时间与划分是否对称有关,而后者又与具体使用的划分算法有关。
快速排序的最坏情况发生在两个区域分别包含 n-1 个元素和 0 个元素时,这种最大程度的不对称
性若发生在每一层递归上,即对应于初始排序表基本有序或基本逆序时,就得到最坏情况下的时
间复杂度为 \(\mathcal{O}(n^2)\)。
有很多方法可以提高算法的效率。
一种方法是当递归过程中划分得到的子序列的规模较小时不要再继续递归调用快速排序,可以直接采用直接插入排序算法进行后续的排序工作。
另一种方法就是尽量选取一个可以将数据中分的枢轴元素。
如从序列的头尾以及中间选取三个元素,再取这三个元素的中间值作为最终的枢轴元素;或者随机从当前表中选取枢轴元素,这样做使得最坏情况在实际排序中几乎不会发生。
在最理想的状态下,也即 Partition() 可能做到最平衡的划分中,得到的两个子问题的大小都不可能大于 \(n/2\),在这种情况下,快速排序的运行速度将大大提升,此时,时间复杂度为 \(\mathcal{O}(n\log_2{n})\)。
好在快速排序平均情况下运行时间与其最佳情况下的运行时间很接近,而不是接近其最坏情况下的运行时间。
快速排序是所有内部排序算法中平均性能最优的排序算法。
-
稳定性:在划分算法中,若右端区间存在两个关键字相同,且均小于基准值的记录,则在交换到左端区间后,它们的相对位置会发生变化,即快速排序是一个不稳定的排序方法。
例如,表 \(L=\{3,2,\}\),经过一趟排序后,\(L=\{2,2,3\}\),最终排序序列也是 \(L=\{2,2,3\}\),显然,2 与 2 的相对次序己经发生了变化。
注意:
在快速排序算法中,并不产生有序子序列,但每一趟排序后将一个元素(基准元素)放到其最终的位置上。
7.4 选择排序
选择排序的基本思想是:
每一趟(例如第 i 趟)在后面 \(n-i+1\)(\(i=1,2,\cdots,n-1\))个待排序元素中选取关键字最小的元素,作为有序子序列的第 i 个元素,直到第 n-1 趟做完,待排序元素只剩下 1 个,就不用再选了。
选择排序中的堆排序算法是考査的重点。
7.4.1 简单选择排序
从上面选择排序的思想中可以很直观地得出简单选择排序算法的思想:假设排序表为 \(L[1\cdots n]\),第 i 趟排序即从 \(L[i\cdots n]\) 中选择关键字最小的元素与 \(L(i)\) 交换,每一趟排序可以确定一个元素的最终位置,这样经过 n-1 趟排序就可以使得整个排序表有序。
下面是简单选择排序算法的伪代码:
void SelectSort(ElemType A[], int n) {
//对表 A 作简单选择排序,A[ ]从 0 幵始存放元素
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]); //与第 i 个位置交换
}
}
简单选择排序算法的性能分析如下:
- 空间效率:仅使用常数个辅助单元,故而空间效率为 \(\mathcal{O}(1)\)。
- 时间效率:从上述伪码中不难看出,简单选择排序过程中,元素移动的操作次数很少,不会超过 \(3(n-1)\) 次,最好的情况是移动 0 次,此时对应的表己经有序;
但元素间比较的次数与序列的初始状态无关,始终是 \(n(n-1)/2\) 次,所以时间复杂度始终是 \(\mathcal{O}(n^2)\)。
- 稳定性:在第 i 趟找到最小元素后,和第 i 个元素交换,可能会导致第 i 个元素与其含有相同关键字元素的相对位置发生改变。
例如,表 \(L=\{2,2,1\}\),经过一趟排序后,\(L=\{1,2,2\}\),最终排序序列也是 \(L=\{1,2,2\}\),显然,2 与 2 的相对次序已经发生了变化。
因此,简单选择排序是一个不稳定的排序方法。
7.4.2 堆排序
堆排序是一种树形选择排序方法,它的特点是:在排序过程中,将 \(L[1\cdots n]\) 看成是一棵完全二叉树的顺序存储结构,利用完全二叉树中双亲结点和孩子结点之间的内在关系,在当前无序区中选择关键字最大(或最小)的元素。
堆的定义如下:
n 个关键字序列 \(L[1\cdots n]\) 称为堆,当且仅当该序列满足 1 或 2:
- \(L(i)\le L(2i)\) 且 \(L(i)\le L(2i+1)\)
- \(L(i)\ge L(2i)\) 且 \(L(i)\ge L(2i+1)\)(\(1\le i\le\lfloor n/2\rfloor\))
满足第 1 种情况的堆称为小根堆(小顶堆),满足第 2 种情况的堆称为大根堆(大顶堆)。
显然,在大根堆中,最大元素存放在根结点中,且对其任一非根结点,它的值小于或等于其双亲结点值。
小根堆的定义刚好相反,根结点是最小元素。
堆经常被用来实现优先级队列,优先级队列在操作系统的作业调度和其他领域有广泛的应用。
图 7-1 所示为一个大根堆。
堆排序的关键是构造初始堆,对初始序列建堆,就是一个反复筛选的过程。
n 个结点的完全二叉树,最后一个结点是第 \(\lfloor n/2\rfloor\) 个结点的孩子。
对第 \(\lfloor n/2\rfloor\) 个结点为根的子树筛选(对于大根堆:若根结点的关键字小于左右子女中关键字较大者,则交换),使该子树成为堆。
之后向前依次对各结点(\(\lfloor n/2\rfloor-1\sim 1\))为根的子树进行筛选,看该结点值是否大于其左右子结点的值,若不是,将左右子结点中较大值与之交换,交换后可能会破坏下一级的堆,于是继续采用上述方法构造下一级的堆,直到以该结点为根的子树构成堆为止。
反复利用上述调整堆的方法建堆,直到根结点,调整过程的示例如图 7-2 所示。
下面是建立大根堆的算法:
void BuildMaxHeap(ElemType A[], int len) {
for(int i=len/2; i>0; i--) //从i=[n/2]...1, 反复调整堆
AdjustDown(A, i, len);
}
void AdjustDown(ElemType A[], int k, int len) {
//函数 AdjustDown 将元素 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]; //被筛选结点的值放入最终位置
}
向下调整的时间与树高有关,为 \(\mathcal{O}(h)\)。
建堆过程中每次向下调整时,大部分结点的高度都较小。
因此,可以证明在元素个数为 n 的序列上建堆,其时间复杂度为 \(\mathcal{O}(n)\),这说明可以在线性时间内,将一个无序数组建成一个大顶堆。
应用堆这种数据结构进行排序的思路很简单,首先将存放在 \(L[1\cdots n]\) 中的 n 个元素建成初始堆,由于堆本身的特点(以大顶堆为例),堆顶元素就是最大值。
输出堆顶元素后,通常将堆底元素送入堆顶,此时根结点己不满足大顶堆的性质,堆被破坏,将堆顶元素向下调整使其继续保持大顶堆的性质,再输出堆顶元素。
如此重复,直到堆中仅剩下一个元素为止。
下面是堆排序算法:
void HeapSort(ElemType A[], int len) {
BuildMaxHeap(A, len); //初始建堆
for(i=len; i>1; i--) { //n-1 趟的交换和建堆过程
Swap(A[il, A[l]); //输出堆顶元素 (和堆底元素交换)
AdjustDown(A, 1, i-1); //整理,把剩余的 i-1 个元素整理成堆
}
}
同时,堆也支持删除和插入的操作。
由于堆顶元素或为最大值或为最小值,删除堆顶元素时,第先将堆的最后一个元素与堆顶元素交换,由于此时堆的性质被破坏,需对此时的根结点进行向下调整操作。
对堆进行插入操作时,先将新结点放在堆的末端,再对这个新结点执行向上调整操作,大根堆的插入操作示例如图 7-3 所示。
下面是向上调整堆的算法:
void AdjustUp(ElemType A[], int k) {
//参数 k 为向上调整的结点,也为堆的元素个数
A[0] = A[k];
int i = k/2; //若结点值大于双亲结点,则将双亲结点向下调,并继续向上比较
while(i>0 && A[i]<A[0]) { //循环跳出条件
A[k] = A[i]; //双亲结点下调
k = i;
i = k/2; //继续向上比较
}//while
A[k] = A[0]; //复制到最终位置
}
堆排序算法的性能分析如下:
- 空间效率:仅使用了常数个辅助单元,所以空间复杂度为 \(\mathcal{O}(1)\)
- 时间效率:建堆时间为 \(\mathcal{O}(n)\),之后有 \(n-1\) 次向下调整操作,每次调整的时间复杂度为 \(\mathcal{O}(h)\),故在最好、最坏和平均情况下,堆排序的时间复杂度为 \(\mathcal{O}(n\log_2{n})\)。
- 稳定性:在进行筛选时,有可能把后面相同关键字的元素调整到前面,所以堆排序算法是一种不稳定的排序方法。
例如,表 \(L=\{1,2,2\}\),构造初始堆时,可能将 2 交换到堆顶,此时 \(L=\{2,1,2\}\),最终排序序列 \(L=\{1,2,2\}\),显然,2 与 2 的相对次序己经发生了变化。
7.5 归并排序和基数排序
7.5.1 归并排序
归并排序与上述基于交换、选择等排序的思想不一样,“归并”的含义是将两个或两个以上的有序表组合成一个新的有序表。
假定待排序表含有 n 个记录,则可以看成是 n 个有序的子表,每个子表长度为 1,然后两两归并,得到 \(\lceil n/2\rceil\) 个长度为 2 或 1 的有序表;再两两归并……如此重复,直到合并成一个长度为 n 的有序表为止,这种排序方法称为 2 路归并排序。
如图 7-4 所示为 2 路归并排序的一个例子。
Merge()
的功能是将前后相邻的两个有序表归并为一个有序表的算法。
设两段有序表 \(A[low\cdots mid]\)、\(A[mid+1\cdots high]\) 存放在同一顺序表中相邻的位置上,先将它们复制到辅助数组 B 中。
每次从对应 B 中的两个段取出一个记录进行关键字的比较,将较小者放入 A 中,当数组 B 中有一段
的下标超出其对应的表长时(即该段的所有元素己经完全复制到 A 中),将另一段中的剩余部分直接复制到 A 中。
算法如下:
ElemType *B=(ElemType *)malloc((n+1)*sizeof(ElemType)); //辅助数组 B
void Merge(ElemType A[], int low, int mid, int high) {
//表 A 的两段 A[low...mid] 和 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++];
}
while(i<=mid)
A[k++] = B[i++]; //若第一个表未检测完,复制
while(j<=high)
A[k++] = B[j++]; //若第二个表未检测完,复制
}
注意:
上面代码中最后两个 while 循环只有一个会执行。
一趟归并排序的操作是,调用 \(\lceil n/2h\rceil\) 次算法 merge()
将 \(L[1\cdots n]\) 中前后相邻且长度为 h 的有序段进行两两归并,得到前后相邻、长度为 2h 的有序段,整个归并排序需进行 \(\lceil\log_2{n}\rceil\)。
递归形式的 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 个单元,所以归并排序的空间复杂度为 \(\mathcal{O}(n)\)。 - 时间效率:每一趟归并的时间复杂度为 \(\mathcal{O}(n)\),共需进行 \(\lceil\log_2{n}\rceil\) 趟归并,所以算法的时间复杂度为 \(\mathcal{O}(n\log_2{n})\)。
- 稳定性:由于
Merge()
操作不会改变相同关键字记录的相对次序,所以 2-路归并排序算法是一个稳定的排序方法。
注意:
一般而言,对于 N 个元素进行 k-路归并排序时,排序的趟数 m 满足 \(k^m=N\),从而 \(m=\log_k{N}\),又考虑到 m 为整数,所以 \(m=\lceil\log_k{N}\rceil\)。
这和前面的 2-路归并是一致的。
7.5.2 基数排序
基数排序是一种很特别的排序方法,它不是基于比较进行排序的,而是采用多关键字排序思
想(即基于关键字各位的大小进行排序的),借助“分配”和“收集”两种操作对单逻辑关键字
进行排序。
基数排序又分为最高位优先(MSD)排序和最低位优先(LSD)排序。
以 r 为基数的最低位优先基数排序的过程:
假设线性表由结点序列 \(a_0,a_1,\cdots,a_{n-1}\) 构成,每个结点 \(a_j\) 的关键字由 d 元组 \((k_j^{d—1},k_j^{d-2},\cdots,k_j^1,k_j^0)\) 组成,其中 \(0\le k_i^j\le r-1\)(\(0\le j\lt n,0\le i\le d-1\))。在排序过程中,使用 r 个队列 \(Q_0,Q_1,\cdots,Q_{r-1}\)。排序过程如下:
对 \(i=0,1,\cdots,d-1\),依次做一次“分配”和“收集”(其实就是一次稳定的排序过程)。
分配:开始时,把 \(Q_0,Q_1,\cdots,Q_{r-1}\) 各个队列置成空队列,然后依次考察线性表中的每一个结点 \(a_j\)(\(j=0,1,\cdots,n-1\)),如果 \(a_j\) 的关键字 \(k_j^i=k\),就把 \(a_j\) 放进 \(Q_k\) 队列中。
收集:把 \(Q_0,Q_1,\cdots,Q_{r-1}\) 各个队列中的结点依次首尾相接,得到新的结点序列,从而组成新的线性表。
图 7-5 所示为基数排序作用于一个由 7 个 3 位数组成的表的过程。
最左端为输入,其余各列表示了对各个不断递增的有效位连续排序后的情况,阴影表示当前正被排序的数位。
基数排序算法的性能分析如下:
- 空间效率:一趟排序需要的辅助存储空间为 r(r 个队列),但以后的排序中重复使用这些队列,所以基数排序的空间复杂度为 \(\mathcal{O}(r)\)。
- 时间效率:基数排序需要进行 d 趟分配和收集,一趟分配需要 \(\mathcal{O}(n)\),—趟收集需要 \(\mathcal{O}(r)\),所以基数排序的时间复杂度为 \(\mathcal{O}(d(n+r))\),它与序列的初始状态无关。
- 稳定性:对于基数排序算法而言,很重要一点就是按位排序时必须是稳定的。
因此,这也保证了基数排序保持稳定性。
7.6 各种内部排序算法的比较及应用
7.6.1 内部排序算法的比较
前面讨论的排序算法有很多,对各算法的比较是考研中必考的内容。
一般基于三个因素进行对比:时空复杂度、算法的稳定性、算法的过程特征。
从时间复杂度来看,简单选择排序、直接插入排序和冒泡排序的平均情况下的时间复杂度都为
\(\mathcal{O}(n^2)\),并且实现过程也较简单,但直接插入排序和冒泡排序在最好的情况下时间复杂度可以达到 \(\mathcal{O}(n)\) ,而简单选择排序则与序列的初始状态无关。
希尔排序作为插入排序的拓展,对较大规模的排序都可以达到很髙的效率,但是目前未得出其精确的渐近时间。
堆排序是利用了一种称为堆的数据结构,可以在线性时间内完成建堆,并且在 \(\mathcal{O}(n\log_2{n})\) 内完成排序过程。
快速排序是基于分治的思想,虽然最坏情况下快速排序时间会达到 \(\mathcal{O}(n^2)\),但快速排序平均性能可以达到 \(\mathcal{O}(n\log_2{n})\),在实际应用中常常优于其他排序算法。
归并排序同样是基于分治的思想,但由于其分割子序列与初始序列的排列无关,因此它的最好、最坏和平均时间复杂度均为 \(\mathcal{O}(nlog_2{n})\)。
从空间复杂度来看,简单选择排序、插入排序、冒泡排序、希尔排序和堆排序都仅葙要借助常数个辅助空间。
快速排序在空间上只使用一个小的辅助栈,用于实现递归,平均情况下大小为
\(\mathcal{O}(\log_2{n})\),当然在最坏的情况下可能会增长到 \(\mathcal{O}(n)\)。
二路归并排序在合并操作中葙要借助较多的辅助空间用于元素复制,大小为 \(\mathcal{O}(n)\),虽然有方法可以克服这个缺点,但是其代价是算法会很复杂而且时间复杂度会增加。
从稳定性看,插入排序、冒泡排序、归并排序和基数排序是稳定的排序方法,而简单选择排序、快速排序、希尔排序和堆排序都是不稳定的排序方法。
对排序方法的稳定性,读者应能从算法本身的原理上去理解,而不应拘泥于死记硬背。
从过程特征看,采用不同的排序算法,在一次循环或几次循环后的排序结果可能是不同的,考研题中经常出现给出一个待排序的初始序列和己经部分排序的序列,问其采用何种排序算法。
这就要对各类排序算法的过程特征十分熟悉,如冒泡排序和堆排序在每次循环后都能产生当前的最大值或最小值,而快速排序一次循环就确定一个元素的最终位置等。
下表列出了各种排序算法的时间复杂度和空间复杂度和稳定性情况。其中空间复杂度仅列举了平均情况下的复杂度,由于希尔排序的时间复杂度依赖于增量函数,所以这里无法准确地给出其时间复杂度。
7.6.2 内部排序算法的应用
通常情况下,对排序算法的比较和应用应考虑以下情况:
- 选取排序方法需要考虑的因素:
- 待排序的元素数目 n。
- 元素本身信息量的大小。
- 关键字的结构及其分布情况。
- 稳定性的要求。
- 语言工具的条件,存储结构及辅助空间的大小等。
- 排序算法小结:
- 若 n 较小(\(n\le 50\)),则可以采用直接插入排序或简单选择排序。
由于直接插入排序所需的记录移动操作较简单选择排序多,因而当记录本身信息量较大时,用简单选择排序较好。
- 若文件的初始状态己按关键字基本有序,则选用直接插入或冒泡排序为宜。
- 若 n 较大,则应采用时间复杂度为 \(\mathcal{O}(n\log_2{n})\) 的排序方法:快速排序、堆排序或归并排序。
快速排序被认为是目前基于比较的内部排序法中最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短。
堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况,这两种排序都是不稳定的。
若要求排序稳定且时间复杂度为 \(\mathcal{O}(n\log_2{n})\),则可选用归并排序。
但本章介绍的从单个记录起进行两两归并的排序算法并不值得提倡,通常可以将它和直接插入排序结合在一起使用。先利用直接插入排序求得较长的有序子文件,然后再两两归并。
直接插入排序是稳定的,因此改进后的归并排序仍是稳定的。
- 在基于比较的排序方法中,每次比较两个关键字的大小之后,仅仅出现两种可能的转移,因此可以用一棵二叉树来描述比较判定过程,由此可以证明:
当文件的 n 个关键字随机分布时,任何借助于“比较”的排序算法,至少需要 \(\mathcal{O}(n\log_2{n})\) 的时间。
- 若 n 很大,记录的关键字位数较少且可以分解时,采用基数排序较好。
- 当记录本身信息童较大时,为避免耗费大量时间移动记录,可用链表作为存储结构。
- 若 n 较小(\(n\le 50\)),则可以采用直接插入排序或简单选择排序。
7.7 外部排序
外部排序可能会考査相关概念、方法和排序过程,外部排序的算法比较复杂,不会在算法设计上进行考査。
本节的主要内容有:
- 外部排序指待排序文件较大,内存一次放不下,箱存放在外部介质的文件的排序。
- 为减少平衡归并中外存读写次数所采取的方法:增大归并路数和减少归并段个数。
- 利用败者树增大归并路数。
- 利用置换-选择排序增大归并段长度来减少归并段个数
- 由长度不等的归并段,进行多路平衡归并,需要构造最佳归并树。
7.7.1 外部排序的基本概念
前面介绍过的排序方法都是在内存中进行的(称为内部排序)。
而在许多实际应用中,经常需要对大文件进行排序,因为文件中的记录很多、信息量庞大,无法将整个文件拷贝进内存中进行排序。
因此,需要将待排序的记录存储在外存上,排序时再把数据一部分一部分的调入内存进行排序。
在排序过程中需要多次进行内存和外存之间的交换,对外存文件中的记录进行排序后的结果仍然被放到原有文件中,这种排序方法就称为外部排序。
7.7.2 外部排序的方法
在实际应用中,由于外存设备的不同,通常又可分为磁盘文件排序和磁带文件排序两大类。
磁带排序和磁盘排序的基本步骤相类似,主要的不同之处在于初始归并段在外存介质中的分布方
式,磁盘是直接存取设备,磁带是顺序存取设备。
下面以磁盘为例进行说明。
文件通常是按块存储在磁盘上的,操作系统也是按块对磁盘上的信息进行读写的。
因为磁盘读/写的机械动作所需时间远远超过内存运算的时间(相比而言,可以忽略不计)。
因此,在外部排序过程中的时间代价主要考虑访问磁盘的次数,即 I/O 次数。
外部排序通常采用归并排序方法。它包括两个相对独立的阶段:
首先,根据内存缓冲区的大小,将外存上含 n 个记录的文件分成若干长度为 h 的子文件,依次读入内存并利用有效的内部排序方法对它们进行排序,并将排序后得到的有序子文件重新写回外存,通常称这些有序子文件为归并段或顺串;
然后,对这些归并段进行逐趟归并,使归并段(有序的子文件)逐渐由小到大,直至得到整个有序文件为止。
例如,一个含有 2000 个记录的文件,每个磁盘块可容纳 250 个记录,则该文件包含 8个磁盘块。
然后对该文件作二路归并排序,每次往内存读入两个磁盘块,排序后再写回磁盘。
若把内存工作区等分为 3 个缓冲区,如图 7-6 所示。
其中的两个为输入缓冲区,一个为输出缓冲区,可以在内存中利用 7.5 节中的简单二路归并 merge
函数实现二路归并。
首先,从参加归并排序的两个输入归并段 R1 和 R2 中分别读入一个块,放在输入缓冲区 1 和输入缓冲区 2 中。
然后,在内存中进行二路归并,归并出来的对象顺序存放在输出缓冲区中。
若输出缓冲区中对象存满,则将其内的对象顺序写到输出归并段(\(R1‘\))中,再将该输出缓冲区清空,继续存放归并后的对象。
若某一个输入缓冲区中的对象取空,则从对应的输入归并段中再读取下一块(这种情况在第一趟归并时不会出现),继续参加归并。
如此继续,直到两个输入归并段中对象全部读入内存并都归并完成为止。
当 \(R1\) 和 \(R2\) 归并完后,再归并 R3 和 R4、R5 和 R6、最后归并 R7 和 R8,这算作一趟归并。
再把上趟的结果 \(R1‘\)、\(R2‘\)、\(R3‘\) 和 \(R4‘\) 两两归并,这又是一趟归并。
最后把 \(R1‘‘\) 和 \(R2‘‘\) 两个归并段归并,结果得到最终的有序文件,一共进行了 3 趟归并,排序过程如图 7-7 所示。
在外部排序中实现两两归并时,不仅要调用 7.5 节中的 merge 过程,而且要进行外存的读/写,由于不可能将两个有序段及归并结果段同时存放在内存中,箱要不停地将数据读出、写入磁盘,这将耗费大量的时间。
一般情况下:
\[外排序的总时间=内部排序所需的时间+外存信息读写的时间+内部归并所需的时间\]
即,\(t_{ES}=r\times t_{IS}+d\times t_{IO}+S\times(n-1)\times t_{mg}\)
其中,r 是初始归并段个数,\(t_{IS}\) 是对每一个初始归并段进行内部排序的时间,d 是访问外存块的次数,\(t_{IO}\) 是每一个块的存取时间,S 是归并趟数,n 是每趟参加二路归并的记录个数,\(t_{mg}\) 是每作一次内部归并,取得一个关键字最小记录的时间。
显然磁盘存取的时间远远大于内部排序和内部归并的时间,因此要提高外排序的速度,应着力减少 d, 即 I/O 次数。
由于外存上信息的读/写是以“物理块”为单位的,且每个物理块可容纳 250 个记录,可知每一趟归并需进行 8 次“读”和 8 次“写”,3 趟归并加上内部排序时所需进行的读/写使得在外排中总共箱进行 \(16\times 4=64\) 次的读写。
故上述二路平衡归并排序的总时间为:
\[8\times t_{IS}+64\times t_{IO}+3\times 2000 t_{mg}\]
对于上例,若采用四路归并排序则只需要 2 趟归并,外排时总的读/写次数便减至 \(2\times 16+ 16=48\)。
因此,增大归并路数,可减少归并趟数,从而减少总的磁盘 I/O 次数,如图 7-8 所示。
一般地,对 r 个初始归并段,作 m 路平衡归并,归并树可用严格 m 叉树(即只有度为 m 与
度为 0 的结点的 m 叉树)来表示。
第一趟可将 r 个初始归并段归并为 \(\lceil r/m\rceil\) 个归并段,以后每一趟归并将 \(\mathcal{l}\) 个归并段归并成 \(\lceil l/m\rceil\) 个归并段,直到最后形成一个大的归并段为止。
\(树的高度=\lceil\log_m{r}\rceil=归并趟数 S\)。
可见,只要增大归并路数 m,或减少初始归并段个数 r,都能减少归并趟数 S,以减少读写磁盘次数 d,达到提髙外部排序速度的目的。
7.7.3 多路平衡归并与败者树
在上节讨论过,归并趟数 \(S=\lceil\log_m{r}\rceil\)。
从而增加归并路数 m 可以减少归并趟数 S, 进而减少访问外存的次数(I/O 次数)。
然而,当增加归并路数 m 时,内部归并的时间将增加。
作内部归并时,在 m 个元素中选择关键字最小的记录需要比较 m-1次。
每趟归并 n 个元素需要作 \((n-1)\times(m-1)\)次比较,S 趟归并总共需要的比较次数为:
\[S\times(n-1)\times(m-1)=\lceil\log_m{r}\rceil \times (n-1)\times(m-1)=\lceil\log_2{r}\rceil \times (n-1)\times(m-1)/\lceil\log_2{m}\rceil\]
其中的 \(\lceil\log_2{r}\rceil\times(n-1)\) 在初始归并段个数 r 与记录个数 n —定时是常数。
而 \((m-1)/\lceil\log_2{m}\rceil\) 随 m 增长而增长,则内部归并时间亦随 m 的增长而增长。
这将抵消由于增大 m 而减少外存访问次数所得到的效益,因此不能使用普通的内部归并排序算法。
为了使内部归并不受 m 的增大的影响,引入了败者树。
败者树是对树形选择排序的一种变形,可以看作一棵完全二叉树。
每个叶结点存放各归并段在归并过程中当前参加比较的记录,内部结点用来记忆左右子树中的“失败者”,而让胜者往上继续进行比较,一直到根结点。
如果比较两个数,大的为失败者、小的为胜利者,则根结点指向的数为最小数。
如图 7-9(a),b3 与 b4 比较,b4 是败者,因此将段号 4 写入父结点 ls4。
b1 与 b2 比较,b2是败者,将段号 2 写入 ls3。
b3 与 b4 的胜者 b3 与 b0 比较,b0 是败者,将段号 0 写入 ls2。
最后两个胜者 b3 与 b1 比较,b1 是败者,段号写入 ls1。
而将胜者 b3 的段号写入 ls0。
此时,根结点 ls0 所指的段的关键字最小。
b3 中的 6 输出后,将下一关键字填入 b3, 继续比较。
因为 m 路归并的败者树深度为 \(\lceil\log_2{m}\rceil\),因此 m 个记录中选择最小关键字,最多需要 \(\lceil\log_2{m}\rceil\) 次比较。
所以总的比较次数为:\[S\times(n-1)\times\lceil\log_2{m}\rceil = \lceil\log_m{r}\rceil \times (n-1)\times \lceil\log_2{m}\rceil = (n-1)\times \lceil\log_2{r}\rceil\]
可见,使用败者树后,内部归并的比较次数与 m 无关了。
因此只要内存空间允许,增大归并路数 m 将有效地减少归并树的高度,从而减少 I/O 次数 d,提髙外部排序的速度。
值得说明的是,归并路数 m 的选择并不是越大越好。
归并路数 m 增大时,相应地需要增加输入缓冲区个数。
如果可供使用的内存空间不变,势必要减少每个输入缓冲区的容量,使得内外存交换数据的次数增大。
当 m 值过大时,虽然归并趟数会减少,但读写外存的次数仍会增加。
7.7.4 置换-选择排序(生成初始归并段)
上节讨论如何使用 m 路归并来减少磁盘访问次数。
从第 2 节的讨论可知,减少初始归并段个数 r 也可以减少归并趟数 S。
若总的记录个数为 n, 每个归并段的长度为 l,则归并段的个数 \(m=\lceil n/l\rceil\)。
如果采用前面介绍过的内部排序方法,将得到长度都相同的初始归并段。
因此,必须探索新的算法来生成初始归并段,这就是本节要介绍的置换-选择算法。
设初始待排文件 FI,初始归并段文件为 FO,内存工作区为 WA,内存工作区可容纳 w 个记录。
置换-选择算法的步骤如下:
- 从待排文件 FI 输入 w 个记录到工作区 WA。
- 从内存工作区 WA 中选出其中关键字取最小值的记录,记为 MIN1MAX。(以后再选出关键字比它大的记录归入本归并段,比它小的归入下一归并段)
- 将 MINIMAX 记录输出到 FO 中去。
- 若 FI 未读完,则从 FI 输入下一个记录到 WA 中。
- 从 WA 中所有关键字比 MINIMAX 记录的关键字大的记录中选出最小的关键字记录,作为新的MINIMAX。
- 重复 3、4、5,直到在 WA 中选不出新的 MINIMAX 记录为止,由此得到一个初始归并排段,输出一个归并段的结束标志到 FO 中去。
- 重复 \(2\sim 6\),直到 WA 为空。由此得到全部初始归并段。
例如,设待排文件 \(FI=\{17,21,05,44,10,12,56,32,29\}\),内存工作区容量 w 为 3。
排序过程如下(波浪线标记的为每次的 MINIMAX):
上述算法中,选择 MINIMAX 记录的过程需利用败者树来实现。
7.7.5 最佳归并树
文件经过置换-选择排序之后,得到的是长度不等的初始归并段。
下面讨论如何组织初始归并段的归并顺序,使 I/O 访问次数最少。
m-路归并排序可用一棵 m 叉树描述。
因为每一次作 m 路归并都需要有 m 个归并段参加,因此,归并树是一棵只有度为 0 和度为 m 的结点的严格 m 叉树。
设由置换-选择得到 9 个初始归并段,其长度(记录数)依次为:\(9,30,12,18,3,17,2,6,24\)。
现作 3-路平衡归并,其归并树如图 7-10 所示
在图 7-10 中,各叶结点表示参加归并的一个初始归并段,叶结点上的权值表示该初始归并段中的记录数,根结点表示最终生成的归并段,叶结点到根结点的路径长度表示在归并过程中的归并趟数,各非叶结点代表归并成的新归并段,则归并树的带权路径长度 WPL 即为归并过程中的总读记录数。
因而在归并过程中,总的 I/O 次数为 \(2\times\text{WPL}=484\)。
归并方案不同,所得归并树亦不同,树的带权路径长度(外存 I/O 次数)亦不同。
为了优化归并树的 WPL,可将第 4 章 Huffinan 树的思想推广到 m 叉树的情形。
在归并树中,让记录数少的初始归并段最先归并,记录数多的初始归并段最晚归并,就可以建立总的 I/O 次数达到最少的最佳归并树。
对上述 9 个初始归并段可构造成一棵如图 7-11 所示的归并树,按此树进行归并,仅需对外存进行 446 次读/写,这棵归并树便称作最佳归并树。
图 7-10 的 Huffinan 树是一棵严格 3 叉树。
若只有 8 个初始归并段,设上例中少了一个长度为 30 的归并段。
如果在设计归并方案时,缺额的归并段留着最后,即除了最后一次作 2-路归并外,其他各次归并仍都是 3-路归并,此归并方案的外存读/写次数为 386。显然不是最佳方案。
正确的做法是,若初始归并段不足构成一棵严格 m 叉树时,需添加长度为 0 的“虚段”,按照 Huffinan 树的原则,权为 0 的叶子应离树根最远。
因此,最佳归并树应如图 7-12 所示。
如何判定添加虚段的数目?
设度为 0 的结点有 \(n_0(=n)\) 个,度为 m 的结点有 \(n_m\) 个,则对严格 m 叉树有 \(n_0=(m-1)n_m+1\),由此可以得出 \(n_m=(n_0-1)/(m-1)\)。
- 如果 \((n_0-1)%(m-1)=0\)(%为取余运算),则说明这 \(n_0\) 个叶结点(初始归并段)正好可以构造 m 叉归并树。此时,内结点有 \(n_m\) 个。
- 如果 \((n_0-1)%(m-1)=u\ne 0\),则说明对于这 \(n_0\) 个叶结点,其中有 u 个多余,不能包含在 m 叉归并树中。
为构造包含所有 \(n_0\) 个初始归并段的 m 叉归并树,应在原有个内结点的基础上再增加一个内结点。
它在归并树中代替了一个叶结点位置,被代替的叶结点加上刚才多出的 u 个叶结点,再加上 \(m-u-1\) 个空归并段,就可以建立归并树。
以图 7-12 为例,用 8 个归并段构成 3 叉树,\((n_0-1)%(m-1)=(8-1)%(3-1)=1\),说明 7 个归并段刚好可以构成一个严格 3 叉树(假设把以 5 为根的树看做一个叶子),为此,将叶子 5 变成一个内结点,再添加 \(3-1-1=1\) 个空归并段,就可以构成一个严格 m 叉树。
原文地址:https://www.cnblogs.com/4thirteen2one/p/9537592.html