【内部排序(插入、交换、选择)】
一、二路归并排序
1. 算法思想与实现步骤
1)算法思想:
二路归并排序是一种分治算法。它将待排序的序列分为两个子序列,分别对这两个子序列进行排序,然后将两个已排序的子序列归并成一个有序序列。
“归并”的含义是将两个或两个以上的有序表合并成一个新的有序表。假定待排序表含有 n 个记录,则可将其视为 n 个有序的子表,每个子表的长度为 1,然后两两归并,得到 ⌈n / 2⌉ 个长度为 2 或 1 的有序表;继续两两归并……如此重复,直到合并成一个长度为 n 的有序表为止,这种排序方法称为 2 路归并排序。
2)实现步骤:
① 分解:
–> 如果序列长度小于或等于 1,直接返回。
–> 否则,将序列拆分为两个子序列(通常是从中间位置拆分)。
② 递归排序:分别对左侧和右侧的子序列递归调用归并排序。
③ 归并:
–> 创建一个临时数组,用于存放合并后的结果。
–> 使用两个指针分别指向左侧和右侧子序列的起始位置,比较两个指针指向的元素,将较小的元素放入临时数组中,然后移动指针。
–> 将剩余的元素复制到临时数组中,最后再将临时数组中的元素复制回原序列。
2. 算法的C++代码
1)Merge() 的功能是将前后相邻的两个有序表归并为一个有序表。设两段有序表 A[low…mid] 、A [mid + 1…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[low…mid]和A[mid+1…high]各自有序,将它们合并成一个有序表
int i, j, k;
for(k=low; k<=high; k++){
B[k] = A[k]; // 将A中所有元素复制到B中
}
for(i=low, j=mid+l, k=i; i<=mid && j<=high; k++) {
if(B[i] <= B[j]) {A[k] = B[i++];} // 比较B的左右两段中的元素,将较小值复制到A中
else {A[k] = B[j++];}
}
while(i<=mid) {A[k++] = B[i++]; } // 若第一个表未检测完,复制
while(j<=high) {A[k++] = B[j++]; } // 若第二个表未检测完,复制
}
注意:上面的代码中,最后两个while 循环只有一个会执行。
2)一趟归并排序的操作是,调用 ⌈n / 2h⌉ 次算法 Merge(),将 L[1…n] 中前后相邻且长度为 h 的有序段进行两两归并,得到前后相邻、长度为 2h 的有序段,整个归并排序需要进行 ⌈log2n⌉ 趟。递归形式的 2 路归并排序算法是基于分治的,其过程如下:
I、分解:将含有 n 个元素的待排序表分成各含 n / 2 个元素的子表,采用 2 路归并排序算法对两个子表递归地进行排序。
II、合并:合并两个已排序的子表得到排序结果。
算法如下:
void MergeSort(ElemType A[], int low, int high) {
if(low < high) {
int mid = (low+high)/2; // 从中间划分两个子序列
MergeSort(A, low, mid); // 对左侧子序列进行递归排序
MergeSort(A, mid+l, high); // 对右侧子序列进行递归排序
Merge(A, low, mid, high); // 归并
}
}
3. 示例
下图所示为 2 路归并排序的一个例子,经过三趟归并后合并成了有序序列。
4. 算法的性能分析
1)空间复杂度:
Merge() 操作中,辅助空间刚好为 n 个单元,所以算法的空间复杂度为 O(n) 。
2)时间复杂度:
每趟归并的时间复杂度为 O(n),共需进行 ⌈log2n⌉ 趟归并,所以算法的时间复杂度为 O(nlog2n) 。
3)稳定性:
由于 Merge() 操作不会改变相同关键字记录的相对次序,所以 2 路归并排序算法是一种稳定的排序方法。
4)适用性:
归并排序适用于顺序存储和链式存储的线性表。
注意:一般而言,对于 N 个元素进行 k 路归并排序时,排序的趟数 m 满足 km = N, 从而 m = logkN,又考虑到 m 为整数,所以 m = ⌈logkN⌉ 。这和前面的 2 路归并是一致的。
二、基数排序
1. 算法思想与实现步骤
1)算法思想:
基数排序是一种很特别的排序方法,它不基于比较和移动进行排序,而基于关键字各位的大小进行排序。基数排序是一种借助多关键字排序的思想对单逻辑关键字进行排序的方法。它将整数按每位数字分组,从最低位到最高位依次进行排序。通常使用计数排序作为每位数字的稳定排序算法。
2)实现步骤:
① 找出最大值:找到待排序数组中最大的数,以确定需要排序的位数。
② 按位排序:
–> 从最低位到最高位进行排序:
–> 针对当前位,使用计数排序对数组进行排序。
–> 以每一位上的数字为关键字,保持前面已排序的结果。
③ 重复步骤 ② :继续处理下一个更高一位,直到所有位数都处理完为止。
2. 示例
假设长度为 n 的线性表中每个结点 aj 的关键字由 d 元组(kjd-1, kjd-2, … , kj1, kj0)组成,满足 0 <= kji <= r - 1(0 <= j < n, 0 <= i <= d - 1)。其中 kjd-1 为最主位关键字,kj0 为最次位关键字。
为实现多关键字排序,通常有两种方法:
- 第一种是最高位优先(MSD)法,按关键字位权重递减依次逐层划分成若干更小的子序列,最后将所有子序列依次连接成一个有序序列。
- 第二种是最低位优先(LSD)法,按关键字位权重递增依次进行排序,最后形成一个有序序列。
下面描述以 r 为基数的最低位优先(LSD)基数排序的过程,在排序过程中,使用 r 个队列 Q0, Q1, … , Qr-1 。基数排序的过程如下:
对 i = 0, 1, … , d-1,依次做一次“分配”和“收集”(其实是一次稳定的排序过程)。
- 分配:开始时,把 Q0, Q1, … , Qr-1 各个队列置成空队列,然后依次考察线性表中的每个结点aj(j = 0, 1, … , n - 1),若 aj 的关键字 kji = k,就把 aj 放进 Qk 队列中。
- 收集:把 Q0, Q1, … , Qr-1 各个队列中的结点依次首尾相接,得到新的结点序列,从而组成新
的线性表。
1)第一趟:每个关键字是 1000 以下的正整数,基数 r = 10,在排序过程中需要借助 10 个链队列,每个关键字由 3 位子关键字构成—— K1K2K3 ,分别代表百位、十位和个位,一共需要进行三趟“分配”和“收集“操作。第一趟分配用最低位子关键字 K3 进行,将所有最低位子关键字(个位)相等的记录分配到同一个队列,如下图(a)所示,然后进行收集操作,第一趟收集后的结果如下图(b)所示。
2)第二趟:分配用次低位子关键字 K2 进行,将所有次低位子关键字(十位)相等的记录分配到同一个队列,如下图(a)所示。第二趟收集后的结果如下图(b)所示。
3)第三趟:分配用最高位子关键字 K1 进行,将所有最高位子关键字(百位)相等的记录分配到同一个队列,如下图(a)所示,第三趟收集后的结果如下图(b)所示,至此整个排序结束。
3. 算法的性能分析
1)空间复杂度:
一趟排序需要的辅助存储空间为 t(t 个队列: t 个队头指针和 t 个队尾指针),但以后的排序中会重复使用这些队列,所以基数排序的空间复杂度为 O(t) 。
2)时间复杂度:
基数排序需要进行 d 趟分配和收集,一趟分配需要 O(n) ,一趟收集需要 O® ,所以基数排序的时间复杂度为 O(d(n + r)) ,它与序列的初始状态无关。
3)稳定性:
对于基数排序算法而言,很重要一点就是按位排序时必须是稳定的。因此,这也保证了基数排序的稳定性。
4)适用性:
基数排序适用于顺序存储和链式存储的线性表。
三、*计数排序
1. 算法思想与实现步骤
1)算法思想:
计数排序也是一种不基于比较的排序算法,适用于范围有限且元素个数较少的整数集合。它通过计算每个元素出现的次数(频率)来确定元素的位置。计数排序的思想是:对每个待排序元素 x,统计小于 x 的元素个数,利用该信息就可确定 x 的最终位置。当有几个元素相同时,该排序方案还需做一定的优化。
2)实现步骤:
① 确定范围:找到待排序数组中的最大值和最小值,以确定计数数组的大小。
② 创建计数数组:创建一个计数数组(长度为最大值与最小值之差加 1),初始化所有元素为 0 。
③ 统计频率:遍历待排序数组,统计每个元素出现的次数,并将频率存入计数数组中。
④ 累加计数:将计数数组中的元素进行累加,以确定每个元素在排序后的最终位置。
⑤ 输出结果:创建一个输出数组,遍历计数数组,将每个元素根据其计数放入输出数组中。
⑥ 复制回原数组:将输出结果复制回原数组。
2. 算法的C++代码
在计数排序算法的实现中,假设输入是一个数组 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;} // 初始化计数数组
for(i=0; i<n; i++) {C[A[i]]++;} // 遍历输入数组,统计每个元素出现的次数,C[A[i]]保存的是等于A[i]的元素个数
for(i=l; i<k; i++) {C[i] = C[i]+C[i-1];} // C[x]保存的是小于或等于x的元素个数
for(i=n-l; i>=0; i--){ // 从后往前遍历输入数组
B[C[A[i]]-1] = A[i]; // 将元素A[i]放在输出数组B[]的正确位置上
C[A[i]] = C[A[i]]-1;
}
}
- 第一个 for 循环执行完后,数组 C 的值初始化为 0。
- 第二个 for 循环遍历输入数组 A,若一个输入元素的值为 x,则将 C[x] 值加 1,该 for 循环执行完后,C[x] 中保存的是等于 x 的元素个数。
- 第三个 for 循环通过累加计算后,C[x] 中保存的是小于或等于 x 的元素个数。
- 第四个 for 循环从后往前遍历数组 ,把每个元素 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] 的前一个位置上。
3. 示例
假设输入数组 A[ ] = {2, 4, 3, 0, 2, 3},第二个 for 循环执行完后,辅助数组 C 的情况如下图(a)所示;第三个 for 循环执行完后,辅助数组 C 的情况如下图(b)所示。图(c)至图(h)分别是第四个 for 循环每迭代一次后,输出数组 B 和辅助数组 C 的情况。
由上面的过程可知,计数排序的原理是:数组的索引(下标)是递增有序的,通过将序列中的元素作为辅助数组的索引,其个数作为值放入辅助数组,遍历辅助数组来排序。
4. 算法的性能分析
1)空间复杂度:
计数排序是一种用空间换时间的做法。输出数组的长度为 n;辅助的计数数组的长度为 k,空间复杂度为 O(n + k) 。若不把输出数组视为辅助空间,则空间复杂度为 O(k) 。
2)时间复杂度:
上述代码的第 1 个和第 3 个 for 循环所花的时间为 O(k),第 2 个和第 4 个 for 循环所花的时间为 O(n),总时间复杂度为 O(n + k) 。因此,当 k = O(n) 时,计数排序的时间复杂度为 O(n);但当 k > O(nlogn) 时,其效率反而不如一些基于比较的排序(如快速排序、堆排序等)。
3)稳定性:
上述代码的第 4 个 for 循环从后往前遍历输入数组,相同元素在输出数组中的相对位置不会改变,因此计数排序是一种稳定的排序算法。
4)适用性:
计数排序更适用于顺序存储的线性表。计数排序适用于序列中的元素是整数且元素范围(0 ~ k - 1)不能太大,否则会造成辅助空间的浪费。
四、各种内部排序算法的比较及应用
1. 比较
1)时间复杂度方面
- 简单选择排序、直接插入排序和冒泡排序平均情况下的时间复杂度都为 O(n2),且实现过程也较为简单,但直接插入排序和冒泡排序最好情况下的时间复杂度可以达到 O(n),而简单选择排序则与序列的初始状态无关。
- 希尔排序作为插入排序的拓展,对较大规模的数据都可以达到很高的效率,但由于希尔排序的时间复杂度依赖于增量函数,所以目前无法准确给出其时间复杂度。
- 堆排序利用了一种称为堆的数据结构,可以在线性时间内完成建堆,且在 O(nlog2n) 内完成排序过程。
- 快速排序基于分治的思想,虽然最坏情况下的时间复杂度会达到 O(n2),但快速排序的平均性能可以达到 O(nlog2n),在实际应用中常常优于其他排序算法。
- 归并排序同样基于分治的思想,但由于其分割子序列与初始序列的排列无关,因此它的最好、最坏和平均时间复杂度均为 O(nlog2n) 。
2)空间复杂度方面
- 简单选择排序、插入排序、冒泡排序、希尔排序和堆排序都仅需借助常数个辅助空间。
- 快速排序需要借助一个递归工作栈,平均大小为 O(Iog2n),当然在最坏情况下可能会增长到 O(n) 。
- 2 路归并排序在合并操作中需要借助较多的辅助空间用于元素复制,大小为 O(n),虽然有方法能克服这个缺点,但其代价是算法会很复杂而且时间复杂度会增加。
3)稳定性方面
- 插入排序、冒泡排序、归并排序和基数排序是稳定的排序方法。
- 简单选择排序、快速排序、希尔排序和堆排序都是不稳定的排序方法。
- 平均时间复杂度为 O(nlog2n) 的稳定排序算法只有归并排序。
对于不稳定的排序方法,只需举出一个不稳定的实例即可。
4)适用性方面
- 折半插入排序、希尔排序、快速排序和堆排序适用于顺序存储。
- 直接插入排序、冒泡排序、简单选择排序、归并排序和基数排序既适用于顺序存储,又适用于链式存储。
2. 应用
通常情况,对排序算法的比较和应用应考虑以下情况:
1)选取排序方法需要考虑的因素
① 待排序的元素数目 n 。
② 待排序的元素的初始状态。
③ 关键字的结构及其分布情况。
④ 稳定性的要求。
⑤ 存储结构及辅助空间的大小限制等。
2)排序算法小结
① 若 n 较小,可采用直接插入排序或简单选择排序。由于直接插入排序所需的记录移动次数较简单选择排序的多,因而当记录本身信息量较大时,用简单选择排序较好。
② 若文件的初始状态已按关键字基本有序,则选用直接插入或冒泡排序为宜。
③ 若 n 较大,则应采用时间复杂度为 O(nlog2n) 的排序方法:快速排序、堆排序或归并排序。当待排序的关键字随机分布时,快速排序被认为是目前基于比较的内部排序方法中最好的方法(平均时间最短)。堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况,这两种排序都是不稳定的。若要求排序稳定且时间复杂度为 O(nlog2n),则可选用归并排序。
④ 在基于比较的排序方法中,每次比较两个关键字的大小之后,仅出现两种可能的转移,因此可以用一棵二叉树来描述比较判定过程,由此可以证明:当文件的 n 个关键字随机分布时,任何借助于“比较”的排序算法,至少需要 O(nlog2n) 的时间。
⑤ 若 n 很大,记录的关键字位数较少且可以分解时,采用基数排序较好。
⑥ 当记录本身信息量较大时,为避免耗费大量时间移动记录,可用链表作为存储结构。
3)补充
① 每一趟排序都能至少确定一个元素在其最终位置上的排序算法有:快速排序、简单选择排序、堆排序、冒泡排序。其中简单选择排序、堆排序、冒泡排序在每趟处理后都能产生当前的最大值或最小值。
② 元素的移动次数与关键字的初始排列次序无关的是:基数排序。
③ 元素的比较次数与关键字的初始排列次序无关的是:简单选择排序、堆排序、归并排序、折半插入排序。
④ 算法的时间复杂度与关键字的初始排列次序无关的是:简单选择排序、堆排序、归并排序、基数排序。
⑤ 算法的排序趟数与关键字的初始排列次序无关的是:插入排序、选择排序、归并排序、基数排序。
⑥ 算法的排序趟数与关键字的初始排列次序有关的是:冒泡排序、快速排序。
⑦ m 路归并,每选出一个元素需要比较关键字 m - 1 次。
⑧ 基数排序是唯一一个不是基于比较的排序算法。
⑨ 有关元素个数规模的总结:
I、直接插入排序、冒泡排序和简单选择排序是基本的排序方法,它们主要用于元素个数 n 不是很大(n < 10000)的情形。
II、对于中等规模的元素序列(n <= 1000),希尔排序是一种很好的选择。
III、对于元素个数 n 很大的情况,可以采用快排、堆排序、归并排序或基数排序,其中快排和堆排序都是不稳定的,而归并排序和基数排序是稳定的排序算法。