数据结构之 排序
一、排序相关概念
根据在排序过程中待排序的记录是否全部被放置在内存中,排序分为:内排序和外排序。
内排序是在排序整个过程中,待排序的所有记录全部被放置在内存中。外排序是由于排序的记录个数太多,不能同时放置在内存中,整个排序过程需要在内外存之间多次交换数据才能进行。
根据排序过程中借助的主要操作,把内排序分为:插入排序、交换排序、选择排序和归并排序。
1.1 排序用到的结构和函数
为了讲清楚排序代码,先提供一个用于排序用的顺序表结构,此结构用于之后讲的所有排序算法。
1
2
3
4
5
6
7
|
#define
MAXSIZE 100 //用于排序数组个数最大值 typedef
struct { int
data[MAXSIZE + 1]; //用于存储要排序数组,r[0]用作哨兵或临时变量 int
length; //用于记录顺序表的长度 }SqList; |
1
|
|
1
2
3
4
5
6
7
|
/*
交换s中数组data的下表i和j的值 */ void
swap(SqList *s, int
i, int
j) { int
temp = s->data[i]; s->data[i]
= s->data[j]; s->data[j]
= temp; } |
二、具体排序算法
2.1 冒泡排序
冒泡排序是一种交换排序,它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。
冒泡的实现在细节上可以有很多种变化,将分别就3种不同的冒泡实现代码,来讲解冒泡排序的思想。
1)最简单的排序实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
//对顺序表s作交换排序(冒泡排序初级版本) void
BubbleSort0(SqList *s) { int
i, j; //s下标从1开始 for (i
= 1; i < s->length; i++) { for (j
= i + 1; j <= s->length; j++) { if (s->data[i]
> s->data[j]) { swap(s,
i, j); } } } } |
这段代码严格意义上来说,不算是标准的冒泡排序算法,因为它不满足“两两比较相邻记录”的冒泡排序思想,它更应该是最最简单的交换排序而已。它的思路是让每一个关键字,都和它后面的每一个关键字比较,如果大则交换,这样第一位置的关键字在一次循环后一定变成了最小值。具体情况可以看下图。
2)冒泡排序算法
再次介绍下冒泡排序的思想:
依次比较相邻的两个数,将小数放在前面,大数放在后面。即在第一趟:首先比较第1个和第2个数,将小数放前,大数放后。然后比较第2个和第3个数,将小数放前,大数放后,如此继续,直到比较最后两个数,将小数放前,大数放后。至此,第一趟结束,将最大的数放在了最后。在第二趟:仍从第一对数开始比较(因为可能由于第2个数和第3个数的交换,使得第1个数不再小于第2个数),将小数放前,大数放后,一直比较到倒数第二个数(倒数第一的位置上已经是最大的),第二趟结束,在倒数第二的位置上得到一个新的最大数(其实在整个数列中是第二大的数)。如此下去,重复以上过程,直至最终完成排序。
下面看看真正的冒泡排序算法,有没有可以改进的地方。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
//冒泡排序算法 void
BubbleSort1(SqList *s) { int
i, j; //下标从1开始 for (i
= 1; i < s->length; i++) { for (
j = s->length - 1; j >= i; j--) //注意j是从后往前循环 { if (s->data[j]
> s->data[j + 1]) //若前者大于后者(注意这里与上一算法的差异) { swap(s,
j, j + 1); //交换 } } } } |
3)冒泡排序的优化
这样的冒泡排序是否还可以优化呢?答案是肯定的。如果待排序的序列式{2, 1, 3, 4, 5, 6, 7, 8, 9},也就是说,除了第一和第二的关键字需要交换外,别的都已经是正常的顺序了。也就是说,此序列已经有序,不需要再继续后面的循环判断工作了。所以,对上面的冒泡排序代码进行改进下,增加一个标记变量flag来实现这一算法的思想。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
#define
TRUE 1 #define
FALSE 0 //对顺序表s作改进的冒泡算法 void
BubbleSort2(SqList *s) { int
i, j; int
flag = TRUE; //flag用来作为标记
for (i
= 1; i < s->length && flag; i++) { flag
= FALSE; for (j
= s->length - 1; j >= i; j--) { if (s->data[j]
> s->data[j + 1]) { swap(s,
j, j + 1); flag
= TRUE; } } }
} |
4)冒泡排序复杂度分析
分析下时间复杂度。当最好的情况,也就是要排序的表本身是有序的,那么比较次数,根据最后改进的代码,可以推断出就是n-1次比较,木有数据交换,时间复杂度为O(n)。当最坏的情况,即待排序表是逆序的情况,此时需要比较1+2+3+.....+(n-1) = n(n-1)/2次。因此,总的时间复杂度为O(n2)。
2.2 简单选择排序
1) 简单选择排序法
就是通过n-i次关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并和第i(1<=i<=n)个记录交换之。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
//对顺序表s作简单排序 void
SelectSort(SqList *s) { int
i, j, min; for (i
= 1; i < s->length; i++) { min
= i; //将当前下标定义为最小值下标
for (j
= i + 1; j <= s->length; j++) { if (s->data[min]
> s->data[j]) //如果有小于当前最小值的关键字
{ min
= j; //将此关键字的下标赋值给min
} } if (i
!= min) //若min不等于i,说明找到最小值,交换
{ swap(s,
i, min); } } } |
2)简单选择排序的复杂度分析
从简单选择排序的过程来看,它最大的特点就是交换移动数据次数相当少,这样也就节约了相应的时间。分析它的时间复杂度,无论最好最差的情况,其比较次数都是一样多,第i趟排序需要进行n-i次关键字的比较,此时需要n-1 +...+1 = n(n - 1)/2次。而对于交换次数而言,当最好的时候,交换次数为0,最差的时候,也就初始降序时,交换次数为n-1次。总的时间复杂度依然为O(n2)。
尽管简单选择排序与冒泡排序同为O(n2),但简单选择排序的性能上还是要优于冒泡排序的。
2.3 直接插入排序
直接插入排序的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
//直接插入排序 void
InsertSort(SqList *s) { int
i, j; for (i
= 2; i < s->length; i++) { if (s->data[i]
< s->data[i - 1]) { j
= i - 1; s->data[0]
= s->data[i]; while (s->data[j]
> s->data[0]) { s->data[j
+ 1] = s->data[j]; j--;
} s->data[j
+ 1] = s->data[0] ; } }
} |
从空间上看,它只需要一个记录的辅助空间,因此关键是看它的时间复杂度。当最好的时候,也就是要排序的表本身就是有序的,此时没有移动的记录,时间复杂度为O(n)。
当最坏的情况,即待排序表是逆序的情况,此时需要比较2+3+...+n = (n+2)(n-1)/2次,而记录的移动次数也达到最大值3+4+...+n+1 = (n+4)(n-1)/2次。
直接插入排序的时间复杂度为O(n2)。可以看出,同样的O(n2)时间复杂度,直接插入排序法比冒泡排序和简单选择排序的性能要好一些。
2.4 希尔排序
下面先给出希尔排序的代码,然后仔细体会希尔排序的思想。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
//对顺序表进行希尔排序 void
ShellSort(SqList *s) { int
i, j; int
increment = s->length; do { increment
= increment / 3 + 1; //增量序列 for (i
= increment + 1; i <= s->length; i++) { if (s->data[i]
< s->data[i - increment]) { //需将s->data[i]插入有序增量子表 s->data[0]
= s->data[i]; for (j
= i - increment; j > 0 && s->data[0] < s->data[j]; j = j - increment) { s->data[j
+ increment] = s->data[j]; //记录后移动,查找插入位置
}
s->data[j
+ increment] = s->data[0]; //插入
} }
} while (increment
> 1); }
<span style= "font-size:16px;font-family:'sans
serif', tahoma, verdana, helvetica;line-height:1.5;" > </span> |
(1)程序开始运行,此时传入SqList参数的值为length = 9, r[10] = {0, 9, 1, 5, 8, 3, 7, 4, 6, 2}。这就是需要等待排序的序列,如下所示。
(2)第5行,变化increment就是那个增量,初始值让它等于待排序的记录数。
(3)第5~20行是do-while循环,它终止的条件是increment不大于1时,其实就是增量为1时就停止循环了。
(4)第8行,这一句很关键,也是难以理解的地方,后面还要谈到它,先放一放。执行increment = 9/3 + 1 =4。
(5)第9~18行是一个for循环,i从4 + 5开始到9结束。
(6)第11行,判断s->data[i]与s->data[i - increment]大小,s->data[5] = 3小于s->data[i-increment]=s->data[1]=9,满足条件,第12行,将s->data[5] = 3暂存如s->data[0]。第14-15行的循环只是为了将s->data[1]
的值赋给s->data[5],由于循环的增量是j-=increment,其实它就是循环了一次,此时j=-3。第16行,再将s->data[0] = 3赋值给s->data[j+increment] = s->data[-3+4] = s->data[1] = 3。如下图所示,事实上,这一段代码就干了一件事情,就是将第5位的5和第1位的9互换了位置。
(7)循环继续,i = 6,s->data[6] = 7 小于 s->data[i -increment] = s->data[2] = 1,因此不交换两者的数据。如下图所示。
(8)循环继续,i = 7,s->data[7] = 4 小于 s->data[i-increment] = s->data[3] = 5,交换两者数据。如下图所示。
(9)循环继续,i=8,s->data[8] = 6 小于 s->data[i-increment] = s->data[4] = 8,交换两者数据。如下图所示。
(10)循环继续,i =9,s->data[9]=2 小于 s->data[i-increment] = s->data[5] = 9,交换两者数据。注意,第13-14是循环,此时还要继续比较s->data[5]与s->data[1]的大小。因为2<3,所以还要交换s->data[5]与s->data[1]的数据,如下图所示。
最终一轮循环过后,数组的排序结果如下图所示。通过这样的排序,已经让整个序列基本有序了。中其实就是希尔排序的精华所在,它将关键字较小的记录,不是一步步地往前移动,而是跳跃式地往前移动,从而使得每次完成一次循环,整个序列就朝着有序迈进了一步。
(11)在完成一轮do循环后,由于increment=4>1因此需要继续do循环。第8行得到increment = 4/3 + 1 = 2。第9~18行for循环,i从2+1=3开始到9结束。当i=3、4时,不用交换,当i=5时,需要交换数据。如下图所示。
(12)此后,i=6、7、8、9均不交换,如下图所示。
(3)再次完成一轮do循环,increment=2>1,再次do循环,第8行得到increment=2/3+1=1,此时这就是最后一轮do循环了。尽管第9~18行for循环,i从1+1=2开始到9结束,但是由于当前序列已经基本有序,可交换数据的情况大为减少,效率其实很高。如下图所示,图中箭头连线为需要交换的关键字。
到处,完成了排序过程。如下所示。
希尔排序的关键并不是随便分组后各自排序,而是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动,使得排序效率提高。
这里的增量旋转就非常关键了,大量研究表明,当增量序列为dlta[k]=2t-k+1 (0<=k<=t<=|log(n+1)|)时,可以获得不错的效果。其时间复杂度为O(n3/2),要好于直接排序。
需要注意的是,增量排序最后一个增量必须等于1才行。另外,由于记录是跳跃式的移动,希尔排序并不是一种稳定的排序算法。
2.5 归并排序
为了说明归并排序的思想,来看下图所示,将本是无序的数组序列{16, 7, 13, 10, 9, 15, 3, 2, 5, 8, 12, 1, 11, 4, 6, 14},通过两两合并排序后再合并,最终获得一个有序的数组。注意观察它的形状,会发现,像极了一棵倒置的完全二叉树,通常涉及到完全二叉树结构的排序方法,效率一般不会低的——这就是讲述的归并排序法。
归并排序算法就是利用归并的思想实现的排序算法。它的原理是假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两再归并,得到|n/2|(|x|表示不小于x的最小整数)个长度为2或1的有序子序列;再两两归并,......,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
//对顺序表s进行归并排序 void
MergeSort(SqList *s) { MSort(s->data,
s->data, 1, s->length); }
//将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]和s[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]中
} }
//将有序的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 , q; 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 (q
= 0; q <= m - i; q++) { tr[k
+ 1] = sr[i + 1]; //将剩余sr[i...m]复制到tr中
} } if (j
< n) { for (q
= 0; q <= n - j; q++) { tr[k
+ 1] = sr[j + 1]; //将剩余的sr[j...n]复制到tr
} } } |
归并排序总的时间复杂度为O(nlogn),而且这是归并排序算法中最坏、最好、平均的时间性能。由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及递归时深度为logn的栈空间,因此空间复杂度为O(n+logn)。
归并排序是一种比较占用内存,但却效率高且稳定的算法。
2.6 快速排序
快速排序的基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分为对着两部分记录继续进行排序,以达到整个序列有序的目的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
//对顺序表s快速排序 void
QuickSort(SqList *s) { QSort(s,
1, s->length); }
//交换顺序表s中子表的记录,使枢纽记录到位,并返回其所在位置 //此时在它之前(后)的记录均不大于(小)于它
//作用:就是将选取的pivotkey不断交换,将比它小的换到它的左边,比它大的换到右边 //
它也在交换中不断更改自己的位置,直到完全满足这个要求即可。 int
Partition(SqList *s, int
low, int
high) { int
pivotkey; pivotkey
= s->data[low]; //用子表的第一个记录作枢纽元素 while (low
< high) { while (low
< high && s->data[high] >= pivotkey) { high--; }
swap(s,
low, high); //将比枢纽记录小的记录交换到低端 while (low
< high && s->data[low] <= pivotkey) { low++; }
swap(s,
low, high); //将比枢纽记录大的记录交换到高端
}
return
low; }
//对顺序表s中子序列s->data[low...high]作快速排序 void
QSort(SqList *s, int
low, int
high) { int
pivot; if (low
< high) { pivod
= Partition(s, low, high); //将s->data[low...high]一分为二 QSort(s,
low, pivot - 1); QSort(s,
pivot + 1, high); } } |
在最优的情况下,快速排序算法的时间复杂度为O(nlogn)。最坏的情况下,其时间复杂度为O(n2)。
1
|
|
其平均空间复杂度为O(logn)。
由于关键字的比较和交换是跳跃进行的,因此,快速排序是一种不稳定的排序方法。