=========================================================================
相关代码gitee自取:
C语言学习日记: 加油努力 (gitee.com)
=========================================================================
接上期:
【数据结构初阶】十、快速排序(比较排序)讲解和实现
(三种递归快排版本 + 非递归快排版本 -- C语言实现)-CSDN博客
=========================================================================
常见排序算法的实现(续上期)
(详细解释在图片的注释中,代码分文件放下一标题处)
四、归并排序
基本思想:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,
该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。
将已有序的子序列合并,得到完全有序的序列;
即先使每个子序列有序,再使子序列段间有序。
若将两个有序表合并成一个有序表,称为二路归并。
归并排序核心步骤:
归并排序的特性总结:
- 归并的缺点在于需要O(N)的空间复杂度,
归并排序的思考更多的是解决在磁盘中的外排序问题
- 该算法时间复杂度:O(N*logN)
- 该算法空间复杂度:O(N)
- 该算法稳定性:稳定
---------------------------------------------------------------------------------------------
_MergeSort函数
--
归并排序函数(递归版本)的子函数(内部函数), 完成归并操作
- 之后会涉及递归操作,这里先设置递归结束条件:
当前区间分割成无意义或不合理区间时,返回上层递归
- 因为要将当前区间分为两部分,所以先获得当前区间的中间下标mid
- 通过mid对当前区间的左区间进行递归,分割出新的左右区间
同理,对当前区间的右区间也进行递归,分割出新的左右区间
- 递归分割完区间后,递归返回时再进行归并操作,
将分割出的区间元素归并排序后放到动态开辟的tmp数组中,
归并完成后再拷贝到原数组中
- 先定义当前区间的左右区间范围,
再定义一个tmp数组的起始下标index
- 使用while循环将分割出的区间元素归并到tmp数组中
- 最后将归并好的tmp数组拷贝回原数组中
图示:
该函数执行逻辑图:
---------------------------------------------------------------------------------------------
MergeSort函数 -- 归并排序函数(递归版本)
- 开辟动态数组tmp:
连续开辟和待排序数组对应类型和个数的动态空间
- 调用_MergeSort子函数进行归并操作
- 执行完归并排序后,释放开辟的动态空间tmp数组
图示:
该函数测试:
---------------------------------------------------------------------------------------------
(难)MergeSortNonR函数 -- 归并排序函数(非递归版本)
- 开辟动态数组tmp:
连续开辟和待排序数组对应类型和个数的动态空间
- 因为不能使用递归进行“归并前分割”操作来分割区间,
所以需要定义一个gap值
- gap默认为1,来进行“一一归二”;
之后gap*2,进行“二二归四”;再gap*2,进行“四四归八”……
使用while循环循环进行“几几归2*几”,
完成本次while循环后,调整gap值(gap*=2*gap),
进行下次当前gap值的“几几归2*几”操作
- 内嵌for循环,找到要进行归并的两个“小区间”,
并确保这两个区间范围不会越界或按需对其进行修正(重点),
然后进行归并操作和拷贝操作
- 退出while循环后,完成非递归归并排序,释放掉tmp数组
图示:
该函数测试:
补充:计数排序(非比较排序)
本期博客再包含前两期博客中:
【数据结构初阶】九、五种比较排序的讲解和实现
(直接插入 \ 希尔 \ 直接选择 \ 堆 \ 冒泡 -- C语言)-CSDN博客
【数据结构初阶】十、快速排序(比较排序)讲解和实现
(三种递归快排版本 + 非递归快排版本 -- C语言实现)-CSDN博客
我们总共了解了七种比较排序,
(比较排序即需要通过比较元素之间的大小来进行排序的排序算法)
先通过下表来总结前面的七种比较排序:
排序算法
(比较排序)平均情况
(时间复杂度)最好情况
(时间复杂度)最坏情况
(时间复杂度)空间复杂度 稳定性 直接插入排序 O(N^2) O(N) O(N^2) O(1) 稳定 希尔排序 O(N*logN) ~ O(N^2) O(N^1.3) O(N^2) O(1) 不稳定 直接选择排序 O(N^2) O(N^2) O(N^2) O(1) 不稳定 堆排序 O(N*logN) O(N*logN) O(N*logN) O(1) 不稳定 冒泡排序 O(N^2) O(N) O(N^2) O(1) 稳定 快速排序 O(N*logN) O(N*logN) O(N^2) O(logN) ~ O(N) 不稳定 归并排序 O(N*logN) O(N*logN) O(N*logN) O(N) 稳定
再补充了解一种非比较排序:计数排序
基本思想:
计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用
计数排序核心步骤:
- 统计相同元素出现的次数
- 根据统计的结果将序列回收到原来的序列中
归并排序的特性总结:
- 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限
- 该算法时间复杂度:O(MAX(N,range))
- 该算法空间复杂度:O(range)
---------------------------------------------------------------------------------------------
CountSort函数 -- 计数排序(鸽巢原理)函数
- 先使用for循环找到a数组中的最大值max和最小值min
- 通过max和min获得数组a的元素范围range,
开辟等大等数的动态统计数组count并检查开辟是否成功,
开辟成功后对其进行初始化
- 使用for循环在统计数组count中统计对应下标元素出现次数
- 再根据统计的结果序列回收到原来的序列(数组)中
图示:
改函数执行逻辑图:
该函数测试:
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
对应代码(续上期)
Sort.h -- 排序头文件
//归并排序函数(递归版本): //第一个参数:接收要排序的数组首元素地址(a) //第二个参数:接收该数组的长度(n) void MergeSort(int* a, int n); //归并排序函数(非递归版本): //第一个参数:接收要排序的数组首元素地址(a) //第二个参数:接收该数组的长度(n) void MergeSortNonR(int* a, int n); //计数排序(鸽巢原理)-- 非比较排序: //第一个参数:接收要排序的数组首元素地址(a) //第二个参数:接收该数组的长度(n) void CountSort(int* a, int n);
---------------------------------------------------------------------------------------------
Sort.c -- 排序函数实现文件
//归并排序函数(递归版本)的子函数(内部函数): //第一个参数:接收要排序的数组首元素地址(a) //第二个参数:接收开辟的等大动态空间首元素地址(tmp) //第三个参数:接收该数组起始位置下标(0) //第四个参数:接收该数组尾部位置下标(数组元素个数-1) //(函数名前加个“_”,表示为该函数的子函数) void _MergeSort(int* a, int* tmp, int begin, int end) { //设置递归结束条件: if (end <= begin) //当前区间分割成无意义或不合理区间时: { //返回上层递归: return; } //先获得当前区间的中间下标mid: int mid = (end + begin) / 2; /* * 之后就可以将当前区间分成两个区间: * [begin, mid] [mid+1, end] * 如果左区间有序,右区间也有序就可以开展归并了 * * 可以先对当前有效的左区间进行递归, * 使其不断分割出新的左右区间; * 同理,也对当前右区间进行递归,不断分割出新的左右区间 * 分割到区间范围: end <= begin 时(不合理、无意义区间), * 就返回递归不再分割 * * 递归分割完区间后,递归返回时再进行归并操作 * * 整个过程类似二叉树的后序遍历,左子树 -> 右子树 -> 根 * 归并: * 左区间归并后有序 -> 右区间归并后有序 -> 归并排序后结果拷贝到原数组 */ //对当前区间的左区间进行递归,分割出新左右区间: _MergeSort(a, tmp, begin, mid); //分割后左区间范围:[begin, mid] //对当前区间的右区间进行递归,分割出新左右区间: _MergeSort(a, tmp, mid+1, end); //分割后左区间范围:[mid+1, end] /* * 递归分割完区间后,递归返回时再进行归并操作, * 将分割出的区间元素归并到动态开辟的tmp数组中, * 归并完成后再拷贝到原数组中: */ //定义当前区间的左区间范围: int begin1 = begin; //当前左区间的左范围 int end1 = mid; //当前左区间的右范围 //定义当前区间的右区间范围: int begin2 = mid+1; //当前右区间的左范围 int end2 = end; //当前右区间的右范围 //再定义一个tmp数组的起始下标begin: int index = begin; //使用whlie循环将分割出的区间元素归并tmp数组中: while (begin1 <= end1 && begin2 <= end2) //当前区间的左右区间范围合理: { /* * 比较当前a数组中当前区间的左右区间元素大小, * 将a数组当前区间中的较小值尾插放入tmp数组中(循环) */ if (a[begin1] <= a[begin2]) //如果当前区间的左区间元素小于等于其右区间元素: { tmp[index++] = a[begin1++]; //将此时a数组中的当前区间的左区间元素尾插进tmp数组 //放入后++调整位置 } else //当前区间的右区间元素小于其左区间元素: { tmp[index++] = a[begin2++]; //将此时a数组中的当前区间的右区间元素尾插进tmp数组 //放入后++调整位置 } } /* * 当循环结束时,不知道是左右哪个区间不合理结束了, * 我们假设循环是左区间合理,右区间不合理而结束, * 那么此时的左区间就还有元素没尾插到tmp数组中, * 所以要将其剩下的元素再尾插到tmp数组中(右区间同理): */ //假设循环是左区间合理,右区间不合理而结束: while (begin1 <= end1) //当前左区间还合理(还有元素): { //将其剩下的元素尾插进tmp数组中: tmp[index++] = a[begin1++]; } //如果循环是右区间合理,左区间不合理而结束: while (begin2 <= end2) //当前右区间还合理(还有元素): { //将其剩下的元素尾插进tmp数组中: tmp[index++] = a[begin2++]; } /* * 到此就将元素都归并排序到了tmp数组中, * 最后还有将tmp数组拷贝到原数组中: */ //使用memcpy库函数将归并好的tmp数组拷贝回原数组: memcpy(a + begin, tmp + begin, (end - begin + 1)*sizeof(int)); // (目的地) (被拷贝位置) (拷贝数据大小) // end-begin+1, 即元素个数,因为区间左闭右闭所以还要+1 } //归并排序函数(递归版本): //第一个参数:接收要排序的数组首元素地址(a) //第二个参数:接收该数组的长度(n) void MergeSort(int* a, int n) { //开辟动态数组tmp: //连续开辟和待排序数组对应类型和个数的动态空间: int* tmp = (int*)malloc(sizeof(int) * n); //检查开辟动态空间是否成功: if (tmp == NULL) //返回NULL,说明开辟失败: { //打印错误信息: perror("malloc fail"); //返回结束当前函数 return; } /* * 之后要进行归并排序,需要使用递归进行, * 而上面代码是开辟动态空间, * 如果在本函数中使用递归进行归并排序操作, * 不断递归会不断地开辟动态空间, * 所以接下来的归并排序操作可以交给子函数进行: */ //调用子函数进行归并排序操作: _MergeSort(a, tmp, 0, n - 1); //第一个参数:接收要排序的数组首元素地址(a) //第二个参数:接收开辟的等大动态空间首元素地址(tmp) //第三个参数:接收该数组起始位置下标(0) //第四个参数:接收该数组尾部位置下标(数组元素个数-1) //(函数名前加个“_”,表示为该函数的子函数) //执行完归并排序后,释放开辟的动态空间: free(tmp); } //空间复杂度:O(N) //时间复杂度:O(N*logN) //归并排序函数(非递归版本): //第一个参数:接收要排序的数组首元素地址(a) //第二个参数:接收该数组的长度(n) void MergeSortNonR(int* a, int n) { /* * 思路: * * 递归版本的归并排序是先通过递归将原数组不断分割, * 分割到无法分割后再进行归并排序操作返回到原数组, * * 而非递归版本的归并排序要在一开始就找到原数组的 * 最小区间,对两个最小区间进行归并操作, * 排序完成一个由两个最小区间组成“大区间”(已有序), * 最小区间都组成“大区间”后, * 再对两个“大区间”进行归并操作,组成一个“大大区间”(已有序) * (以此类推) * 其中组成“大区间”的“小区间”需要定义一个gap值来控制 *(此过程就是递归版本中开始返回进行归并排序的过程) *(两区间有序了才能进行归并合成一个有序的大区间) */ //开辟动态数组tmp: //连续开辟和待排序数组对应类型和个数的动态空间: int* tmp = (int*)malloc(sizeof(int) * n); //检查开辟动态空间是否成功: if (tmp == NULL) //返回NULL,说明开辟失败: { //打印错误信息: perror("malloc fail"); //返回结束当前函数 return; } //gap = 1时,“一一归二”(11归并): /* * “一一归二”: * 控制一个区间的左右区间都各只有一个元素, * 再比较左右区间中元素(此时都只有一个元素)大小, * 进行归并操作将这左右区间归并为一个有两个元素的区间(已排序) * * gap = 2时,“二二归四”(22归并)同理; * gap = 4时,“四四归八”(44归并)同理。 * (gap每次调整都是二倍的关系) */ int gap = 1; //定义gap值 //最外层while循环控制“几几归”: while (gap < n) //当“几”>数组长度时,归并排序完成 { for (int i = 0; i < n; i += 2 * gap) /* * 循环变量修正表达式:i += 2*gap * 使用该循环变量修正表达式是为了确保 * 除了第一次之后的定义左右区间能够定义正确, * 左右区间被归并成一个区间后,就不需要再被进行归并了, * 所以要调整循环变量修正表达式,保证一个区间不会被多次归并 */ { /* * gap为1,那么就需要在当前区间中, * 从左往右依次以2个元素定义一个区间, * 将该区间再分割为左右两个区间, * 所以左右区间都各只有1个元素; * * gap为2 同理,在当前区间中, * 从左往右依次以4个元素定义一个区间, * 将该区间再分割为左右两个区间, * 所以左右区间都各只有2个元素; * * (以此类推) */ //定义左区间的左范围(下标): int begin1 = i; //定义左区间的右范围(下标): int end1 = i + gap - 1; //定义右区间的左范围(下标): int begin2 = i + gap; //定义右区间的右范围(下标): int end2 = i + 2 * gap - 1; //[begin1, end1] [begin2, end2] //判断以当前gap值划分的“左右区间”是否合理(会不会越界): if (end1 >= n || begin2 >= n) /* * 左右区间: * [begin1, end1] [begin2, end2] * * end1 >= n * 通过分析,随着gap的增大,begin1不可能会越界, * 而从end1开始后就有可能会越界了, * 如果end1都已经越界了的话,那就没必要再进行归并了, * * begin2 >= n * 也有可能“左区间”正常,“右区间”越界了,导致 * “几几归2*几”的第二个“几(右区间)”没了, * 导致无法将两个“小区间”归并成“大区间” * * (如果“当前右区间”左右范围都不合理, * 这一组就不用进行归并操作了) */ { //如果满足其中一种情况就无法进行归并操作了, //直接break跳出内嵌for循环: break; } if (end2 >= n) /* * 如果只有"右区间"的右范围越界(end2 >= n)的话, * 那么说明“右区间”还有元素可以和“左区间”进行归并, * 所以要对此时的“右区间”的右范围进行修正: */ { end2 = n - 1; //修正到数组的尾元素下标位置 } //打印查看归并时“几几归2*几”的过程: printf("当前 “%d%d归%d” 的区间:> ", gap, gap, 2 * gap); printf("[%d,%d] [%d,%d]\n", begin1, end1, begin2, end2); /* * 通过gap分出“想要的左右两个区间(a数组的)”后, * 对这左右两个区间进行和递归版本相同的归并操作, * 比较这两个区间后,将“较小区间”尾插进tmp数组, * 这两个区间在tmp数组中变得有序,将其再拷贝回原数组 */ //再定义一个tmp数组的起始下标begin: int index = i; //使用whlie循环将分割出的区间元素归并tmp数组中: while (begin1 <= end1 && begin2 <= end2) //当前区间的左右区间范围合理: { /* * 比较当前a数组中当前区间的左右区间元素大小, * 将a数组当前区间中的较小值尾插放入tmp数组中(循环) */ if (a[begin1] < a[begin2]) //如果当前区间的左区间元素小于其右区间元素: { tmp[index++] = a[begin1++]; //将此时a数组中的当前区间的左区间元素尾插进tmp数组 //放入后++调整位置 } else //当前区间的右区间元素小于其左区间元素: { tmp[index++] = a[begin2++]; //将此时a数组中的当前区间的右区间元素尾插进tmp数组 //放入后++调整位置 } } /* * 当循环结束时,不知道是左右哪个区间不合理结束了, * 我们假设循环是左区间合理,右区间不合理而结束, * 那么此时的左区间就还有元素没尾插到tmp数组中, * 所以要将其剩下的元素再尾插到tmp数组中(右区间同理): */ //假设循环是左区间合理,右区间不合理而结束: while (begin1 <= end1) //当前左区间还合理(还有元素): { //将其剩下的元素尾插进tmp数组中: tmp[index++] = a[begin1++]; } //如果循环是右区间合理,左区间不合理而结束: while (begin2 <= end2) //当前右区间还合理(还有元素): { //将其剩下的元素尾插进tmp数组中: tmp[index++] = a[begin2++]; } //使用memcpy库函数将归并好的tmp数组拷贝回原数组: //(放在内嵌for循环中,归并一组就拷贝一组,没归并就不拷贝了) memcpy(a + i, tmp + i, (end2-i+1) * sizeof(int)); // (目的地)(被拷贝位置) (拷贝数据大小) /* * end2-i+1, 即元素个数,这里“-i”本来应该是begin1, * 但是begin1在进行归并操作时会被++,所以应该是“-i”, * 即减去begin1的起始值i,再“+1”是因为“数组尾元素下标+1” * 才是数组的元素个数 */ } //内嵌for循环 //打印当前一组“几几归2*几”对应的所有区间后, //进行换行,准备下次“几几归2*几”: printf("\n"); // “几几归2*几”: //“一一归二”后,进行“二二归四”, //“二二归四”后,进行“四四归八”, //(以此类推,直到“几”>数组长度) gap *= 2 ; /* * 执行到此,以gap=1为准的归并操作就完成了, * 但是当前只能处理有2^n个元素的数组, * (如果不对归并的“左右区间”进行限定的话) * 不然归并会出各种问题,所以要在归并开始前进行判断, * 看用于归并的“左右区间”会不会越界 */ } //最外层while循环 //执行完归并排序后,释放开辟的动态空间: free(tmp); } //时间复杂度:O(N*logN) //空间复杂度:O(N) //(时间和空间复杂度都和递归版本相同) //计数排序(鸽巢原理)-- 非比较排序: //第一个参数:接收要排序的数组首元素地址(a) //第二个参数:接收该数组的长度(n) void CountSort(int* a, int n) { /* * 思路: * 1. 统计相同元素出现次数 * 2. 根据统计的结果将序列回收到原来的序列中 * (适合对数据范围相对集中的数组进行排序) * (只适用于整型) * * 简单描述:找出被排序数组a中最大元素(max)和最小元素(min), * 创建一个以max为尾元素下标的数组count, * 这样被排序数组a中的元素就都能在count数组的下标中找到, * 而且count数组的下标是有序的, * count数组全部下标对应元素一开始都为0, * * 1. 统计相同元素出现次数: * 假设被排序数组a为:[2,1,5,2,4] ,遍历该数组, * a数组中有元素:2 , * 那么就在count数组下标为2的位置元素++ (0变成1) * 有元素:1,在count数组下标为1的位置元素++ (0变成1) * 有元素:5,在count数组下标为5的位置元素++ (0变成1) * 有元素:2,在count数组下标为2的位置元素++ (1变成2) * 有元素:4,在count数组下标为4的位置元素++ (0变成1) * * 2. 根据统计的结果将序列回收到原来的序列中: * 根据第1步中count数组的统计,count数组为: * * 0 1 2 0 1 1 * (下标0)(下标1)(下标2)(下标3)(下标4)(下标5) * * 这时遍历count数组,按以下规律覆盖写到原数组a中: * * 0有0个,1有1个,2有2个,3有0个,4有1个,5有1个 * (0个的话就不覆盖写了) * ↓ * a数组:1 2 2 4 5 */ //存储a数组(被排序数组)的最小值: int min = a[0]; //默认为首元素 //存储a数组(被排序数组)的最大值: int max = a[0]; //默认为首元素 //使用for循环找到a数组中的最小值和最大值: for (int i = 0; i < n; i++) { //找最小值: if (a[i] < min) //当前元素小于min: { min = a[i]; //将该元素赋给min } //找最大值: if (a[i] > max) //当前元素大于max: { max = a[i]; //将该元素赋给max } } //定义统计数组count的区间(元素个数): int range = max - min + 1; // [min, max] 左闭右闭,所以元素个数还要+1 //为统计数组count开辟动态空间: int* count = (int*)malloc(sizeof(int) * range); //检查是否开辟成功: if (count == NULL) //开辟后返回空指针: { //说明开辟失败,打印错误信息: perror("malloc fail"); //返回结束函数: return; } //对统计数组进行初始化: memset(count, 0, sizeof(int) * range); //使用memset函数将count数组所有元素都初始化为0 // 1. 使用for循环统计相同元素出现次数: for (int i = 0; i < n; i++) { count[a[i] - min]++; /* * 这里count数组的下标写成:[a[i] - min] * 是为了实现 “相对映射” : * 被排序数组a可能是:[100, 135, 122, 199, 111] * 这里max为199,传统方式count数组就要开辟 [0, 199] * 200个空间,而实际只会使用 [100, 199] 这部分空间, * 所以上面 range = 199 - 100 + 1 = 100 只开辟了100个空间, * 但是开辟的100个空间,他的下标范围却是 [0, 99] * a数组中元素无法对应count数组的下标,所以就需要“相对映射” * * 相对映射: * a数组元素 - 节省开辟的空间数(这里是100) = 相对下标 * 以元素100为例: 100 - 100 = 0 , * 这时 a数组中的元素100 就对应 count数组中下标0, * a数组中每有一个元素100,count数组下标0元素++, * a数组中其它元素同理。到时进行恢复操作的时候再将 * count下标 + 节省开辟的空间数(这里是100) 即可 * (即 a数组中对应元素) */ } // 2. 根据统计的结果将序列回收到原来的序列(数组)中: int j = 0; //用于遍历a数组(原数组)下标 for (int i = 0; i < range; i++) //range为数组元素所在范围(区间) { while (count[i]-- != 0) /* * count[i] 为统计数组的当前下标i元素, * 元素为几,说明该元素下标i在a数组中所对应的元素有几个, * 如果count数组中i下标元素为0,则不考虑该下标, * 这里是将i下标元素不为0的进行操作, * 将其i下标在a数组中对应的元素一一恢复: */ { a[j++] = i + min; /* * 因为我们使用了 "相对映射" * 所以 i下标 + 节省开辟的空间数 == a数组中对应元素 */ } } } //时间复杂度:O(N + range) /* * 如果排序的数据比较集中,那么range会比较小 -- O(N) * 如果不集中,range会比较大 -- O(range) */ //空间复杂度:O(range)
---------------------------------------------------------------------------------------------
Test.c -- 排序测试文件
//归并排序(递归版本)测试: void MSTest() { //创建要进行插入排序的数组: int a[] = { 9,1,2,5,7,4,8,6,3,5 }; //调用快速排序(递归版本)进行排序: MergeSort(a, (sizeof(a) / sizeof(int))); //使用自定义打印函数打印排序后数组: printf("使用归并排序(递归版本)后的数组:> "); PrintArray(a, sizeof(a) / sizeof(int)); } //归并排序(非递归版本)测试: void MSNRTest() { //创建要进行插入排序的数组: int a[] = { 9,1,2,5,7,4,8,6,3,5 }; //调用快速排序(非递归版本)进行排序: MergeSortNonR(a, (sizeof(a) / sizeof(int))); //使用自定义打印函数打印排序后数组: printf("使用归并排序(非递归版本)后的数组:> "); PrintArray(a, sizeof(a) / sizeof(int)); } //计数排序(鸽巢原理)测试: void CSTest() { //创建要进行插入排序的数组: int a[] = { 100, 135, 122, 199, 111 }; //调用计数排序(鸽巢原理)进行排序: CountSort(a, (sizeof(a) / sizeof(int))); //使用自定义打印函数打印排序后数组: printf("使用计数排序(鸽巢原理)后的数组:> "); PrintArray(a, sizeof(a) / sizeof(int)); } int main() { //ISTest(); //SSTest(); //BSTest(); //SlSTest(); //QSTest(); //QSNRTest(); //MSTest(); //MSNRTest(); CSTest(); return 0; }