数据结构,第八章排序

参考王道《数据结构》的大体知识结构

个人代码存放 github:数据结构

个人博客(由于用阿里免费的DNS解析,所以很大概率会无法访问,建议第一次访问后等几分钟):stardust的博客

仅记录个人学习情况

语言使用c/c++、java

主要目的是补充知识体系,顺便解释一下一些未说明清楚的地方,以及使用java语言来实现数据结构,如有侵权的行为会进行删除

禁转

持续更新中…

第八章 排序

8.1排序的基本概念

8.1.1排序的定义

排序,就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。为了查找方便通常希望计算机中的表是按关键字有序的。排序的确切定义如下:

输入: n n n个记录 R 1 , R 2 , ⋯   , R n R_1,R_2,\cdots,R_n R1,R2,,Rn,对应的关键字为 k 1 , k 2 , ⋯   , k n k_1,k_2,\cdots,k_n k1,k2,,kn

输出:输入序列的一个重排 R 1 ′ , R 2 ′ , ⋯   , R n ′ R_1^{\prime},R_2^{\prime},\cdots,R_n^{\prime} R1,R2,,Rn,使得 k 1 ′ ≤ k 2 ′ ≤ ⋯ ≤ k n ′ k_1^{\prime}\leq k_2^{\prime}\leq\cdots\leq k_n^{\prime} k1k2kn (其中“ ≤ \leq ”可以换成其他的比较大小的符号)。

算法的稳定性。若待排序表中有两个元素 R i R_i Ri R j R_j Rj,其对应的关键字相同,即 key i = _i= i=key j _j j,且在排序前 R i R_i Ri R j R_j Rj的前面,若使用某一排序算法排序后, R i R_i Ri仍然在 R j R_j Rj 的前面,则称这个排序算法是稳定的,否则称这个排序算法是不稳定的。需要注意的是,算法是否具有稳定性并不能衡量一个算法的优劣,它主要是对算法的性质进行描述。若待排序表中的关键字不允许重复,排序结果是唯一的,则对于排序算法的选择,稳定与否无关紧要。

对于不稳定的排序算法,只需举出一组关键字的实例,说明它的不稳定性即可。

在排序过程中,根据数据元素是否完全存放在内存中,可将排序算法分为两类:(1)内部排序, 是指在排序期间元素全部存放在内存中的排序:(2)外部排序,是指在排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内、外存之间移动的排序。

一般情况下,内部排序算法在执行过程中都要进行两种操作:比较和移动。通过比较两个关键字的大小,确定对应元素的前后关系,然后通过移动元素以达到有序。当然,并非所有的内部排序算法都要基于比较操作,事实上,基数排序就不基于比较排序。

每种排序算法都有各自的优缺点,适合在不同的环境下使用,就其全面性能而言,很难提出一种被认为是最好的算法。通常可以将排序算法分为插入排序、交换排序、选择排序、归并排序和基数排序五大类,后面几节会分别进行详细介绍。内部排序算法的性能取决于算法的时间复杂度和空间复杂度,而时间复杂度一般是由比较和移动的次数决定的。

大多数的内部排序算法都更适用于顺序存储的线性表。

8.2插入排序

插入排序是一种简单直观的排序算法,其基本思想是每次将一个待排序的记录按其关键字大小插入前面已排好序的子序列,直到全部记录插入完成。由插入排序的思想可以引申出三个重要的排序算法:直接插入排序、折半插入排序和希尔排序。

8.2.1 直接插入排序

根据上面的插入排序思想,不难得出一种最简单也最直观的直接插入排序算法。假设在排序过程中,待排序表 L[1…n]在某次排序过程中的某一时刻状态如下:

有序序列 L[1…i-1]L(i)无序序列 L[i+1…n]

要将元素 L(i)插入已有序的子序列 L[1…i-1],需要执行以下操作(为避免混淆,下面用L[]表示一个表,而用 L()表示一个元素):

  1. 查找出 L(i)在 L[1…i-1]中的插入位置 k。
  2. 将L[k…i-1]中的所有元素依次后移一个位置。
  3. 将L(i)复制到L(k)。

为了实现对 L[1…n]的排序,可以将 L(2)~L(n)依次插入前面已排好序的子序列,初始 L[1] 可以视为一个已排好序的子序列。上述操作执行 n − 1 n-1 n1次就能得到一个有序的表。插入排序在实现上通常采用原地排序(空间复杂度为 O ( 1 ) ) O(1)) O(1)), 因而在从后往前的比较过程中,需要反复把已排序元素逐步向后挪位,为新元素提供插入空间。下面是直接插入排序的代码,其中再次用到了前面提到的“哨兵”(作用相同)。

void InsertSort(ElemType A[],int n){
    int i,j;
    for(i=2;i<=n;i++){
		if(A[i]<A[i-1]){
        	A[0]=A[i];
            for(j=i-1;A[0]<A[j];--j)
                A[j+1]=A[j];
            A[j+1]=A[0];
        }
    } 
}

假定初始序列为 49, 38, 65, 97 ,76, 13, 27, 49 , 初始时 49 可以视为一个已排好序的子序列,按照上述算法进行直接插入排序的过程如图 8.1 所示,括号内是已排好序的子序列。

在这里插入图片描述

直接插入排序算法的性能分析如下:

空间效率:仅使用了常数个辅助单元,因而空间复杂度为 O ( 1 ) O(1) O(1)

时间效率 : 在排序过程中,向有序子表中逐个地插入元素的操作进行了 n − 1 n-1 n1趟,每趟操作都分为比较关键字和移动元素,而比较次数和移动次数取决于待排序表的初始状态。

在最好情况下,表中元素已经有序,此时每插入一个元素,都只需比较一次而不用移动元素,因而时间复杂度为 O ( n ) O(n) O(n)

在最坏情况下,表中元素顺序刚好与排序结果中的元素顺序相反(逆序),总的比较次数达到最大,总的移动次数也达到最大,总的时间复杂度为 O ( n 2 ) O(n^2) O(n2)

平均情况下,考虑待排序表中元素是随机的,此时可以取上述最好与最坏情况的平均值作为平均情况下的时间复杂度,总的比较次数与总的移动次数均约为 n 2 / 4 n^2/4 n2/4

因此,直接插入排序算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2)
稳定性:因为每次插入元素时总是从后往前先比较再移动,所以不会出现

相同元素相对位置发生变化的情况,即直接插入排序是一个稳定的排序算法。

适用性:直接插入排序适用于顺序存储和链式存储的线性表,采用链式存储时无须移动元素。

8.2.2折半插入排序

从直接插入排序算法中,不难看出每趟插入的过程中都进行了两项工作:(1)从前面的有序子表中查找出待插入元素应该被插入的位置;(2)给插入位置腾出空间,将待插入元素复制到表中的插入位置。注意到在该算法中,总是边比较边移动元素。下面将比较和移动操作分离,即先折半查找出元素的待插入位置,然后统一地移动待插入位置之后的所有元素。当排序表为顺序表时, 可以对直接插入排序算法做如下改进:因为是顺序存储的线性表,所以查找有序子表时可以用折半查找来实现。确定待插入位置后,就可统一地向后移动元素。算法代码如下:

void InsertSort(ElemType A[],int n){
	int i,j,low,high,mid;
    for(i=2;i<=n;i++){
		A[0]=A[i];
        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];
    }
}

直接插入排序和折半插入排序的比较

从上述算法中,不难看出折半插入排序仅减少了比较元素的次数,时间复杂度约为 O ( n l o g 2 n ) O(n\mathrm{log}_2n) O(nlog2n), 该比较次数与待排序表的初始状态无关,仅取决于表中的元素个数 n ; n; n;而元素的移动次数并未改变它依赖于待排序表的初始状态。因此,折半插入排序的时间复杂度仍为 O ( n 2 ) O(n^2) O(n2),但对于数据量不很大的排序表,折半插入排序往往能表现出很好的性能。折半插入排序是一种稳定的排序算法。折半插入排序仅适用于顺序存储的线性表

