第八章 排序技术
排序是数据处理中经常使用的一种操作,其主要目的是便于查找
学习重点:
① 各种排序算法的基本思想;
② 各种排序算法的执行过程;
③ 各种排序算法的设计;
④ 各种排序算法时间复杂度的分析;
⑤ 各种排序算法之间的比较;
学习难点:
① 快速排序、堆排序、归并排序、基数排序等算法;
② 快速排序算法的时间复杂度分析。
8.1 概述
8.1.1排序的基本概念
在排序问题中,通常将数据元素称为记录。
排序
给定一个记录序列(r1,r2,r3……rn),其相应的关键码分别为(k1,k2,k3…,kn),排序是将这些记录排序成顺序为(rs1,rs2,rs3……rsn)的一个序列,使得到相应的关键码满足ksi≤ks2≤…≤ksn(升序)或ksi≥ks2≥…≥ksn(降序)。简言之,排序是将一个记录的任意序列重新排列成一个按关键码有序的序列。
从操作角度看,排序时线性结构的一种操作,待排序记录可以用顺序存储结构或链接存储结构存储。不失一般性,为突出排序方法的主题,本章讨论的排序算法均采用顺序存储结构,并假定关键码为整型,且记录只有关键码一个数据项,即采用一微整型数组实现,数组的长度为n+1(下标为0处留作他用)。另外,假定排序都是将待排序的记录序列排序为升序序列。
正序、逆序
若待排序序列中的记录已按关键码排好序,称此记录序列为正序;若待排序序列中记录的排列顺序与排好序的顺序正好相反,称此记录序列列为逆序或反序。
趟
在排序过程中,将待排序的记录序列扫描一遍称为一趟。在排序操作中,深刻理解趟的含义能够更好地掌握排序方法的思想和过程。
排序算法的稳定性
假定在待排序的记录序列中,存在多个具有相同关键码的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,ki=kj,且ri在rj之前,在排序后的序列中,ri仍在rj之前,则称这种排序算法稳定;否则称为不稳定。
排序的分类
根据在排序过程中待排序的所有记录是否全部被放置在内存中,可将排序方法分为内排序和外排序两大类。内排序是指在排序的整个过程中,待排序的所有记录全部被放置在内存中;外排序是指由于待排序的记录个数太多,不能同时放置在内存,而需要将一部分记录放置在内存,另一部分记录放置在外存,整个排序过程需要在内外存之间多次交换数据才能得到排序的结果。
8.1.2排序算法的性能
排序时数据处理中经常执行的一种操作,往往属于系统的核心部分,因此排序算法的时间开销是衡量其好坏的最重要的标志。对于基于比较的内排序,在排序过程中通常需要进行下列两种基本操作:①比较,关键码之间的比较;②移动,记录从一个位置移动到另一个位置。所以,在待排序的记录个数一定的条件下,算法的执行时间主要消耗在关键码之间的比较和记录的移动上。因此,高效率的排序算法应该具有尽可能少的关键码比较次数和尽可能少的记录移动次数。
评价排序算法的另一个主要标准是执行算法所需要的辅助存储空间。辅助存储空间是指在待排序的记录个数一定的条件下,除了存放待排序记录占用的存储空间之外,执行算法所需要的其他存储空间。
8.2 插入排序
8.2.1直接插入排序
直接插入排序是插入排序中最简单的排序方法。其基本思想是:依次将待排序序列中的每一个记录插入到一个已排好序的序列中,知道全部记录都排好序。
在直接插入排序中,需解决的关键问题是:
⑴如何构造初始的有序序列?
⑵如何查找待插入记录的插入位置?
直接插入排序算法
void InsertSort(int r[ ], int n)
{
for (i=2; i<=n; i++)
{
r[0]=r[i];
for(j=i-1;r[0]<r[j]; j--)
r[j+1]=r[j];
r[j+1]=r[0];
}
}
直接插入排序只需要一个记录的辅助空间,用来作为插入记录的暂存单元和查找记录的插入的位置过程中的“哨兵”。
直接插入排序是一种稳定的排序方法。
直接插入排序算法简单、容易实现,当序列中的记录基本有序或待排序记录较少时,它是最佳的排序方法。但是当代排序的记录个数较多时,大量的比较和移动操作使直接插入排序算法的效率较低。
8.2.2 希尔排序
希尔排序是对直接插入排序的一种改进,改进的着眼点是:①若待排序记录按关键码基本有序,直接插入排序的效率很高;②由于直接插入排序算法简单,则在待排序记录个数较少时效率也很高。
希尔排序的基本思想是:先将整个待排序记录序列分割成若干个子序列,在子序列内分别进行直接插入排序,待整个序列基本有序时,再对全体记录进行一次直接插入排序。
在希尔排序中,需解决的关键问题是:
⑴应如何分割待排序记录,才能保证整个序列逐步向基本有序发展?
⑵子序列内如何进行直接插入排序?
希尔排序算法
void ShellSort (int r[ ], int n)
{
for (d=n/2; d>=1; d=d/2)
{
for (i=d+1;i<=n; i++)
{
r[0]=r[i];
for (j=i-d; j>0 && r[0]<r[j];j=j-d)
r[j+d]=r[j];
r[j+d]=r[0];
}
}
}
希尔排序算法的时间性能分析是一个复杂的问题,因为它是所取增量的函数。
8.3交换排序
8.3.1起泡排序
起泡排序是交换排序中最简单的排序方法,其基本思想是:两两比较相邻记录的关键码,如果反序则交换,直到没有反序的记录为止。
在起泡排序中,需要解决的关键问题是:
⑴在一趟起泡排序中,若有多个记录位于最终位置,应如何记载?
⑵如何确定起泡排序的范围,使得已经位于最终位置的记录不参与下一趟排序?
⑶如何判别起泡排序的结束?
起泡排序算法
void BubbleSort (int r[ ],int n)
{
exchange=n;
while (exchange!=0)
{
bound=exchange;exchange=0;
for (j=1; j<bound; j++)
if (r[j]>j[j+1]) {
r[j]←→r[j+1];
exchange=j;
}
}
}
起泡排序算法的执行时间取决于排序的趟数
8.3.2快速排序
快速排序是对起泡排序的一种改进,改进的着眼点是:在起泡排序中,记录的比较和移动是在相邻位置进行的,记录每次交换只能后移位置,因而总的比较次数和移动次数较多。在快速排序中,记录的比较和移动是从两端向中间进行的,关键码较大的记录一次能够从恰前面移动到后面,关键码较小的记录一次就能从后面移动到前面,记录移动的距离较远,从而减少了总的比较次数和移动次数。
快速排序一次划分算法
int Partition(int r[], int first, int end)
{
i=first; j=end;
while (i<j)
{
while (i<j && r[i]<=r[j]) j--;
if (i<j) {
r[i]←→r[j];
i++;
}
while (i<j && r[i]<r[j]) i++;
if(i<j){
r[j]←→r[i];
j--;
}
}
return i;
}
整个快速排序的过程可递归进行。若待排序序列中只有一个记录,则结束递归,否则进行一次划分后,再分别对划分得到的两个子序列进行快速排序(即递归处理)。
快速排序算法
void QuickSort(int r[], int first,int end)
{
if (first<end) {
pivot=Partition(r,first,end);
QuickSort(r, first, pivot-1);
QuickSort(r,pivot+1,end);
}
}
快速排序是一种不稳定的排序方法。
快速排序适用于待排序记录个数很大且原始记录随机排列的情况。快速排序的平均性能是迄今为止所有内排序算法最好的一种。
8.4 选择排序
选择排序是一类借助“选择”进行排序的方法,其主要思想是:每趟排序在当前待排序序列中选出关键码最小的记录,添加到有序序列中。
8.4.1 简单选择排序
简单选择排序是选择排序中最简单的排序方法,其基本思想是:第i趟排序在待排序序列r[i]~r[n](1≤i≤n-1)中选取关键码最小的记录,并和第i个记录交换作为有序序列的第i个记录。
在简单选择排序中,需解决的关键问题是:
(1) 如何在待排序序列中选出关键码最小的记录?
(2) 如何确定待排序序列中关键码最小的记录在有序序列中的位置?
具体实现过程为:
(1) 将整个记录序列划分为有序区和无序区,初始时有序区为空,无序区含有待排序的所有记录。
(2) 在无序区中选取关键码最小的记录,将它与无序区中的第一个记录交换,使得有序区扩展了一个记录,同时无序区减少了一个记录。
(3) 不断重复(2),直到无序区只剩下一个记录为止。此时所有的记录都已经按关键码从小到大的顺序排列。
下面给出完整的简单选择排序的算法:
简单选择排序算法 SelectSort
void SelectSort(int r[],int n)
{
for(i=1;i<n;i++)
{
index=i;
for(j=i+1;j<=n;j++)
if(r[j]<r[index])index=j;
if(index!=i)r[i]→→r[index];
}
}
在简单选择排序过程中,只需要一个用来作为记录交换的暂存单元。
简单选择排序是一种不稳定的排序方法。
8.4.2 堆排序
堆排序是简单选择排序的一种改进,改进的着眼点是:如何减少关键码的比较次数。简单选择排序在一趟排序中仅选出最小关键码,没有把一趟比较结果保存下来,因而记录的比较次数较多。堆排序在选出最小关键码的同时,也找出较小关键码,减少了在后面的选择中的比较次数,从而提高了整个排序的效率。
1. 堆的定义
堆是具有下列性质的完全二叉树:每个结点的值都小于或等于其左右孩子结点的值(称为小根堆);或者每个结点的值都大于或等于其左右孩子结点的值(称为大根堆)。
在调整堆的过程中,总是将根节点(即被调整结点)与左右孩子结点进行比较。若不满足堆的条件,则将根结点与孩子结点的较大者进行交换,这个调整过程一直进行到所有子树均为堆或将被调整结点交换到叶子为止。这个自堆顶至叶子调整过程称为“筛选”。
假设当前要筛选结点的编号为k,堆中最后一个结点的编号为m,并且结点k的左右子树均是堆,即r[k+1]~r[m]满足堆的条件,堆调整算法的C++描述如下:
筛选法调整堆的算法 Sift
void sift(int r[],int k,int m)
{
i=k;j=2*i;
while(j<=m)
{
if(j<m&&r[j]<r[j+1]) j++
if(r[i]>r[j]) break
else{
r[i]→→r[j];
i=j;j=2*i;
}
}
}
2. 堆排序
堆排序是利用堆(假设利用大根堆)的特性进行排序的方法。
在堆排序中,需解决的关键问题是:
(1) 如何将一个无序序列构造成一个堆(即初始建堆)?
(2) 如何处理堆顶记录?
(3) 如何调整剩余记录,成为一个新的堆(即重建堆)?
下面给出完整的堆排序算法:
堆排序算法 HeapSort
void HeapSort(int r[],int n)
{
for(i=n/2;i>=1;i--)
sift(r,i,n);
for(i=1;i<n;i++)
{
r[1]→→r[n-i+1];
sift(r,1,n-1);
}
}
在堆排序算法中,只需要一个用来交换的暂存单元。
堆排序是一种不稳定的排序方法。
8.5 归并排序
归并排序是一种借助“归并”进行排序的方法,归并的含义是将两个或两个以上的有序序列归并成一个有序序列的过程。归并排序的主要思想是:将若干有序序列逐步归并,最终归并为一个有序序列。
二路归并排序是归并排序中最简单的排序方法,其基本思想是:将若干个有序序列进行两两归并,直至所有待排序记录都在一个有序序列为止。
8.5.1 二路归并排序的非递归实现
在二路归并排序中,需要解决的关键问题是:
(1) 如何构造初始有序序列?
(2) 如何将两个相邻的有序序列归并成一个有序序列(称为一次归并)?
(3) 怎样完成一趟归并?
(4) 如何控制二路归并的结束?
下面给出具体的一次归并算法。
一次归并算法 Merge
void Merge(int r[],int r1[],int s,int m,int t)
{
i=s;j=m+1;k+s;
while(i<=m&&j<=t)
{
if(r[i]<=r[j]) r1[k++]=r[k++];
else r1[k++]=r[j++];
}
if(i<=m) while(i<=m)
r1[k++]=r[i++];
else while(j<=t)
r1[k++]=r[j++];
}
一趟归并排序算法如下:
一趟归并排序算法 MergePass
void MergePass(int r[],int r1[],int n,int h)
{
i=1;
while (i≤n-2h+1)
{
Merge(r,r1,i+h-1,i+2*h-1)
i+=2*h;
}
if(i<n-h+1) Merge(r,r1,i,i+h-1,n);
else for(k=i;k<=n;k++)
r1[k]=r[k];
}
归并排序非递归算法如下:
归并排序非递归算法 MergeSort1
void MergeSort1(int r[],int r1,int n)
{
h=1;
while(h<n)
{
Mergepass(r,r1,n,h);
h=2*h;
MergePass(r1,r,n,h);
h=2*h;
}
}
二路归并排序在归并过程中需要与待排序记录序列同样数量的存储空间,以便存放归并结果,因此其空间复杂度为O(n)。
二路归并排序是一种稳定的排序方法。
8.5.2 二路归并排序的递归实现
二路归并排序方法也可以用递归的形式描述,即首先将待排序的记录序列分为两个相等的子序列,并分别将这两个子序列用归并方法进行排序,然后调用一次归并算法Merge,再将这两个有序子序列合并成一个含有全部记录的有序序列。
算法如下:
归并排序的递归算法 MergeSort2
void MergeSort2(int r[],int r1[],int s,int t)
{
if(s==t) r1[s]=r[s];
else{
m=(s+t)/2;
MergeSort2(r,r1,s,m);
MergeSort2(r,r1,m+1,t);
Merge(r1,r,s,m,t);
}
}
8.5 分配排序
前面介绍的排序方法都是建立在关键码比较的基础上,分配排序是基于分配和手机的排序方法。其基本思想是:先将待排序记录序列分配到不同的桶里,然后再把各桶中的记录依次收集到一起。
8.5.1 桶式排序
在桶式排序中,需解决的关键问题是:
(1) 如何表示桶?即如何存储具有相同键值的记录?
(2) 如何进行分配操作?
(3) 如何进行收集操作?
分配操作即是将记录插入到相应的队列中,入队在静态链表上实现,并修改相应队列的队头指针和队尾指针,算法如下:
分配算法 Distribute
void Distribute(Node r[],int n,QueueNode q[],int m,int first)
{
i=first;
while (r[i].next!=-1)
{
k=r[i].key;
if(q[k].front==-1)q[k].front=i;
else r[q[k].rear].next=i;
q[k].rear=i;
i=r[i].next;
}
}
收集操作即是将所有队列收尾相接。算法如下:
收集算法 Collet
void Collet(Node r[],int n,QueueNoder q[],int m,int first)
{
k=0;
while (q[k].front!=-1)
k++;
first=q[k].front;
last=q[k].rear;
while (k<m)
{
k++;
if(q[k].front!=-1)
{
r[last].next=q[k].front;
last=q[k].rear;
}
}
r[last].next=-1;
}
桶式排序算法如下:
桶式排序算法
void BuckeSort(Node r[ ], int n, int m)
{
for (i=0; i<n; i++)
r[i].next=i+1;
r[n-1].next=-1; first=0;
for (i=0; i<m; i++)
q[i].front=q[i].rear=-1;
Distribute(r, n, q, m, first);
Collect (r, n, q, m, first);
}
由于桶采用队列作为存储结构,因此,桶式排序是稳定的。
8.5.2 基数排序
基数排序是借助对多关键码进行桶式排序的思想对关键码进行排序,首先给出多关键码排序的定义。
对多关键码进行排序可以有两种基本的方法:
(1) 最主位优先MSD。
(2) 最次位优先LSD。
基数排序需要修改分配操作的算法,第j趟分配时针对第j个子关键码,算法如下:
分配算法
void Distribute (Node r[ ], int n, QueueNode q[ ], int m, int first, int j)
{
i=first;
while (r[i].next!=-1)
{
k=r[i].key[j];
if (q[k].front==-1) q[k].front=i;
else r[q[k].rear].next=i;
q[k].rear=i;
i=r[i].next;
}
}
下面给出基数排序的具体算法:
基数排序算法
void RadixSort (Node r[ ], int n, int m, int d)
{
for (i=o; i<n; i++)
r [i].next=i+1;
r[n-1].next=-1;first=0;
for (i=0; i<m; i++)
q[i].front=q[i].rear=-1;
for (j=0; j<d; i++)
{
Distribute(r, n, q, m, first, j);
Collect (r, n, q, m, first);
}
}
由于桶采用队列作为存储结构,因此基数排序时稳定的。
8.6 各种排序方法的比较
1. 时间复杂度
2. 空间复杂度
3. 稳定性
4. 算法简单性
5. 待排序记录个数n的大小
6. 记录本身信息量的大小
7. 关键码的分布情况
思想火花————学会“盒子以外的思考”