在本文讨论中,如不做特别声明,都假设排序操作是按递增要求的,排序文件以记录作为存储结构,并假设关键字为整数。记录的结构类型如下:
#define MAXSIZE 100
typedef int KeyType;
typedef struct{
KeyType key;
InfoType otherinfo; //其他数据项
}RecType;
typedef RecType SeqList[MAXSIZE+1]; //表中0元素空着或用作哨兵单位
SeqList R; //R为待排的记录文件
1.插入排序
基本思想:每次将一个待排序的记录,按其关键字的大小插入到前面已排好序的文件中的适当位置,直到全部记录插入完为止。
1.1直接插入排序
基本操作:假设待排序的记录存储在数组R[1...n]中,在排序过程的某一时刻,R被分为两个子区间:R[1...i-1]和R[i...n],其中前一个是已排好序的有序区,而后一个是无序区。开始时有序区只有一个元素R[1],无序区为R[2...n]。排序过程中只需要每次从无序区中取出第一个元素,把它插入有序区的适当位置,使之成为新的有序区,依次插入直到无序区为空,有序区中包含n个元素。
void InsertSort(SeqList R)
{
for(int i = 2; i <= n; i++)
if(R[i].key < R[i-1]){ //若R[i].key>=R[i-1].key,则R[i]不动
R[0] = R[i];
for(int j = i-1; R[0].key < R[j].key; j--) //找插入位置
R[j+1] = R[j];
R[j+1 ]= R[i]; //R[i]插入到正确的位置
}
}
在最坏的情况下(反序),插入排序的关键字之间的比较次数和记录移动次数达到最大值。
最大比较次数:
最大移动次数:
因此,在最好的情况下(正序)的时间复杂度是O(n),最坏情况是O(n2),则平均时间复杂度仍为O(n2),因为不需要增加附加空间,所以空间复杂度为O(1)。
例如,给定一组关键字(46,39,17,23,28,55,18,46),要求按直接插入排序算法给出每一趟排序结果,排序过程如下表。
初始关键字 |
[46] [39,17,23,28,55,18,46] |
|
i=2 |
R[0].key=39 |
[39,46] [17,23,28,55,18,46] |
i=3 |
17 |
[17,39,46] [23,28,55,18,46] |
i=4 |
23 |
[17,23,39,46] [28,55,18,46] |
i=5 |
28 |
[17,23,28,39,46] [55,18,46] |
i=6 |
55 |
[17,23,28,39,46,55] [18,46] |
i=7 |
18 |
[17,18,23,28,39,46,55] [46] |
i=8 |
46 |
[17,18,23,28,39,46,46,55 ] |
1.2希尔排序
基本思想:先取定一个小于n的整数d1作为第一个增量,把数组R中的全部元素分成d1个组,所有下标距离为d1的倍数的元素放在同一组中,即R[1],R[1+d1],R[1+2d1],...为第一组,R[2],R[2+d1],R[2+2d1],...为第二组.......按着在各组内进行直接插入排序;然后再取d2(小于d1)为第二个增量,重复上述分组和排序,直到所取的增量dt=1,把所有的元素放在同一组中进行直接排序为止。
先从一个具体的例子来看希尔排序的过程。假设初始关键字(36,25,48,27,65,25,43,58,76,32)。其增量序列为5,3,1,排序过程如下:
下标 |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
根据d分组(组内进行直接插入排序) |
初始关键字 |
36 |
25 |
48 |
27 |
65 |
25 |
43 |
58 |
76 |
32 |
(36,25)(25,43)(48,58)(27,76)(65,32) |
d=5 |
25 | 25 | 48 | 27 | 32 | 36 | 43 | 58 | 76 | 65 | (25,27,43,65)(25,32,58)(48,36,76) |
d=3 |
25 | 25 | 36 | 27 | 32 | 48 | 43 | 58 | 76 | 65 | (25,25,36,27,32,48,43,58,76,65) |
d=1 |
25 | 25 | 27 | 32 | 36 | 43 | 48 | 58 | 65 | 76 |
在希尔排序过程中,开始增量较大,分组较多,每个组内的记录个数较少,因而记录的比较次数和移动次数都较少;越到后来增量越小,分组越少,每个组内的记录个数较多,但同时记录次序也越接近有序,记录的总比较次数和移动次数都要比直接插入排序少得多,n越大越明显。下面是希尔排序算法的具体描述:
void ShellInsert(SeqList R[],int dk)
{ //dk为当前的增量
for(int i = dk+1; i <= n; i++)
if(R[i].key < R[i-dk].key){
R[0] = R[i];
j = i-dk;
while(j > 0 && R[0].key < R[j].key){
R[j+dk] = R[j]; //记录后移,找插入位置
j = j-dk; //找前一个记录
}
R[j+dk] = R[0]; //插入正确位置
}
}
void ShellSort(SeqList R,int d[],int t)
{
for(k = 0; k < t; k++)
ShellInsert(R,d[k]);
}
2.交换排序
基本思想:两两比较待排序记录的关键字,如果发现两个记录的次序相反时即进行交换,直到所有记录都没有反序为止。
2.1冒泡排序
基本思想:通过相邻元素之间的比较和交换,使排序关键字较小的元素逐渐从底部移动到顶部,即从下标较大的元素移向下标较小的位置,就像水底下的气泡一样逐渐向上冒泡,所以使用该方法的排序称为“冒泡排序”。
算法描述如下:
void BubbleSort(SeqList R[])
{
int i, j, flag;
for(int i = 1; i < n; i++){ //最多进行n-1趟
flag = 0; //表示每一趟是否有交换
for(int j = n; j > i+1; j--) //进行第i趟排序
if(R[j].key < R[j-1].key){
R[0] = R[j-1]; //R[0]作为交换的暂存单元
R[j-1] = R[j];
R[j] = R[0];
flag = 1; //有交换,flag置1
}
if(flag == 0) return;
}
}
例如,假定有8个记录的关键字序列为(36,28,45,13,67,36,18,56),给出该序列冒泡排序的全过程。
|
|
从后向前依次进行交换元素对 |
初始关键字 |
[36 28 45 13 67 36 18 56] |
(36,18)(67,18)(45,13)(28,13)(36,13) |
第一趟 |
13 [36 28 45 18 67 36 56] |
(67,36)(45,18)(28,18)(36,18) |
第二趟 |
13 18 [36 28 45 36 67 56] |
(67,56)(45,36)(36,28) |
第三趟 |
13 18 28 [36 36 45 56 67] |
无交换 |
第四趟 |
13 18 28 36 [36 45 56 67] |
结束 |
若待排序记录为有序(最好情况),则一趟扫描完成,关键字比较次数为n-1次且没有移动,时间复杂度为O(n);反之,若待排序记录为逆序,则需要进行n-1趟排序,每趟排序需要进行n-i次比较,而且每次比较都必须移动记录三次才能得到交换目的。
总比较次数:
总移动次数:
因此在平均情况下,比较和移动次数大约为最坏情况下的一半,则冒泡排序算法的时间复杂度为O(n2)。
【改进】实现双向冒泡排序算法
void DbubbleSort(SeqList R, int n)
{
int i, j;
RecType t;
bool NoSwap;
NoSwap = ture; //首先假设有交换,表示记录无序
i = 1;
while(NoSwap){ //当有交换时做循环
NoSwap = false; //置成无交换
for(int j = n-i-1; j >= i+1; j--) //自底向上扫描
if(R[j].key < R[j-1].key){ //若反序(后面的小于前面的)交换
t = R[j];
R[j] = R[j-1];
R[j-1] = t;
NoSwap = true;
}
for(int j = i+1; j < n-i; j++) //自顶向下扫描
if(R[j].key > R[j+1].key){ //若反序(前面的大于后面的)交换
t = R[j];
R[j] = R[j+1];
R[j+1] = t;
NoSwap = ture;
}
i = i+1;
}
}
2.2快速排序
基本思想:首先在当前的无序区R[]中任取一个记录作为排序比较的基准,用此基准将当前序列划分为两个较小的无序区,并使左边的无序区R[low...i-1]中的所有元素的关键字小于或等于基准关键字,右边的无序区R[i+1...high]中所有元素的关键字都大于或等于基准关键字,而基准记录x则位于位置i上。
每趟快速排序的具体操作:设两个指针i和j,它们的初值分别是low和high,基准记录x=R[i],首先从j所指位置起向前搜索,找到第一个比基准x.key小的记录存入i指向的位置,i自增1,然后从i位置向后搜索,找到第一个大于基准的记录存入j指向的位置,j自增1,;重复这两步直到i等于j为止。具体实现如下:
int Partition(SeqList R, int i, int j)
{
x = R[i]; //用区间的第一个记录作为基准
while( i < j ){
while( i < j && R[j].key >= x.key)
j--;
if( i < j ){
R[i] = R[j];
i++;
}
while( i < j && R[i].key <= x.key)
i++;
if( i < j){
R[j] = R[i];
j--;
}
}
R[i] = x;
return i;
}
void QuickSort(SeqList R, int low, int high)
{
if(low < high){
int p = Partition(R,low,high); //做一次划分
QuickSort(R,low,p-1); //对左区间递归排序
QuickSort(R,p+1,high); //对右区间递归排序
}
}
快速排序算法实例详解:
初始关键字 |
[48 33 61 82 72 11 25 47] |
区间第一个数作为基准 |
第一趟 |
[ 33 61 82 72 11 25 47] [47 33 61 82 72 11 25 ] [47 33 82 72 11 25 61] [47 33 25 82 72 11 61] [47 33 25 72 11 82 61] [47 33 25 11 72 82 61] [47 33 25 11] 48 [72 82 61] |
从j向前找到第一个小于等于基准的记录,移动到i 从i向后找到第一个大于等于基准的记录,移动到j 从j向前找到第一个小于等于基准的记录,移动到i 从i向后找到第一个大于等于基准的记录,移动到j 从j向前找到第一个小于等于基准的记录,移动到i 从i向后找到第一个大于等于基准的记录,移动到j i等于j,第一趟结束,基准移动到此位置 |
第二趟 |
[11 33 25] 47 48 [61] 72 [82] |
|
第三趟 |
11 [33 25] 47 48 61 72 82 |
|
第四趟 |
11 [25] 33 47 48 61 72 82 |
快速排序是不稳定的。一般来说,快速排序有非常好的时间复杂度,它优于其他各种排序算法。它的平均时间复杂度为O(nlogn)。但是,当待排序列有序时,复杂度反而增加了,原因是在第一趟快速排序中,经过n-1次比较后,第一个记录仍定位在原来的位置上,并得到一个包含n-1个元记录的子文件;第二次递归调用,经过n-2次比较,第二个记录仍定位在原来的位置,得到一个包括n-2个记录的子文件;依次类推,最后得到排序的总比较次数为:
这使快速排序转变成了冒泡排序,其实际爱你复杂度为O(n2)。
快速排序过程是递归的,因此需要一个栈空间来实现递归,栈的大小取决于递归调用的深度。若每一趟都能使待排序列比较平均的分割为两个字区间,则栈的最大深度为logn+1,即使在最坏的情况下,深度也不会超过n。因此,快速排序需要附件空间为O(logn)。
【改进】基准随机化
int Random(int a,int b) { return rand()%(b-a+1)+a; } int RandomPartition(SeqList R,int i,int j) { int k = Random( i, j); swap( R[k], R[i]); return Partition( R, i, j); }
3.选择排序
基本思想:每一趟在待排的记录中选出关键字最小的记录,依次存放在已排好序的记录序列的最后,直到全部记录排好序为止。
3.1直接选择排序
基本思想:每次从待排序的无序区中选择出关键字最小的记录,将记录与该区中的第一个记录交换位置。
直接选择排序过程如下:
初始关键字 | [28 33 65 82 76 38 24 11] |
第一趟 | 11 [33 65 82 76 38 24 28] |
第二趟 | 11 24 [65 82 76 38 33 28] |
第三趟 | 11 24 28 [82 76 38 33 65] |
第四趟 | 11 24 28 33 [76 38 82 65] |
第五趟 | 11 24 28 33 38 [76 82 65] |
第六趟 | 11 24 28 33 38 65 [82 76] |
第七趟 | 11 24 28 33 38 65 76 [82] |
直接选择排序共需要n-1次选择和交换,每次都需要做n-i次比较,而每次最多比较需要移动3次。因此,总比较次数为:。总移动次数为3(n-1)。由此可见,直接选择排序的平均时间复杂度为O(n2)。具体算法如下:
void SelectSort(SeqList R) { int i, j ,k; for( i = 1; i < n; i++) { //做n-1趟排序 k=i; //k为第i趟排序中关键字最小的位置 for( j = i+1; j <= n; j++) //在[i...n]中选择最小的记录 if( R[j].key < R[k].key) k = j; //记住比R[k].key小的记录的位置; if( k != i) { //与第i个记录交换; R[0] = R[i]; R[i] = R[k]; R[k] = R[0]; } } }
【单链表】采用单链表的存储结构实现直接选择排序
可有两种方法实现,一种是交换结点的数据域和关键字域值,而另一种是建立新表。在此不详细介绍了。
3.2堆排序
堆排序是一种树形选择排序,它的基本思想是:在排序过程中,将记录数组R[1...n]看成是一颗完全二叉树的顺序存储结构,利用完全二叉树中双亲结点和孩子结点之间的内在关系,在当前无序区中选择关键字最大(或最小)记录。
堆排序的关键是如何建堆,具体做法:把待排序的文件的关键字存放在数组R[1...n]中,将R看作是一颗完全二叉树的存储结构,每个节点表示一个记录,源文件的第一个记录R[1]作为二叉树的根,以下记录R[2...n]依次逐层从左到右顺序排列,构成一颗完全二叉树,任意结点R[i]的左孩子是R[2i],右孩子是R[2i+1],双亲是R[i/2]。假设要建大根堆,某一结点i对于它的左孩子和右孩子已经是堆,只需要将R[2i].key和R[2i+1].key中较大者与R[i].key比较,若R[i].key较小则交换,这样有可能破坏下一级的堆,继续采用上述构造方法下一级的堆,直到完全二叉树中结点i构成堆为止。调整为大根堆的算法如下:
void Sift(SeqList R, int i, int h) { //将R[i...h]调整为大根堆 int j; RecType x = R[i]; //把待筛结点暂存x中 j = 2 * i; //R[j]是R[i]的左孩子 while( j < h){ //当R[i]的左孩子不空时执行循环 if( j < h && R[j].key < R[j+1].key) j++; //若右孩子的关键字较大,j为较大的右孩子下标 if( x.key >= R[j].key) break; //找到x的最终位置,终止循环 R[i] = R[j]; //将R[j]调整到双亲位置上 i = j; j = 2 * i; //修改i和j的值,使i指向新的调整点 } R[i] = x; //将被筛结点放入最终的位置 }
根据堆的定义和建堆的过程知道,序号为1的结点是堆中n个结点关键字最大的结点。因此堆排序的过程是:首先把R[1]与R[n]交换,接着对R[1...n-1]进行筛选运算(建大根堆),又得到R[1]为当前的最大关键字,经过n-1次交换和筛选后,所有结点称为递增有序。因此,堆排序算法如下:
void HeapSort(SeqList R, int n) { for( int i = n/2; i > 0; i--) Sift(R,i,n); // for( i=n; i > 1; i--){ //对初始数组建大根堆 R[0] = R[1]; R[1] = R[i]; R[i] = R[0]; //交换 Sift(R,1,i-1); //对R[1...i-1]建大根堆 } }
下面给出一个例子(45,36,72,18,53,31,48,37):
首先构建初始大根堆:
i=4 需要调整,左孩子37>18
i=3 无须调整,72为根的子树满足大根堆
i=2 需要调整,右孩子53>左孩子37>36
i=1 需要调整,右孩子72>左孩子53>45
45为根的子树不满足大根堆,需要调整
得到初始大根堆
接下来就可以进行排序了,如下:
初始大根堆 |
[72 53 48 37 36 31 45 18] 无序区中根和最后一个记录交换 |
调整无序区为大根堆 |
第一趟 |
[18 53 48 37 36 31 45] [72] |
[53 37 48 18 36 31 45] [72] |
第二趟 |
[45 37 48 18 36 31] [53 72] |
[48 37 45 18 36 31] [53 72] |
第三趟 |
[31 37 45 18 36] [48 53 72] |
[45 37 31 18 36] [48 53 72] |
第四趟 |
[36 37 31 18] [45 48 53 72] |
[37 36 31 18] [45 48 53 72] |
第五趟 |
[18 36 31] [37 45 48 53 72] |
[36 18 31] [37 45 48 53 72] |
第六趟 |
[31 18] [36 37 45 48 53 72] |
无序区为大根堆不需要调整 |
第七趟 |
[18] [31 36 37 45 48 53 72] |
结束 |
在堆排序中,需要进行n-1趟选择,每次从待排序的无序区选择一个最大的结点,而选择最大值的方法是在各子树已是堆的基础上对根节点进行筛选,其时间复杂度为O(logn),所以整个排序的时间复杂度为O(nlogn)。
4.归并排序
基本思想:首先将待排序的文件看成为n个长度为1的有序子文件,把这些子文件两两归并,得到n/2个长度为2的有序子文件;然后再两两归并,如此反复,直至得到一个长度为n的有序文件为止。
两个有序序列归并为一个有序序列的算法如下:
void Merge(SeqList R, SeqList MR, int low, int m, int high) { //将R[low...m]和R[m+1...high]归并为有序序列MR[low...high] int i, j ,k; i = low; j = m+1; k = low; while( i <= m && j <= high) if( R[i].key <= R[j].key) MR[k++] = R[i++]; else MR[k++] = R[j++]; while( i <= m) //将R[low...m]中剩余的复制到MR中 MR[k++] = R[i++]; while( j <= high) //将R[m+1...high]中剩余的复制到MR中 MR[k++] = R[j++]; }
具体的一趟归并排序算法如下:
void MergePass(SeqList R, SeqList MR, int len, int n) { //len为子序列长度 for(int i = 1; i+2*len-1 <= n; i=i+2*len) Merge(R,MR,i,i+len-1,i+2*len-1); if( i+len-1 < n) //尚有两个子文件,其中最后一个长度<len Merge(R,MR,i,i+len-1,n); else //文件个数为奇数,最后一个子文件复制到MR中 for( int j = i; j <= n; j++) MR[j] = R[j]; }
整个归并排序算法如下:
void MergeSort(SeqList R, SeqList MR, int n) { int len = 1; while( len < n) { MergePass(R,MR,len,n); len = len * 2; } }
归并排序需要进行logn(向上取整)趟。每一趟归并的时间复杂度为O(n)。因此归并排序的时间复杂度为O(nlogn)。
5.基数排序
箱排序又称为桶排序,基本思想是:设置若干个箱子,依次扫描待排记录,把关键字等于k的记录全部都装入第k各箱子里,然后依序号将非空的箱子首尾连接起来。
基数排序是对箱排序的改进和推广。
来看个例子:(36,25,48,10,82,6,58,56,32)
箱子编号 |
0 1 2 3 4 5 6 7 8 9 |
一趟排序 按个位装箱 |
10 82 25 36 48 32 6 58 56 |
一趟排序结果 |
10 82 32 25 36 6 56 48 58 |
二趟排序 按十位装箱 |
6 10 25 32 48 56 82 36 58 |
二趟排序结果 |
6 10 25 32 36 48 56 58 82 |
把每个分量可能取值的个数rd称为基数。若关键字为数值型,且0<=k<=999,则rd=10(0...9),需要三次装箱;又若关键字为4个小写字母组成的字符,则rd=26(a...z),需要四次装箱。
基数排序的基本思想:首先按关键字的最低位进行装箱,然后按关键字进行箱排序;...最后按最高位进行箱排序。没有进行关键字的比较和看记录的移动,而只是顺序扫描表和进行指针赋值,所以排序的时间主要用在修改指针上。在每趟箱排序中,分配时需要将n个记录装入箱子,其时间为O(n),收集的时间也是O(rd),因此,一趟箱排序需要的时间是O(n+rd)。因为需要进行d趟排序,所以基数排序的时间复杂度为O(d*(rd+n))。
6.内部排序方法的分析比较
6.1时间复杂度
直接插入排序 直接选择排序 冒泡排序 |
O(n2) |
快速排序 归并排序 堆排序 |
O(nlogn) |
希尔排序 | O(nlogn)或O(n1.25) |
基数排序 | O(d*(rd+n)) |
6.2稳定性
稳定的:直接插入排序、冒泡排序、归并排序、基数排序
不稳定的;直接选择排序、希尔排序、快速排序、堆排序
6.3辅助空间(空间复杂度)
直接插入排序 直接选择排序 冒泡排序 基数排序 堆排序 |
O(1) |
快速排序 | O(logn) |
归并排序 | O(n) |
基数排序 | O(n+rd) |
6.4选取排序方法时需要考虑的主要因素
待排序的记录个数
记录本身的大小和存储结构
关键字的分布情况
对排序稳定性的要求
时间和空间复杂度
6.5排序方法的选取
若待排序的一组记录数目n较小(n<50)时,可采用插入排序或选择排序。
若n较大时,则采用快速排序、堆排序或归并排序。
若待排序记录按关键字基本有序,则适宜选用直接插入排序、冒泡排序或快速排序。
当n很大,而且关键字位数较少时,采用链式基数排序较好。
关键字比较次数与记录的初始排列顺序无关的排序是选择排序。
6.6 排序方法对记录存储方式的要求
当记录本身信息量较大时,插入排序、归并排序、基数排序易于在链表上实现。
快速排序、堆排序更适合用索引结构上的排序。
一般的排序方法都是在顺序结构(一维数组)上实现的。