8.2.3 希尔排序

从前面的分析可知,直接插入排序算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2),但若待排序列为“正序”时其时间效率可提高至 O ( n ) O(n) O(n),由此可见它更适用于基本有序的排序表和数据量不大的排序表。希尔排序正是基于这两点分析对直接插入排序进行改进而得来的,又称缩小增量排序。

希尔排序中各子序列采用的排序算法

希尔排序的基本思想是:先将待排序表分割成若干形如 L [ i , i + d , i + 2 d , ⋯   , i + k d ] [i,i+d,i+2d,\cdots,i+kd] [i,i+d,i+2d,,i+kd]的“特殊” 子表,即把相隔某个“增量”的记录组成一个子表,对各个子表分别进行直接插入排序,当整个表中的元素已呈“基本有序”时,再对全体记录进行一次直接插入排序。

根据希尔排序的中间过程判断所采用的增量

希尔排序的过程如下:先取一个小于 n n n的增量 d l d_\mathrm{l} dl,把表中的全部记录分成 d l d_\mathrm{l} dl组,所有距离为 d l d_\mathrm{l} dl 的倍数的记录放在同一组,在各组内进行直接插入排序;然后取第二个增量 d 2 < d 1 d_2<d_1 d2<d1,重复上述过程, 直到所取到的 d t = 1 d_t=1 dt=1,即所有记录已放在同一组中,再进行直接插入排序,由于此时已经具有较好的局部有序性,因此可以很快得到最终结果。到目前为止,尚未求得一个最好的增量序列。仍以 8.2.1节的关键字为例,假定第一趟取增量 d 1 = 5 d_1=5 d1=5,将该序列分成 5 个子序列,即图中第 2 行至第 6 行,分别对各子序列进行直接插入排序,结果如第 7 行所示;假定第二趟取增量 d 2 = 3 d_2=3 d2=3,分别对三个子序列进行直接插入排序,结果如第 11 行所示;最后对整个序列进行一趟直接插入排序,整个排序过程如图 8.2 所示。

在这里插入图片描述

希尔排序算法的代码如下:

void ShellSort(ElemType A[],int n){
    //A[0]暂存元素
	int dk,i,j;
    for(dk=n/2;dk>=1;dk=dk/2){
        for(i=dk+1;i<=n;++i){
            if(A[i]<A[i-dk]){
                A[0]=A[i];
                for(j=i-dk;j>0&&A[0]<A[j];j-=dk){
                    A[j+dk]=A[j];
                }
                //若A[j]<=A[0],则A[j]放在A[0]后面。
                //若比较到A[1],A[1]交换A[0],此时j=1-dk;
                A[j+dk]=A[0];
            }
        }
    }
}

希尔排序算法的性能分析如下:

空间效率:仅使用了常数个辅助单元,因而空间复杂度为 O ( 1 ) O(1) O(1)
时间效率:因为希尔排序的时间复杂度依赖于增量序列的函数,这涉及数学上尚未解决的难题,所以其时间复杂度分析比较困难。当 n n n 在某个特定范围时,希尔排序的时间复杂度约为 O ( n 1.3 ) O(n^{1.3}) O(n1.3)。在最坏情况下希尔排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2)

稳定性:当相同关键字的记录被划分到不同的子表时,可能会改变它们之间的相对次序,因此希尔排序是一种不稳定的排序算法。例如,图8.2中49与49 的相对次序已发生了变化。

适用性:希尔排序仅适用于顺序存储的线性表。

8.3交换排序

所谓交换,是指根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。基于交换的排序算法很多,本书主要介绍冒泡排序和快速排序,其中冒泡排序算法比较简单,一般很少直接考查,通常会重点考查快速排序算法的相关内容。

8.3.1冒泡排序

冒泡排序的基本思想是:从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即A[i-1]>A[i]),则交换它们,直到序列比较完。我们称它为第一趟冒泡,结果是将最小的元素交换到待排序列的第一个位置(或将最大的元素交换到待排序列的最后一个位置),关键字最小的元素如气泡一般逐渐往上“漂浮”至“水面”(或关键字最大的元素如石头一般下沉至水底)。下一趟冒泡时,前一趟确定的最小元素不再参与比较,每趟冒泡的结果是把序列中的最小元素(或最大元素)放到了序列的最终位置 ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ \cdotp\cdotp\cdotp\cdotp\cdotp\cdotp\cdotp\cdotp ⋅⋅⋅⋅⋅⋅⋅⋅这样最多做 n − 1 n-1 n1 趟冒泡就能把所有元素排好序。

图 8.3 所示为冒泡排序的过程, 第一趟冒泡时:27<49,不交换;13<27,不交换;76>13, 交换;97>13,交换:65>13,交换;38>13,交换;49>13,交换。通过第一趟冒泡后,最小元素已交换到第一个位置,也是它的最终位置。第二趟冒泡时对剩余子序列采用同样方法进行排序,如此重复,到第五趟结束后没有发生交换,说明表已有序,冒泡排序结束。

在这里插入图片描述

冒泡排序算法的代码如下:

void BubbleSort(ElemType A[],int n){
    for(int i=0;i<n-1;i++){
        //判断是否发生交换
        bool flag=false;
        for(int j=n-1;j>i;j--){
            if(A[j-1]>A[j]){
				swap(A[j-1],A[j]);
                flag=true;
            }
        }
        //没有交换说明有序
        if(flag==flase){
			return;
        }
    }
}

冒泡排序的性能分析如下:

空间效率:仅使用了常数个辅助单元,因而空间复杂度为 O ( 1 ) O(1) O(1)

时间效率:当初始序列有序时,显然第一趟冒泡后 flag 依然为 false(本趟没有元素交换), 从而直接跳出循环,比较次数为 n − 1 n-1 n1,移动次数为 0,从而最好情况下的时间复杂度为 O ( n ) ; O(n); O(n); 当初始序列为逆序时,需要进行 n − 1 n-1 n1趟排序,第 i i i趟排序要进行 n − i n-i ni次关键字的比较,而且每次比较后都必须移动元素 3 次来交换元素位置。这种情况下,
比较次数 = ∑ i = 1 n − 1 ( n − i ) = n ( n − 1 ) 2 ,   移动次数 = ∑ i = 1 n − 1 3 ( n − i ) = 3 n ( n − 1 ) 2 \begin{aligned}\text{比较次数}&=\sum_{i=1}^{n-1}(n-i)=\frac{n(n-1)}{2},\:\text{移动次数}=\sum_{i=1}^{n-1}3(n-i)=\frac{3n(n-1)}{2}\end{aligned} 比较次数=i=1n1(ni)=2n(n1),移动次数=i=1n13(ni)=23n(n1)

从而,最坏情况下的时间复杂度为 O ( n 2 ) O(n^{2}) O(n2),平均时间复杂度为 O ( n 2 ) O(n^{2}) O(n2)

稳定性:由于 i > j i>j i>j 且 A[i]=A[j]时,不会发生交换,因此冒泡排序是一种稳定的排序算法。

适用性:冒泡排序适用于顺序存储和链式存储的线性表。

冒泡排序中所产生的有序子序列一定是全局有序的(不同于直接插入排序 ),也就是说,有序子序列中的所有元素的关键字一定小于(或大于)无序子序列中所有元素的关键字,这样每趟排序都会将一个元素放置到其最终的位置上。

8.3.2 快速排序

