本笔记根据《大话数据结构》而写。
一、基本概念
排序:假设含有n个记录的序列为{r1,r2,...,rn},其相应的关键字分别为{k1,k2,,...,kn},需要确定1,2,...,n的一种排列p1,p2,...,pn,使其相应的关键字满足kp1<=kp2<=...<=kpn(非递减或非递增)关系,即令序列成为一个按关键字有序的序列{rp1,rp2,...,rpn}
排序稳定性:假设ki=kj(1<=i<=n,1<=j<=n,i!=j),且在排序前的序列中ri领先于rj(i<j)。如果排序后ri仍领先于rj,则称所用的排序方法是稳定的;反之,若可能使得排序后的序列中rj领先于ri,则称所用的排序方法是不稳定的。
根据排序过程中待排序的记录是否全部被放置在内存中,排序分为:内排序和外排序。
排序算法受3个方面影响:时间性能,辅助空间,算法的复杂度。
内排序:插入排序,交换排序,选择排序和归并排序。
按照算法的复杂度分为两大类:冒泡排序、简单选择排序和直接插入排序属于简单算法,而希尔排序、堆排序、归并排序、快速排序属于改进算法。
二、排序用到的结构和函数
先提供一个用于排序用的顺序表结构,在之后的算法都用于要讲的排序算法,并且提供了排序常用到两元素交换函数。
#define MAXSIZE 10 /*用于要排序数组个数最大值,可根据需要修改*/
typedef struct {
int r[MAXSIZE+1]; /*用于存储要排序数组,r[0]用作哨兵或临时变量*/
int length;
}SqList;
/*交换L中数组r的下标为i和j的值*/
void swap(SqList *L,int i,int j){
int temp=L->r[i];
L->r[i]=L->r[j];
L->r[j]=temp;
}
二.1冒泡排序
冒泡排序(Bubble Sort)一种交换排序,它的基本思想是:两两比较相邻记录关键字,如果反序则交换,直到没有反序的记录为止。
二.1.1初级的冒泡排序
思路:让每一个关键字都和后面的每一个关键字比较,如果大则交换,这样第一位置的关键字在一次循环后一定变成最小值。
缺点:效率非常低
/*对顺序表L做交换排序(冒泡排序初级版)*/
void BubbleSort0(SqList *L){
int i,j;
for(i=1;i<L->length;i++){
for(j=i+1;j<=L->length;j++){
if(L->r[i]>L->r[j]){
swap(L,i,j); /*交换L->r[i]与L->r[j]的值*/
}
}
}
}
二.1.2正宗的冒泡排序算法
思路:较小的数字如同气泡慢慢浮到上面
缺点:效率较低
/*对顺序表L做冒泡排序*/
void BubbleSort(SqList *L){
int i,j;
for(i=1;i<L->length;i++){
for(j=L->length-1;j>=1;j--){ /*注意j是从后往前循环*/
if(L->r[j]>r[j+1])/*若前者大于后者*/{
swap(L,j,j+1); /**交换L->[j]与L->[j+1]的值/
}
}
}
}
二.1.3优化的冒泡排序算法
思路:添加一个flag标记位,如果都没有执行到交换,则代表接下来的子序列已经有序,则终止比较;可以避免因有序情况下无意义的循环判断。
缺点:效率较低
/*对顺序表L作改进冒泡算法*/
#define Status int
void BubbleSort2 (SqList *L){
int i,j;
Status flag = true; /*flag用来作标记*/
for(i=1;i<L->length && flag;i++){
flag=false; /*初始为false*/
for(j=L->length-1;j>=1;j--){
if(L->r[j]>L->r[j+1]){
swap(L,j,j+1); /*交换L->r[j]与L->r[j+1]的值*/
flag=true;/*如果有数据交换,则flag为true*/
/*如果if语句没执行,也就是后面都是已经有序的,
flag在一开始标记为flase,终止循环*/
}
}
}
}
时间复杂度:平均情况O(n^2)。最好情况O(n),最坏情况O(n^2)。
辅助空间:O(1)
稳定性:稳定
二.2 简单选择排序
简单选择排序算法思想:在排序时找到合适的关键字再做交换,并且只移动一次就完成相应关键字的排序定位工作。
简单选择排序法(Simple Selection Sort)就是通过n-i次关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并和第i(1<=i<=n)个记录交换之。
/*对顺序表L作简单选择排序*/
void SelectSort(SqList *L){
int i,j,min;
for(i=1;i<L->length;i++){
min=i; /*将当前下标定义为最小值下标*/
for(j=i+1;j<=j->length;j++){ /*循环之后的数据*/
if(L->r[min]>L->r[j]) /*如果有小于当前最小值的关键字*/
min=j; /*将此关键字的下标赋值给min*/
}
if (i!=min) /*若min不等于i,说明找到最小值,交换*/
swap(L,i,min); /*交换L->r[i]与L->r[min]的值*/
}
}
复杂度分析:最大的特点就是交换移动数据次数相当少,这样也就节约了相应的时间。最好情况和最坏情况的比较次数都是一样n*(n-1)/2次,而对于交换次数而言,最好的时候交换次数为0,最差的时候交换次数为n-1次,总的时间复杂度是O(n^2)。与冒泡排序相比,简单选择排序的性能上还是略优于冒泡排序。
二.3 直接插入排序
直接插入排序(Straight Insertion Sort):将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。
/*对顺序表L作直接插入排序*/
void InsertSort(SqList *L){
int i,j;
for(i=2;i<=L->length;i++){ //这里是从2开始,因为从后往前比较,先比较r[2]和r[1]的值,而r[0]是辅助空间,作为哨兵变量存放 ,这种是一步步把前边排好序,再向后排序的方式
if(L->r[i]<L->r[i-1]) /*需将L->r[i]插入有序子表*/
{
L->r[0]=L->r[i]; /*r[0]作为哨兵*/
for(j=i-1;L->r[j]>L->r[0];j--){ /*不断循环知道L->r[j]>L->r[0]或者更坏情况下的L->r[0]>L->r[0]的情况不成立则退出循环*/
L->r[j+1]=L->r[j]; /*记录后移*/
}
L->r[j+1]=L->r[0]; /*插入到正确的位置,因为是先移到下一位到r[j+1]再执行j--,所以空出的位置应该是L->r[j+1],把值插入*/
}
}
}
复杂度分析:从空间上看,需要一个记录的辅助空间,因此关键看它的时间复杂度。
最好的情况是有序的,比较次数就是n-1次,没有移动的记录,时间复杂度为O(n)。
最坏情况,待排序表是逆序的情况,需要比较(n+2)(n-1)/2次(i=2开始,用递推法可得,当为i时外层for循环比较1次,内层for循环比较i-1次,共i次,那么得到2+3+...+n=(n+2)(n-1)/2次比较),移动次数也达到了(n+4)(n-1)/2次(i=2开始,用递推法可得,当为i时外层for交换1次,内层for交换i次,所以共交换i+1次,那么3+4+...+n+1=(n+4)(n-1)/2次交换)
如果排序记录是随机的,根据概率相同的原则,平均比较和移动次数约为n*n/4次,直接得到时间复杂度为O(n^2)。
同样的时间复杂度,直接插入排序却比冒泡和简单选择排序的性能要好一些。
二.4 希尔排序
希尔排序(Shell Sort)是突破算法是时间复杂度O(n^2)的第一批算法。它实际是对直接插入排序的改进算法,把待排序记录分割,减少待排序记录的个数,并且使整个序列向基本有序发展。将相距某个”增量“的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。
其中以下程序中的increment可以看作是直接插入排序中的1,直接插入排序中的1就是Shell Sort以"增量"increment=1的算法,比较即可知道。
程序比较复杂,可以根据《大话数据结构》对该算法的计算机模拟分析。
/*对顺序表L作希尔排序*/
void ShellSort(SqList *L) {
int i,j;
int increment=L->length;
do {
increment=increment/3+1; /*增量序列*/
for (i=increment+1;i<=L->length;i++) {
if (L->r[i]<L->r[i-increment]) {
/*需将L->r[i]插入有序增量子表*/
L->r[0]=L-r[i]; /*暂存在L->r[0]*/
for (j=i-increment;j>0 && L->r[0]<L->r[j];j-=increment)
L-r[j+increment]=L->r[j]; /*记录后移,寻找插入位置*/
L->r[j+increment]=L->r[0]; /*插入*/
}
}
}while (increment > 1);
}
复杂度分析:通过阅读这段代码可知,将相隔某个”增量“的记录组成一个子序列,实现跳跃式的移动,使得排序的效率更高。
但是选取什么增量才让算法的效率最高,仍然是数学难题。
平均的情况是O(nlogn)~O(n^2)
最好情况:O(n^1.3)
最坏情况:O(n^2)
辅助空间:O(1)
由于记录是跳跃式的移动,希尔排序不是一种稳定的排序算法。
二.5 堆排序
堆是具有下列性质的完全二叉树:每个节点的值都大于或等于其左右孩子节点的值,称为大顶堆;或者每个节点的值都小于或等于其左右孩子节点的值,称为小顶堆。
如果按照层序遍历方式给节点从1开始编号,则节点之间满足以下关系:
k[i]>=k[2i],k[i]>=k[2i+1],下标i与2i和2i+1是双亲子女关系。
堆排序(Heap Sort)就是利用堆(假设利用大顶堆)进行排序的方法。基本思想是,将待排序的序列构成一个大顶堆。此时,整个序列的最大值就是堆顶的根节点。将它一走(其实就是将其与堆数组的末尾元素交换,此时,末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中的次小值。如此反复执行,便能得到一个有序序列了。
需要解决两个问题:1)如何由一个无序序列构建成一个堆? 2)如果在输出堆顶元素后,调整剩余元素成为一个新的堆?
/*对顺序表L进行堆排序*/
void HeapSort(SqList *L) {
int i;
for (i=L->length/2;i>0;i--) { /*把L中的r构建成一个大顶堆*/
HeapAdjust(L,i,L->length);
}
for (i=L->length;i>1;i--) {
swap (L,1,i); /*将堆顶记录和当前未经排序子序列的最后一个记录交换*/
HeapAdjust(L,1,i-1); /*将L->r[1...i-1]重新调整为大顶堆*/
}
}
第一段for循环为什么i从length/2开始,因为循环的length/2,length/2-1,...1都是有孩子的节点,该循环是为了构建成一个大顶堆。
那么到底是如何从下往上、从右到左,将子树调整成大顶堆的呢?看HeapAdjust函数是如何是实现的?
/*已知L->r[s..m]中记录的关键字除L->r[s]之外均满足堆的定义*/
/*本函数调整L->r[s]的关键字,使L->r[s..m]成为一个大顶堆*/
void HeapAdjust(SqList *L,int s,int m) {
int temp,j;
temp=L->r[s];
for(j=2*s;j<=m;j*=2) { /*沿关键字较大的孩子节点向下筛选*/
if (j<m && L->r[j]<L->r[j+1])
++j; /*j为关键字较大的记录的下标*/
if (temp>=L->r[j])
break; /*rc应插入在位置s上*/
L->r[s]=L->r[j];
s=j;
}
L->r[s]=temp; /*插入*/
}
算法比较复杂,详细解释看《大话数据结构》。
在HeapSort函数中,第二段for循环则实现了交换第一个值(最大值)和最后一个值,并且再对除去最后一个值(最大值)的剩余的子树(i-1个元素)重构大顶堆。不断循环则可以排序成功。
算法复杂度:它的主要运行时间消耗在初始构建堆和在重建堆时的反复筛选上。整个构建堆的时间复杂度为O(n),在正式排序时,第i次取堆顶记录重建堆需要用O(logi)时间,并且需要取n-1次堆顶记录。因此重建堆的时间复杂度为O(nlogn)。
所以总体的时间复杂度为O(nlogn)。最好最坏和平均时间复杂度均为O(nlogn),性能要远远好过冒泡、简单选择、直接插入的O(n^2)的时间复杂度。
空间复杂度:只有一个暂存单元。
由于记录的比较和交换是跳跃式进行,因此是不稳定排序方法。
由于构建堆的比较复杂,不适合数目较少的序列进行排序。
二.6 归并排序
归并排序(Merging Sort):原理是假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到大于或等于n/2个长度为2或1的有序子序列;再两两归并,......,如此反复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。
首先外封装一个函数MergeSort(SqList *L)
/*对顺序表L作归并排序*/
void MergeSort(SqList *L) {
MSort(L->r,L->r,1,L->length);
}
然后看看怎么实现MSort函数
/*将SR[s..t]归并排序为TR1[s..t]*/
void MSort(int SR[],int TR1[],int s, int t) {
int m;
int TR2[MAXSIZE+1];
if(s==t)
TR1[s]=SR[s];
else {
m=(s+t)/2; /*将SR[s..t]平分为SR[s..m]和SR[m+1..t]*/
MSort(SR,TR2,s,m); /*递归将SR[s..m]归并为有序的TR2[s..m]*/
MSort(SR,TR2,m+1,t); /*递归将SR[m+1..t]归并为有序TR2[m+1..t]*/
Merge(TR2,TR1,s,m,t); /*将TR2[s..m]和TR2[m+1..t]归并到TR1[s..t]*/
}
}
Msort函数实现的功能其实是两两分拆,变为独立的数组,从SR[]转变为TR2[],不断递归,当分拆结束后,就开始使用Merge函数对分拆成的TR2[]进行排序,变为有序的TR1[],最终达到排序的目的。
接下来看下Merge函数的实现
/*将有序的SR[i..m]和SR[m+1..n]归并为有序的TR[i..n]*/
void Merge(int SR[], int TR[], int i,int m,int n) {
int j,k,l;
for (j=m+1,k=i;i<=m && j<=n; k++) { /*将SR中记录由小到大归并入TR*/
if(SR[i]<SR[j])
TR[k]=SR[i++];
else
TR[k]=SR[j++];
}
if(i<=m) {
for (l=0;l<=m-i;l++)
TR[k+l]=SR[i+l]; /*将剩余的SR[i..m]复制到TR*/
}
if(j<=n) {
for (l=0;l<=n-j;l++)
TR[k+l]=SR[j+l]; /*将剩余的SR[j..n]复制到TR*/
}
}
算法较为复杂,可以详细看《大话数据结构》。
复杂度分析:一趟归并需要将SR[1]~SR[n]中相邻的长度为h的有序序列进行两两归并,并将结果放到TR1[1]~TR[n]中,这需要好肥O(n)的时间。而由完全二叉树的深度可知,整个归并排序需要进行log2(n)的整数次,因此总的时间复杂度为O(nlogn),这是最好最坏也是平均的时间性能。
原始记录序列需要数量相同的存储空间n,归并过程需要logn的占空间,所以空间复杂度为O(n+logn)。
由于Merge函数中有if(SR[i]<SR[j])语句,需要两两比较,无需跳跃,因此归并排序是一种稳定的排序算法。
总的来说,归并排序是一种比较占内存,却效率高且稳定的算法。
二.7 快速排序
快速排序(Quick Sort)基本思想:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
优化后的快速排序:
/*快速排序优化算法*/
int Partition1(SqList *L, int low,int high) {
int pivotkey;
int m=low+(high-low)/2; /*计算数组中间的元素的下标*/
if(L->r[low]>L->r[high])
swap(L,low,high); /*交换左端和右端数据,保证左端较小*/
if(L->r[m]>L->r[high]) /*交换中间与右端数据,保证中间较小*/
swap(L,high,m);
if(L->r[m]>L->r[low])
swap(L,m,low); /*交换中间与左端数据,保证中间较小,那么左端就是中间值*/
/*此时L.r[low]已经为整个序列左中右三个关键字的中间值*/
pivotkey=L->r[low]; /*用子表的第一个记录作枢轴记录*/
L->r[0]=pivotkey; /*将枢轴关键字备份到L->r[0]*/
while (low<high) {
while(low<high && L->r[high]>=pivotkey)
high--;
L->r[low]=L->r[high]; /*采用替换而不是交换的方式进行操作*/
while(low<high && L->r[low]<=pivotkey)
low++;
L->r[high]=L->r[low]; /*采用替换而不是交换的方式进行操作*/
}
L->r[low]=L->r[0]; /*将枢轴数值替换回L.r[low]*/
return low; /*返回枢轴所在位置*/
}
/*对顺序表L中的子序列L.r[low...high]作快速排序*/
#define MAX_LENGTH_INSERT_SORT 7
void QSort1(SqList *L, int low,int high) {
int pivot;
if((high-low)>MAX_LENGTH_INSERT_SORT) {
while (low<high) {
pivot=Partition1(L,low,high); /*L.r[low..high]一分为二*/
/*算出枢轴值pivot*/
QSort1(L,low,pivot-1); /*对低于表递归排序*/
low=pivot+1; /*尾递归,等同于QSort1(L,pivot+1,high);但比递归有更高性能*/
}
}
else
InsertSort(L); /*根据阀值判断是否采用插入排序*/
}
快速排序关键在于Partition1的实现,它就是先选取当中的一个关键字,然后想尽办法将它放到一个位置(对于关键字上面有优化,采用三数取中法),使得它左边的值都比它小,右边的值比它大,我们将这样的关键字叫做枢轴(pivot)。然后调用QSort1其实就是对枢轴两边的两组数组在做同样的Partition1操作,知道顺序全部正确为止。
复杂度分析:对于第一次Partition1应该是对整个数组扫描一遍,做n次比较,然后获得的枢轴将数组一份为二,那么格子还要T(n/2)的时间,不断划分下去就有了O(nlogn)。最坏情况是正序或逆序的排序,需要执行n-1次递归,然后第i次划分需要n-i次关键字的比较,所以比较次数为n(n-1)/2,最终的时间复杂度为O(n^2)。
平均复杂度为O(nlogn),最好情况为O(logn),最坏为O(n^2)。
空间复杂度:O(n)。
由于关键字的比较和交换是跳跃进行的,因此,快速排序是一种不稳定的排序方法。
三.总结
按排序过程中借助的主要操作分内排序:插入排序、交换排序、选择排序、归并排序四类。
插入排序:直接插入排序、希尔排序。
选择排序:简单选择排序、堆排序。
交换排序:冒泡排序、快速排序。
归并排序:归并排序。
按算法的简单性来分类:
简单算法:冒泡、简单选择、直接插入。
改进选法:希尔、堆、归并、快速。
排序方法 平均情况 最好情况 最坏情况 辅助空间 稳定性
冒泡 O(n^2) O(n) O(n^2) O(1) 稳定
简单选择 O(n^2) O(n^2) O(n^2) O(1) 稳定
直接插入 O(n^2) O(n) O(n^2) O(1) 稳定
希尔 O(nlogn)~O(n^2) O(n^1.3) O(n^2) O(1) 不稳定
堆 O(nlogn)O(nlogn)O(nlogn) O(1) 不稳定
归并 O(nlogn)O(nlogn) O(nlogn) O(n) 稳定
快速 O(nlogn) O(nlogn)O(n^2) O(logn)~O(n) 不稳定