§9 - 内部排序
1 - 概述
相关概念
- 假设一个含有 n 个记录的序列 {R1, R2, …, Rn},其相应关键字序列为 {K1, K2, …, Kn},这些关键字可以相互比较,即存在关系:;按照此关系将序列重新排列为 {Rp1, Rp2, …, Rpn} 的操作称为排序
- 如果待排序序列中存在多个具有相同关键字的记录,若经过排序这些记录的相对次序保持不变,则称这种排序算法是稳定的,否则称这种排序算法是不稳定的
- 在内存(RAM)中进行的排序过程称为内部排序;若待排序序列中记录量大以至于内存不能一次性容纳全部记录,则在排序过程中需访问外存,该排序过程称为外部排序;以下“排序”特指内部排序
待排序序列的存储表示
待排序序列采用顺序存储,其数据类型定义如下:
typedef struct {
int key;
// 其它数据项
} RcdType;
typedef struct {
RcdType R[MAXSIZE+1]; // R[0]闲置
int length;
} SqList;
评价排序算法的主要标准
- 时间性能
- 取决于两个基本操作的次数
- 比较关键字
- 移动记录
- 具体表现与记录序列实例有关
- 最好
- 最坏
- 平均
- 取决于两个基本操作的次数
- 空间性能
- 取决于所需的辅助空间
2 - 插入类排序
将序列划分为已排序部分和未排序部分,即在插入第 i 个记录 Ri 时,{R1, R2, ..., Ri-1} 是已排序部分,{Ri, Ri+1, ..., Rn} 是未排序部分,此时:
- 在已排序部分中查找 Ri 的插入位置:R[1...j].key ≤ R[i].key < R[j+1...i-1].key(保持稳定性)
- 将 {Rj+1, ..., Ri-1} 中的所有记录均后移一位
- 将 Ri 插入到 Rj+1 的位置上
基于顺序查找的插入排序
实现
void InsertionSort(SqList &L) {
for (int i=2;i<=L.length;++i) {
L.R[0]=L.R[i]; // 设置监视哨
int j;
for (j=i-1;L.R[0].key<L.R[j].key;--j) // 查找
L.R[j+1]=L.R[j]; // 后移
L.R[j+1]=L.R[0]; // 插入
}
}
算法评价
-
时间性能
-
最好:序列中记录按关键字递增有序
比较次数:
移动次数:
时间复杂度:
-
最坏:序列中记录按关键字递减有序
比较次数:
移动次数:
时间复杂度:
-
平均:序列中记录随机排列
比较次数:取最好情况和最坏情况的平均值,约为
移动次数:取最好情况和最坏情况的平均值,约为
时间复杂度:
-
-
空间性能
- 空间复杂度:
适用于序列中记录数量很小的情况
稳定性:稳定
基于二分查找的插入排序
实现
void BiInsertionSort(SqList &L) {
for (int i=2;i<=L.length;++i) {
L.R[0]=L.R[i]; // 将L.R[i]暂存到L.R[0]
int low=1,high=i-1;
while (low<=high) { // 查找
int mid=(low+high)/2;
if (L.R[0].key<L.R[mid].key)
high=mid-1;
else
low=mid+1;
}
// 此时,low=high+1
for (int j=i-1;j>=low;--j) // 后移
L.R[j+1]=L.R[j];
L.R[low]=L.R[0]; // 插入
}
}
算法评价
- 平均时间复杂度:
- 空间复杂度:
当序列中记录数量很大时,可大幅降低关键字的比较次数
稳定性:稳定
希尔排序(缩小增量排序)
算法描述
-
将含有 n 个记录的待排序序列分成 d 个子序列:(d 称为增量)
{R[1], R[d+1], R[2d+1], …, R[kd+1]}
{R[2], R[d+2], R[2d+2], …, R[kd+2]}
...
{R[d], R[2d], R[3d], …, R[(k+1)d]}
-
分别对 d 个子序列进行 d 次插入排序,保持子序列之间的相对位置不变
-
增量 d 在排序过程中从大到小逐渐缩小,直至最后一趟排序减为 1
增量的取法:一般公认,增量没有公因子,最后一趟为 1
示例:
待排序序列关键字:{49, 38, 65, 97, 76, 13, 27, 49, 55, 04}
-
第一趟排序:d = 5
子序列:
{49, 13 },排序后:{13, 49 } { 38, 27 },排序后:{ 27, 38 } { 65, 49 },排序后:{ 49, 65 } { 97, 55 },排序后:{ 55, 97 } { 76, 04},排序后:{ 04, 76}整个序列排序后:{13, 27, 49, 55, 04, 49, 38, 65, 97, 76}
-
第二趟排序:d = 3
子序列:
{13, 55, 38, 76},排序后:{13, 38, 55, 76} { 27, 04, 65 },排序后:{ 04, 27, 65 } { 49, 49, 97 },排序后:{ 49, 49, 97 }整个序列排序后:{13, 04, 49, 38, 27, 49, 55, 65, 97, 76}
-
第三趟排序:d = 1
整个序列排序后:{04, 13, 27, 38, 49, 49, 55, 65, 76, 97}
实现
// 一趟排序
void ShellInsertionSort(SqList &L,int d) {
for (int i=d+1;i<=L.length;++i) {
L.R[0]=L.R[i];
int j;
for (j=i-d;L.R[0].key<L.R[j].key&&j>0;j-=d)
L.R[j+d]=L.R[j];
L.R[j+d]=L.R[0];
}
}
void ShellSort(SqList &L,int d[],int t) {
for (int i=0;i<t;++i)
ShellInsertionSort(L,d[i]);
}
算法评价
希尔排序将相隔某个增量的记录组成一个子序列,关键字较小的记录跳跃式地往前移,在进行最后一趟增量为 1 的排序时,序列已基本有序,只需作少量的关键字比较和记录移动即可完成排序
希尔排序的平均时间复杂度为 ,空间复杂度为 ;它没有时间复杂度为 的快速排序算法快,因此对中等规模的序列表现良好,但对大规模的序列不是最优选择,总之比时间复杂度为 的普通插入排序快得多
稳定性:不稳定
3 - 交换类排序
冒泡排序
算法描述
- 从 R[1] 开始,两两比较 R[i] 和 R[i+1] (i = 1, 2, ..., n-1) 的关键字大小,若 R[i].key > R[i+1].key,则交换 R[i] 和 R[i+1] 的位置,第一趟完成后 R[n] 是序列中关键字最大的记录
- 从 R[1] 开始,两两比较 R[i] 和 R[i+1] (i = 1, 2, ..., n-2) 的关键字大小,若 R[i].key > R[i+1].key,则交换 R[i] 和 R[i+1] 的位置,第二趟完成后 R[n-1] 是序列中关键字次大的记录
- 反复进行 n-1 趟上述过程
示例:
待排序序列关键字:{65, 97, 76, 13, 27, 49, 58}
- 第一趟结果:{65, 76, 13, 27, 49, 58, 97}
- 第二趟结果:{65, 13, 27, 49, 58, 76, 97}
- 第三趟结果:{13, 27, 49, 58, 65, 76, 97}
- 第四趟结果:{13, 27, 49, 58, 65, 76, 97}
- 第五趟结果:{13, 27, 49, 58, 65, 76, 97}
- 第六趟结果:{13, 27, 49, 58, 65, 76, 97}
实现
#include <algorithm>
// 冒泡排序
void BubbleSort(SqList &L) {
for (int i=0;i<L.length-1;++i) // i记趟数
for (int j=1;j<=L.length-i-1;++j)
if (L.R[j].key>L.R[j+1].key)
swap(L.R[j],L.R[j+1]);
}
算法改进
对于示例,第三趟完成后序列已经有序,后面三趟没有发生交换,浪费时间
改进:标记每趟是否有发生交换,若没有发生交换,则表示序列已经有序,结束排序
#include <algorithm>
// 改进的冒泡排序
void BubbleSort(SqList &L) {
for (int i=0,flag=1;i<L.length-1&&flag;++i) { // i记趟数,flag=1表示有发生交换
flag=0;
for (int j=1;j<=L.length-i-1;++j)
if (L.R[j].key>L.R[j+1].key) {
swap(L.R[j],L.R[j+1]);
flag=1;
}
}
}
某一趟完成后序列不一定整体有序,但其无序部分末尾却有可能已经有序
进一步改进:下一趟的比较到本趟最后一次发生交换的位置之前为止
#include <algorithm>
// 进一步改进的冒泡排序
void BubbleSort(SqList &L) {
for (int i=L.length;i>1;) {
int lastidx=1;
for (int j=1;j<i;++j)
if (L.R[j].key>L.R[j+1].key) {
swap(L.R[j],L.R[j+1]);
lastidx=j;
}
i=lastidx;
}
}
算法评价(对进一步改进的冒泡排序)
-
时间性能
-
最好:序列中记录按关键字递增有序,只需一趟冒泡
比较次数:n-1
移动次数:0
时间复杂度:
-
最坏:序列中记录按关键字递减有序,需要 n-1 趟冒泡
比较次数:
移动次数:
时间复杂度:
-
平均:序列中记录随机排列
时间复杂度:
-
-
空间性能
- 空间复杂度:
稳定性:稳定
快速排序
快速排序是基于分治的排序算法,既是受二叉搜索树启发,也是冒泡排序的改进,被评为 20 世纪十大算法之一
算法描述
- 选定一个记录并以其关键字为枢轴(Pivot),凡关键字小于枢轴的记录均移动至该记录之前,凡关键字大于枢轴的记录均移动至该记录之后;使得序列被划分成两部分:枢轴前的序列记录的关键字小于等于枢轴,枢轴后的序列记录的关键字大于等于枢轴,枢轴对应记录已经在合适的位置上
- 分别对划分所得两部分子序列“递归”进行快速排序,直至序列只有一个记录
“凡关键字小于枢轴的记录均移动至该记录之前,凡关键字大于枢轴的记录均移动至该记录之后”用双指针实现:
-
设置 low 指针和 high 指针初始化为序列最小索引和序列最大索引
-
选定枢轴并将其对应记录复制到 R[0],然后将其对应记录与 R[low] 交换或直接选定 R[low].key 为枢轴,先处理 R[high](或:将其对应记录与 R[high] 交换或直接选定 R[high].key 为枢轴,先处理 R[low])
-
处理 R[high]:若 R[high].key ≥ R[0].key,则 high 前移一位,继续处理 R[high];否则将 R[high] 复制到 R[low],处理 R[low]
处理 R[low]:若 R[low].key ≤ R[0].key,则 low 后移一位,继续处理 R[low];否则将 R[low] 复制到 R[high],处理 R[high]
-
重复执行 3,直至 low = high,此时将 R[0] 复制到 R[low]
实现
int Partition(SqList &L,int low,int high) {
int pivot=L.R[low].key; // 直接选定R[low].key为枢轴
L.R[0]=L.R[low];
while (low<high) {
// 先处理R[high]
while (low<high&&L.R[high].key>=pivot)
--high;
L.R[low]=L.R[high];
while (low<high&&L.R[low].key<=pivot)
++low;
L.R[high]=L.R[low];
}
L.R[low]=L.R[0];
return low;
}
void QuickSort(SqList &L,int s,int t) {
if (s<t) {
int pivotloc=Partition(L,s,t);
QuickSort(L,s,pivotloc-1);
QuickSort(L,pivotloc+1,t);
}
}
算法评价
快速排序涉及递归,需要使用栈保存函数状态,所以其空间复杂度取决于递归至最深处栈中元素数,而其时间复杂度取决于 Partition 函数的时间复杂度 与执行该函数的次数(与递归至最深处栈中元素数数量级相同)之积,因此
-
最好:每次划分后,枢轴恰好将序列均分成两部分,枢轴位置在中间
空间复杂度:
时间复杂度:
-
最坏:每次划分后,其他所有记录恰好都在枢轴对应记录的一侧,快速排序退化为冒泡排序(与二叉搜索树退化为链表类似)
空间复杂度:
时间复杂度:
-
平均:序列中记录按关键字随机分布
结论:
空间复杂度:
时间复杂度:
尽可能远离最坏情况、接近最好情况的方法:
-
选定合适的枢轴
随机选定枢轴,或随机选取三个关键字并选定居中者为枢轴,一般选定 R[low].key, R[high].key, R[(low+high)/2].key 中居中者为枢轴(“中值快排法”)
-
与插入排序、堆排序结合使用
其他优化:三路快速排序
稳定性:不稳定
4 - 选择类排序
简单选择排序
算法描述
令 i 从 1 至 n-1,反复进行 n-1 趟以下过程:通过 n-i 次关键字间的比较,从 n-i+1 个记录中选择关键字最小的记录并与第 i 个记录交换
实现
#include <algorithm>
void SelectSort(SqList &L) {
for (int i=1;i<=L.length-1;++i) {
int minidx=i,min=L.R[i].key;
for (int j=i+1;j<=L.length;++j)
if (L.R[j].key<min) {
minidx=j;
min=L.R[j].key;
}
if (minidx!=i)
swap(L.R[minidx],L.R[i]);
}
}
算法评价
-
时间性能
-
比较次数:
移动次数:最好情况为 0,最坏情况为 3(n-1)
最好、最坏、平均时间复杂度:(时间性能与记录序列实例无关)
-
-
空间性能
- 空间复杂度:
稳定性:不稳定
树形选择排序
算法描述
模拟锦标赛进行选择排序:(结合示例理解)
- 先对 n 个记录的关键字进行两两比较得到 ⌈n/2⌉ 个较小者,再对 ⌈n/2⌉ 个较小者进行两两比较得到 ⌈⌈n/2⌉/2⌉ 个更小者,如此重复直至选出最小者,得到关键字最小的记录
- 将所得记录在序列中的对应记录的关键字改为 ∞,并根据 1 中原则更新该记录的产生路径上的关键字值,得到此时关键字最小的记录
- 重复执行 2,直至得到 n 个记录
示例:
待排序序列关键字:{49, 38, 65, 97, 76, 13, 27, 49}
算法评价
- 平均时间复杂度:(除关键字最小的记录外,每得到一个记录需进行 次比较)
- 空间复杂度:
稳定性:稳定
树形选择排序虽然有 的时间复杂度,但是排序过程中频繁与 ∞ 进行多余的比较,且开辟的辅助空间较多,堆排序可以弥补这些缺点
堆排序
数列 {, , ..., }:
- 若满足 且 ,则称为小顶堆(Max heap)
- 若满足 且 ,则称为大顶堆(Min heap)
通常将堆写成完全二叉树的形式, 是 的左孩子, 是 的右孩子
示例:
大顶堆 {98, 81, 49, 73, 36, 27, 40, 55, 64, 12} 可以写成:
算法描述
堆排序利用堆的特性对序列进行排序
需要解决两个问题:
-
如何在输出堆顶元素之后,调整剩余元素成为一个新的堆?
在输出堆顶元素之后,以堆中最后一个元素替代之,此时,对应二叉树中根结点的左右子树均为堆,仅需对根结点自上至下进行调整,使剩余元素成为一个新的堆
示例输出 98 后,将 12 提至堆顶,接着“对根结点自上至下进行调整”的过程如下:
-
如何由一个无序序列建堆?
对应二叉树中叶子结点均满足堆的定义,需从高度为 2 的非叶子结点到根结点,对以之为根的子树根据 1 中原则进行调整,使所有子树也均满足堆的定义
由无序序列 {40, 55, 49, 73, 12, 27, 98, 81, 64, 36} 建立示例中的大顶堆的过程如下:
具体过程与树形选择排序类似:
- 按关键字建大顶堆,得到关键字最大的记录
- 将所得记录与此时堆中最后一个记录交换(无需辅助空间,降低空间复杂度),调整剩余记录成为一个新的堆,得到此时关键字最大的记录
- 重复执行 n-1 次 2
实现
#include <algorithm>
// 已知{L.R[s],...,L.R[m]}中,记录的关键字除L.R[s]外均满足大顶堆的特性,HeapAdjust函数对L.R[s]自上至下进行调整,使{L.R[s],...,L.R[m]}成为一个大顶堆
void HeapAdjust(SqList &L,int s,int m) {
for (int j=2*s;j<=m;j*=2) {
if (j<m&&L.R[j].key<L.R[j+1].key)
++j;
if (L.R[s].key>=L.R[j].key)
break;
swap(L.R[s],L.R[j]);
s=j;
}
}
void HeapSort(SqList &L) {
for (int i=L.length/2;i>0;--i)
HeapAdjust(L,i,L.length);
for (int i=L.length;i>1;--i) {
swap(L.R[1],L.R[i]);
HeapAdjust(L,1,i-1);
}
}
算法评价
-
时间性能
-
建堆比较次数:结论为
调整比较次数:至多
(对深度为 h 的堆,HeapAdjust 比较次数至多 2(h-1);具有 n 个结点的完全二叉树的深度为 )
最好、最坏、平均时间复杂度:(时间性能与记录序列实例无关)
-
-
空间性能
- 空间复杂度:
适用于在记录数量较大的序列中,求关键字最大或最小的几个记录
稳定性:不稳定
5 - 归并排序
算法描述
基本思想:递归地将两个或两个以上有序子序列合并为一个有序序列,通常采用二路归并排序
示例:
待排序序列关键字:{25, 57, 48, 37, 12, 92, 86}
- 第一趟结果:{[25, 57], [37, 48], [12, 92], [86]}
- 第二趟结果:{[25, 37, 48, 57], [12, 86, 92]}
- 第三趟结果:{[12, 25, 37, 48, 57, 86, 92]}
实现
// source: https://github.com/imxtx/algorithms
// 将位置相邻的有序子序列arr[left...mid]和arr[mid+1...right]合并为一个有序序列tempArr[left...right]
void merge(RcdType arr[],RcdType tempArr[],int left,int mid,int right) {
// 标记左半区第一个未排序的元素
int l_pos=left;
// 标记右半区第一个未排序的元素
int r_pos=mid+1;
// 临时数组元素的下标
int pos=left;
// 合并
while (l_pos<=mid&&r_pos<=right) {
if (arr[l_pos].key<=arr[r_pos].key) // 左半区第一个剩余元素更小
tempArr[pos++]=arr[l_pos++];
else // 右半区第一个剩余元素更小
tempArr[pos++]=arr[r_pos++];
}
// 合并左半区剩余的元素
while (l_pos<=mid)
tempArr[pos++]=arr[l_pos++];
// 合并右半区剩余的元素
while (r_pos<=right)
tempArr[pos++]=arr[r_pos++];
// 把临时数组中合并后的元素复制回原来的数组
while (left<=right) {
arr[left]=tempArr[left];
++left;
}
}
// 归并排序
void msort(RcdType arr[],RcdType tempArr[],int left,int right) {
// 如果只有一个元素,那么不需要继续划分
// 只有一个元素的区域,本身就是有序的,只需要被合并即可
if (left<right) {
// 找中间点
int mid=(left+right)/2;
// 递归划分左半区
msort(arr,tempArr,left,mid);
// 递归划分右半区
msort(arr,tempArr,mid+1,right);
// 合并已经排序的部分
merge(arr,tempArr,left,mid,right);
}
}
// 归并排序入口
void MergeSort(SqList &L) {
// 分配一个辅助数组
RcdType tempArr[N];
// 调用实际的归并排序
msort(L.R,tempArr,1,L.length);
}
算法评价
-
时间性能
-
每一趟归并的时间复杂度为 ,共进行 趟
最好、最坏、平均时间复杂度:(时间性能与记录序列实例无关)
-
-
空间性能
- 辅助数组:
- 递归栈:
- 空间复杂度:
稳定性:稳定
6 - 基数排序
多关键字记录的排序
记录序列对多关键字有序是指:序列中任意两个记录都满足字典有序
方法一:最高位优先(Most Significant Digit first)
具体示例:
对打乱的扑克牌进行排序,排序后扑克牌对以下两个关键字有序:
- 花色:♣ < ♦ < ♥ < ♠
- 面值:2 < 3 < ... < A
- 花色地位高于面值
方法:先对所有扑克牌按花色进行排序,再分别对每批花色相同的扑克牌按面值进行排序
抽象描述:
- 先对所有记录,按地位最高的关键字进行排序
- 再分别对每批地位最高关键字相同的记录,按地位次高的关键字进行排序
- 再分别对每批地位最高关键字和地位次高关键字都相同的记录,按地位第三高的关键字进行排序
- 以此类推,直至按地位最低的关键字排序完毕
方法二:最低位优先(Last Significant Digit first)
具体示例:
对打乱的学生记录进行排序,排序后学生记录对以下三个关键字有序:
- 系别:1 < 2 < 3 < ...
- 班别:1 < 2 < 3 < ...
- 学号:1 < 2 < 3 < ...
- 系别地位高于班别,班别地位高于学号
方法:先对所有学生记录按学号进行排序,再对所有学生记录按班别进行稳定的排序,再对所有学生记录按系别进行稳定的排序
| 学生记录 | 3, 2, 30 | 1, 2, 15 | 3, 1, 20 | 2, 3, 18 | 2, 1, 20 |
|---|---|---|---|---|---|
| 按学号排序后的学生记录 | 1, 2, 15 | 2, 3, 18 | 3, 1, 20 | 2, 1, 20 | 3, 2, 30 |
| 按班别排序后的学生记录 | 3, 1, 20 | 2, 1, 20 | 1, 2, 15 | 3, 2, 30 | 2, 3, 18 |
| 按系别排序后的学生记录 | 1, 2, 15 | 2, 1, 20 | 2, 3, 18 | 3, 1, 20 | 3, 2, 30 |
抽象描述:
- 先对所有记录,按地位最低的关键字进行排序
- 再对所有记录,按地位次低的关键字进行稳定的排序
- 再对所有记录,按地位第三低的关键字进行稳定的排序
- 以此类推,直至按地位最高的关键字排序完毕
计数排序
算法描述
计数排序采用**“桶”**的思想,不需要比较关键字
示例:
待排序序列关键字:{2, 4, 1, 2, 5, 3, 4, 8, 7}
预处理 1:遍历待排序序列,构建桶数组
预处理 2:从小到大遍历桶数组,计算累计值数组
从大到小遍历待排序序列(保持稳定性),借助累计值数组将记录放到结果数组对应位置,过程如下:
算法评价
设待排序序列记录数为 n,桶数为 m
- 时间复杂度:
- 空间复杂度:
适用于关键字范围较小且已知的序列
稳定性:稳定
基数排序
算法描述
基数排序是用**“多关键字记录的排序”和“计数排序”**的思想实现“单关键字记录排序”的排序算法:
- 数字型或字符串型的单关键字可以看作由多个数位或多个字符构成的多关键字→可以采用“多关键字记录的排序”中最低位优先的方法
- 其中每组关键字序列都范围较小且已知,且使用相同的桶数组(这些“桶”称为基数)→可以采用“计数排序”作为最低位优先方法中所需的稳定排序算法
示例:
待排序序列关键字:{477, 241, 467, 005, 363, 081, 005}
设置基数为 0~9,先按关键字个位数进行计数排序,再按关键字十位数进行计数排序,最后按关键字百位数进行计数排序
算法改进(采用链式存储结构减少辅助空间)
- 待排序记录指针相连构成单链表
- 分配:按当前“关键字位”的值,将记录分配到代表各基数的链队列中
- 收集:按基数从小到大,将各队列首尾相连构成按当前“关键字位”有序的单链表
- 按“关键字位”从低到高,重复 2 和 3
示例:
待排序序列关键字:{477, 241, 467, 005, 363, 081, 005}
-
个位:
-
十位:
-
百位:
算法评价(对改进的基数排序)
设待排序序列记录数为 n,基数个数为 rd,分配收集趟数为 d
-
时间复杂度:(时间性能与记录序列实例无关)
其中,分配为 ,收集为
-
空间复杂度:
稳定性:稳定
7 - 五类排序算法比较
时间性能
- 平均时间复杂度
- :快速排序、堆排序、归并排序
- :基于顺序查找的插入排序、冒泡排序、简单选择排序
- 特殊:希尔排序 、基数排序
- 时间性能与记录序列实例无关的排序算法:简单选择排序、堆排序、归并排序、基数排序
- 当待排序序列按关键字有序时,基于顺序查找的插入排序和冒泡排序的时间复杂度能达到 ,快速排序的时间复杂度退化为
空间性能
- :基于顺序查找的插入排序、希尔排序、冒泡排序、简单选择排序、堆排序
- 基数排序
- 快速排序
- 归并排序 (空间性能最差)