快速排序(以下有时简称快排)的基本思想是基于分治法的:在待排序表 L[1…n]中任取一个元素 pivot 作为枢轴(或称基准,通常取首元素),通过一趟排序将待排序表划分为独立的两部分 L[1…k-1]和 L[k+1…n],使得 L[1…k-1]中的所有元素小于 pivot, L[k+1…n]中的所有元素大于或等于 pivot,则 pivot 放在了其最终位置 L(k)上,这个过程称为一次划分。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或为空为止,即所有元素放在了其最终位置上。

一趟快速排序的过程是一个交替搜索和交换的过程,下面通过实例来介绍,附设两个指针 i和 j,初值分别为 low 和 high, 取第一个元素 49 为枢轴赋值到变量 pivot。

在这里插入图片描述
在这里插入图片描述

快速排序的中间过程的分析

在这里插入图片描述

用二叉树的形式描述这个举例的递归调用过程,如图所示。

在这里插入图片描述

假设划分算法已知,记为Partition(),返回的是上述的 k,则 L(k)已放在其最终位置。因此可以先对表进行划分,然后对两个子表递归地调用快速排序算法进行排序。代码如下:

void QuickSort(ElemType A[],int low,int high){
    if(low<high){
        int privotpos=Partition(A,low,high);
        QuickSort(A,low,privotpos-1);
        QuickSort(A,privotpos+1,high);
    }
}

快速排序中划分操作的应用

快速排序算法的性能主要取决于划分操作的好坏。考研所考查的快速排序的划分操作通常总以表中第一个元素作为枢轴来对表进行划分,则将表中比枢轴大的元素向右移动,将比枢轴小的元素向左移动,使得一趟 Partition()操作后,表中的元素被枢轴一分为二。代码如下:

int Partition(ElemType A[],int low,int high){
    ElemType privot=A[low];
    while(low<high){
        while(low<high&&A[high]>=privot){
            --high;
        }
        A[low]=A[high];
        while(low<high&&A[low]<=privot){
            ++low;
        }
        A[high]=A[low];
    }
    A[low]=privot;
    return low;
}

快速排序算法的性能分析如下:

快速排序中递归次数的影响因素分析

空间效率:由于快速排序是递归的,因此需要借助一个递归工作栈来保存每层递归调用的必要信息,其容量与递归调用的最大层数一致。最好情况下为 O ( log ⁡ 2 n ) O(\log_2n) O(log2n);最坏情况下,要进行 n − 1 n-1 n1 次递归调用,因此栈的深度为 O ( n ) ; O(n); O(n);平均情况下,栈的深度为 O ( log ⁡ 2 n ) O(\log_2n) O(log2n)

时间效率:快速排序的运行时间与划分是否对称有关,快速排序的最坏情况发生在两个区域分别包含 n − 1 n-1 n1个元素和 0 个元素时,这种最大限度的不对称性若发生在每层递归上,即对应于初始排序表基本有序或基本逆序时,就得到最坏情况下的时间复杂度为 O ( n 2 ) O(n^2) O(n2)

有很多方法可以提高算法的效率:一种方法是尽量选取一个可以将数据中分的枢轴元素,如从序列的头尾及中间选取三个元素,再取这三个元素的中间值作为最终的枢轴元素;或者随机地从当前表中选取枢轴元素,这样做可使得最坏情况在实际排序中几乎不会发生。

在最理想的状态下,即 Partition()能做到最平衡的划分,得到的两个子问题的大小都不可能大于 n / 2 n/2 n/2,在这种情况下,快速排序的运行速度将大大提升,此时,时间复杂度为 O ( n l o g 2 n ) O(n\mathrm{log}_2n) O(nlog2n)。好在快速排序平均情况下的运行时间与其最佳情况下的运行时间很接近,而不是接近其最坏情况下的运行时间。快速排序是所有内部排序算法中平均性能最优的排序算法。

稳定性:在划分算法中,若右端区间有两个关键字相同,且均小于基准值的记录,则在交换到左端区间后,它们的相对位置会发生变化,即快速排序是一种不稳定的排序算法。例如,表 L = L= L= { 3 , 2 , 2 } \{3,2,2\} {3,2,2},经过一趟排序后 L = { 2 , 2 , 3 } L=\{2,2,3\} L={2,2,3},最终排序序列也是 L = { 2 , 2 , 3 } L=\{2,2,3\} L={2,2,3},显然,2与 2 的相对次序已发生了变化。

快速排序适合采用的存储方式

适用性:快速排序仅适用于顺序存储的线性表。

在快速排序算法中,并不产生有序子序列,但每一趟排序后会将上一趟划分的各个无序子表的枢轴(基准)元素放到其最终的位置上。

8.4选择排序

选择排序的基本思想是:每一趟(如第 i i i趟)在后面 n − i + 1 ( i = 1 , 2 , ⋯   , n − 1 ) n-i+1(i=1,2,\cdots,n-1) ni+1(i=1,2,,n1)个待排序元素中选取关键字最小的元素,作为有序子序列的第 i i i个元素,直到第 n − 1 n-1 n1趟做完,待排序元素中选取关键字最小的元素,作为有序子序列的第 i i i个元素、直到第 n − 1 n-1 n1趟做完,待排序元素只剩下 1 个,就不用再选。选择排序中的堆排序是历年统考考查的重点。

8.4.1 简单选择排序

根据上面选择排序的思想,可以很直观地得出简单选择排序算法的思想:假设排序表为L[1…n],第 i i i 趟排序即从 L[i…n]中选择关键字最小的元素与 L(i)交换,每一趟排序可以确定一个元素的最终位置,这样经过n-1趟排序就可使得整个排序表有序。

简单选择排序算法的代码如下:

void SelectSort(ElemType A[],int n){
	for(int i=0;i<n-1;i++){
        int min=i;
        for(int j=i+1;j<n;j++){
            if(A[j]<A[min]){
                min=j;
            }
        }
        if(min!=i){
            swap(A[i],A[min]);
        }
    }
}

简单选择排序算法的性能分析如下:

空间效率:仅使用常数个辅助单元,所以空间效率为 O ( 1 ) O(1) O(1)

时间效率:从上述伪码中不难看出,在简单选择排序过程中,元素移动的操作次数很少,不会超过 3 ( n − 1 ) (n-1) (n1)次,最好的情况是移动 0 次,此时对应的表已经有序;但元素间比较的次数与序列的初始状态无关,始终是 n ( n − 1 ) / 2 n(n-1)/2 n(n1)/2次,因此时间复杂度始终是 O ( n 2 ) O(n^2) O(n2)

稳定性:在第 i i i趟找到最小元素后,和第 i i i个元素交换,可能会导致第 i i i个元素与含有相同关键字的元素的相对位置发生改变。例如,表 L = { 2 , 2 , 1 } L=\{2,2,1\} L={2,2,1},经过一趟排序后 L = { 1 , 2 , 2 } L=\{1,2,2\} L={1,2,2},最终排序序列也是 L = { 1 , 2 , 2 } L=\{1,2,2\} L={1,2,2},显然,2与 2 的相对次序已发生变化。因此,简单选择排序是一种不稳定的排序算法。

适用性:简单选择排序适用于顺序存储和链式存储的线性表,以及关键字较少的情况。

8.4.2堆排序

堆的定义如下 , n n n个关键字序列 L[ 1. . . n] 称为堆 , 当且仅当该序列满足 :

  1. L ( i ) > = L ( 2 i ) L( i) > = L( 2i) L(i)>=L(2i) 且 $L( i) > = L( 2i+ 1) $或
  2. L ( i ) < = L ( 2 i ) L(i)<=L(2i) L(i)<=L(2i) L ( i ) < = L ( 2 i + 1 ) ( 1 ≤ i ≤ ⌊ n / 2 ⌋ ) L(i)<=L(2i+1)(1\leq i\leq\lfloor n/2\rfloor) L(i)<=L(2i+1)(1in/2⌋)

