排序(C)
排序
- 1. 前言
- 2. 插入排序
- 2.1 直接插入排序
- 2.2 希尔排序
- 3. 选择排序
- 3.1 直接选择排序
- 3.2 堆排序
- 4. 交换排序
- 4.1 冒泡排序
- 1. 冒泡排序
- 2. qsort函数
- 3. 改造冒泡排序
- 4.2 快速排序
- 1. hoare 版本
- 2. lomuto 前后指针
- 3. 快速排序之三路划分
- 4. 非递归快速排序
- 5. 归并排序
- 1. 递归版本的归并排序
- 2. 非递归版本的归并排序
- 3. 文件归并排序
- 6. 计数排序
- 7. 总结
1. 前言
本文将要介绍排序共有八种,如下图所示:
2. 插入排序
2.1 直接插入排序
直接插入排序是一种简单的插入排序方法。
其基本思想:将待排序的序列按其的值的大小逐个插入到一个已经排好序的有序序列中,知道将所有的待排序序列插入完为止,然后得到一个新的序列。
直接插入排序的具体步骤如下图所示:
将步骤编写成代码为:
//直接插入排序 --- 参数为待排序的数组,以及该数组的元素个数
void InsertSort(int* arr, int n)
{//循环遍历整个数组for (int i = 0; i < n - 1; i++){//定义一个变量end始终指向有序序列的最后一项int end = i;//定义一个变量存储下标为end + 1的数据int tmp = arr[end + 1];//循环比较while (end >= 0){//若下标为end的数据大于tmp中存储的数据//就将下标为end的数据放在下标为end + 1的位置处if (arr[end] > tmp){arr[end + 1] = arr[end];end--;}else{break;}}//代码运行到这,表明了此时end等于0//这时将tmp中的数据赋值给数组下标为end + 1的位置//这一步就是将小的数据放在数组的前面的操作arr[end + 1] = tmp;}
}
调用该函数观察其运行结果是否将乱序序列排序成升序
调用函数:
int main()
{int arr[] = {4, 9, 2, 8, 5, 7, 1, 3, 2, 6 };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");InsertSort(arr, size);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}return 0;
}
运行结果:
接下来来计算直接插入排序的时间复杂度:显而易见,上述代码的时间复杂度为O(N^2)。
当小的数据在前,大的数据在后的情况下,排升序,那么这就是直接插入排序最好的情况。此时直接插入排序的时间复杂度为O(N);
当小的数据在后,大的数据在前的情况下,排升序,那么这就是直接插入排序最差的情况。此时直接插入排序的时间复杂度为O(N^2)。
在平时的数据中很少遇到最坏的情况,所以平常情况下直接插入排序的时间复杂度 大于O(N),小于O(N^2),接近于O(N^2),达不到O(N^2)。
直接插入排序的时间复杂度太大了,说明这种插入排序方法并不快捷,那么是否存在时间复杂度小的插入排序方法呢?答案是存在的,那就是希尔排序。
2.2 希尔排序
希尔排序法又称为缩小增量法。
基本思想:选定一个整数(通常是gap = gap / 3 + 1),把待排序数据分成各组,所有距离相等的记录分在同一组内,并对每一组内的记录进行排序,然后改变gap,即gap = gap / 3 + 1得到下一个gap,再将数组分成各组,进行插入排序,当gap = 1时,就相当于是直接插入排序。
希尔排序是在直接插入排序算法的基础上进行改进而得到的,其中的某些步骤与直接插入排序差不多。
当gap > 1时,执行的是预排序;当gap = 1时,执行的是直接插入排序
希尔排序的具体步骤如下图所示:
在 gap > 1 时的这些排序操作是不能将无序的数据排成升序的,只是将小的数据全都排到了前面,大的数据排到了后面,之后当 gap = 1 执行直接插入排序是时,就能大大降低时间复杂度。
对 gap = 2 时的希尔排序步骤详细的说明:
看到这肯定产生了不少的问题?为什么必须是gap = gap / 3 + 1呢?下面进行分析:
假设初始gap为7,若是gap / 3,之后gap = 2,再后gap = 0,只需循环3遍
若是gap / 2,之后gap = 3,再后gap = 1,再后是0,只需循环4遍
若是gap / 5,之后gap = 1,再后gap = 0,只需循环3遍
所以最后选择gap = gap / 3 + 1,但是为什么之后还要加上1呢? 上面分析过,当gap > 1时,执行的是预排序;当gap = 1时,执行的才是直接插入排序;加上1最后gap一定会为1,也一定会执行直接插入排序操作
将步骤编写成代码:
//希尔排序 --- 参数为待排序的数组,以及该数组的元素个数
void ShellSort(int* arr, int n)
{//定义gap,让初始gap的值为数组的元素个数int gap = n;//预排序while (gap > 1){//将n个数据分成gap组gap = gap / 3 + 1;//对每组数据进行直接插入排序for (int i = 0; i < n - gap; i++){int end = i;int tmp = arr[end + gap];while (end >= 0){if (arr[end] > tmp){arr[end + gap] = arr[end];end -= gap;}else{break;}}arr[end + gap] = tmp;}}
}
调用该函数观察其运行结果是否将乱序序列排序成升序
调用函数:
int main()
{int arr[] = {4, 9, 2, 8, 5, 7, 1, 3, 2, 6 };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");ShellSort(arr, size);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}return 0;
}
运行结果:
接下来来分析希尔排序的时间复杂度:这是一复杂度问题,因为其复杂度与gap的初始选取值有关,选取的值不同其时间复杂度就不同,但是希尔排序的时间复杂度大概为n的1.3次方。
3. 选择排序
选择排序的基本思想:
每次从待排序的数据中选出最小或最大的一个数据,存放在序列的首位置,直到所有待排序数据全部都排序完。
3.1 直接选择排序
排序思想:
假设序列的数据个数为n – 设有序序列的首元素下标为 i ,末元素下标为 n – 1
1. 在下标为 i ~ n – 1的范围内寻找值最小或最大的数据
2. 若最小或最大的数据不为i ~ n – 1范围内的第一个或最后一个数据, 那么就将找到的数据与i ~ n – 1这个范围内的第一个或最后一个数据进行交换
3. 接着在剩余的 i + 1 ~ n – 1这个范围内执行相同的1,2步骤,直到只剩下1个元素
直接选择排序的具体步骤如下图所示:
将步骤编写成代码:
//选择排序
void SelectSort(int* arr, int n)
{//定义两个变量begin与end//begin指向数组下标为0的位置,end指向数组下标为n - 1的位置int begin = 0;int end = n - 1;//只要begin所指向的数组下标小于end所指向的数组的下标,循环就不会停止while (begin < end){//定义两个变量min与max//min指向的位置与begin相同,max指向的位置与begin相同int min = begin;int max = begin;//让min遍历序列寻找begin~end范围内最小的数据的下标//让max遍历序列寻找begin~end范围内最大的数据的下标for (int i = begin + 1; i <= end; i++){if (arr[i] < arr[min]){min = i;}if (arr[i] > arr[max]){max = i;}}//找到begin~end范围内的最小值与最大值的下标后//将数组下标为min的数据与数组下标为begin的数据进行交换//将数组下标为mmax的数据与数组下标为end的数据进行交换//进行特殊处理//当max所指向的位置与begin的相同或min指向的位置与end相同时//变换max所指向的位置,让max指向min所指向的位置if (max == begin){max = min;}Swap(&arr[begin], &arr[min]);Swap(&arr[end], &arr[max]);//交换完毕后,begin++,end--,缩小序列的范围begin++;end--;}
}
调用该函数观察其运行结果是否将乱序序列排序成升序
调用函数:
int main()
{int arr[] = { 2, 4, 7, 1, 8, 5, 6, 9, 0, 3 };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");SelectSort(arr, size);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}return 0;
}
运行结果:
接下来来分析直接选择排序的时间复杂度:显而易见其时间复杂度为O(N^2)。
3.2 堆排序
什么是堆排序呢?是不是建一个堆,然后编写出堆基本的操作,然后再频繁的取堆顶数据就行了呀?其实堆排序并不是这样实现的,堆排序是运用了堆的思想,而不是堆这一个数据结构。将待排序的序列看成一个堆结构,而不是建一个堆。如下图所示:
上图所示的二叉树并不是一个堆结构,要想将它变成一个堆结构,需要运用调整算法,具体是建大堆,还是建小堆,这与我们想要得到的结果有关。若想要得到升序的序列,则调整为大堆;若想要得到降序的序列,则调整为小堆。
若使用向下调整算法建一个大堆:
向下调整算法是知道父结点的下标去求子结点的下标,将父结点向下移动,那么从哪里开始呢?是从19开始吗?不行,为什么呢?
因为它的两个子树并不是堆结构。既然从头开始行不通,那么就从尾开始,从值为17的结点开始,该结点是最后一个结点。
向下调整是将父结点向下移动,但是值为17的结点并没有孩子结点,所以它并不能称之为父结点,还是得从值为17的父结点开始进行调整。
那么值为17的父结点的下标为多少呢?假设该二叉树结点数为n,那么值为17结点的下标为n - 1,知道子结点的下标,求父结点的下标,根据父结点的下标 = ( 子结点的下标 - 1 )/ 2,即可求得父结点的下标。
向下调整算法具体代码解析参考博客链接: link
具体调整过程如下图所示:
建堆完毕后,现在我们的目的是将原序列变成升序的序列,但是现在这个堆是升序的吗?并不是,所以现在我们就要将原序列变成升序的序列。我们都知道堆的堆顶是最值,现在我们将堆顶的数据与堆底的数据进行交换,然后有效数据个数再 - - ,这时就会发现交换完毕后的二叉树变成了非堆结构,为了让它变成堆结构,使用向下调整算法。具体步骤如下图所示:
分析完毕,接下来开始编写代码:
//向下调整算法
void AdjustDown(int* arr, int parent, int n)
{int child = 2 * parent + 1;while (child < n){//建小堆:arr[child] > arr[child + 1]//建大堆:arr[child] < arr[child + 1]if (child + 1 < n && arr[child] < arr[child + 1]){child++;}//建小堆:arr[child] < arr[parent]//建大堆:arr[child] > arr[parent]if (arr[child] > arr[parent]){Swap(&arr[child], &arr[parent]);parent = child;child = 2 * parent + 1;}else{break;}}
}//堆排序
void HeapSort(int* arr, int n)
{//建堆 -- 向下调整建大堆for (int i = (n - 1 - 1) / 2; i >= 0; i--){AdjustDown(arr, i, n);}//调整为升序序列//定义一个变量end来表示堆中最后结点的下标int end = n - 1;while (end > 0){//将堆顶的数据与堆底的数据交换Swap(&arr[0], &arr[end]);//向下调整算法AdjustDown(arr, 0, end);end--;}
}
调用该函数观察其运行结果是否将乱序序列排序成升序
调用函数:
int main()
{int arr[] = { 19, 37, 56, 29, 20, 17 };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");HeapSort(arr, size);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}return 0;
}
运行结果:
用向上调整算法建堆后,接下来用向下调整算法来建大堆。
向上调整算法是知道孩子结点的下标去求父结点的下标,将孩子结点向上移动。接下来来画图讲解:
用向上调整算法建堆完毕后,要将原序列变成有序序列,需要将堆顶的数据与堆底的数据进行交换,然后有效数据个数再 - - ,然后再根据向上调整算法将交换数据后的非堆结构变成堆结构。具体的步骤与使用向下调整建堆时介绍的步骤一样,只不过是调整的算法改变了。
分析完毕,下面开始编写代码:
//向下调整算法
void AdjustDown(int* arr, int parent, int n)
{int child = 2 * parent + 1;while (child < n){//建小堆:arr[child] > arr[child + 1]//建大堆:arr[child] < arr[child + 1]if (child + 1 < n && arr[child] < arr[child + 1]){child++;}//建小堆:arr[child] < arr[parent]//建大堆:arr[child] > arr[parent]if (arr[child] > arr[parent]){Swap(&arr[child], &arr[parent]);parent = child;child = 2 * parent + 1;}else{break;}}
}//向上调整算法
void AdjustUp(int* arr, int child)
{while (child > 0){int parent = (child - 1) / 2;//建小堆:arr[child] < arr[parent]//建大堆:arr[child] > arr[parent]if (arr[child] > arr[parent]){Swap(&arr[child], &arr[parent]);child = parent;}else{break;}}
}void HeapSort(int* arr, int n)
{//建堆 -- 向上调整建大堆for (int i = 0; i < n; i++){AdjustUp(arr, i);}//调整为升序序列//定义一个变量end来表示堆中最后结点的下标int end = n - 1;while (end > 0){//将堆顶的数据与堆底的数据交换Swap(&arr[0], &arr[end]);//向上调整算法AdjustDown(arr, 0, end);end--;}
}
既然两种算法都可以建堆,那么哪种算法更好呢?也就是说哪种建堆算法的时间复杂度最小呢?接下来来分别求取向下建堆的时间复杂度与向上建堆的时间复杂度,为了简化,这里使用满二叉树来证明。建堆的时间复杂度即为建堆时所有结点的调整次数。
计算向下调整算法建堆的时间复杂度:
向下调整算法建堆是将父结点向下移动,假设共有 h 层,最后一层的结点有孩子结点吗?没有,那么它们就不是父结点,既然不是父结点就无需向下移动。
假设每次的移动过都是最坏的情况,那么第一层共有1个结点,需要向下移动 h - 1 层;第二层共有2个结点,需要向下移动 h - 2 层,以此类推:
第一层有2^0次方个结点,需要向下移动h – 1层
第二层有2^1次方个结点,需要向下移动h – 2层
第三层有2^2次方个结点,需要向下移动h – 3层
第四层有2^3次方个结点,需要向下移动h – 4层
……
第h - 1层有2^(h – 2)次方个结点,需要向下移动1层那么需要移动结点的总移动步数为:每层结点个数 * 向下调整次数
所以时间复杂度为:
T(h) = 2^0 * (h – 1) + 2^1 * (h – 2) + 2^2 * (h – 3) + 2^3 * (h – 4) + …… + 2^(h – 2) * 1
扩大2倍:2T(h) = 2^1 * (h-1) + 2^2 * (h – 2) + 2^3 * (h -3) + 2^4 * (h – 4) + …… +2^(h – 2) * 2 + 2^(h – 1) * 1
错位相减:T(h) = -2^0 * (h – 1) + 2^1 + 2^2 + 2^3 + …… + 2^(h – 2) + 2^(h – 1) * 1 = -h + 2^0 + 2^1 + 2^2 + 2^3 + …… + 2^(h – 2) + 2^(h – 1) * 1
等比数列求和:T(h) = 2^h – 1 - h又因为满二叉树的总结点的个数n = 2^h – 1,所以原式可以化为:T(n) = n – log以2为底 n + 1 的对数,约等于n。所以向下调整建堆的时间复杂度为O(N).
计算向上调整建堆的时间复杂度:
向下调整算法建堆是将孩子结点向上移动,假设共有 h 层,第一层已经是顶层了,所以第一层的结点不需要向上移动。
假设每次移动都是最坏情况,那么第二层共有 2 个结点,需要向上移动 1 层;第三层共有4个结点,需要向上移动2层,以此类推:
第一层有2^0次方个结点,需要向上移动 0 层
第二层有2^1次方个结点,需要向上移动 1 层
第三层有2^2次方个结点,需要向上移动 2 层
第四层有2^3次方个结点,需要向上移动 3 层
……
第 h 层有2^(h – 1)次方个结点,需要向上移动h - 1层
那么需要移动结点的总移动步数为:每层结点个数 * 向上调整次数
所以时间复杂度为:
T(h) = 2^1 * 1 + 2^2 * 2 + 2^3 * 3 + …… + 2^(h – 1) * (h – 1)
扩大2倍:2T(h) = 2^2 * 1 + 2^3 * 2 + 2^4 * 3 + …… + 2^(h-1) * (h – 2) + 2^h * (h – 1)
错位相减:T(h) = - [ 2^1 * 1 + 2^2 + 2^3 + 2^4 + 2^(h – 1) ] + 2^h * (h – 1)
等比数列求和:T(h) = - [ 2^(h – 1) – 1 ] + 2^h * (h – 1) = 2^h * (h – 2) + 2又因为满二叉树的总结点的个数n = 2^h – 1,所以原式可以化为:T(n) = (n + 1) * [ log以2为底 n + 1 的对数 – 2 ] + 2 ,约等于nlogn,所以向下调整建堆的时间复杂度为O(nlogn).
比较两种建堆算法的时间复杂度可知,向下调整算法的时间复杂度更小.
但是为什么会出现这样的结果呢?在向上调整建堆算法中从第一层到最后一层结点数在不断的增多,每层结点的移动次数也在不断的增多,结点数少的层数需要移动的次数也少,结点多的层数移动的次数也多,如此一来总的移动次数就会很大;
而向下调整建堆算法中,从第一层到最后一层结点数在不断的增多,每层结点的移动次数在不断的减少,结点少的层数移动的次数多,结点多的层数移动的次数少,如此一来总的移动次数就会很小。
然而无论选择哪一种调整算法,堆排序的时间复杂度均为O(NlogN).
4. 交换排序
4.1 冒泡排序
排序思想:两两相邻元素之间进行比较,怎么一个相邻元素间进行比较呢?接下来来画图讲解:
1. 冒泡排序
接下来开始编写代码:
//冒泡排序
void BubbleSort(int* arr, int n)
{//趟数for (int i = 0; i < n - 1; i++){//每趟排序的具体过程for (int j = 0; j < n - 1 - i; j++){//若下标为 j 的数据大于下标为 j + 1的数据,那么就将两个数据进行交换if (arr[j] > arr[j + 1]){Swap(&arr[j], &arr[j + 1]);}}}
}
调用该函数观察其运行结果是否将乱序序列排序成升序
调用函数:
int main()
{int arr[] = { 4, 9, 2, 8, 5, 7, 1, 3, 2, 6 };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");BubbleSort(arr, size);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}return 0;
}
运行结果:
但是该冒泡排序的效率还不够高效,为什么呢?当序列本身就是升序序列时,冒泡排序仍然会傻乎乎的去排序。在经过一趟冒泡排序后,一对相邻的数据都没有交换,此时就应该让冒泡排序意思到待排序的序列是有序的。如何解决这个问题,如下代码所示:
//冒泡排序
void BubbleSort(int* arr, int n)
{//定义一个变量flag,初始化为1,假设待排序的序列是升序的int flag = 1;//趟数for (int i = 0; i < n - 1; i++){//每趟排序的具体过程for (int j = 0; j < n - 1 - i; j++){//若下标为 j 的数据大于下标为 j + 1的数据,那么就将两个数据进行交换if (arr[j] > arr[j + 1]){Swap(&arr[j], &arr[j + 1]);//进入到了if语句中,说明待排序的序列不是升序的,此时将flag赋值为0flag = 0;}}//每趟冒泡排序交换后,判断flag是否为1,若为1,跳出循环if (1 == flag){break;}}
}
接下来来分析冒泡排序的时间复杂度:显而易见其时间复杂度为O(N^2)。
2. qsort函数
接下来来介绍一个特别的排序函数qsort函数,qsort函数是库函数,这个库函数可以用来排序任意类型的数据。那么它的函数原型是怎样的呢?
void qsort ( void *base, size_t num, size_t size, int ( *compar )( const void* e1,const void* e2));
这个函数的参数代表着什么呢?下面来一一讲解:
参数base的类型为void*类型,可知它是一个指针,那么它指向的对象是谁呢?它指向的是待排序序列的第一个元素
参数num指的是待排序序列的元素个数
参数size指的是待排序序列的每个元素的大小,单位是字节
参数compar是一个函数指针,这个指针指向的是一个用来比较两个序列元素之间的大小的函数,就是这个函数才让qsort能够排序任意类型的数据
下面来分析函数参数的作用:
参数base是void*类型,这代表着它可以接收任意数据类型的地址;
既然要排序那么肯定得要知道待排序序列的元素个数吧,所以需要参数num;
由于base的类型是void*类型,不能知道序列中每个数据的具体大小,所以需要size;
而既然要比较不同数据类型的元素,就得为不同的数据类型提供不同的比较方式,
所以需要参数compar,函数compar的两个参数的类型是void*类型确保了它能够比较任意数据类型;
当e1指向的元素小于e2指向的元素时,compare函数会返回一个小于0的数字;
当e1指向的元素等于e2指向的元素时,compare函数会返回0;
当e1指向的元素大于e2指向的元素时,compare函数会返回大于0的数字。
接下来就来使用qsort来见识它的威力:
使用库函数qsort时,需要引用头文件stdlib.h
//排序整数
int CompareInt(const void* e1, const void* e2)
{//比较两个整数之间的大小关系 --- 两数相减return (*(int*)e1 - *(int*)e2);
}int main()
{int arr[] = { 4, 9, 2, 8, 5, 7, 1, 3, 2, 6 };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");//arr代表待排序首元素的地址//size代表待排序的元素个数//sizeof(arr[0])代表待排序序列的每个元素的大小//qsort(arr, size, sizeof(arr[0]), CompareInt);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}return 0;
}
其代码的运行结果:
qsort排序时默认排升序的,若想要排降序,将比较函数里的e1与e2的位置颠倒即可。
接下来让qsort来排序结构体类型的数据,排序结构体是根据不同的成员类型来排序的,待排序的结构体为:
//待排序的结构体的定义
typedef struct Tec
{char name[20];char character;double salary;
}Tec;
初始化为:
struct Tec arr[] = { {"shicheng", 'c', 9.98 }, {"cuiyan", 'f', 10.62 },{"yuxi", 'g', 23.99}, {"huanxing", 'a', 8.57} };
接下来按照 name 来排序,代码如下:
int CompareByName(const void* e1, const void* e2)
{//比较两个字符串之间的大小 --- 使用字符串函数strcmpreturn strcmp(((Tec*)e1)->name, ((Tec*)e2)->name);
}int main()
{struct Tec arr[] = { {"shicheng", 'c', 9.98 }, {"cuiyan", 'f', 10.62 },{"yuxi", 'g', 23.99}, {"huanxing", 'a', 8.57} };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%8s\t", arr[i]);}printf("\n");qsort(arr, size, sizeof(arr[0]), CompareByName);printf("排序后:");for (int i = 0; i < size; i++){printf("%8s\t", arr[i]);}return 0;
}
排序后的结果应当为:“cuiyan”,“huanxing”,“shicheng”,“yuxi”,下面来看看运行结果是否与预想的结果一致:
接下来按照 character 来排序,代码如下:
int CompareByCharac(const void* e1, const void* e2)
{//字符的比较也可以直接相减return (((Tec*)e1)->character - ((Tec*)e2)->character);
}int main()
{struct Tec arr[] = { {"shicheng", 'c', 9.98 }, {"cuiyan", 'f', 10.62 },{"yuxi", 'g', 23.99}, {"huanxing", 'a', 8.57}};int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%c\t", arr[i].character);}printf("\n");qsort(arr, size, sizeof(arr[0]), CompareByCharac);printf("排序后:");for (int i = 0; i < size; i++){printf("%c\t", arr[i].character);}return 0;
}
排序后的结果应当为:‘a’,‘c’,‘g’,‘f’,下面来看看运行结果是否与预想的结果一致:
接下来来按照 salary 排序,代码如下:
int CompareBySalary(const void* e1, const void* e2)
{//直接返回精度会丢失,且如果差值在 (-1, 1) 之间,强制转 int 会变成 0double ret = ((Tec*)e1)->salary - ((Tec*)e2)->salary;return ret >= 0 ? 1 : -1;
}int main()
{struct Tec arr[] = { {"shicheng", 'c', 9.98 }, {"cuiyan", 'f', 10.62 },{"yuxi", 'g', 23.99}, {"huanxing", 'a', 8.57} };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%.2f\t", arr[i].salary);}printf("\n");qsort(arr, size, sizeof(arr[0]), CompareBySalary);printf("排序后:");for (int i = 0; i < size; i++){printf("%.2f\t", arr[i].salary);}return 0;
}
排序后的结果应当为:8.57,9.98,10.62,23.99,下面来看看运行结果是否与预想的结果一致:
3. 改造冒泡排序
既然这个qsor如此强大,那么可不可将它的函数原型结合到冒泡排序中,使之冒泡排序也可以排序任意类型的数据?当然是可以的。既然如此,就需要改变之前写的冒泡排序的代码。两层 for 循环需要改变吗?不需要,因为这是冒泡排序算法的核心,当然不能改变。
需要改变的是数据的比较不能直接 arr[j] > arr[j + 1] ,况且冒泡排序的参数也得改变,int *arr,不能单单只是 int 类型,数据类型的不同,比较的方式也就不同。
所以元素在比较大小时,需要根据不同的数据类型来提供不同的比较方法。参考qsort的函数参数,将冒泡排序的参数也改变成与qsort函数相同的参数。接下来来改造冒泡排序,代码如下:
//冒泡排序
void BubbleSort(void *base, int n, int width, int(*cmp)(const void* e1, const void *e2))
{int flag = 1;//趟数for (int i = 0; i < n - 1; i++){//每趟排序的具体过程for (int j = 0; j < n - 1 - i; j++){if (cmp() > 0){Swap();flag = 0;}}if (1 == flag){break;}}
}
那么问题来了 BubbleSort 函数中的 cmp 函数后面的括号中的内容是什么。根据BubbleSort函数的参数来看,括号中的内容为待排序数据的地址,那么待排序的数据的地址怎么获得呢?
我们知道base是指向序列的首元素地址的,现在 base 的数据类型为void*类型,该类型是不能进行算数运算的;
倘若base是int*类型,那么base + 1 就可以找到 base 下一个元素的地址;若想要找到下标为 j 的元素的地址,让 base + j 即可,遗憾的是 base 是 void* 类型,不是 int* 类型;
既然如此,可以将 base 具体转化为一种数据类型,这样不就好了吗?那么转化为什么类型呢?
倘若将 base 转化成 int* 类型,对于待排序的序列中的元素为 int 类型是没有什么问题的,但是当待排序的序列为 char 类型,或者double类型时,这种转化方式不就出问题了吗?
现在再来看BubbleSort函数的参数,width 表示待排序序列的每个元素的大小
假设要找下标为 j 的元素的地址,直接让 base 跳过 j * width 个字节即可,那么如何让 base 每次移动都是移动一个字节,移动 j * width 次就能移动到下标为 j 的元素处?
我们可以将 base 强制类型转换为 char* 类型,这样就可以达到目的了,要找到下标为 j 的元素的地址,通过 (char*)base + j * width即可;那要找到下标为 j + 1 的元素的地址,通过 (char*)base + (j + 1) * width 即可。
总的来说,将 base 强制类型转化为 char* 类型,就能找到待排序序列的地址。
那Swap函数后面的括号中的内容是什么?可以很明显知道的是,内容有待交换的两个元素的地址,然后还有什么呢?由于要排序任意类型的元素,那么就得要知道待排序的序列的每个元素的大小,也就是 width 。所以 Swap 函数的参数不仅有待交换的两个元素的地址,还有 width.
分析完毕,接下来完成上面代码的未完成部分:
void Swap(char* buf1, char* buf2, int width)
{for (int i = 0; i < width; i++){char tmp = *buf1;*buf1 = *buf2;*buf2 = tmp;buf1++;buf2++;}
}//冒泡排序
void BubbleSort(void *base, int n, int width, int(*cmp)(const void* e1, const void *e2))
{int flag = 1;//趟数for (int i = 0; i < n - 1; i++){//每趟排序的具体过程for (int j = 0; j < n - 1 - i; j++){if (cmp((char*)base + j * width, (char*)base + (j + 1)*width) > 0){Swap((char*)base + j * width, (char*)base + (j + 1) * width, width);flag = 0;}}if (1 == flag){break;}}
}
接下来根据改造后的冒泡排序,来排序以下的结构体:
typedef struct Tec
{char name[20];int age;char character;double salary;
}Tec;
结构体的初始化为:
struct Tec arr[] = { {"shicheng", 23, 'c', 9.98 }, {"cuiyan", 29, 'f', 10.62 },{"yuxi", 25, 'g', 23.99}, {"huanxing", 19, 'a', 8.57} };
接下来按照 name 来排序,代码如下(冒泡排序省略):
int CompareAge(const void* e1, const void* e2)
{return strcmp(((Tec*)e1)->name, ((Tec*)e2)->name);
}int main()
{struct Tec arr[] = { {"shicheng", 23, 'c', 9.98 }, {"cuiyan", 29, 'f', 10.62 },{"yuxi", 25, 'g', 23.99}, {"huanxing", 19, 'a', 8.57} };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%8s\t", arr[i]);}printf("\n");BubbleSort(arr, size, sizeof(arr[0]), CompareName);printf("排序后:");for (int i = 0; i < size; i++){printf("%8s\t", arr[i]);}return 0;
}
运行结果:
接下来按照 age 来排序,代码如下(冒泡排序省略):
int CompareName(const void* e1, const void* e2)
{return ((Tec*)e1)->character - ((Tec*)e2)->character;
}int main()
{struct Tec arr[] = { {"shicheng", 23, 'c', 9.98 }, {"cuiyan", 29, 'f', 10.62 },{"yuxi", 25, 'g', 23.99}, {"huanxing", 19, 'a', 8.57} };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d\t", arr[i].age);}printf("\n");BubbleSort(arr, size, sizeof(arr[0]), CompareAge);printf("排序后:");for (int i = 0; i < size; i++){printf("%d\t", arr[i].age);}return 0;
}
运行结果:
接下来按照 character 来排序,代码如下(冒泡排序省略):
int CompareCharac(const void* e1, const void* e2)
{return ((Tec*)e1)->character - ((Tec*)e2)->character;
}int main()
{struct Tec arr[] = { {"shicheng", 23, 'c', 9.98 }, {"cuiyan", 29, 'f', 10.62 },{"yuxi", 25, 'g', 23.99}, {"huanxing", 19, 'a', 8.57} };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%c\t", arr[i].character);}printf("\n");BubbleSort(arr, size, sizeof(arr[0]), CompareCharac);printf("排序后:");for (int i = 0; i < size; i++){printf("%c\t", arr[i].character);}return 0;
}
运行结果:
接下来按照 salary 来排序,代码如下(冒泡排序省略):
int CompareSalary(const void* e1, const void* e2)
{double ret = ((Tec*)e1)->salary - ((Tec*)e2)->salary;return ret >= 0 ? 1 : -1;
}int main()
{struct Tec arr[] = { {"shicheng", 23, 'c', 9.98 }, {"cuiyan", 29, 'f', 10.62 },{"yuxi", 25, 'g', 23.99}, {"huanxing", 19, 'a', 8.57} };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%.2f\t", arr[i].salary);}printf("\n");BubbleSort(arr, size, sizeof(arr[0]), CompareSalary);printf("排序后:");for (int i = 0; i < size; i++){printf("%.2f\t", arr[i].salary);}return 0;
}
运行结果:
用改造后的冒泡排序对字符串类型,int类型,char类型,double类型的数据进行排序,都排序成功,表明了冒泡排序的改造是成功的。
4.2 快速排序
快速排序是一种二叉树结构的交换排序方法,其排序思想为:
任取待排序元素序列中的某元素作为基准值,按照该基准值将待排序序列分隔成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子树再重复找基准值,直到所有元素都排序在相应的位置
有人可能会有疑问,为什么会是一种二叉树的结构呢?下面来画图讲解:
这样一看是不是很像一个二叉树。
根据快速排序的思想可知,要实现排序首先得找到基准值,那如何找到基准值呢?方法有两种: hoare 版本,lomuto 前后指针。
1. hoare 版本
算法思路:
- 创建左右变量, left 与 right ,确定基准值,一开始假设基准值为最左或最右的元素,并用key来指向假设的基准值的下标
left 从左往右找比基准值大的元素;right 从右向左找比基准值小的元素- 如果开始假设基准值为最左的元素,那先从右向左找到比基准值小的元素,再从左向右找到比基准值大的数据;如果开始假设基准值为最右的元素,那先从左向右找到比基准值小的元素,再从右向左找到比基准值大的数据,找到之后,需要判断left 与 right 之间的关系:
若 left <= right ,让 left 与 right 指向的元素交换 ;若 left > right ,让 right 与 key 指向的元素交换。
当 right 与 key 指向的数据交换后,此时 right 指向的元素就是要找到基准值,直接返回 right。
了解 hoare 版本的找基准值算法后,下面画图来具体讲解:
从上图可知,根据基准值将原序列分成左子序列与右子序列后,仍然还是执行与原序列相同的操作,那么就可以通过递归来实现,分成左子序列递归与右子序列递归。根据以上的分析可以得出,快速排序的参数为待排序的序列,指向序列的左端的变量 left ,指向序列右端的变量 right 。那么左子序列递归的参数为待排序序列,指向序列左端的变量 left ,指向序列右端的变量 基准值 key - 1;右子序列递归的参数为待排序序列,指向序列左端的变量 基准值 key + 1 ,指向序列右端的变量 right。那么递归的终止条件是什么?当 left 大于等于 right 时,就停止递归。
下面开始编写代码:
//hoare版本的找基准值
int QuickHoare(int* arr, int left, int right)
{//假设基准值开始为序列最左端的元素int key = left;left += 1;while (left <= right){//先让right从右向左找比基准值小的元素while (left <= right && arr[right] > arr[key]){right--;}//代码运行到这,说明right已经找到了//再让left从左向右找比基准值大的元素while (left <= right && arr[left] < arr[key]){left++;}//代码运行到这,说明left已经找到了//接下来来判断 left 与 right 间的大小关系if (left <= right){Swap(&arr[left++], &arr[right--]);}}//代码运行到这,说明left > right//此时将基准值放到下标为 right 处Swap(&arr[key], &arr[right]);return right;
}//快速排序
void QuickSort(int* arr, int left, int right)
{//递归终止的条件if (left >= right){return;}//找基准值int key = QuickHoare(arr, left, right);//递归左子序列QuickSort(arr, left, key - 1);//递归右子序列QuickSort(arr, key + 1, right);
}
调用该函数观察其运行结果是否将乱序序列排序成升序
调用函数:
int main()
{int arr[] = {4, 2, 9, 3, 8, 6, 5, 1, 7};int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");QuickSort(arr, 0, size - 1);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}return 0;
}
运行结果:
接下来来分析快速排序的时间复杂度:快速排序中使用了递归,而递归的时间复杂度的计算公式为 递归次数 * 单次递归的时间复杂度,并且快速排序中还有找基准值QuickHoare 函数,那么该函数的时间复杂度为多少?
由于内层的两个 while 循环都是在外层 while 循环的范围内进行的,所以 QuickHoare 函数的时间复杂度为 O(N)。每次递归都会执行 QuickHoare 函数,所以单次递归的时间复杂度为 O(N),而递归次数又为 logn,所以快速排序的时间复杂度为 nlogn。
下面将 QuickHoare 函数中的while (left <= right && arr[right] > arr[key]) 改为 while (left <= right && arr[right] >= arr[key]);
while (left <= right && arr[left] < arr[key]) 改为while (left <= right && arr[left] <= arr[key]) 会怎么样?下面来画图分析:
所以切记不要将 while (left <= right && arr[right] > arr[key]) 改为 while (left <= right && arr[right] >= arr[key]),不要将 while (left <= right && arr[left] < arr[key]) 改为 while (left <= right && arr[left] <= arr[key]) 。此外倘若数组本身就是升序的,也会发生上图的情况,快速排序的时间复杂度为O( N ^ 2 )。
2. lomuto 前后指针
算法思路:
1. 创建两个前后变量 prev 与 pcur,来确定基准值, 一开始假设基准值为最左或最右的元素,并用key来指向假设的基准值的下标
prev 指向序列下标为0的位置处,pcur 指向序列下标为 prev + 1 的位置处
2. prev 指向下标为0的位置处不动,让 pcur 去遍历序列,去找比基准值小的数据
若 pcur 找到了比基准值小的数据,那么就让 prev 向后移动,即 prev++ ,若 prev 与 pcur 指向的位置不一样,就让它俩所指向的元素交换;若 prev 与 pcur 指向的位置一样,就不用交换,判断完毕后,再让 pcur 向后移动,即 pcur ++
3. 若 pcur 未找到比基准值小的元素,就让 pcur 继续向后移动,即 pcur ++
4. 当 pcur 越界后,将 key 与 prev 指向的元素交换,此时 prev 指向的元素即为基准值,之后再将 prev 返回
了解 lomuto 前后指针的找基准值算法后,下面画图来具体讲解:
下面开始编写代码:
//lomuto前后指针
int QuickLomuto(int* arr, int left, int right)
{int key = left;//定义两个前后变量int prev = left;int pcur = prev + 1;//pcur不能越界while (pcur <= right){if (arr[pcur] < arr[key] && prev++ != pcur){Swap(&arr[prev], &arr[pcur]);}pcur++;}//代码运行到这,说明 pcur 已经越界了//将 key 与 prev指向的元素进行交换Swap(&arr[key], &arr[prev]);//此时prev指向的元素就是要找到基准值return prev;
}//快速排序
void QuickSort(int* arr, int left, int right)
{//递归终止的条件if (left >= right){return;}//找基准值int key = QuickLomuto(arr, left, right);//递归左子序列QuickSort(arr, left, key - 1);//递归右子序列QuickSort(arr, key + 1, right);
}
调用该函数观察其运行结果是否将乱序序列排序成升序
调用函数:
int main()
{int arr[] = {4, 2, 9, 3, 8, 6, 5, 1, 7};int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");QuickSort(arr, 0, size - 1);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}return 0;
}
运行结果:
3. 快速排序之三路划分
之前编写的快速排序的代码在排序序列中的数据都为相同的元素或绝大多数的元素都是相同的时候,其时间复杂度会大大增加。那有没有什么解决办法,下面就来介绍三路划分。
三路划分的核心思想类似 hoare 的左右指针与 lomuto 的前后指针的结合,其核心要点是将待排序的序列分成三个部分:比基准值 key 小的值,与基准值 key 相等的值,比基准值 key 大的值,所以叫做三路划分算法
算法思想:
- key 默认取 left 位置的值
- left 指向待排序序列的最左边,right 指向待排序序列的最右边,pcur指向 left + 1的位置
- 当 pcur 指向的元素小于基准值 key 时,将 left 与 pcur 指向的元素进行交换,接着再各自向后移动,即 pcur++,left++
- 当 pcur 指向的元素大于基准值 key 时,将 right 与 pcur 指向的元素进行交换,接着 right 再向前移动,即 right –
- 当 pcur 指向的元素等于基准值 key 时,让 pcur 接着向后移动,即pcur ++
- 当 pcur > right 时,就停止循环
接下来就用该算法排序,如下图所示:
接下来开始编写代码:
void QuickSort(int* arr, int left, int right)
{//递归结束条件if (left >= right){return;}int begin = left;int end = right;//随机化基准值int randi = left + rand() % (right - left + 1);Swap(&arr[left], &arr[randi]);int key = arr[left];int pcur = left + 1;while (pcur <= right){if (arr[pcur] < key){Swap(&arr[left], &arr[pcur]);left++;pcur++;}else if (arr[pcur] > key){Swap(&arr[right], &arr[pcur]);right--;}else{pcur++;}}//找到基准值后,划分为三部分QuickSort(arr, begin, left - 1);QuickSort(arr, right + 1, end);
}
调用该函数观察其运行结果是否将乱序序列排序成升序
调用函数:
int main()
{int arr[] = { 4, 7, 5, 8, 7, 6, 3, 7, 1 };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");QuickSort(arr, 0, size - 1);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}return 0;
}/**
为什么随机化基准值是left + rand() % (right - left + 1)?
right - left + 1:计算当前序列的长度(包含 left 和 right)
rand() % (right - left + 1):生成一个 [0, size-1] 的随机偏移量
left + rand() % (right - left + 1):将随机偏移量加到 left 上,得到 [left, right] 范围内的随机范围
*/
运行结果:
当待排序的序列的元素全都不同时,其时间复杂度为O(nlogn);当待排序的序列的元素全部相同时,其时间复杂度为O(N);使用随机化基准值,当序列为升序时,其时间复杂度也为O(nlogn)。之前的快速排序的初始基准值也可以采用随机化基准值。
4. 非递归快速排序
前面的快速排序都是运用了递归来实现的,那么可不可以不使用递归来实现快速排序呢?当然可以。参考递归快速排序的代码可知,函数的参数有 left 与 right,这两个参数为要待排序的序列的范围,明确了范围才能准确的找到基准值。
假设待排序序列的元素个数为 n ,现在待排序的序列的范围是 0 ~ n - 1,找到基准值之后,还要根据基准值来将原序列分成左右子序列,等到那时,待找基准值的序列的范围又变了。
所以在找基准值之前,需要将序列的范围存储起来,也就是将序列的首位置的下标与末位置的下标存储起来,将它们存储到哪呢?这时就不得不借助数据结构来实现了,栈与队列均可以。
借助数据结构 —— 栈来实现:
接下来画图讲解:
根据上述的分析可以直到要实现栈的操作有:栈的初始化,取栈顶数据,入栈,出栈,判断栈是否为空,销毁栈中的数据,这些代码就不呈现了。分析完毕,下面开始编写代码:
void QuickSortNoR(int* arr, int left, int right)
{//定义一个栈ST st;//初始化栈StackInit(&st);//入栈 --- 右到左StackPush(&st, right);StackPush(&st, left);//只要栈中的数据不为0,就不会停止排序while (!StackEmpty(&st)){//取两次栈顶的元素//注意自己先前的入栈顺序int begin = StackTop(&st);StackPop(&st);int end = StackTop(&st);StackPop(&st);//获取待排序的序列的左右下标 [begin , end]//使用lomuto前后指针来找基准值int key = begin;int prev = begin;int pcur = prev + 1;while (pcur <= end){if (arr[pcur] < arr[key] && prev++ != pcur){Swap(&arr[prev], &arr[pcur]);}pcur++;}Swap(&arr[key], &arr[prev]);key = prev;//begin key end//处理左子序列与右子序列if (begin < key - 1){//入栈StackPush(&st, key - 1);StackPush(&st, left);}if (key + 1 < end){//入栈StackPush(&st, end);StackPush(&st, key + 1);}}//销毁栈StackDestory(&st);
}
调用该函数观察其运行结果是否将乱序数据排序成升序
调用函数:
int main()
{int arr[] = { 4, 2, 9, 3, 8, 6, 5, 1, 7 };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");QuickSortNoR(arr, 0, size - 1);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}return 0;
}
运行结果:
借助数据结构 — 队列来实现:
用队列来实现与栈差不多,只不过是将初始化栈,销毁栈的数据,入栈,取栈顶的数据,出栈,判断栈是否为空变成了初始化队列,销毁队列的数据,入队列,取队头的数据,出队列,判断队列是否为空罢了。
下面开始编写代码:
void QuickSortQueue(int* arr, int left, int right)
{//创建一个队列Queue q;//初始化队列QueueInit(&q);//入队列QueuePush(&q, left);QueuePush(&q, right);//只要队列中的数据不为0,就不会停止排序while (!QueueEmpty(&q)){//取两次队头的数据int begin = QueueFront(&q);QueuePop(&q);int end = QueueFront(&q);QueuePop(&q);//获取待排序的序列的左右下标 [begin , end]//使用lomuto前后指针来找基准值int key = begin;int front = begin;int rear = begin + 1;while (rear <= end){if (arr[rear] < arr[key] && front++ != rear){Swap(&arr[front], &arr[rear]);}rear++;}Swap(&arr[key], &arr[front]);key = front;// begin key end//处理左子序列与右子序列if (begin < key - 1){//入队列QueuePush(&q, begin);QueuePush(&q, key - 1);}if (key + 1 < end){//入队列QueuePush(&q, key + 1);QueuePush(&q, end);}}//销毁队列QueueDestory(&q);
}
调用该函数观察其运行结果是否将乱序数据排序成升序
调用函数:
int main()
{int arr[] = { 4, 2, 9, 3, 8, 6, 5, 1, 7 };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");QuickSortQueue(arr, 0, size - 1);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}return 0;
}
运行结果:
5. 归并排序
算法思想:归并排序是建立在归并操作上的一种有效的排序算法。它将已有序的子序列进行合并,得到一个完全有序的新序列;若将两个有序序列合并成一个有序序列,那么称之为二路归并。
归并排序的核心思想如下图所示:
1. 递归版本的归并排序
先定义一个变量 mid 来表示序列的中间值的下标,如何找 mid 呢?可以 ( left + right ) / 2,也可以 left + ( right - left ) / 2,后面的方法更好。
根据 mid 将原序列划分成左右两个序列,之后再继续找这两个序列的 mid ,然后继续划分,这里我们可以使用递归来实现。
那么递归的终止条件是 当序列的左区间 left 大于等于右区间 right 时,递归结束,因为此时序列中只有一个元素或没有元素。分解的步骤完成了,那合并的步骤呢?合并序列跟合并数组一样,我们可以创建一个数组来临时存放这些元素,为了能够让数组存储这些元素,需要为它开辟与原序列相同大小的内存空间。合并完后,再将新序列中的元素拷贝到旧序列中,可以使用内存函数memcpy。
接下来开始编写代码:
//归并排序
void MergeMid(int* arr, int left, int right, int *tmp)
{//递归的终止条件if (left >= right){return;}//分解int mid = left + (right - left) / 2;//根据中间值的下标mid,将原序列划分成左右两个序列//左序列区间:[left,mid] 右序列区间:[mid + 1,right]//继续找左右序列的中间值的下标midMergeMid(arr, left, mid, tmp);MergeMid(arr, mid + 1, right, tmp);//代码运行到这,说明序列已经二分完毕,不能再划分下去了//合并//不改变原序列的 left 与 right 的位置int begin = left;int end = mid;int rev = mid + 1;int cur = right;//指向tmp的下标索引int index = left;//遍历两个序列区间[begin,end] [rev,cur]while (begin <= end && rev <= cur){if (arr[begin] < arr[rev]){tmp[index++] = arr[begin++];}else{tmp[index++] = arr[rev++];}}//代码运行到这,说明begin > end 或 rev > cur//分情况while (begin <= end){tmp[index++] = arr[begin++];}while (rev <= cur){tmp[index++] = arr[rev++];}//将tmp中的元素拷贝到原序列中memcpy(arr, tmp, sizeof(int) * (right + 1));
}void MergeSort(int* arr, int n)
{//创建一个与原数组一样大的内存空间,用来临时存放待排序序列中的元素int* tmp = (int*)malloc(sizeof(int) * n);if(tmp == NULL){perror("malloc fail!");exit(1);}MergeMid(arr, 0, n - 1, tmp);//将申请的内存空间还给操作系统free(tmp);tmp = NULL;
}
调用该函数观察其运行结果是否将乱序数据排序成升序
调用函数:
int main()
{int arr[] = { 3, 5, 7, 1, 2, 8, 9, 4, 6, 0 };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");MergeSort(arr, size);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}
return 0;
}
运行结果:
接下来来计算归并排序的时间复杂度,上面的归并排序中使用了递归,所以递归的时间复杂度就是该归并排序的时间复杂度,递归的时间复杂度的计算公式为:单次递归的时间复杂度 * 递归的次数,递归的次数为 logn,单次递归的时间复杂度为 n,故递归的时间复杂度为 O(nlogn),即归并排序的时间复杂度为 O(nlogn),由于在排序中开辟了内存空间,所以归并排序的空间复杂度为O(N)。
2. 非递归版本的归并排序
先定义一个变量 gap 来表示当前的每个序列的序列元素的个数,当序列中的元素个数为偶数个的时候,在分解的步骤执行完毕后,每个序列的元素个数为 1,所以初始情况下 gap = 1,由于归并排序是按照二分的方式去分解的,那么合并时每个序列中的元素就是以两倍的大小增长,所以在合并之后 gap 扩大两倍。什么时候合并结束呢?当序列中的元素个数与原序列的元素个数相同时,合并结束,也就是当 gap >= 原序列的元素个数时,停止合并。接下来画图进行讲解:
接下来开始编写代码:
//非递归版本的归并排序
void MergeSortNoR(int* arr, int n)
{int* tmp = (int*)malloc(sizeof(int) * n);if (tmp == NULL){perror("malloc fail!");exit(1);}int gap = 1;while (gap < n){//根据gap来划分组,两两合并for (int i = 0; i < n; i += 2 * gap){int begin = i;int end = i + gap - 1;int rev = i + gap;int cur = i + gap + gap - 1;int index = i;//两个有序序列合并while (begin <= end && rev <= cur){if (arr[begin] < arr[rev]){tmp[index++] = arr[begin++];}else{tmp[index++] = arr[rev++];}}while (begin <= end){tmp[index++] = arr[begin++];}while (rev <= cur){tmp[index++] = arr[rev++];}//将排序好的序列中的元素拷贝到原序列memcpy(arr + i, tmp + i, sizeof(int) * (cur - i + 1));}gap *= 2;}
}
调用该函数观察其运行结果是否将乱序数据排序成升序
调用函数:
int main()
{int arr[] = { 3, 5, 7, 1, 2, 8, 9, 4, 6, 0 };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");MergeSortNoR(arr, size);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}return 0;
}
运行结果:
不但没有将原本的乱序序列排成升序序列,还造成了数组下标的越界访问,为什么会造成数组下标的越界访问呢?下面来画图分析:
正确的代码如下:
//非递归版本的归并排序
void MergeSortNoR(int* arr, int n)
{int* tmp = (int*)malloc(sizeof(int) * n);if (tmp == NULL){perror("malloc fail!");exit(1);}int gap = 1;while (gap < n){//根据gap来划分组,两两合并for (int i = 0; i < n; i += 2 * gap){int begin = i;int end = i + gap - 1;int rev = i + gap;int cur = i + gap + gap - 1;int index = i;//左序列的元素个数不足 gap 个if(end >= n){//跳出循环,gap扩大两倍后,再进入循环break; }//没有右子序列if (rev >= n){//跳出循环,gap扩大两倍后,再进入循环break; }//右序列的元素个数不足gap个if (cur >= n){cur = n - 1;}//两个有序序列合并while (begin <= end && rev <= cur){if (arr[begin] < arr[rev]){tmp[index++] = arr[begin++];}else{tmp[index++] = arr[rev++];}}while (begin <= end){tmp[index++] = arr[begin++];}while (rev <= cur){tmp[index++] = arr[rev++];}//将排序好的序列中的元素拷贝到原序列memcpy(arr + i, tmp + i, sizeof(int) * (cur - i + 1));}gap *= 2;}
}
调用该函数观察其运行结果是否将乱序数据排序成升序
调用函数:
int main()
{int arr[] = { 3, 5, 7, 1, 2, 4, 6, 0 };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");MergeSortNoR(arr, size);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}return 0;
}
运行结果:
3. 文件归并排序
首先先来介绍外排序:外排序是指能够处理极大量数据的排序算法。通常来说,外排序处理的数据不能一次装入内存,只能放在读写较慢的外存储器上。
外排序通常采用的是一种“排序-归并”的策略。在排序阶段,先读入能存放在内存中的数据量,将其排序输出到一个临时文件,以此进行,将待排序数据分成多个序列的临时文件。然后在归并阶段将这些临时文件组合成一个大的有序文件,也即排序结果。
跟外排序相对应的是内排序。在前面提到的所有排序,以及之后的计数排序都是内排序,这些排序的思想适应的是数据在内存中,支持随机访问。归并排序的思想不需要随机访问数据,只需要依次按序列读取数据,所以归并排序既是一个内排序,也是一个外排序。
文件归并排序思路:
- 读取 n 个值,经过排序后,读写到文件file1中,再读取 n 个值,经过排序后,读写到文件file2中
- 文件file1和文件file2运用归并排序的思想,依次读取比较,取小值尾插到文件mfile中,mfile归并为一个有序的文件
- 将文件file1与文件file2删除,再将mfile重命名为file1
- 再次读取 n 个数据,经过排序后,读写到文件 file2
- 继续归并 file1 与 file2 ,重复步骤2,直到文件中无法再读取数据。最后将归并出的有序序列放到文件 flie1 中
具体的流程图如下所示:
编写一个函数创建N个随机数,并写入到目标文件 data.txt 中,代码如下:
//创建N个随机数,写入到文件中
void CreatData()
{//造数据,再多少自己定,这里为了方便观察,造100个数据int n = 100;//生成随机数种子srand(time(0));//创建一个文件,文件名为data.txtconst char* file = "data.txt";//打开文件,往文件中写入数据FILE* fin = fopen(file, "w");//判断文件是否打开成功if (fin == NULL){perror("fopen error!");exit(1);}//造n个随机数for (int i = 0; i < n; i++){int x = rand() + i;//往文件中输入该随机数fprintf(fin, "%d\n", x);}//关闭文件fclose(fin);
}
编译一下,观察文件 data.text是否创建成功,且该文件中是否存在100个随机数,鼠标选中当前的.c文件,右击:
点击 ”打开所在的文件夹“ :
确实在当前的文件路径下生成了 “data.txt” 文件,再点开该文件,可以发现确实产生了100个随机数。如下图所示:
接下来开始编写代码:
int CompareInt(const void* e1, const void* e2)
{return *(int*)e1 - *(int*)e2;
}//返回实际读取到的数据个数,没有数据返回0
//为了避免每次读取数据时,都要打开数据源文件,在这里传指向源文件的指针
int ReadNDataSortToFile(FILE* fout, int n, const char* dstfile)
{//创建一个内存空间大小为n个int整型的数组int x = 0;int* arr = (int*)malloc(sizeof(int) * n);if (arr == NULL){perror("malloc error!");return 0;}//读取n个数据int j = 0;for (int i = 0; i < n; i++){//从之前打开的文件中读取数据//判断是否能够读取到n个数据,即判断fscanf是否读取到EOFif (fscanf(fout, "%d", &x) == EOF){break;}arr[j++] = x;}//使用函数qsort来排序//判断j是否为0,即是否读取到了数据,若一个数据都没有读取到,那么就没必要排序了if (j == 0){free(arr);return 0;}qsort(arr, j, sizeof(int), CompareInt);//以写的方式打开dstfile文件FILE* fin = fopen(dstfile, "w");if (fin == NULL){free(arr);perror("fopen dstfile fail!");return 0;}//将排序好的数据写入文件dstfilefor (int i = 0; i < j; i++){fprintf(fin, "%d\n", arr[i]);}free(arr);fclose(fin);return j;
}//创建一个函数,用来将文件file1与文件file2,归并到文件mfile中
void MergeFile(const char* file1, const char* file2, const char* mfile)
{//以读的方式打开文件flie1FILE* fout1 = fopen(file1, "r");if (fout1 == NULL){perror("fopen file1 fail!");exit(4);}//以读的方式打开文件flie2FILE* fout2 = fopen(file2, "r");if (fout2 == NULL){perror("fopen file2 fail!");exit(5);}//以写的方式打开文件mflieFILE* mfin = fopen(mfile, "w");if (mfin == NULL){perror("fopen mfile fail!");exit(6);}//归并int x1 = 0;int x2 = 0;int ret1 = fscanf(fout1, "%d", &x1);int ret2 = fscanf(fout2, "%d", &x2);//读取到的数据不能为EOFwhile (ret1 != EOF && ret2 != EOF){if (x1 < x2){//往mfile文件中写入x1与x2中的较小值fprintf(mfin, "%d\n", x1);ret1 = fscanf(fout1, "%d", &x1);}else{fprintf(mfin, "%d\n", x2);ret2 = fscanf(fout2, "%d", &x2);}}//处理剩余的数据while (ret1 != EOF){fprintf(mfin, "%d\n", x1);ret1 = fscanf(fout1, "%d", &x1);}while (ret2 != EOF){fprintf(mfin, "%d\n", x2);ret2 = fscanf(fout2, "%d", &x2);}//关闭文件file1,file2,mfilefclose(fout1);fclose(fout2);fclose(mfin);
}int main()
{CreateData();//生成3个文件,file1,flie2,mflieconst char* file1 = "file1.txt";const char* file2 = "file2.txt";const char* mfile = "mfile.txt";//以读的方式打开文件data.txtFILE* fout = fopen("data.txt", "r");if (fout == NULL){perror("fopen data.txt fail!");exit(3);}//每次读10个数据ReadNDataSortToFile(fout, 10, file1);ReadNDataSortToFile(fout, 10, file2);//循环归并while (1){MergeFile(file1, file2, mfile);//删除文件file1与file2 --- 使用函数removeremove(file1);remove(file2);//将文件mfile重命名为file1 --- 使用函数renamerename(mfile, file1);//每次都读取10个数据//判断文件data.txt中是否还有数据//没有数据了,说明文件归并结束,归并好的数据在文件file1中if (ReadNDataSortToFile(fout, 10, file2) == 0){break;}}fclose(fout);return 0;
}
观察代码的运行结果是否将文件中的100个数据都排成升序:
确实将100个数据排成升序了,也可移增加文件data.txt中的数据,但是记得要修改每次读取数据的个数。
6. 计数排序
计数排序又称为鸽巢原理,计数排序具体算法思想如下:
1. 统计相同元素出现的次数
2. 根据统计的结果将序列回收到原来的排序中
下面就用具体的数据来演示一下计数排序是怎么将数据排成升序的:
计数排序并没有比较元素的大小,就将序列排成有序的了。
但是看到这,也许会产生疑问,万一待排序序列中的元素都为几百,几千的数据呢?此时新建数组的下标还是从 0 开始 一直到 几百多,几千多吗?并不是。并且如何确定我们要新建的数组的大小呢?下面用具体的数据来讲解:
分析完毕,下面开始编写代码:
//计数排序
void CountSort(int* arr, int n)
{//找当前序列的最大值与最小值int min = arr[0];int max = arr[0];for (int i = 1; i < n; i++){if (arr[i] < min){min = arr[i];}if (arr[i] > max){max = arr[i];}}//确定新建数组的大小int range = max - min + 1;int *count = (int*)malloc(sizeof(int) * range);if(count == NULL){perror("malloc fail!");exit(1);}//初始化新建数组 --- 使用函数memset,将数组全部初始化为0memset(count, 0, sizeof(int) * range);//统计相同元素出现的次数for (int i = 0; i < n; i++){count[arr[i] - min]++; //arr[i] - min 代表下标}//将count数组中的数据还原到原数组int index = 0; //原数组下标的索引//遍历count数组for (int i = 0; i < range; i++){while (count[i]--){arr[index++] = i + min;}}
}
调用该函数观察其运行结果是否将乱序数据排序成升序
调用函数:
int main()
{int arr[] = { 101, 108, 108, 103, 101, 105, 101 };int size = sizeof(arr) / sizeof(arr[0]);printf("排序前:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}printf("\n");CountSort(arr, size);printf("排序后:");for (int i = 0; i < size; i++){printf("%d ", arr[i]);}return 0;
}
运行结果:
接下来来分析计算排序的时间复杂度:显然计数排序的时间复杂度为O(N + range),空间复杂度为O(N).
计数排序虽然时间复杂度很小,但是能使用的场景非常有限,它只适用于排序序列中的最大值与最小值相差不大的序列,倘若排序列中的最大值与最小值相差较大的序列,如序列中的最小值与最大值为 0 和 200,200 和 10000等等的序列就不行了。
7. 总结
下面来介绍评价上面的八种排序的优劣的一个方面:稳定性。
什么是稳定性:假设在待排序的序列中,具有多个相同的元素,若经过排序后,这些相同的元素的相对位置保持不变,即在原序列中 arr[ i ] == arr [ j ],且元素 arr[ i ] 在未排序前在 arr[ j ] 的前面,在排序后,元素 arr[ i ] 仍然在 arr[ j ] 的前面,那么则称这种排序算法是稳定的;反之则不稳定。
接下来来总结上述几种排序: