目录
8.1 插入排序
8.1.1 直接插入排序
8.1.2 折半插入排序
8.1.3 希尔排序
8.2 交换排序
8.2.1 冒泡排序
8.2.2 快速排序
8.3 选择排序
8.3.1 简单选择排序
8.3.2 堆排序
1. 什么是堆
2. 堆排序的基本思想
8.4 归并排序和基数排序
8.4.1 归并排序
8.4.2 基数排序
8.5 各种内部排序的比较与应用
8.5.1 各种排序算法的性质
8.5.2 内部排序算法的应用
8.6 外部排序
8.6.1 外部排序的基本概念
8.6.2 外部排序的方法
8.6.3 多路平衡归并与败者树
8.6.4 置换-选择排序(生成初始归并段)
8.6.5 最佳归并树
插入排序基本思想是每次将一个待排序的记录按其关键字大小插入前面已排好序的子序列,直到全部记录插入完毕。
void InsertSort(ElemType a[], int n)
{
int i, j;
for (i = 2; i < n; i++) {//依次将a[2]~a[n]插入到前面已排序序列
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)。
时间效率:在排序过程中,向有序子表中逐个插入元素的操作进行了n-1趟,每趟操作都分为比较关键字和移动元素,而比较次数和移动次数取决于待排序表的初始状态。
所以平均情况下总的比较次数与总移动次数约为n^2/4。因此直接插入排序算法的时间复杂度为。
稳定性:由于每次插入元素总是从后往前比较再移动,所以不会改变相对位置,即直接插入排序是一个稳定的排序方法。
适用性:顺序存储和链式存储结构均适用。
void InsertSort2(ElemType a[], int n)
{
int i, j;
int 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;//若mid==a[0]为保证稳定性,继续向右查找
}
for (j = i - 1; j >= high + 1; --j)//插入到high之后
a[j + 1] = a[j];
a[high + 1] = a[0];
}
}
当排序表为顺序表时,才可以使用折半插入排序。从上述算法中,元素的比较次数为,该比较次数仅取决于元素个数n;而移动次数并未改变。因此折半插入排序的时间复杂度仍是。
折半插入排序是一种稳定的排序。
直接插入排序的时间复杂度是,但若是待排序列是正序的,其时间复杂度可提升为 。希尔排序正是基于这两点分析对直接插入排序进行改进而得来的,又称缩小增量排序。
void ShellSort(ElemType a[], int n)
{
int dk,i,j;
for (dk = n / 2; dk >= 1; dk /= 2) {
for (i = dk + 1; i <= n; ++i) {
if (a[i] < a[i - dk]) {
a[0] = a[i]; //此处a[0]仅作暂存作用
for (j = i - dk; j > 0 && a[0] < a[j]; j -= dk)
a[j + dk] = a[j];
a[j + dk] = a[0];
}
}
}
}
空间效率:仅使用了常数个辅助单元,空间复杂度为。
时间效率:当n处于某个特定范围时,时间复杂度约为,再最坏情况下时间复杂度为
希尔算法可能会改变记录的相对次序,因此是不稳定的算法;并且只适用于线性表为顺序存储结构的情况。
所谓交换,是指根据序列中两个元素关键字的比较结果来兑换这两个记录在序列中的位置。
冒泡排序的基本思想是:从后往前或者从前往后两两比较相邻元素的值,若为逆序(a[i-1]>a[i]),则交换他们。
每趟冒泡排序就是把序列中最小的元素放在序列的最终位置。
//冒泡算法
void swap(int a, int b)
{
int temp;
a = temp;
a = b;
b = temp;
}
void BubbleSort(ElemType a[], int n)
{
int i, j;
bool flag;
for (i = 0; i < n; i++) {
flag=false;//标记此趟是否发生了交换
for (j = n - 1; j > n; j--) {
if (a[j] < a[j - 1]){
swap(a[j], a[j - 1]);
flag = true;
}
}
}
if (flag = false)
return;
}
空间效率:仅使用了常数个辅助单元,空间复杂度为O(1)。
时间效率:最好情况下是初始序列有序,仅需比较n-1次,无需移动;最坏情况下是序列逆序,需要比较n-1次并且移动n-i次。从而平均时间复杂度为O(n^2)。
稳定性:冒泡排序是稳定的算法。
在待排序列任选一个元素作为pivot,通常是第一个元素,通过一趟排序使得pivot分为两部分,左部分比pivot小,右部分比pivot大,pivot放在其最终位置上。
//快速排序
int Partition(ElemType a[], int low, int high)
{
ElemType pivot = a[low];
while (low < high) {
while (low < high && a[high] > pivot)
high--; //以此找到比pivot小的元素
a[low] = a[high];
while (low < high && a[low] < pivot)
low++; //寻找比pivot大的元素
a[high] = a[low];
}
a[low] = pivot;
return low;
}
void QuickSort(ElemType a[], int low,int high)
{
if (low < high) { //跳出递归条件
int privotpos = Partition(a, low, high);
QuickSort(a, low, privotpos);
QuickSort(a, high, privotpos);
}
}
对于快速排序而言,排序的快慢取决于选取基准元素对整体切割的水平,左右越均匀,切割的越快,极端不均匀切割的最慢。
空间效率:由于快速排序是递归的,需要借助一个递归工作栈,最好情况下为,最坏情况下因为要进行n-1次递归调用,所以栈的深度为,平均情况下,栈的深度为。
时间效率:最好情况下时间复杂度为,最坏情况下为。
稳定性:不稳定啊啊。
选择排序的基本思想是:每一趟在后面n-i+1个待排序列元素中选取关键字最小的元素作为有序子序列第i个元素,直到n-1趟做完,待排序元素只剩1个。
每一趟排序确定一个元素的最终位置,可以在头可以在尾,经过n-1趟排序就可使得整个排序有序。
//快速排序
void SelectSort(ElemType a[], int n)
{
int i,j,min;
for (i = 0; i < n - 1; i++) {
min = i;
for (j = i + 1; j < n; j++) {
if (a[j] < a[min])
min = j;
}
if (min != i)
swap(a[min], a[i]);
}
}
空间效率:仅使用常数个辅助单元,故空间效率为。
时间效率:最好情况下元素只需移动0次,不会超过3(n-1)次;但元素间比较次数与序列的初始状态无关,始终是n(n-1)/2次,因此时间复杂度始终是。
稳定性:不稳定。
也就是父结点大于左右孩子结点。
注:在堆中最大的结点是叶子结点,若结点数为n,叶子结点存在的范围为。
可以将上述一维数组视为一棵完全二叉树,满足大根堆的n个元素建成初始堆;由于堆本身的特点,堆顶元素就是最大值。输出堆顶元素后,通常将堆底元素送入堆顶,此时根结点已不满足堆的性质,堆被破坏,将堆顶元素向下调整后再输出堆顶元素。
堆排序的关键是构造初始堆。如图构造:
//堆排序
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)
{
int i;
a[0] = a[k];
for (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; //修改k值以便继续查找
}
}
a[k] = a[0];//被筛选结点值放入最终位置
}
//堆排序算法
void HeapSort(ElemType a[], int len)
{
int i;
BuildMaxHeap(a, len); //创建初始堆
for (i = len; i > 1; i--) {
swap(a[i], a[1]);
HeadAdjust(a, 1, i - 1);
}
}
同时,堆也支持插入操作,对堆进行插入操作时,先将新结点放在堆的末端,再对这个结点进行调整如图。
空间效率:仅使用了常数个辅助单元,所以空间复杂度为。
时间效率:建堆时间为,之后有n-1次向下调整操作,每次调整时间复杂度为,堆排序时间复杂度。删除结点的时间复杂度可以看成向上调整删除结点为根的子树,因此时间复杂度为。
堆排序是不稳定算法。
归并排序中 归并 的含义是将两个或两个以上的有序表组合成一个新的有序表。假定带排序表含有n个记录,则可将其视为n个有序的子表,每个字表的长度为1,然后两两归并,得到个长度为2或1的有序表;继续两两合并直到合并成一个长度为n的有序表为止。
//归并排序
ElemType* b = (ElemType*)malloc((n + 1) * sizeof(ElemType));
void Merge(ElemType a[], int low, int mid, int high)
{
int i, j, k;
for (k = low; k <= high; k++)
b[k] = a[k]; //建立辅助数组b
for (i = low, j = mid + 1, k = i; 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++];
}
void MergeSort(ElemType a[], int low, int high) {
if (low <= high) {
int mid = (low + high) / 2;
MergeSort(a, low, mid);
MergeSort(a, low, high);
Merge(a, low, mid, high);
}
}
空间效率:Merge操作中,辅助空间刚好为n个单元,所以空间复杂度为。
时间效率:每趟归并的时间复杂度为,共需进行趟归并,所以算法的时间复杂度为。
稳定性:是稳定的排序方法。
基数排序不基于比较和移动进行排序,而是基于关键字各个位(十位百位···)进行排序。
为实现多关键字排序,通常有两种方法---最高位优先法(MSD)和最低位优先发(LSD),按关键字权重递增依次进行排序,最后形成一个有序序列。
通常采用链式基数排序如下图。
第一趟分配:
第二趟分配:
第三趟分配:
空间效率:一趟排序需要的辅助存储空间为r,但以后的排序中会重复使用这些队列,所以基数排序的空间复杂度为。
时间效率:基数排序需要d趟分配和收集,一趟分配需要O(n),一趟收集需要O(r),所以基数排序时间复杂度为。
对于之前讨论的算法,对各算法基于时间复杂度、空间复杂度、算法稳定性、算法过程特性经行了分析;由于算法稳定性,对同一个序列进行不同的排序,得到的结果可能不同。
1. 直接插入排序:
时间复杂度:最好情况为O(n),最坏情况为O(n^2),平均情况为O(n^2)。
空间复杂度为O(1)。
算法稳定。
2. 冒泡排序:
时间复杂度:最好情况为O(n),最坏情况为O(n^2),平均情况为O(n^2)。
空间复杂度为O(1)。
算法稳定。
3. 简单选择排序:
时间复杂度:最好情况为O(n^2),最坏情况为O(n^2),平均情况为O(n^2)。
空间复杂度为O(1)。
算法不稳定。
4. 希尔排序
时间复杂度:未知。
空间复杂度为O(1)。
算法不稳定。
5. 快速排序
时间复杂度:最好情况为,最坏情况为O(n^2),平均情况为。
空间复杂度为。
算法不稳定。
6. 堆排序:
时间复杂度:最好情况为,最坏情况为,平均情况为。
空间复杂度为O(1)。
算法不稳定。
7. 二路归并排序:
时间复杂度:最好情况为,最坏情况为,平均情况为。
空间复杂度为O(n)。
算法稳定。
8. 基数排序:
时间复杂度:最好情况为,最坏情况为,平均情况为。
空间复杂度为O(n)。
算法稳定。
若n较小,可采用直接插入排序或简单选择排序;由于直接插入排序移动次数较多,若文件较大,选择简单选择排序。
若文件已基本有序,选择冒泡排序于直接插入排序。
若n较大,则应采用时间复杂度为的排序方法:快速排序、堆排序或归并排序;若待排序列关键字随机分布时,快速排序是性能最好的排序,不会出现最坏情况。
在基于比较的排序方法中,每次比较两个关键字的大小之后,仅可能出现两种可能的转移,因此可以用一颗二叉树来描述判定过程:由此可以证明,当文件的n个关键字随机分布时,任何借于"比较"的算法至少需要的时间。
对于任意序列进行基于比较的排序,对任意n个关键字的比较次数至少为 。
若n超级大,记录的关键字为数较少且可以分解,采用基数排序。
当记录本身信息量较大时,为避免耗费大量时间移动记录,可用链表作为存储结构。
之前介绍过的排序方法都是在内存中进行的,称为内部排序。而在许多应用中,经常需要对大文件进行排序,因为文件中记录很多、信息量庞大,无法将整个文件复制进内存中进行排序。因此需要将待排序的记录存储在外存上,排序时再将数据一部分一部分地调入内存进行排序。在排序过程中需要多次进行内存和外存之间的交换。这种排序方法称为外部排序。
文件通常是按块存储在磁盘上的,操作系统也是按块对磁盘上的信息进行读写的。因为磁盘读写的机械动作所需的时间远远超过内存运算的时间,因此在外部排序过程中时间代价主要考虑访问磁盘的次数,即I/O次数。
外部排序通常采用归并排序法,它包括两个相对独立的阶段。
在外部排序中实现两两归并时,由于不可能将两个有序段及归并结果段同时存放在内存中,因此需要不停的将数据读出、写入磁盘,而这会耗费大量的时间。一般情况下
外部排序的总时间= 内部排序所需时间+ 外存信息读写的时间+ 内部归并所需的时间
如图每一趟归并需要进行16次读和16次写,3趟归并加上内部排序时所需要进行32*3+32=128次读写操作。若改用4路归并排序,则只需要2趟归并,外部排序总I/O=32*2+32=96次;因此增大归并路数,可以减少归并趟数,进而减少总的I/O次数。
为了使内部归并不受归并趟数k的增大所受影响,引入了败者树。败者树是树形选择排序的一种变体,可以视为一棵完全二叉树。k个叶子节点分别存放k个归并断在归并过程中当前参加比较的记录,内部结点用来记忆左右子树中的“失败者”,而让胜者继续往上比较直至根结点。
因为k路归并的败者树的深度为,因此k个记录中选择最小关键字,最多需要次比较,所以总的比较次数为。
所以使用败者树后,内部归并的比较次数与k无关,只要内存空间允许,增大归并路数k将有效减少归并树的高度,从而减少I/O次数,提高外部排序的速度。
由于减少初始归并段个数r可以减少归并趟数S。之前的老办法是:若总记录个数为n每个归并段长度为l,则归并段个数r=,它依赖于内存工作区的大小。现在利用置换选择排序
此算法选择MINIMAX可通过败者树来实现。
再得到长度不等的初始归并段后,要通过最佳归并树来得到I/O次数最少的树。叶结点到根结点的路径长度表示其参加归并的趟数,各非叶结点代表归并生成的新归并段,根结点表示最终生成的归并段。树的带权路径长度WPL为归并过程中的总读写记录数。故I/O次数=2*WPL=484。
若初始归并段不足以构造一棵严格的k叉树,需添加长度为0的虚段,按照哈夫曼树的原则继续建树。