数据结构学习笔记——第8章 排序
- 8 排序
-
- 8.1 排序的基本概念
-
- 8.2 插入排序
-
- 8.2.1 直接插入排序
- 8.2.2 折半插入排序
- 8.2.3 希尔排序
- 8.3 交换排序
-
- 8.4 选择排序
-
- 8.5 归并排序和基数排序
-
- 8.6 各种内部排序算法的比较及应用
-
- 8.6.1 内部排序算法的比较
- 8.6.2 内部排序算法的应用
- 8.7 外部排序
-
- 8.7.1 外部排序的基本概念
- 8.7.2 外部排序的方法
- 8.7.3 多路平衡归并与败者树
- 8.7.4 置换-选择排序(生成初始归并段)
- 8.7.5 最佳归并树
8 排序
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',R_2', \cdots ,R_n' R1′,R2′,⋯,Rn′,使得 k 1 ′ ≤ k 2 ′ ≤ ⋯ ≤ k n ′ k_1' \leq k_2' \leq \cdots \leq k_n' k1′≤k2′≤⋯≤kn′(其中 “ ≤ \leq ≤” 可以换成其他的比较大小的符号)
- 算法的稳定性
- 若待排序表中有两个元素 R i R_i Ri 和 R j R_j Rj,其对应的关键字相同即 k e y i = k e y j key_i = key_j keyi=keyj,且在排序前 R i R_i Ri 在 R j R_j Rj 的前面,若使用某一排序算法排序后, R i R_i Ri 仍然在 R j R_j Rj 的前面,则称这个排序算法是稳定的,否则称排序算法是不稳定的
- 算法是否具有稳定性并不能衡量一个算法的优劣,它主要是对算法的性质进行描述
- 如果待排序表中的关键字不允许重复,则排序结果是唯一的,那么选择排序算法时的稳定与否就无关紧要
- 在排序过程中,根据数据元素是否完全在内存中,可将排序算法分为两类:
- ① 内部排序,是指在排序期间元素全部存放在内存中的排序
- ② 外部排序,是指在排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内、外存之间移动的排序
- 插入排序、交换排序、选择排序、归并排序是基于比较和移动的,而基数排序不基于比较
- 内部排序算法的性能取决于算法的时间复杂度和空间复杂度,而时间复杂度一般是有由比较和移动的次数决定的
- 在此通常默认排序结果为非递减有序序列
8.2 插入排序
- 插入排序的基本思想是每次将一个待排序的记录按其关键字大小插入到已排好序的子序列中,知道全部记录插入完成
- 由插入排序的思想可以引申出三个重要的排序算法:直接插入排序、折半插入排序和希尔排序
8.2.1 直接插入排序
- 假设在排序过程中,将排序表 L [ 1... n ] L[1...n] L[1...n] 在某次排序过程中的某一刻时态状态如下:
- 要将元素 L ( i ) L(i) L(i) 插入到已有序的子序列 L [ 1... i − 1 ] L[1...i-1] L[1...i−1] 中,需要执行以下操作(用 L [ ] L[] L[] 表示一个表,用 L ( ) L() L() 表示一个元素):
- 1)查找除 L ( i ) L(i) L(i) 在 L [ 1... i − 1 ] L[1...i-1] L[1...i−1] 中的插入位置 k k k
- 2)将 L [ k . . . i − 1 ] L[k...i-1] L[k...i−1] 中的所有元素依次后移一个位置
- 3)将 L ( i ) L(i) L(i) 复制到 L ( k ) L(k) L(k)
- 为了实现对 L [ 1... n ] L[1...n] L[1...n] 的排序,可以将 L ( 2 ) L(2) L(2) ~ L ( n ) L(n) L(n) 依次插入到前面已排好序的子序列中,初始 L [ 1 ] L[1] L[1] 可视为一个已排好序的子序列
- 上述操作执行 n − 1 n-1 n−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];
}
}
}
- 直接插入排序的性能分析如下:
- 空间效率:仅适用了常数个辅助单元,因为空间复杂度为 O ( 1 ) O(1) O(1)
- 时间效率:在排序过程中,向有序子表中逐个插入元素的操作进行了 n − 1 n-1 n−1 趟,每趟操作都分为比较关键字和移动元素,而比较次数和移动次数取决于待排序表的初始状态
- 在最好情况下,表中元素已经有序,此时没插入一个元素,都只需比较一次而不用移动元素,因而时间复杂度为 O ( n ) O(n) O(n)
- 在最坏情况下,表中元素顺序刚好与排序结果中的元素顺序相反(逆序),总的比较次数达到最大,为 ∑ i = 2 n i \sum_{i=2}^{n}i ∑i=2ni,总的移动次数也达到最大,为 ∑ i = 2 n ( i + 1 ) \sum_{i=2}^{n}(i+1) ∑i=2n(i+1)
- 在平均情况下,考虑待排序表中元素是随机的,此时可以取上述最好与最坏情况的平均值作为平均情况下的时间复杂度,总的比较次数与总的移动次数均约为 n 2 / 4 n^2/4 n2/4
- 因此,直接插入排序算法的是时间复杂度为 O ( n 2 ) O(n^2) O(n2)
- 稳定性:由于每次插入元素时总是从后向前先比较再移动,所以不会出现相同元素相对位置发生变化的情况,即直接插入排序是一个稳定的排序方法
- 适用性:直接插入排序算法适用于顺序存储和链式存储的线性表。为链式存储时,可以从前往后查找指定元素的位置
8.2.2 折半插入排序
- 从直接插入排序中,不难看出每趟插入过程中都进行了两项工作:① 从前面的有序子表中查找除带插入元素应该被插入的位置;② 给插入位置腾出空间,将待插入元素复制到表中的插入位置。注意到在该算法中,总是边比较边移动元素。下面将比较和移动操作分离,即先折半查找出元素的待插入位置,然后统一地移动待插入位置之后的所有元素
- 当排序表为顺序表时,可以对直接插入排序算法做如下改进:
- 由于是顺序存储的线性表,所以查找有序子表时可以用折半查找来实现
- 确定待插入位置后,就可统一地向后移动元素
void InsertSort_BinarySearch(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; j--) {
A[j+1] = A[j];
}
A[high+1] = A[0];
}
}
- 折半插入排序算法的性能分析如下:
- 空间效率:仅使用了常数个辅助单元,因而空间复杂度为 O ( 1 ) O(1) O(1)
- 时间效率:折半插入排序仅减少了比较元素的次数,约为 O ( n l o g 2 n ) O(nlog_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 ] L[i, i+d, i+2d, \cdots , i+kd] L[i,i+d,i+2d,⋯,i+kd] 的“特殊”子表,即把像个某个“增量”的记录组成一个子表,对各个子表分别进行直接插入排序,当整个表中的元素已呈“基本有序”时,再对全体记录进行一次直接插入排序
- 希尔排序的过程如下:
- 先取一个小于 n n n 的步长 d 1 d_1 d1,把表中的全部记录分成 d 1 d_1 d1 组,所有距离为 d 1 d1 d1 的倍数的记录放在同一组,在各组内进行直接插入排序
- 然后取第二个步长 d 2 < d 1 d_2 < d_1 d2<d1,重复上述过程
- 直到所取得 d t = 1 d_t = 1 dt=1,即所有记录已放在同一组中,再进行直接插入排序,由于此时已经具有较好的局部有序性,故可以很快得到最终结果
- 到目前为止,尚未求得一个最好的增量序列,希尔提出的方法是 d 1 = n / 2 d_1 = n/2 d1=n/2, d i + 1 = ⌊ d i / 2 ⌋ d_{i+1} = \lfloor d_i/2 \rfloor di+1=⌊di/2⌋,并且最后一个增量等于 1 1 1
void ShellSort(ElemType A[], int n) {
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+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.3 交换排序
- 交换,是指根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置
- 基于交换的排序算法很多,这里主要借号冒泡排序和快速排序
8.3.1 冒泡排序
- 冒泡排序的基本思想是:
- 从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即 A [ i − 1 ] > A [ 1 ] A[i-1]>A[1] A[i−1]>A[1]),则交换它们,直到序列比较完。我们称它为第一趟冒泡排序,结果是将最小的元素交换到待排序的第一个位置(或将最大的元素交换到待排序序列的最后一个位置)
- 下一趟冒泡时,前一趟确定的最小元素(或最大元素)不再参与比较,每趟冒泡的结果是把序列中的最小元素(或最大元素)放到了序列的最终位置
- 这样最多做 n − 1 n-1 n−1 趟冒泡就能把所有元素排好序
void BubbleSort(ElemType A[], int n) {
for(int i = 0; i < n-1; i++){
bool 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;
}
}
- 冒泡排序的性能分析如下:
- 空间效率:仅使用了常数个辅助单元,因而空间复杂度为 O ( 1 ) O(1) O(1)
- 时间效率:
- 当初始序列有序时,显然第一趟冒泡后
flag
依然为false
(本趟冒泡没有元素交换),从而直接跳出循环,比较次数为 n − 1 n-1 n−1,移动次数为 0 0 0,从而最好情况下的时间复杂度为 O ( n ) O(n) O(n)
- 当初始序列为逆序时,需要进行 n − 1 n-1 n−1 趟排序,第 i i i 趟排序要进行 n − i n-i n−i 次关键字的比较,而且每次比较前后都必须移动元素 3 3 3 次来交换元素位置。这种情况下,比较次数 = ∑ i = 1 n − 1 = n ( n − 1 ) 2 = \sum _{i=1}^{n-1} = \frac{n(n-1)}{2} =∑i=1n−1=2n(n−1),移动次数 = ∑ i = 1 n − 1 3 ( n − i ) = 3 n ( n − 1 ) 2 = \sum_{i=1}^{n-1}3(n-i) = \frac{3n(n-1)}{2} =∑i=1n−13(n−i)=23n(n−1),从而,最坏情况下的时间复杂度为 O ( n 2 ) O(n^2) O(n2)
- 其平均时间复杂度也为 O ( n 2 ) O(n^2) O(n2)
- 稳定性:由于
i > j
且A[i] = A[j]
时,不会发生交换,因此冒泡排序是一种稳定的排序方法
- 适用性:适用于顺序存储和链式存储的线性表
- 注意:
- 冒泡排序中所产生的的有序子序列一定是全局有序的(不同于直接插入排序),也就是说,有序子序列中的所有元素的关键字一定小于(或大于)无序子序列中所有元素的关键字,这样每趟排序都会将一个元素放置到其最终的位置上
8.3.2 快速排序
- 快速排序的基本思想是基于分治法的:
- 在待排序的 L [ 1... n ] L[1...n] L[1...n] 中任取一个元素 p i v o t pivot pivot 作为枢轴(或基准,通常取首元素),通过一趟排序将待排序表划分为独立的两部分 L [ 1.. k − 1 ] L[1..k-1] L[1..k−1] 和 L [ k + 1... n ] L[k+1...n] L[k+1...n],使得 L [ 1.. k − 1 ] L[1..k-1] L[1..k−1] 中的所有元素小于 p i v o t pivot pivot, L [ k + 1... n ] L[k+1...n] L[k+1...n] 中的所有元素大于等于 p i v o t pivot pivot,则 p i v o t pivot pivot 放在了其最终位置 L ( k ) L(k) L(k) 上,这个过程称为一趟快速排序(或一次划分)
- 然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或为空位置,即所有元素放在了其最终位置上
void QuickSort(ElemType A[], int low, int high) {
if(low < high) {
int pivotpos = Partition(A, low, high);
QuickSort(A, low, pivotpos - 1);
QuickSort(A, pivotpos + 1, high);
}
}
- P a r t i t i o n ( ) Partition() Partition() 基本思路(以严蔚敏《数据结构》教材的版本为例):
- 初始化标记 l o w low low 为划分部分的第一个元素位置, h i g h high high 为最后一个元素的位置,然后不断地移动两个标记并交换元素:
- 1) h i g h high high 向前移动找到第一个比 p i v o t pivot pivot 小的元素
- 2)交换当前两个位置的元素
- 3) l o w low low 向后移动找到第一个比 p i v o t pivot pivot 大的元素
- 4)交换当前两个位置的元素
- 5)继续移动标记,执行1),2),3),4)的过程,直到 l o w low low 大于等于 h i g h high high 为止
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;
}
- 快速排序算法的性能分析如下:
- 空间效率:由于快速排序是递归的,需要借助一个递归工作栈来保存每层递归调用的必要空间,其容量应与递归调用的最大深度一致
- 最好情况下,为 O ( l o g 2 n ) O(log^2n) O(log2n)
- 最坏情况下,因为要进行 n − 1 n-1 n−1 次递归调用,所以栈的深度为 O ( n ) O(n) O(n)
- 平均情况下,栈的深度为 O ( l o g 2 n ) O(log^2n) O(log2n)
- 时间效率:快速排序的运行时间与划分是否对称有关
- 快速排序的最坏情况发生在两个区域包含 n − 1 n-1 n−1 个元素和 0 0 0 个元素时,这种最大程度的不对称性若发生在每层递归上,即对应于初始排序表基本有序或基本逆序时,就得到最坏情况下的时间复杂度为 O ( n 2 ) O(n^2) O(n2)
- 有很多算法可以提高算法效率:一种方法是尽量选取一个可以将数据中分的枢轴元素,如从序列的头尾及中间选取三个元素,再取这三个元素的中间值作为最终的枢轴元素;或者随机地从当前表中选取枢轴元素,这样做可使得最坏情况在实际排序中几乎不会发生
- 在理想状态下,即 P a r t i t i o n ( ) Partition() Partition() 可能做到最平衡的划分,得到的两个子问题的大小都不能可能大于 n / 2 n/2 n/2,在这种情况下,快速排序的运行速度将大大提升,此时,最好情况下的时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
- 快速排序平均情况下的运行时间与其最爱情况下的运行时间很接近,因此,平均情况下的时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
- 快速排序是所有内部排序算法中平均性能最优的排序算法
- 稳定性:在划分算法中,若右端区间有两个关键字相同,且均小于基准值的记录,则在交换到左端区间后,他们的相对位置会发生变化,对左端区间同理,因此,快速排序是一种不稳定的排序方法
- 适用性:适用于顺序存储的线性表(稍作修改可以适用于链式存储的线性表)
- 注意:
- 在快速排序算法中,并不产生有序子序列,但每趟排序后会将枢轴(基准)元素放到其最终的位置上
8.4 选择排序
- 选择排序的基本思想是:
- 每一趟(如第 i i i 趟)在后面 n − i + 1 ( i = 1 , 2 , ⋯ , n − 1 ) n-i+1(i=1,2,\cdots ,n-1) n−i+1(i=1,2,⋯,n−1) 个待排序元素中选取关键字最小的元素,作为有序子序列的第 i i i 个元素,直到第 n − 1 n-1 n−1 趟做完,带排序元素只剩下 1 1 1 个,就不用再选了
- 一趟排序会将一个元素放置在最终位置上
8.4.1 简单选择排序
- 简单选择排序算法的思想:
- 假设排序表为 L [ 1... n ] L[1...n] L[1...n],第 i i i 趟排序即从 L [ 1... n ] L[1...n] L[1...n] 中选择关键字最小的元素与 L ( i ) L(i) L(i) 交换,没一趟排序可以确定一个元素的最总位置,这样经过 n − 1 n-1 n−1 趟排序就可使得整个排序表有序
void SelectSort(ElemType A[], int n) {
for(int i = 0; i < n - 1; i++) {
int minpos = i;
for(int j = i + 1; j < n - 1; j++) {
if(A[j] < A[minpos])
minpos = j;
}
if(minpos != i)
swap(A[i], A[minpos]);
}
}
- 简单选择排序算法的性能分析如下:
- 空间效率:仅使用常数个辅助单元,故空间复杂度为 O ( 1 ) O(1) O(1)
- 时间效率:在简单选择排序过程中,元素移动的操作次数很少,不会超过 3 ( n − 1 ) 3(n-1) 3(n−1) 次,最好情况是移动 0 0 0 次,此时对应的表已经有序;但元素间比较的次数与序列的初始状态无关,始终是 n ( ( n − 1 ) 2 n((n-1)2 n((n−1)2 次,因此时间复杂度始终是 O ( n 2 ) O(n^2) O(n2)
- 稳定性:在第 i i i 趟找到最小元素后,和第 i i i 个元素交换,可能会导致第 i i i 个元素与其含有相同关键字的元素的相对位置发生改变,因此,简单选择排序是一种不稳定的排序方法
- 适用性:适用于顺序存储和链式存储的线性表
8.4.2 堆排序
- 堆的定义如下:
- n n n 个关键字序列 L [ 1... n ] L[1...n] L[1...n] 称为堆,当且仅当该序列满足:
- ① L ( i ) ≥ L ( 2 i ) L(i) \geq L(2i) L(i)≥L(2i) 且 L ( i ) ≥ L ( 2 i + 1 ) L(i) \geq L(2i+1) L(i)≥L(2i+1) 或
- ② L ( i ) ≤ L ( 2 i ) L(i) \leq L(2i) L(i)≤L(2i) 且 L ( i ) ≤ L ( 2 i + 1 ) L(i) \leq L(2i+1) L(i)≤L(2i+1)
- ( 1 ≤ i ≤ ⌊ n / 2 ⌋ ) (1 \leq i \leq \lfloor n/2 \rfloor) (1≤i≤⌊n/2⌋)
- 可以将该一维数组视为一棵完全二叉树的顺序存储结构:
- 满足条件①的堆称为大根堆(大顶堆),大根堆的最大元素存放在根结点,且其任一非根结点的值小于等于其双亲结点值
- 满足条件②的堆称为小根堆(小顶堆),小根堆的定义与大根堆相反,根结点是最小元素
- 堆排序的思路:
- 首先将存放在 L [ 1... n ] L[1...n] L[1...n] 中的 n n n 个元素建成初始堆,由于堆本身的特点(以大根堆为例),对顶元素就是最大值
- 输出堆顶元素后,通常将堆底元素送入堆顶,此时根结点已不满足大根堆的性质,堆被破坏,将堆顶元素向下调整使其继续保持大根堆的性质,再输出堆顶元素
- 如此重复,直到堆中仅剩一个元素位置
- 如何构造初始堆?
- 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/2⌋−1∼1)为根的子树进行筛选,看该结点值是否大于其左右子节点的值,若不大于,则将左右子结点中的较大值与之交换,交换后可能会破坏下一级的堆,于是继续采用上述方法构造下一级的堆,直到以该节点定位根的子树构成对位置
- 反复利用上述调整堆的方法建堆,直到根结点
- 输出堆顶元素后,如何将剩余元素调整成新的堆?
- 输出堆顶元素后,将堆的最后一个元素与堆顶元素交换,此时堆的性质被破坏,需要向下进行筛选,筛选方法同上
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];
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 4n 4n,时间复杂度为 O ( n ) O(n) O(n),这说明可以在线性时间内将一个无序数组简称一个堆
void HeapSort(ElemType A[], int len) {
BuildMaxHeap(A, len);
for(i = len; i > 1; i--) {
Swap(A[i], A[1]);
HeadAdjust(A, 1, i-1);
}
}
- 同时,堆也支持插入操作。对堆进行插入操作时,先将新结点放在堆的末端,再对这个新结点向上执行调整操作
- 堆排序适合关键字较多的情况(如 n > 1000 n>1000 n>1000)
- 堆排序算法的性能分析如下:
- 空间效率:仅使用了常数个辅助单元,所以空间复杂度为 O ( 1 ) O(1) O(1)
- 时间效率:建堆时间为 O ( n ) O(n) O(n),之后又 n − 1 n-1 n−1 次向下调整操作,每次调整的时间复杂度为 O ( h ) O(h) O(h),故在最好、最坏和平均情况下。堆排序的时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
- 稳定性:在进行筛选时,有可能把后面相同关键字的元素调整到前面,所以堆排序算法是一种不稳定的排序方法
- 适用性:适用于顺序存储的线性表(修改后也适用于链式存储)
8.5 归并排序和基数排序
8.5.1 归并排序
- 归并排序的基本思想:(2路归并排序)
- 假定待排序表含有 n n n 个记录,则可将其视为 n n n 个有序的子表,每个子表的长度为 1 1 1
- 然后两两归并,得到 ⌈ n / 2 ⌉ \lceil n/2 \rceil ⌈n/2⌉ 个长度为 2 2 2 或 1 1 1 的有序表
- 继续两两归并……
- 如此重复,直到合并成一个长度为 n n n 的有序表为止
- M e r g e ( ) Merge() Merge() 的功能是将前后相邻的两个有序表归并为一个有序表,算法如下:
ElemType *B = (ElemType*)malloc((n+1)*sizeof(ElemType));
void Merge(ElemType A[], int low, int mid, int high) {
int k;
for(k = low; k <= high; k++) {
B[k] = A[k];
}
int i, j;
for(i = low, j = mid + 1, k =low; i <=mid && j <= high; k++) {
if(B[i] <= B[j])
A[k] = B[i++];
else
A[k] = B[j++];
}
while(i <= mid) {
A[k++] = B[i++];
}
while(j <= high) {
A[k++] = B[j++];
}
}
- 设 h = h i g h − l o w + 1 h = high-low+1 h=high−low+1,则 M e r g e ( ) Merge() Merge() 算法的时间复杂度为 O ( h ) O(h) O(h)
- 一趟归并排序的操作是,调用 ⌈ n / 2 h ⌉ \lceil n/2h \rceil ⌈n/2h⌉ 次算法 M e r g e ( ) Merge() Merge(),将 L [ 1... n ] L[1...n] L[1...n] 中前后相邻且长度为 h h h 的有序段进行两两归并,得到前后相邻、长度为 2 h 2h 2h 的有序段,整个归并排序需要进行 ⌈ l o g 2 n ⌉ \lceil log_2n \rceil ⌈log2n⌉ 趟
- 递归形式的 2 路归并排序算法是基于分治的,其过程如下:
- 分解:将含有 n n n 个元素的待排序表分成各含 n / 2 n/2 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 路归并排序算法的性能分析如下:
- 空间效率: M e r g e ( ) Merge() Merge() 操作中,辅助空间刚好为 n n n 个单元,所以算法的空间复杂度为 O(n)
- 时间效率:每趟归并的时间复杂度为 O ( n ) O(n) O(n),共需进行 ⌈ l o g 2 n ⌉ \lceil log_2n \rceil ⌈log2n⌉ 趟归并,所以算法的时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
- 稳定性:由于 M e r g e ( ) Merge() Merge() 操作不会改变相同关键字记录的相对次序,所以 2 路归并排序算法是一种稳定的算法
- 适用性:适用于顺序存储和链式存储的线性表
- 注意:
- 一般而言,对于 N N N 个元素进行 k k k 路归并排序时,排序的趟数 m m m 满足 k m = N k^m=N km=N,从而 m = l o g k N m=log_kN m=logkN,又考虑到 m m m 为整数,所以 m = ⌈ l o g k N ⌉ m=\lceil log_kN \rceil m=⌈logkN⌉,这与前面的 2 路归并排序时一致的
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 kjd−1,kjd−2,⋯,kj1,kj0)组成,满足 0 ≤ k j i ≤ r − 1 ( 0 ≤ j < n , 0 ≤ i ≤ d − 1 ) 0 \leq k_j^i \leq r-1(0 \leq j 0≤kji≤r−1(0≤j<n,0≤i≤d−1),其中 k j d − 1 k_j^{d-1} kjd−1 为最主位关键字, k j 0 k_j^0 kj0 为最次位关键字
- 为实现多关键字排序,通常有两种方法:
- 最高位优先(MSD)法,按关键字位权重递减一次逐层划分成若干更小的子序列,最后将所有子序列依次连接成一个有序序列
- 最低位优先(LSD)法,按关键字权重递增依次排序,最后形成一个有序序列
- 以 r r r 为基数的最低位优先基数排序的过程:(在排序过程中使用 r r r 个队列 Q 0 , Q 1 , ⋯ , Q r − 1 Q_0, Q_1, \cdots , Q_{r-1} Q0,Q1,⋯,Qr−1)
- 对 i = 0 , 1 , ⋯ , d − 1 i = 0, 1, \cdots , d-1 i=0,1,⋯,d−1,依次做一次“分配”和“收集”(其实是一次稳定的排序过程)
- 分配:开始时,把 Q 0 , Q 1 , ⋯ , Q r − 1 Q_0, Q_1, \cdots , Q_{r-1} Q0,Q1,⋯,Qr−1 各个队列置成空队列,然后一次考察线性表中的每个结点 a j ( j = 0 , 1 , ⋯ , n − 1 ) a_j(j = 0, 1, \cdots , n-1) aj(j=0,1,⋯,n−1),若 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 队列中
- 收集:把 Q 0 , Q 1 , ⋯ , Q r − 1 Q_0, Q_1, \cdots , Q_{r-1} Q0,Q1,⋯,Qr−1 各个队列中的结点依次收尾相连,得到新的结点序列,从而组成新的线性表
- 通常采用链式基数排序
- 基数排序的性能分析如下:
- 空间效率:一趟排序需要的辅助存储空间为 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),一趟收集需要 O ( r ) O(r) O(r),所以基数排序的时间复杂度为 O ( d ( n + r ) ) O(d(n+r)) O(d(n+r)),它与序列的初始状态无关
- 稳定性:对于基数排序算法而言,很重要一点就是按位排序时必须是稳定的,因此,这也保证了基数排序的稳定性
8.6 各种内部排序算法的比较及应用
8.6.1 内部排序算法的比较
- 一般基于三个因素进行对比:时空复杂度、算法的稳定性、算法的过程特征
- 从过程特征看:冒泡排序、简单选择排序、快速排序和堆排序每趟处理后就可以确定一个元素的最终位置
8.6.2 内部排序算法的应用
- 通常情况,对排序算法的比较和应用考虑以下情况:
- 1)选取排序方法需要考虑的因素
- ① 待排序的元素数目 n
- ② 元素本身信息量的大小
- ③ 关键字的结构及其分布情况
- ④ 稳定性的要求
- ⑤ 语言工具的条件,存储结构及辅助空间的大小等
- 2)排序算法小结
- ① 若 n 较小,可采用直接插入排序或简单选择排序。由于直接插入排序所需的记录移动次数较简单选择排序的多,因而当记录本身信息量较大时,用简单选择排序较好
- ② 若文件的初始状态已按关键字基本有序,则选用直接插入或冒泡排序为宜
- ③ 若 n 较大,则应采用时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) 的排序算法:快速排序、堆排序或归并排序。快速排序被认为是目前基于比较的内部排序算法中最好的算法,当待排序的关键字随机分布时,快速排序的平均时间最短。堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况。这两种排序都是不稳定的,若要求排序稳定且时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n),则可选用归并排序。但从单个记录起进行的两两归并的排序算法并不值得提倡,通常可以将它和直接插入排序结合在一起使用。先利用直接插入排序求得较长的有序子文件,然后两两归并。直接插入排序时稳定的,因此改进后的归并排序仍是稳定的
- ④ 在基于比较的排序方法中,每次比较两个关键字的大小之后,仅出现两种可能的转移,因此可以用一棵二叉树来描述比较判定过程,由此可以证明:当文件的 n 个关键字随机分布时,任何借助于“比较”的内部排序算法,至少需要 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) 的时间
- ⑤ 若 n 很大,记录的关键字位数较少且可以分解时,采用基数排序较好
- ⑥ 当记录本身信息量较大时,为避免耗费大量时间移动记录,可用链表作为存储结构
8.7 外部排序
8.7.1 外部排序的基本概念
- 前面介绍过的排序方法都是在内存中进行的(称为内部排序)
- 而在许多应用中,经常需要对大文件进行排序,因为文件中的记录狠多、信息量庞大,无法将整个文件复制进内存中进行排序
- 因此,需要将待排序的记录存储在外存上,排序时再把数据一部分一部分地调入内存进行排序,在排序过程中需要多次进行内存和外存之间的交换,这种排序方法就成为外部排序
8.7.2 外部排序的方法
- 文件通常是按块存储在磁盘上的,操作系统也是按块对磁盘上的信息进行读写的。因为磁盘读/写的机械动作所需的时间远远超过内存运算的时间(相比而言可以忽略不计),因此外部排序过程中的时间代价主要考虑访问磁盘的次数,即 I/O 次数
- 外部排序通常采用归并排序法,它包括两个相对独立的阶段:
- ① 根据内存缓冲区大小,将外存上的文件分成若干长度为 l l l 的子文件,依次读入内存并利用内部排序方法对它们进行排序,并将排序后的道的有序子文件重新写回外存,称这些有序子文件为归并段或顺串
- ② 对这些归并段进行逐趟归并,使归并段(有序子文件)逐渐由小到大,直至得到整个有序文件为止
- 例如:
- 一个含有 20000 个记录的文件,首先通过 4 次内部排序得到 4 个初始归并段 R1~R4,每个段都含有 5000 个记录
- 然后对该文件做两两归并,直至得到一个有序文件:
- 把内存工作区等分为三个缓冲区,其中两个为输入缓冲区,一个为输出缓冲区
- 首先,将两个输入归并段 R1 和 R2 中分别读入一个块,放在输入缓冲区 1 和输入缓冲区 2 中
- 然后,在内存中进行 2 路归并,归并后的对象顺序存放在输出缓冲区中
- 若输出缓冲区中对象存满,则将其顺序写到输出归并段(R1’)当中,再清空输出缓冲区,继续存放归并后的对象
- 若某个输入缓冲区中的对象取空,则从对应的输入归并段中再读取下一块,继续参加归并
- 如此继续,直到两个输入归并段中的对象全部读入内存并都归并完成为止
- 当 R1 和 R2 归并完后,再归并 R3 和 R4,这是一趟归并
- 再把上趟的结果 R1’ 和 R2’两两归并,结果得到最终的有序文件,一共进行了 2 趟归并
- 一般情况下,外部排序的总时间 = 内部排序所需时间 + 外部信息读写的时间 + 内部归并所需的时间
- t E S = r ∗ t I S + d ∗ t I O + S ( n − 1 ) t m g t_{ES} = r * t_{IS} + d* t_{IO} + S(n-1)t_{mg} tES=r∗tIS+d∗tIO+S(n−1)tmg
- 其中, t E S t_{ES} tES 为外部排序时间, r r r 为初始划分的归并段的个数, t I S t_{IS} tIS 为每个归并段的内部排序时间, d d d 为进行磁盘 I/O 的次数, t I O t_{IO} tIO 为一个归并段一次读/写的时间, S S S 内部归并比较的趟数, n − 1 n-1 n−1 为每趟需要比较的次数, t m g t_{mg} tmg 为一个记录取得一个最小关键字所需要的时间
- 例:20000 个记录,初始归并段 5000 个记录,则 t E S = 4 ∗ t I S + 3 ∗ ( 4 + 4 ) ∗ t I O + 2 ∗ 20000 ∗ t m g t_{ES} = 4*t_{IS} + 3*(4+4)*t_{IO}+2*20000*t_{mg} tES=4∗tIS+3∗(4+4)∗tIO+2∗20000∗tmg
- 显然,外存信息读写的时间远大于内部排序和内部归并的时间,因此应着力减少 I/O 次数
- 一般地,对 r r r 个初始归并段,做 k k k 路平衡归并,归并树可用严格 k k k 叉树(即只有度为 k k k 与度为 0 0 0 的结点的 k k k 叉树)来表示。第一趟可将 r r r 个出使归并段归并为 ⌈ r / k ⌉ \lceil r/k \rceil ⌈r/k⌉ 个归并段,以后每 m m m 趟归并将 m m m 个归并段归并成 ⌈ m / k ⌉ \lceil m/k \rceil ⌈m/k⌉ 个归并段,直至最后形成一个大的归并段为止。树的高度 = ⌈ l o g k r ⌉ = = \lceil log_kr \rceil = =⌈logkr⌉= 归并趟数 S S S。可见,只要增大归并路数 k k k,或减少初始归并段个数 r r r 都能减少归并趟数 S S S,进而减少读写磁盘的次数,达到提高外部排序速度的目的
8.7.3 多路平衡归并与败者树
- 从8.7.2的讨论可知,增加归并路数 k k k 能减少归并趟数 S S S,进而减少 I/O 次数。然而,增加归并路数 k k k 时,内部归并的渐渐将增加。做内部归并时,在 k k k 个元素中选择关键字最小的记录需要比较 k − 1 k-1 k−1 次。每趟归并 n n n 个元素需要做 ( n − 1 ) ( k − 1 ) (n-1)(k-1) (n−1)(k−1) 次比较, S S S 趟归并总共需要的比较次数为
- S ( n − 1 ) ( k − 1 ) = ⌈ l o g k r ⌉ ( n − 1 ) ( k − 1 ) = ⌈ l o g 2 r ⌉ ( n − 1 ) ( k − 1 ) / ⌈ l o g 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(n−1)(k−1)=⌈logkr⌉(n−1)(k−1)=⌈log2r⌉(n−1)(k−1)/⌈log2k⌉
- 式中, ( k − 1 ) / ⌈ l o g 2 k ⌉ (k-1) / \lceil log_2k \rceil (k−1)/⌈log2k⌉ 随 k k k 增长而曾昭,因此内部归并时间亦随 k k k 的增长而增长。这将抵消由于增大 k k k 而减少外存访问次数所得到的效益。因此,不能使用普通的内部归并排序算法
- 为了使内部归并并不受 k k k 的增大的影响,引入了败者树:
- 败者树是树形选择排序的一种变体,可视为一棵完全二叉树
- k k k 个叶结点分别存放 k k k 个归并段在归并过程中当前参加比较的记录,内部结点用来记忆左右子树中的“失败者”,而让胜者继续往上进行比较,一直到根结点
- 若比较两个数,大的为失败者、小的为胜利者,则根结点指向的树为最小数
- 因为 k k k 路归并的败者树深度为 ⌈ l o g 2 k ⌉ \lceil log_2k \rceil ⌈log2k⌉,因此 k k k 个记录中选择最小的关键字,最多需要 ⌈ l o g 2 k ⌉ \lceil log_2k \rceil ⌈log2k⌉ 次比较。所以总的比较次数为
- S ( n − 1 ) ⌈ l o g 2 k ⌉ = ⌈ l o g k r ⌉ ( n − 1 ) ⌈ l o g 2 k ⌉ = ( n − 1 ) ⌈ l o g 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(n−1)⌈log2k⌉=⌈logkr⌉(n−1)⌈log2k⌉=(n−1)⌈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 S S。若总的记录个数为 n n n,每个归并段的长度为 l l l,则归并段的个数 r = ⌈ n / l ⌉ r = \lceil n/l \rceil r=⌈n/l⌉。采用内部排序方法得到的各个初始归并段长度都相同(除最后一段外),它依赖于内部排序书可用内存工作区的大小。因此,必须探索新的方法,来产生更长的初始归并段,这就是这里要介绍的置换-选择算法
- 设初始待排文件为 FI,初始归并段输出文件为 FO,内存工作区为 WA,FO 和 WA 的初始状态为空,WA 可容纳 w w w 个记录。置换-选择算法步骤如下:
- 1)从 FI 输入 w w w 个记录到工作区 WA
- 2)从 WA 中选出其中关键字取最小的记录,记为 MINIMAX 记录
- 3)将 MINIMAX 记录输出到 FO 中去
- 4)若 FI 不空,则从 FI 输入下一个记录到 WA 中
- 5)从 WA 中所有关键字比 MINIMAX 记录的关键字大的记录中选出最小关键字记录,作为新的 MINIMAX 记录
- 6)重复 3)~5),直至在 WA 中选不出新的 MINIMAX 记录为止,由此得到一个初始归并段,输出一个归并段的结束标志到 FO 中区
- 7)重复 2)~6),直至 WA 为空。由此得到全部初始归并段
- 上述算法,在 WA 中选择 MINIMAX 记录的过程需利用败者树来实现
8.7.5 最佳归并树
- 文件经过置换-选择排序后,得到的是长度不等的处室归并段。下面讨论如何组织长度不等的初始归并段的归并顺序,使得 I/O 次数最少。
- 归并树:用来描述 m m m 路归并,并只有度为 0 0 0 和度为 m m m 的结点的严格 m m m 叉树
- 设由置换-选择排序得到 9 个初始归并段,其记录的长度依次为 9, 30, 12, 18, 3, 17, 2, 6, 24,现做 3 3 3 路平衡归并,则其归并树如下图:
- 图中,各叶结点表示一个初始归并段,上面的权值表示该归并段的长度,叶结点到根的路径长度表示其参加归并的趟数,各非叶结点代表归并成的新归并段,根结点表示最终生成的归并段。树的带权路径长度 W P L WPL WPL 为归并过程中的总读记录,故 I/O 次数 = 2 × W P L = 484 = 2 \times WPL = 484 =2×WPL=484
- 显然,归并方案不同,所得归并树亦不同,输的带权路径长度(I/O 次数)亦不同。为了优化归并树的 W P L WPL WPL,可将哈弗曼树的思想推广到 m m m 叉树的情形,在归并树中,让记录数少的初始归并段最先归并,记录数多得初始归并段最晚归并,就可以建立总的 I/O 次数最少的最佳归并树
- 当叶子结点不够时,即初始归并段不足以构成一棵严格 k k k 叉树时,需添加长度为 0 0 0 的“虚段”,按照哈弗曼树的原则,权为 0 0 0 的叶子应离树根最远
- 如何判断添加虚段的数目?——设度为 0 0 0 的结点有 n 0 n_0 n0 个,度为 k k k 的结点有 n k n_k nk 个,则对严格 k k k 叉树有 n 0 = ( k − 1 ) n k + 1 n_0 = (k-1)n_k + 1 n0=(k−1)nk+1,由此可得 n k = ( n 0 − 1 ) / ( k − 1 ) n_k = (n_0-1)/(k-1) nk=(n0−1)/(k−1)
- 若 ( n 0 − 1 ) % ( k − 1 ) = 0 (n_0-1) \% (k-1) = 0 (n0−1)%(k−1)=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 \neq 0 (n0−1)%(k−1)=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 1 1 个内结点。它在归并树中代替了一个叶结点的位置,被代替的叶结点加上刚才多出的 u u u 个叶结点,即再加上 k − u − 1 k-u-1 k−u−1 个空归并段,就可以建立归并树
- 如上图所示,用 8 8 8 个归并段构成 3 3 3 叉树, ( n 0 − 1 ) % ( k − 1 ) = ( 8 − 1 ) % ( 3 − 1 ) = 1 (n_0-1) \% (k-1) = (8-1) \% (3-1) = 1 (n0−1)%(k−1)=(8−1)%(3−1)=1,说明 7 个归并段刚好可以构成一个严格 3 3 3 叉树,并且多出 1 1 1 个归并段。为此,将其中一个叶结点用内结点代替,使其成为被代替的叶结点以及多出的 1 1 1 个归并段的双亲,此时,再添加 3 − 1 − 1 = 1 3 -1 - 1 = 1 3−1−1=1 个空归并段同样作为该双亲结点的叶结点,就可以构成一棵严格 k k k 叉树