堆的性质与特点

可以将堆视为一棵完全二叉树,满足条件(D)的堆称为大根堆(大顶堆),大根堆的最大元素存放在根结点,且其任意一个非根结点的值小于或等于其双亲结点值。满足条件(2)的堆称为小根堆(小顶堆),小根堆的定义刚好相反,根结点是最小元素。图 8.5 所示为一个大根堆。

相当于中序遍历的二叉树,>len/2的部分相当于终端结点

在这里插入图片描述

堆排序的思路很简单:首先将存放在 L[1…n]中的 n n n个元素建成初始堆,因为堆本身的特点(以大顶堆为例),所以堆顶元素就是最大值。输出堆顶元素后,通常将堆底元素送入堆顶,此时根结点已不满足大顶堆的性质,堆被破坏,将堆顶元素向下调整使其继续保持大顶堆的性质,再输出堆顶元素。如此重复,直到堆中仅剩一个元素为止。可见,堆排序需要解决两个问题:(1)如何将无序序列构造成初始堆?(2)输出堆顶元素后,如何将剩余元素调整成新的堆?

初始建堆的操作

堆排序的关键是构造初始堆。 n n n个结点的完全二叉树,最后一个结点是第 ⌊ n / 2 ⌋ \lfloor n/2\rfloor n/2个结点的孩子。对以第 ⌊ n / 2 ⌋ \lfloor n/2\rfloor n/2个结点为根的子树筛选(对于大根堆,若根结点的关键字小于左右孩子中关键字较大者,则交换),使该子树成为堆。之后向前依次对以各结点( ⌊ n / 2 ⌋ − 1 ∼ 1 ) \lfloor n/2\rfloor-1 \sim 1) n/211)为根的子树进行筛选,看该结点值是否大于其左右子结点的值,若不大于,则将左右子结点中的较大值与之交换, 交换后可能会破坏下一级的堆,于是继续采用上述方法构造下一级的堆,直到以该结点为根的子树构成堆为止。反复利用上述调整堆的方法建堆,直到根结点。

如图 8.6 所示,初始时调整 L ( 4 ) L(4) L(4)子树,09<32,交换,交换,交换后满足堆的定义;向前继续调整 L L L(3)子树,78<左右孩子的较大者 87,交换,交换后满足堆的定义;向前调整 L ( 2 ) L(2) L(2)子树,17<左右孩子的较大者 45,交换后满足堆的定义;向前调整至根结点 L ( 1 ) L(1) L(1),53<左右孩子的较大者 87, 交换,交换后破坏了 L ( 3 ) L(3) L(3)子树的堆,采用上述方法对 L ( 3 ) L(3) L(3)进行调整,53<左右孩子的较大者 78, 交换,至此该完全二叉树满足堆的定义。

在这里插入图片描述

输出堆顶元素后调整堆的比较次数的分析

输出堆顶元素后,将堆的最后一个元素与堆顶元素交换,此时堆的性质被破坏,需要向下进行筛选。将 09 和左右孩子的较大者 78 交换,交换后破坏了 L ( 3 ) L(3) L(3)子树的堆,继续对 L ( 3 ) L(3) L(3)子树向下筛选,将 09 和左右孩子的较大者 65 交换,交换后得到了新堆,调整过程如图 8.7 所示。

在这里插入图片描述

下面是建立大根堆的算法:

void BuildMaxHeap(ElemType A[],int len){
    //所有的非终端结点
    for(int i=len/2;i>0;i--){
        HeadAdjust(A,i,len);
    }
}
void HeadAdjust(ElemType A[],int k,int len){
    A[0]=A[k];
    //i是k的子结点
    for(int i=2*k;i<=len;i*=2){
        //判断现在的右节点是否大于左结点,取最大的孩子结点
        if(i<len&&A[i]<A[i+1]){
            i++;
        }
        if(A[0]>=A[i]){
            break;
        }else{
            //将子结点向上调整
            A[k]=A[i];
            //结点向下移动
            k=i;
        }
    }
    A[k]=A[0];
}

调整的时间与树高有关,为 O ( h ) O(h) O(h)。在建含 n n n 个元素的堆时,关键字的比较总次数不超过 4 n n n,时间复杂度为 O ( n ) O(n) O(n),这说明可以在线性时间内将一个无序数组建成一个堆。

下面是堆排序算法:

void HeapSort(ElemType A[],int len){
    BuildMaxHeap(A,len);
    for(int i=len;i>1;--i){
        //输出堆顶元素(和堆底元素交换)
        Swap(A[i],A[1]);
        HeadAdjust(A,1,i-1);
    }
}

推的插入操作及比较次数的分析

同时,堆也支持插入操作。对堆进行插入操作时,先将新结点放在堆的末端,再对这个新结点向上执行调整操作。大根堆的插入操作示例如图 8.8所示。

在这里插入图片描述

堆在海量数据中选出最小 k k k 个数的应用及效率分析

堆排序适合关键字较多的情况。例如,在1亿个数中选出前 100 个最大值。首先使用一个大小为 100 的数组,读入前 100 个数,建立小顶堆,而后依次读入余下的数,若小于堆顶则舍弃, 否则用该数取代堆顶并重新调整堆,待数据读取完毕,堆中 100 个数为所求。

堆排序算法的性能分析如下:

空间效率:仅使用了常数个辅助单元,所以空间复杂度为 O ( 1 ) O(1) O(1)

时间效率:建堆时间为 O ( n ) O(n) O(n),之后有 n − 1 n-1 n1次向下调整操作,每次调整的时间复杂度为 O ( h ) O(h) O(h),所以在最好、最坏和平均情况下,堆排序的时间复杂度为 O ( n log ⁡ 2 n ) O(n\log_2n) O(nlog2n)

稳定性:进行筛选时,有可能把后面相同关键字的元素调整到前面,所以堆排序算法是一种不稳定的排序算法。例如,表 L = { 1 , 2 , 2 } L=\{1,2,2\} L={1,2,2},构造初始堆时可能将 2 交换到堆顶,此时 L = { 2 , 1 , 2 } L=\{2,1,2\} L={2,1,2}, 最终排序序列为 L = { 1 , 2 , 2 } L=\{1,2,2\} L={1,2,2},显然,2与2的相对次序已发生变化。

适用性:堆排序仅适用于顺序存储的线性表。

8.5归并排序、基数排序和基数排序

8.5.1归并排序

二路归并操作的功能

归并排序与上述基于交换、选择等排序的思想不一样,归并的含义是将两个或两个以上的有序表合并成一个新的有序表。假定待排序表含有 n n n个记录,则可将其视为 n n n个有序的子表,每个子表的长度为 1,然后两两归并,得到 ⌈ n / 2 ⌉ \lceil n/2\rceil n/2 个长度为 2 或 1 的有序表;继续两两归并…如此重复,直到合并成一个长度为 n n n 的有序表为止,这种排序算法称为二路归并排序。

图 8.9 所示为二路归并排序的一个例子,经过三趟归并后合并成了有序序列。

在这里插入图片描述

归并排序思想的应用

Merge()的功能是将前后相邻的两个有序表归并为一个有序表。设两段有序表A[low…mid]、A[mid+1…high]存放在同一顺序表中的相邻位置,先将它们复制到辅助数组 B 中。每次从 в 的两段中取出一个记录进行关键字的比较,将较小者放入 A中,当 B 中有一段的下标超出其对应的表长(即该段的所有元素都已复制到 A 中)时,将另一段的剩余部分直接复制到A 中。算法如下:

//辅助数组
ElemType *B=(ElemType *)malloc((n+1)*sizeof(ElemType));
void Merge(ElemType A[],int low,int mid,int high){
    int i,j,k;
    for(i=low;k<=high;k++){
        B[k]=A[k];
    }
    //i为前半部分下标,j为后半部分下标,k为原数组的下标
    for(i=low,j=mid+1,k=i;i<=mid&&j<=high;k++){
        //较小值复制到A中
        if(B[i]<=B[j]){
            A[k]=B[i++];
        }else{
            A[k]=B[j++];
        }
    }
    //这两个while只会有一个执行,将剩下的元素复制到A中
    while(i<=mid){
        A[k++]=B[i++];
    }
    while(j<=high){
        A[k++]=B[j++];
    }
}

在上面的代码中,最后两个 while 循环只有一个会执行。

一趟归并排序的操作是,调用 ⌈ n / 2 h ⌉ \lceil n/2h\rceil n/2h次算法 merge(),将 L[1…n]中前后相邻且长度为 h h h 的有序段进行两两归并,得到前后相邻、长度为 2 h h h 的有序段,整个归并排序需要进行 ⌈ log ⁡ 2 n ⌉ \lceil\log_2n\rceil log2n趟。

递归形式的二路归并排序算法是基于分治的,其过程如下。

分解:将含有 n n n个元素的待排序表分成各含 n / 2 n/2 n/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);
    }
}

归并排序和插入排序的对比

二路归并排序算法的性能分析如下:

空间效率:Merge()操作中,辅助空间刚好为 n n n 个单元,因此算法的空间复杂度为 O ( n ) O(n) O(n)。时间效率:每趟归并的时间复杂度为 O ( n ) O(n) O(n),共需进行$\left\lceil {\log_2n} \right\rceil 趟归并,因此算法的时间复杂度为 趟归并,因此算法的时间复杂度为 趟归并,因此算法的时间复杂度为O(n\mathrm{log}_2n)$。

稳定性:由于 Merge()操作不会改变相同关键字记录的相对次序,因此二路归并排序算法是一种稳定的排序算法。

适用性:归并排序适用于顺序存储和链式存储的线性表。

一般而言,对于 N N N个元素进行 k k k路归并排序时,排序的趟数 m m m满足 k m = N k^m=N km=N,从而 m = log ⁡ k N m=\log_kN m=logkN,又考虑到 m m m 为整数,因此 m = ⌈ log ⁡ k N ⌉ m=\lceil\log_kN\rceil m=logkN。这和前面的二路归并排序算法是一致的。

8.5.2 基数排序

基数排序是一种很特别的排序算法,它不基于比较和移动进行排序,而基于关键字各位的大小进行排序。基数排序是一种借助多关键字排序的思想对单逻辑关键字进行排序的方法。

假设长度为 n n n 的线性表中每个结点 a j a_j aj的关键字由 d d d 元组( k j d − 1 , k j d − 2 , ⋯   , k j 1 , k j 0 k_j^{d-1},k_j^{d-2},\cdots,k_j^1,k_j^0 kjd1,kjd2,,kj1,kj0)组成,满足 0 ⩽ k j i ⩽ r − 1 ( 0 ⩽ j < n , 0 ⩽ i ⩽ d − 1 ) 0\leqslant k_j^i\leqslant r-1(0\leqslant j<n,0\leqslant i\leqslant d-1) 0kjir1(0j<n,0id1)。其中 k j d − 1 k_j^{d-1} kjd1为最主位关键字, k j 0 k_j^0 kj0为最次位关键字。

为实现多关键字排序,通常有两种方法:第一种是最高位优先(MSD)法,按关键字位权重递减依次逐层划分成若干更小的子序列,最后将所有子序列依次连接成一个有序序列;第二种是最低位优先(LSD)法,按关键字位权重递增依次进行排序,最后形成一个有序序列。

下面描述以 r r r为基数的最低位优先基数排序的过程,在排序过程中,使用 r r r个队列 Q 0 , Q 1 , ⋯ Q_0,Q_1,\cdots Q0,Q1,, Q r − 1 Q_{r-1} Qr1。基数排序的过程如下:

i = 0 , 1 , ⋯   , d − 1 i=0,1,\cdots,d-1 i=0,1,,d1,依次做一次分配和收集(其实是一次稳定的排序过程)。

  1. 分配:开始时,把 Q 0 , Q 1 , ⋅ ⋅ ⋅ , Q r − 1 Q_0,Q_1,\cdotp\cdotp\cdotp,Q_{r-1} Q0,Q1,⋅⋅⋅,Qr1各个队列置成空队列,然后依次考察线性表中的每个结点 a j ( j = 0 , 1 , ⋯   , n − 1 ) a_j(j=0,1,\cdots,n-1) aj(j=0,1,,n1),若 a j a_j aj的关键字 k j i = k k_j^i=k kji=k,就把 a j a_j aj放进 Q k Q_k Qk队列中。
  2. 收集:把 Q 0 , Q 1 , ⋯   , Q r − 1 Q_0,Q_1,\cdots,Q_{r-1} Q0,Q1,,Qr1各个队列中的结点依次首尾相接,得到新的结点序列,从而组成新的线性表。

基数排序的中间过程的分析

通常采用链式基数排序,假设对如下 10 个记录进行排序:

在这里插入图片描述

每个关键字是 1000 以下的正整数,基数 r = 10 r=10 r=10,在排序过程中需借助 10 个链队列,每个关键字由 3 位子关键字构成一一K 1 ^{1} 1K 2 ^2 2K 3 ^3 3,分别代表百位、十位和个位,一共进行三趟“分配”和“收集”操作。第一趟分配用最低位子关键字 K 3 ^{3} 3进行,将所有最低位子关键字(个位)相等的记录分配到同一个队列,如图 8.10(a)所示,然后进行收集操作。第一趟收集后的结果如图 8.10(b)所示。

在这里插入图片描述

第二趟分配用次低位子关键字 K 2 ^{2} 2进行,将所有次低位子关键字(十位)相等的记录分配到同一个队列,如图 8.11(a)所示。第二趟收集后的结果如图 8.11(b)所示。

在这里插入图片描述

第三趟分配用最高位子关键字 K 1 ^{1} 1进行,将所有最高位子关键字(百位)相等的记录分配到同一个队列,如图 8.12(a)所示,第三趟收集后的结果如图 8.12(b)所示,至此整个排序结束。

在这里插入图片描述

基数排序算法的性能分析如下。

空间效率:一趟排序需要的辅助存储空间为 r r r( r r r个队列: r r r个队头指针和 r r r个队尾指针),但以后的排序中会重复使用这些队列,所以基数排序的空间复杂度为 O ( r ) O(r) O(r)

元素的移动次数与序列初态无关的排序算法

时间效率:基数排序需要进行 d d d趟“分配”和”收集”操作。一趟分配需要遍历所有关键字,时间复杂度为 O ( n ) ; O(n); O(n);一趟收集需要合并 r r r个队列,时间复杂度为 O ( r ) O(r) O(r)。因此基数排序的时间复杂度为 O ( d ( n + r ) ) O(d(n+r)) O(d(n+r)),它与序列的初始状态无关。

稳定性:每一趟分配和收集都是从前往后进行的,不会交换相同关键字的相对位置,因此基数排序是一种稳定的排序算法。

适用性:基数排序适用于顺序存储和链式存储的线性表。

*8.5.3计数排序

计数排序思想的应用

计数排序也是一种不基于比较的排序算法。计数排序的思想是:对每个待排序元素 x x x,统计小于 x x x的元素个数,利用该信息就可确定 x x x的最终位置。例如,若有8个元素小于 x x x,则 x x x就排在第9号位置上。当有几个元素相同时,该排序方案还需做一定的优化。

计数排序并不在统考大纲的范围内,但其排序思想在历年真题中多次涉及。

在计数排序算法的实现中,假设输入是一个数组 A[n],序列长度为 n,我们还需要两个数组:B[n]存放输出的排序序列,C[k]存储计数值。用输入数组 A 中的元素作为数组 C 的下标(索引),而该元素出现的次数存储在该元素作为下标的数组 c中。算法如下:

计数排序相关的思想和代码的分析

void CountSort(ElemType A[],ElemType B[],int n,int k){
    int i,C[k];
    //初始化计数数组
    for(i=0;i<k;i++)
        C[i]=0;
    //存A[i]的个数
    for(i=0;i<n;i++)
        C[A[i]]++;
    //计算小于等于元素i的元素个数
    for(i=1;i<k;i++)
        C[i]+=C[i-1];
    //将A放在输出数组B的正确位置上
    for(i=n-1;i>=0;i--){
        B[C[A[i]-1]]=A[i];
        //若有相同元素,则下一个元素放在该元素的前一个位置
        C[A[i]]-=1;
    }
}

第一个 for 循环执行完后,数组 c.的植初始化为 O。第二个 for 循环遍历输入数组 A. 未一个输入元素的值为 x,则将 C[x]值加 1,该 for 循环执行完后,C[x]中保存的是等于 x 的元素个数。第三个 for 循环通过累加计算后,C[x]中保存的是小于或等于x的元素个数。第四个for 循环从后往前遍历数组 A,把每个元素 A[i]放入它在输出数组 B 的正确位置上。若数组 A 中不存在相同的元素,则 C[A[i]]-1 就是A[i]在数组 B 中的最终位置,这是因为共有 C[A[i]] 个元素小于或等于 A[i] 。若数组 A 中存在相同的元素,将每个元素 A[i]放入数组 B[]后,都要将 C[A[i]]减 1,这样,当遇到下一个等于 A[i]的输入元素 (若存在)时,该元素就可放在数组 B 中 A[i] 的前一个位置上。

假设输入数组A[]={2,4,3,0,2,3},第二个for 循环执行完后,辅助数组 c \mathfrak{c} c的情况如图8.13(a) 所示;第三个 for 循环执行完后,辅助数组 c 的情况如图 8.13(b)所示。图 8.13©至图 8.13(h)分别是第四个 for 循环每迭代一次后,输出数组 B 和辅助数组 c 的情况。

在这里插入图片描述

由上面的过程可知,计数排序的原理是:数组的索引(下标)是递增有序的,通过将序列中的元素作为辅助数组的索引,其个数作为值放入辅助数组,遍历辅助数组来排序。

计数排序算法的性能分析如下:

空间效率:计数排序是一种用空间换时间的做法。输出数组的长度为 n n n;辅助的计数数组的长度为 k k k,空间复杂度为 O ( n + k ) O(n+k) O(n+k)。若不把输出数组视为辅助空间,则空间复杂度为 O ( k ) O(k) O(k)

时间效率:上述代码的第 1 个和第 3 个 for 循环所花的时间为 O ( k ) O(k) O(k),第2 个和第 4 个 for 循环所花的时间为 O ( n ) O(n) O(n),总时间复杂度为 O ( n + k ) O(n+k) O(n+k)。因此,当 k = O ( n ) k=O(n) k=O(n)时,计数排序的时间复杂度为 O ( n ) ; O(n); O(n); 但当 k > O ( n k>O(n k>O(nlog n ) n) n)时,其效率反而不如一些基于比较的排序(如快速排序、堆排序等)。

稳定性:上述代码的第4个 for 循环从后往前遍历输入数组,相同元素在输出数组中的相对位置不会改变,因此计数排序是一种稳定的排序算法。

适用性:计数排序更适用于顺序存储的线性表。计数排序适用于序列中的元素是整数且元素范围(0~k-1)不能太大,否则会造成辅助空间的浪费。

8.6 各种内部排序算法的比较及应用

8.6.1 内部排序算法的比较

前面讨论的排序算法很多,对各种排序算法的比较是考研常考的内容。一般基于五个因素进行对比:时间复杂度、空间复杂度、稳定性、适用性和过程特征。

各种排序算法的特点、比较和适用场景

从时间复杂度看:简单选择排序、直接插入排序和冒泡排序平均情况下的时间复杂度都为 O ( n 2 ) O(n^2) O(n2),且实现过程也较为简单,但直接插入排序和冒泡排序最好情况下的时间复杂度可以达到 O ( n ) O(n) O(n),而简单选择排序则与序列的初始状态无关。希尔排序作为插入排序的拓展,对较大规模的数据都可以达到很高的效率,但目前未得出其精确的渐近时间。堆排序利用了一种称为堆的数据结构,可以在线性时间内完成建堆,且在 O ( n l o g 2 n ) O(n\mathrm{log}_2n) O(nlog2n)内完成排序过程。快速排序基于分治的思想, 虽然最坏情况下的时间复杂度会达到 O ( n 2 ) O(n^2) O(n2),但快速排序的平均性能可以达到 O ( n log ⁡ 2 n ) O(n\log_2n) O(nlog2n),在实际应用中常常优于其他排序算法。归并排序同样基于分治的思想,但由于其分割子序列与初始序列的排列无关,因此它的最好、最坏和平均时间复杂度均为 O ( n log ⁡ 2 n ) O(n\log_2n) O(nlog2n)

从空间复杂度看:简单选择排序、插入排序、冒泡排序、希尔排序和堆排序都仅需借助常数个辅助空间。快速排序需要借助一个递归工作栈,平均大小为 O ( log ⁡ 2 n ) O(\log_2n) O(log2n),当然在最坏情况下可能会增长到 O ( n ) O(n) O(n)。二路归并排序在合并操作中需要借助较多的辅助空间用于元素复制,大小为 O ( n ) O(n) O(n), 虽然有方法能克服这个缺点,但其代价是算法会很复杂而且时间复杂度会增加。

排序算法的稳定性判断及改进

从稳定性看:插入排序、冒泡排序、归并排序和基数排序是稳定的排序算法,而简单选择排序、快速排序、希尔排序和堆排序都是不稳定的排序算法。平均时间复杂度为 O ( n log ⁡ 2 n ) O(n\log_2n) O(nlog2n)的稳定排序算法只有归并排序,对于不稳定的排序算法,只需举出一个不稳定的实例即可。对于排序算法的稳定性,读者应能从算法本身的原理上去理解,而不应拘泥于死记硬背。

更适合采用顺序存储的排序算法

从适用性看:折半插入排序、希尔排序、快速排序和堆排序适用于顺序存储。直接插入排序、冒泡排序、简单选择排序、归并排序和基数排序既适用于顺序存储,又适用于链式存储。

根据排序的中间过程判断所采用的排序算法

每趟排序后都至少能确定一个元素的最终位置的排序算法

从过程特征看:采用不同的排序算法,在一趟或几趟处理后的排序结果通常是不同的,考研题中经常出现给出一个待排序的初始序列和已部分排序的序列,问其采用何种排序算法。这就要对各类排序算法的过程特征十分熟悉,如冒泡排序、简单选择排序和堆排序在每趟处理后都能产生当前的最大值或最小值,而快速排序一趟处理至少能确定一个元素的最终位置等。

表 8.1 列出了各种排序算法的时空复杂度和稳定性情况,其中空间复杂度仅列举了平均情况的复杂度,因为希尔排序的时间复杂度依赖于增量函数,所以无法准确给出其时间复杂度。

在这里插入图片描述

8.6.2 内部排序算法的应用

通常情况,对排序算法的比较和应用应考虑以下情况。

