数据结构初阶(16)排序算法——归并排序
2.4 归并排序
归并排序(Merge Sort)是基于分治思想的经典排序算法。
核心逻辑: 分而治之——把复杂排序问题拆分成简单子问题解决,再合并子问题的结果。
联系
链表的合并:两个有序链表l1、l2
- 创建新链表l3(带头结点),遍历l1、l2,小的尾插到l3。
数组的合并:两个有序数组a1(sz = s1+s2)、a2(sz=s2)
- 从大下标开始遍历两个数组,大的放到a1的末尾。
时间复杂度:O(N)。
差异
- 链表可以把结点取下来,可以达到空间复杂度O(1)。
- 数组a1(sz=s1)、a2(sz=s2),则只能把数据拷贝到新的空间,空间复杂度O(N)。
前提:左区间有序、右区间有序。
怎么让左区间有序、右区间有序呢?
这就需要一种类似于二叉树后序的思想——分治:
- 先让左区间有序;
- 再让右区间有序;
- 最后让整体有序;
基本思想
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。
归并排序的基本操作是将已有序的子序列合并,得到完全有序的序列——即先使每个子序列有序,再使子序列段间有序。
若将两个有序表合并成一个有序表,称为二路归并。
归并排序核心步骤:
- 将无序数据从中间分成两个子序列。
- 将子序列不断的划分,直到只剩一个数据——该子序列有序了。
拆分的终点:能够进行下一步归并——即子区间有序,只有剩1个数据的时候,才能保证子区间有序;
拆分的终点:只剩一个数据。
归并的起点:只有一个数据。- 子序列有序了,就可以开始进行两两合并,直到所有的子序列合并完,排序就完成了。
时间复杂度能够达到O(N*logN),还算不错。
递归的时候,不是每次递归都去开辟一个子数组(4个数据空间、2个数据空间、……),归并过去再拷贝回来。
而是一次性开辟一个和arr等大的tmp数组,每次归并都在tmp执行,执行完拷贝回arr数组。
如下图所示。
动图演示
下图只展示了从最初一个有序子序列只含有一个数据开始的归并的过程。
没有展示分解的过程。
算法步骤
分解阶段:
- 将当前待排序数组从中间位置分为两部分。
- 计算中点: mid = left + (right - left) / 2。
- 递归地对左右两部分继续分解,直到子数组长度为1。
- 当前子数组长度为1时,说明该子数组是有序的。
- 开始从最底层向上回朔。
合并阶段:
- 将两个已排序的子数组合并为一个有序数组。
- 使用双指针法比较两个子数组的元素。
- 较小的元素放入临时数组。
- 归并完将数据从临时数组拷贝回原数组。
- 执行下一次归并,直到数组全部有序。
代码实现(递归)
算法思想是利用二叉树后序的思想,实际代码控制的是数组。
逻辑结构和物理结构是分离的。
若是完全按照逻辑结构来实现物理结构——链式二叉树,反而控制起来更加地复杂。
逻辑结构那样去画,是为了更好地帮助我们理解,但是我们没有使用跟逻辑结构一样的物理结构去实现,因为这样反而是更加麻烦的。
(理想很丰满、现实很骨感)
思路(递归实现):
- 前提:
申请一个临时数组tmp,因为我们不能在原数组归并,不然会造成覆盖,需要在临时归并,在拷回原数组里。
由于创建了临时数组,我们就需要在创建一个子函数用来专门递归,不然每次调用都会申请空间。- 子函数思路:
- 将序列从中间分成两个区间[left,mid] [mid + 1,right],调用递归,直到只剩一个数据,一个数据就表示有序了。
- 在把递归的两个区间进行合并,begin1和end2表示左序列,begin1和end2表示右序列,比较两个序列的值插入到临时数组里,其中肯定会有一个先结束,当其中一个序列拷贝完了就停止拷贝。
- 把剩下未合并的数据,全部放到tmp数组里。
- 最后在tmp数组的数据拷到原数组里。
//子函数:用来递归
void _MergeSort(int* a, int begin, int end, int* tmp)
{//最小递归子问题——只剩1个数据(有序)if (begin == end)return;int mid = (begin + end) / 2;//把传进来的区间从中间分割开,分成两个子区间// [begin, mid-1] [mid, end]// [begin, mid] [mid+1, end] ——这种划分,在偶数个数据的时候才能平分区间//如果这两段区间有序——>那么就可以进行归并——>如何判断有序?//——>不判断,不管有没有序,直接分到只剩一个,再一次归并回来,每次归并回来的都有序//经典后序_MergeSort(a, begin, mid, tmp); //1.先让左区间有序_MergeSort(a, mid + 1, end, tmp); //2.再让右区间有序//两段子区间已经有序,归并,使整个区间有序 //3.最后让整体区间有序int begin1 = begin, end1 = mid;int begin2 = mid + 1, end2 = end; //操作:创建变量保存两个区间的下标//意义1:不直接操作begin、mid、end//意义2:增强代码可读性//i最好不给0,而是给数组的区间始端,使归并数据的在tmp中的位置与原a中的位置一一对应int i = begin;//(1)归并到tmp数组// 依次比较,取小的尾插到tmp数组——>有一个区间结束,循环就结束——结束条件“或”//循环:想的是结束条件,写的是继续条件:用“且”while (begin1 <= end1 && begin2 <= end2) {//取小的尾插if (a[begin1] <= a[begin2]){tmp[i++] = a[begin1++]; //后置++:先拷贝,再递增}else{tmp[i++] = a[begin2++];}}//有一个结束了,谁没结束,就继续拷贝while (begin1 <= end1){tmp[i++] = a[begin1++];}while (begin2 <= end2){tmp[i++] = a[begin2++];}//(2)把归并好的数组区间拷贝回原区间//因为最后tmp数组空间是要释放的,要保留的是原数组memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));//闭区间元素个数= 区间差 + 1//不能图方便直接拷贝整个数组,拷贝区间之外的数据拷贝回原数组会覆盖掉原数组的数据
}void MergeSort(int* a, int n)
{//首先开一个临时数组int* tmp = (int*)malloc(sizeof(int) * n);if (tmp == NULL){perror("malloc fail");return;}//开完数组,就导致当前这个结构无法帮助我们递归——不能每次递归都去开一个数组//就需要一个新的负责归并递归的函数,参数:a、tmp、区间_MergeSort(a, 0, n - 1, tmp);//释放临时数组free(tmp);tmp = NULL;
}
注意一些细节上的处理。
[begin, mid-1] [mid, end]
[begin, mid] [mid+1, end] ——这种划分,在偶数个数据的时候才能平分区间。
例如8个数据,mid = (0 + 7) / 2 =3,第一种就是[0,2]、[3,7],第二种就是[0,3]、[4,7]。
例如9个数据,mid = (0 + 8) / 2 =4,第一种就是[0,3]、[4,8],第二种就是[0,4]、[5,8]。
注意保证归并排序稳定的细节:
//……//取小的尾插if (a[begin1] <= a[begin2]){tmp[i++] = a[begin1++]; //后置++:先拷贝,再递增}else{tmp[i++] = a[begin2++];}//……
//这里如果写成——if (a[begin1] < a[begin2]),就不稳定了
细节注意:
- 归并的时位置都是相对位置,如[2,2][3,3]合并,合并完存放在tmp数组里也要是[2,3],所以两个序列都是两个区间的相对位置,所以:
左序列:begin1 = left,end1 = mid;
右序列:begin2 = mid+1,end2 = right;- i = begin,不是i = 0,是为了将原数组的相对位置的数据合并到tmp数组的相对位置。
如a[2]就要放到tmp[2]。- 拷贝数据函数memmove使用,格式:memmove(宿dst、源src、字节数num)。
- 同样拷贝数据时,也要注意相对位置,如memmove(a, tmp, (right-left+1) * sizeof(int)),这样每次就会从tmp下标0开始拷贝,并不是相对位置。
- 子函数使用的闭区间,传的是拷贝数据个数,需要+1,如:[0,9],一个10个数。
递归展开图。
代码实现(非递归)
递归实现的经典问题——栈溢出。所以需要将递归改为非递归。
快速排序的递归改递归,可以直接借助栈来实现——前序的思想,借助栈能比较容易地改成非递归。
归并排序是后序的思想,就不太好简单地借助栈来改成非递归。
分析:[0,7]拆分后,[4,7]入栈,[0,3]入栈,然后[0,3]出栈,[2,3]、[0,1]入栈,[0,1]出栈,[0,0]入栈,[1,1]入栈,这两个区间取出来就有序了,下一步该取哪个区间出来进行归并?——[0,1]已经出栈找不到了。
另一种改递归的方法就是直接借助循环——类似于斐波那契数列。
- 斐波那契数列的递归实现:用N-1项、N-2项算第N项。
- 斐波那契数列的循环实现:知道第1项、第2项,以此来依次算出第3项、第4项、……、第N项。(顺着走就可以)
归并排序的非递归改造也一样,因为知道最小条件是什么,并且是固定的——单个数默认有序。
那就可以:
- 把arr数列看作单个数自身有序;
- 两两归并;
相当于直接走回溯归并的过程,把分割的过程跳过了。
gap表示每组数据的个数,一次两组数据归并。
- 两两归并首先要确定本次两两归并的区间下标。
(1)先来尝试写出第1组归并的,两个区间。
注意
- 闭区间:右下标 = 左下标 + 元素个数 - 1
(2)然后执行第一次归并
(3)然后循环起来,执行完gap=1的所有归并
确定每次归并的两个区间,然后执行归并。
注意
- 循环迭代式 j += 2*gap:j每次跳过两个gap,来到下一次“两个区间归并”。
那么2*gap就是一次归并的个数,通过每组个数(gap)推导出每组数据的区间,如[0,0]和[1,1]归并,gap为1,j = 0。
得出[j,j + gap - 1],[j+ gap,j + 2*gap - 1]。
排下一次就需要j += 2 * gap,就可以访问下一次归并的起始位置了,如果先是[0,0]和[1,1]归并,再就是[2,2]和[3,3]归并。
归并好的数据需要放入到临时数组大家可以通过上图代入值。
这是只是单趟排好多组的样子,这里模拟递归最后一层的情况,和递归相反,我们是从直接最后一层开始归并,递归还需要慢慢的分解递归下来,然后才能开始慢慢归并。
(4)最后循环起来,执行完 gap=2 * gap 的所有归并(3层循环)
想要整体排好序,gap每次就乘以2,直到gap为n/2,就是说把未排序的数据分成两组数据,两组数据归并完就排好了序。
每次单趟归并完就把临时数组的数据拷回原数组里,再用原数据归并,直到排好序,如下图:
实现思路
- 同样也需要创建一个临时数组tmp,不能在原数据归并会覆盖。
- gap为每组数据个数,先从gap为1,一次两组数据归并,模拟递归最后一层的归并,如:[0,0]和[1,1]归并
- 通过每组个数(gap)推导出每组数据的区间,得出两组数据区间是[j,j + gap - 1],[j + gap,j + 2*gap - 1]。
- j需要加上2*gap,原来的区间已归并好了,所以需要指向下一次归并的起始位置,直到 j >= n结束,循环条件就要设为j < n,表示排好差距为gap的多组数据。
- 排好差距为gap多组数据后,gap*2,下一趟需下一趟归并的一组数据个数翻倍,直到gap为gap >= n结束——只有一个组数据,不能归并。循环条件就是gap < n,就是说把未排序的数据分成两组数据,两组数据归并完就排好了序。
- 把临时数组的数据拷到原数组。
代码测试
正常运行。
程序运行直接崩溃。所以只能调试运行,检查上述代码的bug。
测试gap == 1的两两归并没有问题,再走一轮,按F5。
gap == 2的递归出现问题,出现随机数——数组越界访问。
打印观察。
因为begin1 = j,而 j 是小于 n 的,所以begin1不可能越界,剩下的3个都有可能会越界。
于是在代码中,需要增加对其余3个区间下标的处理,避免越界。
越界处理
- 归并排序的非递归改造的重点:解决越界问题
上面的图解、第一次的测试代码,正好拿2的指数——8个数据演示的。
而上面的测试代码,不是2的指数,2^3、2^4……等,而是10个数据——就会越界。
分组无法平分,肯定会导致一方越界,一共有3种越界。
越界情况:
- 右区间不存在 ——begin2 >= n
- 左区间缺少值 ——end1 >= n
- 右区间超过数据长度 ——end2 >= n
越界情况1和2可以当作一种情况来处理:右区间不存在。
- 此时只有左区间,左区间同时也是在原数组中是有序的,所以可以直接跳出循环,不用处理最后这一个区间。
- 只需要处理——有双区间的、需要归并的数据。
越界情况3的处理:纠正右区间的结束标识,让结束标识到最后一个数据即可。
然后正常执行归并。
处理
- 右区间不存在,直接跳出循环break,不进行归并。
- 右区间超过数据长度,进行修正,右区间结束标识n-1,就是最后一个数据的下标。
由于右区间不存在直接跳出的情况,tmp数组就会有随机值情况,那就不能使用把当前间距为gap的数据,全部归并好之后,再一次性全部拷贝到原数据中去的方法。
就只能使用:归并了多少,就拷背多少,归并完一次gap(而不是一整组gap),直接就拷贝。
即把数据拷贝放入到for循环中。
变化的代码。
memcpy(a + j, tmp + j, sizeof(int) * (end2 - j + 1));
- 注意1:拷贝源和目的地的起始位置
- 注意2:拷贝的数据个数
- 首先不是2*gap——应该使用修正后的end2减去起始位置;
- 其次左闭右闭,end2 - begin1后还要 + 1才是数据个数;
- 由于这里begin1++了,故用 j 替代,j 是这两组数据归并的开始位置;
完整代码
void MergeSortNonR(int* a, int n)
{int* tmp = (int*)malloc(sizeof(int) * n);if (tmp == NULL){perror("malloc fail");return;}//定义每组数据归并的个数int gap = 1;while (gap < n){//printf("gap:%d->", gap);for (int j = 0; j < n; j += 2 * gap) //两组gap进行一次归并——>j每次跳过两组gap{int begin1 = j, end1 = begin1 + gap - 1;int begin2 = begin1 + gap, end2 = begin2 + gap - 1;if (end1 >= n || begin2 >= n)break;if (end2 >= n)end2 = n - 1;//i是归并到tmp中的对应起始位置int i = j;// 依次比较,取小的尾插tmp数组while (begin1 <= end1 && begin2 <= end2){if (a[begin1] <= a[begin2]){tmp[i++] = a[begin1++];}else{tmp[i++] = a[begin2++];}}while (begin1 <= end1){tmp[i++] = a[begin1++];}while (begin2 <= end2){tmp[i++] = a[begin2++];}//归并一个组,拷贝一个组memcpy(a + j, tmp + j, sizeof(int) * (end2 - j + 1));gap *= 2;}free(tmp);tmp = NULL;
}
性能分析
时间复杂度
分解:在每一层递归中,都需要将数组分成两个子数组,因此递归树的深度为logn。
合并:在每一层递归中,需要将两个有序子数组合并成一个有序数组,这个操作的时间复杂度为O(n)。
因此总的时间复杂度为O(nlogn)。
空间复杂度
需要申请一个临时数组tmp,长度和原数组一样大。
所以空间复杂度为O(N) 。
时间复杂度
- 不管数组初始是否有序,时间复杂度都是O(N*logN)
拆分过程是对数级(每次规模减半,拆分次数为log2n);
合并过程是线性级(每次合并遍历n个元素);空间复杂度
- 因合并需要额外临时数组存数据,空间复杂度O(n), n为元素个数。
稳定性:
- 稳定排序
归并排序非递归的越界处理是难点,需要多加画图和调试分析情况,才能很好控制越界问题。
特性总结
归并排序的特性总结:
1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(N)
4. 稳定性:稳定。