注意:文中彩色代码均在Visual Studio 2022编译器中编写,本文为C语言数据结构手抄版,文中有部分改动,非原创。
目录
第七章 排序
学习目标
1.深刻理解各种内部排序方法的基本思想及其特点。
2,熟悉各种内部排序方法的排序过程。
3·掌握各种内部排序的算法的时间复杂度的分析方法,并熟记其分析结论。
4·能根据各种排序方法的优缺点及不同的应用场合,选择合适的方法进行排序。
7.1.基本概念
排序(Sort)是数据处理中经常使用的一种重要的运算。如何进行排序,特别是高效率地进行排序是计算机应用中的重要课题之一。本章着重介绍有关内部排序的一些常用方法,包括排序思想、排序过程、算法实现、时间和空间性能的分析及各种排序方法的比较和选择。
所谓排序,就是要整理文件中的记录,使得它按给定的关键字递增(或递减)的次序排列。如果待排序文件中存在多个关键字相同的记录,经过排序后,这些具有相同关键字的记录之间的相对次序保持不变,则称这种排序方法是稳定的;反之,则是不稳定的。
若整个待排序数据都在内存中处理,不涉及数据的内、外存交换,则称这种排序为内部排序(简称内排序);反之为外排序。按所用排序策略的不同,内部排序方法又可以插入、交换、选择、归并和分配排序。在本章中仅讨论内排序。
通常,在排序的过程中需要进行两种基本操作:比较两个关键字的大小、改变指向记录的指针或移动记录本身。而待排序记录的存储方式一般有三种:顺序结构、链式结构和辅助表形式。
评价排序算法的标准主要有两条:执行算法需要的时间,以及算法所需要的附加空间。另外,算法本身的复杂度也是考虑的重要因素之一。排序的时间开销,一般情况下可以用算法执行中关键字的比较次数和记录的移动次数来衡量。在本章的讨论中,若无特别声明,都假定排序操作是按递增要求的,排序文件以顺序表作为存储结构,并假定关键字为整数。
7.2.插入排序
插入排序的基本思想是:每次将一个待排序的记录按其关键字的大小插入到前面已排好序的文件中的适当位置,直到全部记录插入完为止。插入排序主要包括直接插入排序和希尔排序两种。
7.2.1.直接插入排序
直接插入排序是一种比较简单的排序方法,它的基本操作是:假设待排序的记录存储在数组array[1...n]中,在排序过程的某一时刻, array被划分成两个子区间, array[1...i-1]和array[i...n],其中前一个为已排好序的有序区,而后一个为无序区,开始时有序区中只含有一个元素array[1],无序区为array[2...n]。排序过程中,只需要每次从无序区中取出第一个元素,把它插入到有序区的适当位置,使之成为新的有序区,依次这样经过n-1次插入后,无序区为空,有序区中包含了全部n个元素,至此排序完毕。其算法描述如下:
#define _CRT_SECURE_NO_WARNINGS //规避C4996告警 #include <stdio.h> void insertSort() { int array[] = { 4, 2, 8, 5, 6, 10, 8, 1, 2, 0 }, sentry = 0, j; for (int i = 1; i < 10; i++) { if (array[i] < array[i - 1]) { sentry = array[i]; for (j = i; j > 0 && array[j - 1] > sentry; j--) { array[j] = array[j - 1]; } array[j] = sentry; } } for (int i = 0; i < 10; i++) { printf("%d ", array[i]); } } int main() { insertSort(); return 0; } |
运行结果:
0 1 2 2 4 5 6 8 8 10 |
算法中的sentry的作用是在进入查找循环之前保存array[i]的副本;在查找循环中"监视”数组下标变量j是否越界,一旦越界则说明找到了sentry的插入位置。因此,常把sentry称为哨兵。
直接插入排序算法有两重循环:外循环表示要进行n-1趟排序,内循环表明完成一趟排序所进行的记录关键字间的比较和记录的后移。在每一趟排序中,最多可能进行i次比较,移动i-1+2=i+1个记录(内循环前后作两次移动)。所以,在最坏情况下(反序),插入排序的关键字之间比较次数和记录移动次数达最大值。
由上述分析可知,当待排序文件的初始状态不同时,直接插入排序的时间复杂性有很大差别。最好的情况是文件初始为正序,此时的时间复杂度是O(n)。最坏的情况是文件初始状态为反序,相应的时间复杂度为O(n2)。容易证明,该算法的平均时间复杂度也是O(n2),这是因为对当前无序区array[2...i-1] (2≤i≤n),平均比较次数为(i-1) /2,所以总的比较和移动次数约为n (n-1) /4≈n2/4,因为插入排序不需要增加附加空间,所以其空间复杂度S(n)为O(1)。若排序算法所需要的额外空间相对于输入数据量来说是一个常数,则称该类排序算法为就地排序。因此,直接插入排序是一个就地排序。
例如,给定一组关键字(46, 39, 17, 23, 28, 55, 18, 46),要求按直接插入排序算法给出每一趟排序结果。
经过8趟排序,即可得到排序结果。从排序的结果中可以看到,两个相同关键字46在排序结束的相对次序仍保持不变,根据排序稳定性的定义可知,直接插入排序算法是稳定的。当然,排序的稳定性不能只是看一个特例。
7.3.2.折半插入排序
折半插入排序:在直接选择排序的基础之上,如果待排序下标i之前的位置已经是有序排列时,采用二分查找法将哨兵与待排序下标i一半位置的元素值比对,相等则插入一半位置,如果小于则继续去左子表直接选择排序,否则去右子表直接插入排序。
其算法描述如下:
#define _CRT_SECURE_NO_WARNINGS //规避C4996告警 #include <stdio.h> void insertSort() { int array[] = { 4, 2, 8, 5, 6, 10, 8, 1, 2, 0 }; int sentry = 0, start = 0, end = 0, middle = 0; for (int i = 1; i < 10; i++) { sentry = array[i]; start = 0; end = i - 1; while (start <= end) { middle = (start + end) / 2; if (sentry < array[middle]) { end = middle - 1; } else { start = middle + 1; } } for (int j = i; j > end + 1; j--) { array[j] = array[j - 1]; } array[end + 1] = sentry; } for (int i = 0; i < 10; i++) { printf("%d ", array[i]); } } int main() { insertSort(); return 0; } |
运行结果:
0 1 2 2 4 5 6 8 8 10 |
折半插入排序--算法分析
· 折半查找比顺序查快,所以折半插入排序就平均性能来说比直接插入排序要快;
· 它所需要的关键码比较次数与待排序对象序列的初始排列无关,仅依赖于对象个数。在插入第i个对象时,需要经过{[(log以2为底i)+ 1向下取整}次关键码比较,才能确定它应插入的位置。
当n较大时,总关键码比较次数比直接插入排序的最坏情况要好很多,但是比最好情况要差。
在对象的初始排列已经按关键码排好序或接近有序时,直接插入排序比折半插入排序执行的关键码比较次数要少。
· 折半插入排序的对象移动次数与直接插入排序相同,依赖于对象的初始排列。
减少了比较次数,但是没有减少移动次数。
平均性能优于直接插入排序。
时间复杂度为O(n2),空间复杂度为O(1),是一种稳定的排序方法。
7.2.3.希尔排序
希尔排序又称"缩小增量排序”,它是由希尔(D.LShell)在1959年提出的。其基本思想是:先取定一个小于数组长度size的整数group1作为第一个增量,把数组array中的全部元素分成group1个组,所有下标距离为group1的倍数的元素放在同一组中,即array[1], array[1+group1], array[1+group1],···为第一组, array[2], array[2+group1], array[2+group1], ···为第二组,接着在各组内进行直接插入排序;然后再取group2 (group2<group1)为第二个增量,重复上述分组和排序,直到所取的增量groupt=1 (groupt<groupt-1<···<group2<group1),把所有的元素放在同一组中进行直接插入排序为止。
假设初始关键字序列为(81,94,11,96,12,35,17,95,28,58,41,75,15),其增量序列的取值依次为5, 3, 1,排序过程如下图所示。
假设初始关键字序列为(36, 25, 48,27, 65, 25, 43, 58, 76, 32),其增量序列的取值依次为5, 3, 1,排序过程如图7.1所示。
通过上面的例子可以看到,两个相同关键字25,排序后25排到了25的前面,因此,希尔排序肯定是不稳定的。在希尔排序过程中,开始增量较大,分组较多,每个组内的记录个数较少,因而记录比较和移动次数都较少;越到后来增量越小,分组就越少,每个组内的记录个数也较多,但同时记录次序也越来越接近有序,因而记录的比较和移动次数也都较少。无论是从理论上还是实验上都已证明,在希尔排序中,记录的总比较次数和总移动次数都要比直接插入排序少得多,特别是当n越大时越明显。下面是希尔排序算法的具体描述:
#define _CRT_SECURE_NO_WARNINGS //规避C4996告警 #include <stdio.h> struct map { int index; int value; }; void insertSort(struct map** groupMap, int size, int* array) { int sentry = 0; for (int i = 0; i < size - 1; i++) { if ((*groupMap[i]).value > (*groupMap[i + 1]).value) { sentry = (*groupMap[i]).value; (*groupMap[i]).value = (*groupMap[i + 1]).value; array[(*groupMap[i]).index] = array[(*groupMap[i + 1]).index]; (*groupMap[i + 1]).value = sentry; array[(*groupMap[i + 1]).index] = sentry; } } } void sort(int* array, int size) { int sentry = 0, j; for (int i = 1; i < size; i++) { if (array[i] < array[i - 1]) { sentry = array[i]; for (j = i; j > 0 && array[j - 1] > sentry; j--) { array[j] = array[j - 1]; } array[j] = sentry; } } for (int i = 0; i < 10; i++) { printf("%d ", array[i]); } } int main() { int array[] = { 81,94,11,96,12,35,17,95,28,58,41,75,15 }, size = 13; int group = 5, start = 0, addIndex = start, index = 0; while (1) { if (group == 1) { sort(&array, size); break; } while (1) { struct map** groupMap = malloc(sizeof(struct map*) * (size / group) + 1); while (1) { struct map* map = malloc(sizeof(struct map)); map->index = addIndex; map->value = array[addIndex]; groupMap[index] = map; index++; addIndex += group; if (addIndex >= size) break; } insertSort(groupMap, index, &array); start++; addIndex = start; index = 0; if (start > group) break; } group = group - 2, start = 0, addIndex = start, index = 0; } return 0; } |
运行结果:
11 12 15 17 28 35 41 58 75 81 |
因为希尔排序的时间依赖于增量序列,如何选择该序列使得比较次数和移动次数最少,至今未能从数学上解决。但已有人通过大量的实验给出目前较好的结果,即当n较大时,比较和移动次数大约在n1.25~1.6n1.25之间。尽管有各种不同的增量序列,但都有一共同特征,那就是最后一个增量必须是1,而且应尽量避免增量序列中的增量groupt互为倍数的情况。
7.3.交换排序
交换排序的基本思想是:两两比较待排序记录的关键字,如果发现两个记录的次序相反时即进行交换,直到所有记录都没有反序时为止。本节将介绍两种交换排序方法:冒泡排序和快速排序。
7.3.1.冒泡排序
冒泡排序(Bubble Sort)是一种简单的排序方法,其基本思想是:通过相邻元素之间的比较和交换,使关键字较小的元素逐渐从底部移向顶部,就像水底下的气泡一样逐渐向上冒泡,所以使用该方法的排序称为"冒泡”排序。当然,随着排序关键字较小的元素逐渐上移(前移),排序关键字较大的元素也逐渐下移(后移),小的上浮,大的下沉,所以冒泡排序又被称为"起泡”排序。
冒泡排序过程具体描述为:首先将array[0].key和array[1].key进行比较,若array[0].key>array[1].key,则交换array[0]和array[1],使轻者上浮,重者下沉;接着比较array[1].key和array[2].key,同样使轻者上浮,重者下沉,依此类推,直到比较array[n-1].key和array[n].key,若反序则交换,第一趟排序结束,此时,记录array[0]的关键字最小。然后再对array[1]~array[n]的记录进行第二趟排序,使次小关键字的元素被上浮到array[1]中,重复进行n-1趟后,整个冒泡排序结束。其算法描述如下:
#define _CRT_SECURE_NO_WARNINGS //规避C4996告警 #include <stdio.h> void bubbleSort() { // int array[] = { 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 }, size = 10, temp = 0, flag = 0, count = 0; int array[] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }, size = 10, temp = 0, flag = 0, count = 0; for (int i = 0; i < size; i++) { count++; flag = 0; for (int j = 0; j < size - 1 - i; j++) { if (array[j] > array[j + 1]) { flag = 1; temp = array[j]; array[j] = array[j + 1]; array[j + 1] = temp; } } if (flag == 0) { break; } } for (int i = 0; i < 10; i++) { printf("%d ", array[i]); } printf("\n排序结束,遍历了%d次!", count); } int main() { bubbleSort(); return 0; } |
运行结果:
0 1 2 3 4 5 6 7 8 9 排序结束,遍历了1次! |
例如,假定有8个记录的关键字序列为(36, 28, 45, 13, 67, 36, 18, 56),如图7.2所示为该序列起泡排序的全过程,其中方括号内为下一趟排序的区间,方括号前面的一个关键字为本趟排序上浮出来的最小关键字。
从冒泡排序的算法可以看出,若待排序记录为有序(最好情况),则一趟扫描完成,关键比较次数为n-1次且没有移动,比较的时间复杂度为O(n2);反之,若待排序序记录为逆序,则需要进行n-1趟排序,每趟排序需要进行n-i次比较,而且每次比较都必须移动记录三次才能达到交换目的。因
比较和移动记录的总次数大约为最坏情况下的一半,所以,冒泡排序算法的时间复杂度为O(n2)。从上面的例子中可以看出,原本要七趟的排序,但实际上只排了四趟,因为在第四趟排序时已经没有要交换的记录(即无反序记录),循环语句结束。另外,冒泡排序算法是稳定的。
【例7.1】设计一个修改冒泡排序算法以实现双向冒泡排序的算法。
分析:冒泡排序算法是从最下面两个相邻的关键字进行比较,且使关键字较小的记录换至关键字较大的记录之上(即小的在上,大的在下),使得经过一趟冒泡排序后,关键字最小的记录到达最上端;接着,再在剩下的记录中找关键字最小的记录,并把它换在第二个位置上。依此类推,一直到所有的记录都有序为止。双向冒泡排序则是交替改变扫描方向,即一趟从下向上通过两个相邻关键字的比较,将关键字最小的记录换至最上面位置,再一趟则是从第二个记录开始向下通过两个相邻记录关键字的比较,将关键字最大的记录换至最下面的位置;然后再从倒数第二个记录开始向上两两比较至顺数第二个记录,将其中关键字较小的记录换至第二个记录位置,再从第三个记录向下至倒数第二个记录两两比较,将其中较大关键字的记录换至倒数第二个位置,依此类推,直到全部有序为止。
#define _CRT_SECURE_NO_WARNINGS //规避C4996告警 #include <stdio.h> int main() { int array[] = { 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 }, size = 10; int temp = 0, flag = 1, start = 0, end = size - 1, count = 0; while (flag == 1) { flag = 0; count++; for (int i = start; i < end; i++) { if (array[i] > array[i + 1]) { flag = 1; temp = array[i]; array[i] = array[i + 1]; array[i + 1] = temp; } } end--; for (int i = end; i > start; i--) { if (array[i] < array[i - 1]) { flag = 1; temp = array[i]; array[i] = array[i - 1]; array[i - 1] = temp; } } start++; }
for (int i = 0; i < 10; i++) { printf("%d ", array[i]); } printf("\n排序结束,遍历了%d次!", count); return 0; } |
运行结果:
0 1 2 3 4 5 6 7 8 9 排序结束,遍历了6次! |
7.3.2.快速排序
快速排序(Quick Sort)又称为划分交换排序。快速排序是对冒泡排序的一种改进方法,在冒泡排序中,进行记录关键字的比较和交换是在相邻记录之间进行,记录每次交换只能上移或下移一个相邻位置,因而总的比较和移动次数较多。在快速排序中,记录关键字的比较和记录的交换是从两端向中间进行的,待排序关键字较大的记录一次就能够交换到后面单元中,而关键字较小的记录一次就能够交换到前面单元中,记录每次移动的距离较远,因此总的比较和移动次数较少,速度较快,故称为"快速排序”。
快速排序的基本思想是:首先在当前无序区array[low...high]中任取一个记录作为排序比较的基准(不妨设为x),用此基准将当前无序区划分为两个较小的无序区array[low...i-1]和array[i+1...high],并使左边的无序区中所有记录的关键字均小于等于基准的关键字,右边的无序区中所有记录的关键字均大于等于基准的关键字,而基准记录x则位于最终排序的位置i上,即array[low...i-1]中关键字≤x.key≤array[i+1...high]中的关键字。这个过程称为一趟快速排序(或一次划分)。当array [low...i-1]和array[i+1...high]均非空时,分别对它们进行上述划分,直到所有的无序区中的记录均已排好序为止。
一趟快速排序的具体操作是:设两个指针start和end,它们的初值分别为1ow和high,基准记录x=array [start],首先从end所指位置起向前搜索找到第一个关键字小于基准x.key的记录存入当前start所指向的位置上, start自增1,然后再从start所指位置起向后搜索,找到第一个关键字大于x.key的记录存入当前end所指向的位置上, end自减1;重复这两步,直至start等于end为止。其一趟排序过程的实例如图7.3 (a)所示。
记录关键字序列{ 45,53,18,49,36,76,13,97,36,32 },整个快速排序过程实现:
#define _CRT_SECURE_NO_WARNINGS //规避C4996告警 #include <stdio.h> void sort(int* array, int size, int start, int end) { int startIndex = start, endIndex = end; int record = array[start]; while (start < end) { while (start < end && array[end] >= record) { end--; } if (start < end) { array[start] = array[end]; } while (start < end && array[start] <= record) { start++; } if (start < end) { array[end] = array[start]; } if (start == end) { array[start] = record; break; } } for (int i = startIndex; i < startIndex + size; i++) { printf("%d ", array[i]); } printf("\n"); if (size > 2) { sort(array, start - startIndex, startIndex, start - 1); sort(array, endIndex - start, start + 1, endIndex); } } int main() { int array[] = { 45,53,18,49,36,76,13,97,36,32 }, size = 10; int start = 0, end = size - 1; sort(&array, size, start, end); for (int i = 0; i < 10; i++) { printf("%d ", array[i]); } return 0; } |
运行结果:
32 36 18 13 36 45 76 97 49 53 13 18 32 36 36 13 18 36 36 53 49 76 97 49 53 97 13 18 32 36 36 45 49 53 76 97 |
这种算法实现每次只能选取第一个元素做为关键字,无法随机选取关键字。
随机选取关键字需要对上述代码做些许优化:
#define _CRT_SECURE_NO_WARNINGS //规避C4996告警 #include <stdio.h> #include <time.h> void sort(int* array, int size, int start, int end) { if (size == 0) return; int startIndex = start, endIndex = end; int record = array[start + rand() % size]; while (start < end) { while (start < end && array[end] > record) { end--; } while (start < end && array[start] < record) { start++; } if (start == end) break; if (array[start] == array[end]) { start++; continue; } array[start] = array[start] ^ array[end]; array[end] = array[start] ^ array[end]; array[start] = array[start] ^ array[end]; } for (int i = startIndex; i < startIndex + size; i++) { printf("%d ", array[i]); } printf("\n"); if (size > 2) { sort(array, start - startIndex, startIndex, start - 1); sort(array, endIndex - start, start + 1, endIndex); } } int main() { srand((unsigned int)time(NULL)); int array[] = { 45,53,18,49,36,76,13,97,36,32 }, size = 10; int start = 0, end = size - 1; sort(&array, size, start, end); for (int i = 0; i < 10; i++) { printf("%d ", array[i]); } return 0; } |
运行结果:
45 32 18 49 36 36 13 53 97 76 13 32 18 36 36 49 45 13 18 32 36 13 32 36 45 49 76 97 13 18 32 36 36 45 49 53 76 97 |
由于是随机选择关键字,每次排序的过程是不一样的。
从排序结果可以说明,快速排序是不稳定的。一般说来,快速排序有非常好的时间复杂度,它优于其他各种排序算法。可以证明,对n记录进行快速排序的平均时间复杂度为O(nlog2n)。但是,当待排序文件的记录已按关键字有序或基本有序时(递增或递减有序),复杂度反而增大了,原因是在第一趟快速排序中经过n-1次比较后,第一个记录仍定位在它原来的位置上,并得到一个包含n-1个记录的子文件,第二次递归调用,经过n-2次比较,第二个记录仍定位在它原来的位置上,从而得到一个包括n-2录的子文件。依此类推,最后得到排序的总比较次数为
这使得快速排序转变成冒泡排序,其时间复杂度为O(n2)。在这种情况下,可以对排序算法加以改进。从时间上分析,快速排序比其他排序算法要快;但从空间上来看,由于快速排序过程是递归的,因此需要一个栈空间来实现递归,栈的大小取决于递归调用的深度。若每一趟排序都能使待排序文件比较均匀地分割成两个子区间,则栈的最大深度为
,即使在最坏的情况下,栈的最大深度也不会超过n。因此,快速排序需要附加空间为O(log2n)。
7.4.选择排序
选择排序的基本思想:每一趟在待排序的记录中选出关键字最小的记录,依次存放在已排好序的记录序列的最后,直到全部记录排序完为止。本节主要介绍直接选择排序和堆排序两种选择排序方法。
7.4.1.直接选择排序
直接选择排序(Straight Select Sort)是一种简单的排序方法。它的基本思想是:每次从待排序的无序区中选择出关键字值最小的记录,将该记录与该区中的第一个记录交换位置。初始时, array[1...n]为无序区,有序区为空。第一趟排序是在无序区array[1...n]中选出最小的记录,将它与array[1]交换, array[1]为有序区;第二趟排序是在无序区array[2...n]中选出最小的记录与array[2]交换,此时array[1...2]为有序区;依此类推,做n-1趟排序后,区间array[1...n]中记录按递增有序。例如有一组关键字(38, 33, 65, 82, 76, 38, 24, 11),查按选择排序过程如图7.4所示,其中方括号内为待排序的无序区,方括号前面为已排好序的记录。
直接选择排序的具体算法描述如下:
#define _CRT_SECURE_NO_WARNINGS //规避C4996告警 #include <stdio.h> void bubbleSort() { int array[] = { 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 }, size = 10, index = -1; for (int i = 0; i < size; i++) { index = i; for (int j = i; j < size; j++) { if (array[index] > array[j]) { index = j; } } if (index != i) { array[i] ^= array[index]; array[index] = array[i] ^ array[index]; array[i] ^= array[index]; } } for (int i = 0; i < 10; i++) { printf("%d ", array[i]); } } int main() { bubbleSort(); return 0; } |
运行结果:
0 1 2 3 4 5 6 7 8 9 |
通过上述的例子,可以看到在直接选择排序中,共需要进行n-1次选择和交换,无论待排序记录初始状态如何,每次选择需要作n~i次比较,而每次比较需要移动3次。因此,总比较次数为:
总移动次数(最大值): 3(n-1)。
由此可见,直接选择排序的平均时间复杂度为O(n2),由于在直接选择排序中存在着不相邻记录之间的交换,因而可能会改变具有相同元素记录的前后位置,所以此排序法是不稳定的。
以上算法是在顺序表结构上实现的直接选择排序,那么,如果是在链式存储结构上又是如何实现直接选择排序的呢?下面就通过一个例子来介绍链式结构下的直接选择排序方法。
7.4.2.堆排序
堆排序(Heap Sort)是对直接选择排序法的一种改进。从前面的讨论中可以看到,采用直接选择排序时,为了从n个关键字中找最小关键字需要进行n-1次比较,然后再在余下的n-1个关键字中找出次小关键字,需要进行n-2比较。事实上,在查找次小关键字所进行的n-2次比较中,有许多比较很可能在前面的n-1次比较中已做过,只是当时并没有将这些结果保存下来,因此,在后一趟排序时又重复进行了这些比较操作。树形排序可以克服这一点。
堆排序(Heap Sort)是一种树形选择排序,它的基本思想是:在排序过程中,将记录数组array[1...n]看成是一棵完全二叉树的顺序存储结构,利用完全二叉树中双亲结点和孩子结点之间的内在关系,在当前无序区中选择关键字最大(或最小)记录。
堆的定义如下:n个记录的关键字序列k1, h2, ..., kn称为堆,当且仅当满足以下关系:
前者称为小根堆,后者称为大根堆。例如关键字序列(76, 38, 59, 27, 15, 44)就是一个大根堆,还可以将此调整为小根堆(15, 27, 44, 76, 38, 59),它们对应的完全二,叉树如图7.5所示。
堆排序正是利用大根堆(或小根堆)来选取当前无序区中元素最大(或最小)的记录实现排序的。每一趟排序的操作是:将当前无序区调整为一个大根堆,选取关键字最大的堆顶记录,将它和无序区中最后一个记录交换,这正好与选择排序相反。堆排序就是一个不断建堆的过程。
先给出一个序列:45, 36, 18, 53, 72, 30, 48, 93, 15, 35要想此序列称为一个堆,我们按照上述方法,首先从最后一个分支节点(10/2),其值为72开始,一次对每个分支节点53,18,36,45进行调整(下沉)
图解流程
因此,堆排序的关键就是如何构造堆。其具体做法是:把待排序的文件的元素存放在数组array[1...n]之中,将array看作一棵完全二叉树的存储结构,每个结点表示一个记录,源文件的第一个记录array[0]作为二叉树的根,以下各记录array[1...n]依次逐层从左到右顺序排列,构成一棵完全二叉树(根结点为1),任意结点array[i]的左孩子是array[2i],右孩子是array[2i+1],双亲是array。在这里,假设建大根堆:假如完全二叉树的某一个结点i左子树、右子树已经是堆,只需要将array[2i].key和array[2i+1].key中的较大者与array[i].key比较,若R[i].key较小则交换,这样有可能破坏下一级的堆,于是继续采用上述方法构造下一级的堆,直到完全二叉树中以结点i为根的子树成为堆。此过程就像过筛子一样,把较小的元素逐层筛下去,而较大的被逐层选上来,所以把这种建堆的方法称为筛选法。调整为大根堆的算法如下:
void maxRoot(int* array, int size) { int root = size, left = size, right = size, max = 0; if ((size - 1) % 2 == 0) { right = size - 1; left = right - 1; } else { left = size - 1; right = left + 1; } root = (left - 1) / 2; while (root < size - 1 && root >= 0) { if (left >= size) return; max = left; if (right < size && array[left] < array[right]) { max = right; } if (array[root] < array[max]) { array[root] = array[root] ^ array[max]; array[max] = array[root] ^ array[max]; array[root] = array[root] ^ array[max]; } right = left - 1; left = right - 1; root = (left - 1) / 2; } } |
根据堆的定义和上面的建堆过程可以知道,序号为0的结点arrar[0] (即堆顶),是堆中n个结点中关键字最大的结点。因此堆排序的过程比较简单,首先把arrar[0]与arrar[n]交换,使arrar[n]为关键字最大的结点,接着对arrar[1...n-1]中结点进行筛选运算,又得到arrar[1]为当前无序区arrar[1...n-1]中具有最大关键字的结点,再把arrar[1]与当前无序区内最后一个结点arrar[n-1]交换,使arrar[n-1]为次大关键字结点,依次这样,经过n-1次交换和筛选,运算之后,所有结点成为递增有序,即排序结束。因此,堆排序算法如下:.
#define _CRT_SECURE_NO_WARNINGS //规避C4996告警 #include <stdio.h> void maxRoot(int* array, int size) { int root = size, left = size, right = size, max = 0; if ((size - 1) % 2 == 0) { right = size - 1; left = right - 1; } else { left = size - 1; right = left + 1; } root = (left - 1) / 2; while (root < size - 1 && root >= 0) { if (left >= size) return; max = left; if (right < size && array[left] < array[right]) { max = right; } if (array[root] < array[max]) { array[root] = array[root] ^ array[max]; array[max] = array[root] ^ array[max]; array[root] = array[root] ^ array[max]; } right = left - 1; left = right - 1; root = (left - 1) / 2; } } int main() { int array[] = { 45, 36, 72, 18, 53, 31, 48, 36 }, size = 8, index = 0; maxRoot(&array, size); while(size > 1) { array[index] = array[index] ^ array[size - 1]; array[size - 1] = array[index] ^ array[size - 1]; array[index] = array[index] ^ array[size - 1]; maxRoot(&array, --size); } for (int i = 0; i < 8; i++) { printf("%d ", array[i]); } return 0; } |
运行结果:
18 31 36 36 45 48 53 72 |
在堆排序中,需要进行n-1趟选择,每次从待排序的无序区中选择一个最大值(或最小值)的结点,而选择的方法是在各子树已是堆的基础上对根结点进行筛选运算实现的,其时间复杂度为O(log2n),所以整个堆排序的时间复杂度为O(nlog2n)。显然,堆排序比直接选择排序的速度快得多。堆排序和直接选择排序一样,都是不稳定的。
7.5.归并排序
归并排序(Merge Sort)的基本思想是:首先将待排序文件看成n个长度为1的有序子文件,把这些子文件两两归并,得到个长度为2的有序子文件;然后再把这[(n/2)向上取整]个有序的子文件两两归并,如此反复,直到最后得到一个长度为n的有序文件为止,这种排序方法称为二路归并排序。
· 基本思想:将两个或两个以上的有序子序列"归并”为一个有序序列。
· 在内部排序中,通常采用的是二路归并排序。即:将两个位置相邻的有序子序列array[low...m]和array[m+1...high]归并为一个有序序列array[low...high]。
例如,有初始关键字序列(72, 18, 53, 36, 48, 31,36),其二路归并排序过程如图7.8所示。
二路归并排序中的核心操作是将数组中前后相邻的两个有序序列归并为一个有序序列。
一趟归并排序的基本思想是,在某趟归并中,设各子文件长度为len (最后一个子文件的长度可能会小于len),则归并前array[1...n]中共有「n/size」个有序子文件。调用归并操作对子文件进行归并时,必须对子文件的个数可能是奇数、最后一个子文件的长度可能小于size这两种特殊情况进行处理:若子文件个数为奇数,则最后一个子文件无需和其他子文件归并;若子文件个数为偶数,则要注意最后一对子文件中后一个子文件的区间上界为n.具体的一趟归并排序算法如下:
void insertSort(int* array, int start, int end) { int sentry = 0, j = 0; for (int i = start + 1; i < end; i++) { if (array[i] < array[i - 1]) { sentry = array[i]; for (j = i; j > start && array[j - 1] > sentry; j--) { array[j] = array[j - 1]; } array[j] = sentry; } } } |
剩下的只需要不停的归并,最终(最后一次归并长度为size)即可得到一个顺序序列。
#define _CRT_SECURE_NO_WARNINGS //规避C4996告警 #include <stdio.h> void insertSort(int* array, int start, int end) { int sentry = 0, j = 0; for (int i = start + 1; i < end; i++) { if (array[i] < array[i - 1]) { sentry = array[i]; for (j = i; j > start && array[j - 1] > sentry; j--) { array[j] = array[j - 1]; } array[j] = sentry; } } } void sort(int* array, int size) { int step = 2, sortSize = 0; while (step <= size) { for (int i = 0; i < size;) { sortSize = i + step; if (sortSize > size) sortSize = size; insertSort(array, i, sortSize); i += step; } if (step == size) break; step *= 2; if (step > size) step = size; } for (int i = 0; i < size; i++) { printf("%d ", array[i]); } } int main() { int array[] = { 45, 36, 18, 53, 72, 30, 48, 93, 15, 35 }, size = 10, index = 0; sort(&array, size); return 0; } |
二路归并排序的过程需要进行
趟。每一趟归并排序的操作,就是将两个有序子文件进行归并,而每一对有序子文件归并时,记录的比较次数均小于等于记录的移动次数,记录移动的次数均等于文件中记录的个数n,即每一趟归并的时间复杂度为O(n)。因此,二路归并排序的时间复杂度为O(nlog2n)。
二路归并排序是稳定的,因为在每两个有序子文件归并时,若分别在两个有序子文件中出现有相同关键字的记录时,归并排序算法能够使前一个子文件中同一关键字的记录被先复制,后一子文件中同一关键字的记录被后复制,从而确保它们的相对次序不会改变。
7.6.分配排序
前面几节讨论的排序算法都是基于关键字之间的比较来实现的,从理论上已经证明:对于以上采用比较方法的排序,无论用何种方法都至少需要进行
次比较。而有不需要比较的排序方法,可使时间复杂降为一线性阶O(n),分配排序算法就是基于这种不需要比较的排序算法,常用的分配排序有箱排序和基数排序。
7.6.1箱排序/桶排序
箱排序又称桶排序,其基本思想是:设置若干个箱子(桶),依次扫描待排序的记录,把关键字在箱子(桶)范围内的记录全部都装入对应的箱子(桶)里(分配),然后按序号依次将各非空的箱子(桶)首尾连接起来。
算法步骤:
1.计算出数组array中最大值,使用最大值与桶数量计算出每个桶的范围。
2.遍历数组array,根据数值的范围将数组元素分配到对应的桶中。
3.桶内数据排序。
4.按照顺序一次取出桶中的数据,即得到了排好序的元素数值。
数组容器实现:桶容器使用2.2.2小节构建的动态数组dynamicArray.h中的arrayList。这个arrayList动态数组有插入排序的实现,代码中就可以省略桶中元素排序的的实现。
#define _CRT_SECURE_NO_WARNINGS //规避C4996告警 #include <stdio.h> #include <stdlib.h> #include "dynamicArray.h" int sort(void* data, void* ArrayData) { return *(int*)data < *(int*)ArrayData; } struct ArrayList* initBarrel(int* array, int size, int* max, int barrelNum) { for (int i = 0; i < size; i++) { if (array[i] > *max) { *max = array[i]; } } struct ArrayList* barelArray = initArrayList(barrelNum); for (int i = 0; i < barrelNum; i++) { struct ArrayList* subBarrel = initArrayList(size * 2 / barrelNum); barelArray->insert(0, subBarrel, barelArray); } return barelArray; } void sortValue(int* array, int size, int barrelNum) { int max = -1; struct ArrayList* barelArray = initBarrel(array, size, &max, barrelNum); int scope = max / barrelNum; if (max % barrelNum != 0) scope++; for (int i = 0; i < size; i++) { int index = (array[i] / scope) % barrelNum; struct ArrayList* subBarrel = barelArray->get(index, barelArray); int* value = malloc(sizeof(int)); *value = array[i]; subBarrel->addSort(value, sort, subBarrel); } // print the barrel value for (int i = 0; i < barrelNum; i++) { struct ArrayList* subBarrel = barelArray->get(i, barelArray); int size = subBarrel->size(subBarrel); for (int j = 0; j < size; j++) { int* value = (subBarrel->get(j, subBarrel)); printf("%d ", *value); free(value); } } // ruin函数会销毁自己以及数组中的所有元素,回收内存空间。 barelArray->ruin(barelArray); } int main() { int array[] = { 45, 36, 18, 53, 72, 30, 48, 93, 15, 35 }, size = 10, barrelNum = 4; sortValue(&array, size, barrelNum); return 0; } |
运行结果:
15 18 30 35 36 45 48 53 72 93 |
链表容器实现:桶容器使用2.3.2小节构建的企业级链表linkList.h中的LinkList。这个LinkList链表同样有插入排序的实现,代码中就可以省略桶中元素排序的的实现,但是这个LinkList链表的结点结构需要自定义。
#define _CRT_SECURE_NO_WARNINGS //规避C4996告警 #include <stdio.h> #include <stdlib.h> #include "linkList.h" #include "dynamicArray.h" struct ArrayList* initBarrel(int* array, int size, int* max, int barrelNum) { for (int i = 0; i < size; i++) { if (array[i] > *max) { *max = array[i]; } } struct ArrayList* barelArray = initArrayList(barrelNum); for (int i = 0; i < barrelNum; i++) { struct LinkList* subBarrel = initLinkList(); barelArray->insert(0, subBarrel, barelArray); } return barelArray; } struct SortNode { struct SortNode* next; int value; }; int sort(void* data, void* linkListNode) { return ((struct SortNode*)data)->value < ((struct SortNode*)linkListNode)->value; } void printData(void* data) { printf("%d ", ((struct SortNode*)data)->value); } void sortValue(int* array, int size, int barrelNum) { int max = -1; struct ArrayList* barelArray = initBarrel(array, size, &max, barrelNum); int scope = max / barrelNum; if (max % barrelNum != 0) scope++; for (int i = 0; i < size; i++) { int index = (array[i] / scope) % barrelNum; struct LinkList* subBarrel = barelArray->get(index, barelArray); struct SortNode* node = malloc(sizeof(struct SortNode)); node->value = array[i]; subBarrel->addSort(node, sort, subBarrel); } struct LinkList* subBarrel = barelArray->get(0, barelArray); for (int i = 1; i < barrelNum; i++) { struct LinkList* joinBarrel = barelArray->get(i, barelArray); if (joinBarrel->size(joinBarrel) <= 0) continue; subBarrel->joint(joinBarrel,0, joinBarrel->size(joinBarrel), subBarrel); } subBarrel->foreach(printData, subBarrel); subBarrel->clear(subBarrel); // ruin函数会销毁自己以及数组中的所有元素,回收内存空间。 barelArray->ruin(barelArray); } int main() { int array[] = { 45, 36, 18, 53, 72, 30, 48, 93, 15, 35 }, size = 10, barrelNum = 4; sortValue(&array, size, barrelNum); return 0; } |
运行结果:
15 18 30 35 36 45 48 53 72 93 |
7.6.2.基数排序
基数排序(Radix Sort)是对箱排序的改进和推广。箱排序只适用于关键字取值范围较小的情况,否则所需要箱子的数目barrelNum太多,会导致存储空间的浪费和计算时间的增长。但若仔细分析关键字的结构,就可能得出对箱排序结果的改进。
例如, n=10,被排序的记录关键字ki(36, 25, 48, 10, 32, 25, 6, 58, 56, 82),其取值是在0..99之间的整数。因为ki是两位整数,所以可以将其分解,先对ki的个位数(K%10)进行箱排序,然后在排序的基础上再对ki的十位数(ki/10)进行箱排序,这样只需要标号为0, 1, .., 9的这10个箱子进行二趟箱排序即可完成排序操作,而不需要100个箱子来进行一趟箱排序。第一趟箱排序是对输入的记录序列顺序扫描,将它们按关键字的个位数字装箱,然后依箱号递增将各个非空箱子首尾连接起来,即可得到第一趟排序结果,显然结果已按个位有序;第二趟箱排序是在前一趟排序结果的基础上进行的,即顺序扫描第一趟的结果,将扫描到的记录按关键字的十位数字装箱,再将非空箱子首尾连接,即可得到最终的排序结果。整个排序过程及结果如表7.1所示。
因为箱子个数barrelNum的数量级不会大于O(n),所以上述排序方法的时间复杂度为O(n)。一般情况下,记录数组array[1..n]中任一记录array[i]的关键字都是由d个分量ki0ki1...kid-1构成的,每个分量的取值范围相同C0≤kij≤Crd-1 (0≤j≤d-1),我们把每个分量可能取值的个数rd称为基数。基数的选择和关键字的分解因关键字的类型而异。例如,若关键字为数值型,且其值在0≤k≤999范围内,则可把每一个十进数字看成一个关键字,即可认为ki由三个关键字(ki0ki1ki2)组成,其中ki0是百位数,ki1是十位数,ki2是个位数。若关键字是4个小写字母组成的字符串时,则可看成由4个关键字(ki0ki1ki2ki3)组成,其中kij是表示串中第j+1个字符。因此,若关键字是十进整数值时, rd=10 (0..9);若关键字为字母组成的字符串时, rd=26 ('a'. .'z')。
基数排序的基本思想是:首先按关键字的最低位kid-1进行箱排序,然后再按关键字的kid-2进行箱排序, …,最后按最高位ki0进行箱排序。在d趟箱排序中,需要设置箱子的个数就是基数rd。前文给出的例子,就是一个基数rd为10、d为2的基数排序。
实际上,只需要对前面介绍的箱排序(桶排序)算法做适当的修改就可以得到基数排序的算法。
选用链表存储的方式,将箱排序(桶排序)的箱子数量改为10,用来表示0-9个数字,根据基数的值重复做箱排序(桶排序)运算。
#define _CRT_SECURE_NO_WARNINGS //规避C4996告警 #include <stdio.h> #include <stdlib.h> #include "linkList.h" #include "dynamicArray.h" struct SortNode { struct SortNode* next; int value; }; int sort(void* data, void* linkListNode) { return ((struct SortNode*)data)->value < ((struct SortNode*)linkListNode)->value; } void printData(void* data) { printf("%d ", ((struct SortNode*)data)->value); } struct LinkList* sortValue(int* array, struct ArrayList* barelArray, int size, int isUnit, int baseNum, int barrelNum) { for (int i = 0; i < size; i++) { int index = -1; if (isUnit == 1) { index = array[i] % baseNum; } else { index = (array[i] / baseNum) % baseNum; }
struct LinkList* subBarrel = barelArray->get(index, barelArray); struct SortNode* node = malloc(sizeof(struct SortNode)); node->value = array[i]; subBarrel->addSort(node, sort, subBarrel); } struct LinkList* subBarrel = barelArray->get(0, barelArray); for (int i = 1; i < barrelNum; i++) { struct LinkList* joinBarrel = barelArray->get(i, barelArray); if (joinBarrel->size(joinBarrel) <= 0) continue; subBarrel->joint(joinBarrel,0, joinBarrel->size(joinBarrel), subBarrel); joinBarrel->clear(joinBarrel); } return subBarrel; } struct ArrayList* initBarrel(int* array, int size, int barrelNum) { struct ArrayList* barelArray = initArrayList(barrelNum); for (int i = 0; i < barrelNum; i++) { struct LinkList* subBarrel = initLinkList(); barelArray->insert(0, subBarrel, barelArray); } return barelArray; } int main() { int array[] = { 278, 109, 63, 930, 589, 184, 505, 269, 8, 83 }, size = 10, barrelNum = 10; struct ArrayList* barelArray = initBarrel(array, size, barrelNum); int baseNum = 10, baseSize = 3; struct LinkList* linkArray = sortValue(&array, barelArray, size, 1, baseNum,barrelNum); for (int j = 1; j < baseSize; j++) { struct SortNode* node = linkArray->list->header.next; for (int i = 0; i < size && node != NULL; i++) { array[i] = node->value; node = node->next; } linkArray->clear(linkArray); linkArray = sortValue(&array, barelArray, size, 0, baseNum, barrelNum); baseNum *= 10; } linkArray->foreach(printData, linkArray); linkArray->clear(linkArray); // ruin函数会销毁自己以及数组中的所有元素,回收内存空间。 barelArray->ruin(barelArray); return 0; } |
运行结果:
8 63 83 109 184 269 278 505 589 930 |
【例7.5】已知关键字序(278, 109, 063, 930, 589, 184, 505, 269, 008, 083},写出基数排序(升序)的排序过程。
初始状态: p→278→109→063→930→589→184→505→269→008→083第一趟分配,即按个位装箱后状态如下(按尾插法进行装入,即尾指针总是指向新插入的记录,下同):
第一趟收集后,已按个位有序:
p→930→063→083→184→505→278→008→109→589→269
第二趟分配,即按十位装箱后的状态如下:
第二趟收集后,已按十位有序:
p→505→008→109→930→063→269→278→083→184→589
第三趟分配,即按百位装箱后的状态如下:
第三趟收集后,已按百位有序:
p→008→063→083→109→184→269→278→505→589→930
至此,整个排序任务结束。在基数排序中,没有进行关键字的比较和记录的移动,而只是扫描链表和进行指针赋值,所以排序的时间主要用在修改指针上。初始化链表的时间为O(n),在每一趟箱排序中,清空箱子的时间是O(rd),分配时需要将n个记录装入箱子,其时间为O(n),收集的时间也是O(rd),因此,一趟箱排序的时间是O(rd+n)。因为要进行d趟箱排序,所以链式基数排序的时间复杂度是O(d* (rd+n))。
7.7.内部排序方法的分析比较
本节之前介绍了各种排序方法,为了使读者更进一步熟悉和应用这些方法,下面对介绍过的排序方法从几个方面进行分析和比较。
1.时间复杂度
(1)直接插入、直接选择、冒泡排序算法的时间复杂度为O(n2)。
(2)快速、归并、堆排序算法的时间复杂度为O(nlog2n)。
(3)希尔排序算法的时间复杂度很难计算,有几种较接近的答案: O(nlog2n)或O(n1.25)
(4)基数排序算法的时间复杂度为O(d*(rd+n)),其中rd是基数, d是关键字的位数,n是元素个数。
2.稳定性
(1)直接插入、冒泡、归并和基数排序算法是稳定的;
(2)直接选择、希尔、快速和堆排序算法是不稳定的。
3·辅助空间(空间复杂度)
(1)直接插入、直接选择、冒泡、希尔和堆排序算法需要辅助空间为O(1)。
(2)快速排序算法需要辅助空间为O(log2n);
(3)归并排序算法需要辅助空间为O(n);
(4)基数排序算法需要辅助空间为O(n+rd)
4·选取排序方法时需要考虑的主要因素
(1)待排序的记录个数。
(2)记录本身的大小和存储结构。
(3)关键字的分布情况。
(4)对排序稳定性的要求。
(5)时间和空间复杂度等。
5.排序方法的选取
(1)若待排序的一组记录数目n较小(如n<50)时,可采用插入排序或选择排序。
(2)若n较大时,则应采用快速排序、堆排序或归并排序。
(3)若待排序记录按关键字基本有序时,则适宜选用直接插入排序或冒泡排序。
(4)当n很大,而且关键字位数较少时,采用链式基数排序较好。
(5)关键字比较次数与记录的初始排列顺序无关的排序方法是选择排序。
6·排序方法对记录存储方式的要求
一般的排序方法都可以在顺序结构(一维数组)上实现。当记录本身信息量较大时,为了避免移动记录耗费大量的时间,可以采用链式存储结构。例如插入排序、归并排序、基数排序易于在链表上实现,使之减少记录的移动次数,但有的排序方法,如快速排序、堆排序在链表上却难于实现,在这种请况下,可以提取关键字建立索引表,然后对索引表进行排序。
n1.25~1.6n1.25
排序对比表格
1:比较次数与序列初态无关的算法
2:排序在一趟结束后不一定能选出一个元素放在其最终位置上
3:待排序数据已有序时,花费时间反而最多的是
4:就平均性能而言,目前最好的内排序方法
5:占用辅助空间最多的是
6:对初始状态为递增的表按递增顺序排序,最省时间的是
7:在最后一趟开始之前,所有元素可能都不在最终位置上
快速排序 最坏情况栈为单支树是为O(n)
归并排序 大数据处理
堆排序比直接选择排序的速度快得多
尔排序记录总比较次数和总移动次数都比直接插入排序少得多
堆排序只是需要在元素比较进行交换时需要常数个存储空间,它需要的辅助空间为O(1);
快速排序在递归过程中需要栈结构来保存递归的信息,它需要的辅助空间为O(log2n);
归并排序需要长度为元素个数的线性空间来保存归并的结果,它需要的辅助空间为O(n)。
小结
本章着重讨论有关内部排序的一些常用方法,主要有插入排序、交换排序、选择排序、归并排序及基数排序,介绍其排序思想、排序过程、算法实现时间和空间性能的分析及各种排序方法的比较和选择。
迄今为止,已有的排序方法远远不止本章讨论的这些,人们之所以热衷于研究多种排序方法,不仅是由于排序在计算机程序设计中所处的重要地位,而且还因为不同的方法各有其优缺点,可适用于不同的场合。选取排序方法时需要考虑的因素有:①待排序的记录数目n; ②记录本身信息量的大小; ③关键字的结构及其初始状态;④对排序稳定性的要求; ⑤记录的存储结构; ⑥时间和空间复杂度等。
本 章所讨论的内部排序算法,除基数排序外,都是在顺序表上实现的。当记录本身信息量较大时,为了避免耗费大量时间移动记录,可以用链表作为存储结构。读者不仅应该掌握本章所介绍的几种主要排序算法的基本思想,同时还应该学会基本的算法分析技术,比较这些算法的时间和空间复杂性,从而能够在实际应用中正确选择适当的排序方法。