选取排序算法时需要考虑的因素

1.选取排序算法需要考虑的因素
  1. 待排序的元素个数 n n n

  2. 待排序的元素的初始状态。

  3. 关键字的结构及其分布情况。

  4. 稳定性的要求。

  5. 存储结构及辅助空间的大小限制等。

2.排序算法小结果
  1. n n n较小,可采用直接插入排序或简单选择排序。由于直接插入排序所需的记录移动次数较简单选择排序的多,因此当记录本身信息量较大时,用简单选择排序较好。

  2. n n n较大,应采用时间复杂度为 O ( n l o g 2 n ) O(n\mathrm{log}_2n) O(nlog2n)的排序算法:快速排序、堆排序或归并排序。当待排序的关键字随机分布时,快速排序被认为是目前基于比较的内部排序算法中最好的算法。堆排序所需的辅助空间少于快速排序,且不会出现快速排序可能的最坏情况, 这两种排序都是不稳定的。若要求稳定且时间复杂度为 O ( n l o g 2 n ) O(n\mathrm{log}_2n) O(nlog2n),可选用归并排序。

  3. 若文件的初始状态已按关键字基本有序,则选用直接插入或冒泡排序为宜。

  4. 在基于比较的排序算法中,每次比较两个关键字的大小之后,仅出现两种可能的转移,因此可以用一棵二叉树来描述比较判定过程,由此可以证明:当文件的 n n n个关键字随机分布时,任何借助于“比较”的排序算法,至少需要 O ( n l o g 2 n ) O(n\mathrm{log}_2n) O(nlog2n)的时间。

  5. n n n 很大,记录的关键字位数较少且可以分解时,采用基数排序较好。

  6. 当记录本身信息量较大时,为避免耗费大量时间移动记录,可用链表作为存储结构。

8.7外部排序

外部排序可能会考查相关概念、方法和排序过程,外部排序的算法比较复杂,不会在算法设计上进行考查。本节的主要内容有:

  1. 外部排序指的是大文件的排序,即待排序的记录存储在外存中,待排序的文件无法一次性装入内存,需要在内存和外存之间进行多次数据交换,以达到排序整个文件的目的。
  2. 为减少平衡归并中外存读 r r r写次数所采取的方法:增大归并路数和减少归并段个数。
  3. 利用败者树增大归并路数。
  4. 利用置换一选择排序增大归并段长度来减少归并段个数。
  5. 由长度不等的归并段进行多路平衡归并,需要构造最佳归并树。

8.7.1外部排序的基本概念

前面介绍过的排序算法都是在内存中进行的(称为内部排序)。而在许多应用中,经常需要对大文件进行排序,因为文件中的记录很多,无法将整个文件复制进内存中进行排序。因此,需要要将特排序的记录存储在外存上,排序时再把数据一部分一部分地调入内存进行排序,在排序过程中需要多次进行内存和外存之间的交换。这要将待排序的记录存储在外存上,排序时再把数据一部分一部分地调入内不程中需要多次进行内存和外存之间的交换。这种排序算法就称为外部排序。

8.7.2外部排序的方法

文件通常是按块存储在磁盘上的,操作系统也是按块对磁盘上的信息进行读/写的。因为磁盘读/写的机械动作所需的时间远远超过在内存中进行运算的时间(相比而言可以忽略不计),因此在外部排序过程中的时间代价主要考虑访问磁盘的次数,即I/O次数。

对大文件排序时使用的排序算法

外部排序通常采用归并排序算法。它包括两个阶段:(1)根据内存缓冲区大小,将外存上 的文件分成若干长度为 ℓ \ell 的子文件,a依次读入内存并利用内部排序算法对它们进行排序,并将排序后得到的有序子文件重新写回外存,称这些有序子文件为归并段或顺串:(2)对这些归并段进行逐趟归并,使归并段(有序子文件)逐渐由小到大,直至得到整个有序文件为止。

例如,一个含有 2000 个记录的文件,每个磁盘块可容纳 125 个记录,首先通过 8 次内部排序得到 8 个初始归并段 R1~R8,每段都含 250 条记录。然后对该文件做如图 8.15 所示的两两归并,直至得到一个有序文件。可以把内存工作区等分为三个缓冲区,如图8.14 所示,其中的两个为输入缓冲区,一个为输出缓冲区。首先,从两个输入归并段 R1 和 R2 中分别读入一个块,放在输入缓冲区 1 和输入缓冲区 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.16 所示。

在这里插入图片描述
一般地,对 r r r个初始归并段,做 k k k路平衡归并(即每趟将 k k k个或 k k k个以下的有序子文件归并成一个有序子文件)。第一趟可将 r r r 个初始归并段归并为 ∣ r / k ∣ |r/k| r/k个归并段,以后每趟归并将 m m m 个归并段归并成 ⌈ m / k ⌉ \lceil m/k\rceil m/k个归并段,直至最后形成一个大的归并段为止。树的高度-1 = ⌈ log ⁡ k r ⌉ = =\lceil\log_kr\rceil= =logkr=归并趟数S。可见,只要增大归并路数 k k k,或减少初始归并段个数 r r r,都能减少归并趟数 S,进而减少读/写磁盘的次数,达到提高外部排序速度的目的。

8.7.3 多路平衡归并与败者树

增加归并路数 k k k能减少归并趟数 S,进而减少 I/O 次数。然而,增加归并路数 k k k时,内部归并的时间将增加。做内部归并时,在 k k k个元素中选择关键字最小的元素需要 k − 1 k-1 k1次比较。每趟归并 n n n 个元素需要做 ( n − 1 ) ( k − 1 ) (n-1)(k-1) (n1)(k1)次比较,S趟归并总共需要的比较次数为。
S ( n − 1 ) ( k − 1 ) = ⌈ log ⁡ k r ⌉ ( n − 1 ) ( k − 1 ) = ⌈ log ⁡ 2 r ⌉ ( n − 1 ) ( k − 1 ) / ⌈ log ⁡ 2 k ⌉ S(n-1)(k-1)=\lceil\log_kr\rceil(n-1)(k-1)=\lceil\log_2r\rceil(n-1)(k-1)/\lceil\log_2k\rceil S(n1)(k1)=logkr(n1)(k1)=log2r(n1)(k1)/log2k

式中, ( k − 1 ) / log ⁡ 2 k (k-1)/\log_2k (k1)/log2k]随 k k k增长而增长,因此内部归并时间亦随 k k k 的增长而增长。这将抵消因增大 k k k而减少外存访问次数所得到的效益。因此,不能使用普通的内部归并排序算法。

为了使内部归并不受 k k k 的增大的影响,引入了败者树。败者树是树形选择排序的一种变体, 可视为一棵完全二叉树。 k k k个叶结点分别存放 k k k个归并段在归并过程中当前参加比较的元素,内部结点用来记忆左右子树中的“失败者”,而让胜利者往上继续进行比较,一直到根结点。若比较两个数,大的为失败者、小的为胜利者,则根结点指向的数为最小数。

如图 8.17(a)所示,b3 与 b4 比较,b4 是败者,将段号 4 写入父结点 Is[4]。bl 与 b2 比较, b2 是败者,将段号 2 写入 Is[3]。b3 与 b4 的胜者 b3 与 b0 比较,b0 是败者,将段号 0 写入 Is[2]。最后两个胜者 b3 与 bl 比较,b1 是败者,将段号 1 写入 Is[l]。而将胜者 b3 的段号 3 写入 Is[0]。此时,根结点 Is[0]所指的段的关键字最小。对于 k k k路归并,初始构造败者树需要 k − 1 k-1 k1次比较。b3 中的 6 输出后,将下一关键字填入 b3,继续比较。

在这里插入图片描述

因为 k k k 路归并的败者树深度为 ⌈ log ⁡ 2 k + 1 ⌉ \lceil\log_2k+1\rceil log2k+1,所以从 k k k 个记录中选择最小关键字,仅需进行 ⌈ log ⁡ 2 k ⌉ \lceil \log_2k \rceil log2k次比较。因此总的比较次数约为

S ( n − 1 ) ⌈ log ⁡ 2 k ⌉ = ⌈ log ⁡ k r ⌉ ( n − 1 ) ⌈ log ⁡ 2 k ⌉ = ( n − 1 ) ⌈ log ⁡ 2 r ⌉ S(n-1)\lceil\log_2k\rceil=\lceil\log_kr\rceil(n-1)\lceil\log_2k\rceil=(n-1)\lceil\log_2r\rceil S(n1)log2k=logkr(n1)log2k=(n1)log2r

可见,使用败者树后,内部归并的比较次数与 k k k 无关了。因此,只要内存空间允许,增大归并路数 k k k将有效地减少归并树的高度,从而减少 I/O 次数,提高外部排序的速度。

值得说明的是,归并路数 k k k并不是越大越好。归并路数 k k k增大时,相应地需要增加输入缓冲区的个数。若可供使用的内存空间不变,势必要减少每个输入缓冲区的容量,使得内存、外存交换数据的次数增大。当 k k k 值过大时,虽然归并趟数会减少,但读/写外存的次数仍会增加。

8.7.4置换-选择排序(生成初始归并段)

从 8.7.2 节的讨论可知,减少初始归并段个数 r r r也可以减少归并趟数 S。若总的记录个数为 n n n, 每个归并段的长度为 ℓ \ell ,则归并段的个数 r = ⌈ n / C ⌉ r=\lceil n/C\rceil r=n/C。采用内部排序算法得到的各个初始归并段长度都相同(除最后一段外),它依赖于内部排序时可用内存工作区的大小。因此,必须探索新的方法,用来产生更长的初始归并段,这就是本节要介绍的置换-选择算法。

置换-选择排序生成初始归并段的实例

设初始待排文件为 FI,初始归并段输出文件为 FO,内存工作区为 WA,FO 和 WA 的初始状态为空,WA 可容纳 w w w 个记录。置换-选择算法的步骤如下:

  1. 从 FI 输入 w w w 个记录到工作区 WA。
  2. 从WA 中选出其中关键字取最小值的记录,记为 MINIMAX 记录。
  3. 将 MNINIMAX 记录输出到 FO 中去。
  4. 若FI不空,则从FI输入下一个记录到 WA 中。
  5. 从WA中所有关键字比MINIMAX记录的关键字大的记录中选出最小关键字记录,作为新的 MNINMAX 记录。
  6. 重复3一5,直至在 WA 中选不出新的 MNIMAX 记录为止,由此得到一个初始归并
  7. 重复2-6,直至 WA 为空。由此得到全部初始归并段。

设待排文件 FI = { 17 , 21 , 05 , 44 , 10 , 12 , 56 , 32 , 29 } =\{17,21,05,44,10,12,56,32,29\} ={17,21,05,44,10,12,56,32,29}, WA 容量为 3,排序过程如表 8.2 所示。

在这里插入图片描述

上述算法,在 WA 中选择 MINIMAX 记录的过程需利用败者树来实现。

8.7.5 最佳归并树

文件经过置换-选择排序后,得到的是长度不等的初始归并段。下面讨论如何组织长度不等的初始归并段的归并顺序,使得 I/O 次数最少。假设由置换-选择排序得到 9 个初始归并段,其长度(记录数)依次为 9,30,12,18,3,17,2,6,24。现做 3 路平衡归并,其归并树如图 8.18 所示。

在图8.18 中,各叶结点表示一个初始归并段,上面的权值表示该归并段的长度,叶结点到根的路径长度表示其参加归并的趟数,各非叶结点代表归并成的新归并段,根结点表示最终生成的归并段。树的带权路径长度 WPL 为归并过程中的总读记录数,所以 I/O 次数=2×WPL=484。

构造三叉哈夫曼树及相关的分析和计算

显然,归并方案不同,所得归并树不同,树的带权路径长度(I/O 次数)亦不同。为了优化归并树的 WPL,可以将哈夫曼树的思想推广到 m m m叉树的情形,在归并树中,让记录数少的初始归并段最先归并,记录数多的初始归并段最晚归并,就可以建立总的 IO 次数最少的最佳归并树。上述 9 个初始归并段可构造成一棵如图 8.19 所示的归并树,按此树进行归并,仅需对外存进行446 次读/写,这棵归并树便称为最佳归并树。

在这里插入图片描述

图 8.19 中的哈夫曼树是一棵严格 3 叉树,即树中只有度为 3 或 0 的结点。若只有 8 个初始归并段,如上例中少了一个长度为 30 的归并段。若在设计归并方案时,缺额的归并段留在最后, 即除最后一次做二路归并外,其他各次归并仍是 3 路归并,此归并方案的 I/O 次数为 386。显然, 这不是最佳方案。正确的做法是:若初始归并段不足以构成一棵严格 k k k叉树(也称正则 k k k叉树) 时,需添加长度为 0 的“虚段”,按照哈夫曼树的原则,权为 0 的叶子应离树根最远。因此,最佳归并树应如图 8.20 所示,此时的 IO 次数仅为 326。

在这里插入图片描述

如何判定添加虚段的数目?

设度为0的结点有 n 0 n_{0} n0个,度为 k k k的结点有 n k n_{k} nk个,归并树的结点总数为 n n n,则有:

  • n = n k + n 0 n=n_{k}+n_{0} n=nk+n0(总结点数=度为 k k k的结点数+度为0的结点数)
  • n = k n k + 1 n=kn_{k}+1 n=knk+1(总结点数=所有结点的度数之和+1)

因此,对严格k又树有 n 0 = ( k − 1 ) n k + 1 n_{0}=(k-1)n_{k}+1 n0=(k1)nk+1,由此可得 n k = ( n 0 − 1 ) ( k − 1 ) n_{k}=(n_{0}-1)(k-1) nk=(n01)(k1)

  • ( n 0 − 1 ) % ( k − 1 ) = 0 (n_0-1)\%(k-1)=0 (n01)%(k1)=0(%为取余运算),则说明这 n 0 n_0 n0个叶结点(初始归并段)正好可以构造 k k k叉归并树。此时,内结点有 n k n_k nk个。
  • ( n 0 − 1 ) % ( k − 1 ) = u ≠ 0 (n_0-1)\%(k-1)=u\neq0 (n01)%(k1)=u=0,则说明对于这 n 0 n_0 n0个叶结点,其中有 u u u个多余,不能包含在 k k k叉归并树中。为构造包含所有 n 0 n_0 n0个初始归并段的 k k k叉归并树,应在原有 n k n_k nk个内结点的基础上再增加1个内结点。它在归并树中代替了一个叶结点的位置,被代替的叶结点加上刚才多出的 u u u个叶结点,即再加上 k − u − 1 k-u-1 ku1个空归并段,就可以建立归并树。

实现最佳归并时需补充的虚段数量的分析

以图8.19为例,用8个归并段构成3叉树, ( n 0 − 1 ) % ( k − 1 ) = ( 8 − 1 ) % ( 3 − 1 ) = 1 (n_0-1)\%(k-1)=(8-1)\%(3-1)=1 (n01)%(k1)=(81)%(31)=1,说明7个归并段刚好可以构成一棵严格3叉树(假设把以5为根的树视为一个叶子)。为此,将叶子5变成一个内结点,再添加3-1-1=1个空归并段,就可以构成一棵严格3叉树。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值