归并排序
归并排序本质就是一种思想,在很多题目都可以用到
一、归并排序的原理
归并排序(MergeSort) 是建立在归并操作上的一种有效的排序算法,采用分治法排序,分为分解、合并两个步骤。
分解:将数组分割成两个数组,再分别将两个数组又细分成2个数组,直到,最后每个数组都是一个元素,这时将该单元素数组看为有序数组
合并:将分割的有序数组进行排序,排成有序数组后继续为上一个分割它的数组合并,直到数组被合并成原来的数组,此时已经排好序了
二、递归实现
当左右区间有序时,就可以开始归并排序
因此需要先 递归分治,将区间像之前的方法一直分解
🐻为什么要开临时数组?
因为在原数组中直接进行排序非常困难,很容易混乱,为了有条理,可以开临时数组临时处理,之后再拷贝回原数组
🐻归并排序—–单趟的思路
左右区间有序时,从每个区间的首个元素开始,一一比较,将较小的优先放进临时数组中,归并排序完成后,再将临时数组中的数据拷贝回到原数组
🐻归并排序—–整体逻辑
观察动图:递归分割区间至一个数值后,就会开始合并,进行归并排序
由上述过程得出的归并排序的总代码:
void _MergeSort(int* a, int L, int R, int* tmp) {
// 先递归不断分解区间
if (L == R) return;
// 分成两段区间:[L, mid] [mid+1, R]
// 递归分割:
int mid = (L + R) / 2;
_MergeSort(a, L, mid, tmp);
_MergeSort(a, mid + 1, R, tmp);
// 归并
// 注意对区间的细节把控,其他的思路较为简单
int begin1 = L, end1 = mid;
int begin2 = mid + 1, end2 = R;
int i = L; // 从区间的 左侧 L 开始:易错点
while (begin1 <= end1 && begin2 <= end2) {
if (a[begin1] < a[begin2]) {
tmp[i++] = a[begin1++];
}
else if (a[begin1] >= a[begin2]) { // 还有一种相等的情况,随便加在一个地方就行
tmp[i++] = a[begin2++];
}
}
// 当区间没走完,就把剩下的拷贝进数组
while (begin1 <= end1){
tmp[i++] = a[begin1++];
}
while (begin2 <= end2){
tmp[i++] = a[begin2++];
}
// 将 tmp 数组中相应区间的数据拷贝 回 原数组的 相应区间位置
memcpy(a + L, tmp + L, sizeof(int) * (R - L + 1));
}
void MergeSort(int*a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL) {
perror("malloc fail !");
return;
}
_MergeSort(a, 0, n - 1, tmp);
}
三、非递归实现
不能像上一章节中讲解的快速排序非递归写法,直接用 栈模拟
快排本质是一种 前序遍历,就可以直接用栈模拟:先对当前区间进行一次快排,再分解左右区间时入栈,顺序就是 根节点->左节点->右节点(这里前序遍历的思想属于 二叉树章节)
而 归并排序本质是 后序遍历,是一直分解到最小左右区间后,再执行归并排序,就是 先访问左右节点,最后再到 根节点,顺序是: 左节点->右节点->根节点
不能用栈模拟,而是用 循环
🐻先理解 递归版本的过程思想:
- 在使用 递归 进行归并排序时,我们分析了 函数 递归到最后 时,两个需要归并的数组中每个区间里面只有一个元素,所以直接比较两个元素大小,然后再添加到 tmp 数组中,再将 tmp 数组相应区间元素拷贝进 原数组 a
- 然后再返回上一层函数中,此时该层函数中 两个需要归并的区间中每个区间里面有 两个 元素,并且这两个区间 已经有序,所以直接 对这两个区间进行归并排序
- 依次类推上去,直到回到第一次调用函数
- 此时数组 arr 中左边的数组区间 有序,右边的数组区间 也有序,此时将左右两个数组区间 进行归并,将归并排序结果放进 tmp 数组,再将tmp中的内容拷贝到arr,arr也变为有序数组了。
🐻再理解 循环实现的 非递归版本:
- gap 代表 单个区间元素个数,也就是 区间长度
- 一次 归并排序 两个 长度为 gap 的区间
- 若想要排序下一组(即两个 长度为 gap 的区间),就需要每轮的最后,一次跳过 2*gap 长度(代表跳过前面排序的 两个 长度为 gap 的区间)
gap
从1开始对 两个有序数组 进行排序,直到 gap >= n 时结束 ( n 为数组长度,这个就是外层while循环的结束条件 )
🦐演示过程
🦐利用 循环 实现归并排序时,我们也可以 **先将数组中每一个元素 单独作为一组**
🦐 第一轮 gap == 1 时:让 两两元素 归并排序 为一个含有两个元素的有序数组,然后 gap *= 2(gap 变成了 2)
🦐第二轮 gap == 2 时:让 两个含有两个元素的有序数组 归并 为一个含有四个元素的有序数组,然后 gap *= 2(gap 变成了 4)
🦐第三轮 gap == 3 时:然后再让 两个含有四个元素的有序数组归并为一个含有八个元素的有序数组,然后 gap *= 2(gap 变成了 8)
🦐就这样一直循环下去,直到 gap >= n ( n 为数组长度,这个就是外层while循环的结束条件 ) 时结束,结束时,数组就有序了
🦐演示 单趟的逻辑
演示代码书写逻辑
以 gap == 1 和 gap == 2 举例
可以发现规律,区间 右边界 和 gap 有关:
自己带入下标算一下就知道了
int begin1 = L, end1 = begin1 + gap - 1; // L == 0 是数组左边界
// 第二个区间也同理
int begin2 = end1 + 1, end2 = begin2 + gap - 1;
// 两个区间
[L, begin1 + gap - 1] [end1 + 1, begin2 + gap - 1]
分别代表两两需要归并排序的区间
🦐举例演示 单趟的逻辑:以 gap == 1 为例
int gap = 1; // 注意 gap == 1 时 只进行了 前两个区间的 归并排序,这里就是举例演示 单趟的逻辑
// 两个区间:[L, begin1 + gap - 1] [end1 + 1, begin2 + gap - 1]
int begin1 = L, end1 = begin1 + gap - 1;
int begin2 = end1+1, end2 = begin2 + gap - 1;
int j = begin1;
// 下面就是 上面递归版本中的单层归并逻辑,代码没变
while (begin1 <= end1 && begin2 <= end2) {
if (a[begin1] < a[begin2]) {
tmp[j++] = a[begin1++];
}
else if (a[begin1] >= a[begin2]) { // 还有一种相等的情况,随便加在一个地方就行
tmp[j++] = a[begin2++];
}
}
// 当区间没走完,就把剩下的拷贝进数组
while (begin1 <= end1) {
tmp[j++] = a[begin1++];
}
while (begin2 <= end2) {
tmp[j++] = a[begin2++];
}
// 将 tmp 数组中相应区间的数据拷贝 回 原数组的 相应区间位置
memcpy(a + L, tmp + L, sizeof(int) * (R - L + 1));
🦐演示 整体的逻辑
思路:
由上面的分析可知,gap 是不断变化的,且每轮都要循环遍历全部区间
因此,需要使用
外层一个while 循环 gap
内层一个 for:循环每组的起点,为了遍历到全部组别
整体逻辑的 初步代码如下
void _MergeSort(int* a, int L, int R, int* tmp, int n) {
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
// for 循环的 i 就是每一组(一组里包含两个小区间,因为归并排序同时是两个小区间对比排序的)的起点
// 单趟逻辑
int begin1 = i, end1 = begin1 + gap - 1;
int begin2 = end1 + 1, end2 = begin2 + gap - 1;
int j = begin1;
// 注意:用 gap 将区间分成两组,有可能两组元素个数是不平均的
// 下面就是 递归版 归并排序的归并逻辑
while (begin1 <= end1 && begin2 <= end2) {
if (a[begin1] < a[begin2]) {
tmp[j++] = a[begin1++];
}
else if (a[begin1] >= a[begin2]) { // 还有一种相等的情况,随便加在一个地方就行
tmp[j++] = a[begin2++];
}
}
// 当区间没走完,就把剩下的拷贝进数组
while (begin1 <= end1) {
tmp[j++] = a[begin1++];
}
while (begin2 <= end2) {
tmp[j++] = a[begin2++];
}
// 将 tmp 数组中相应区间的数据拷贝 回 原数组的 相应区间位置
memcpy(a + i, tmp + i, sizeof(int) * (2 * gap)); // 注意:这里的左边界 L 应该是 i
}
gap *= 2;
}
}
🦐数组越界问题
但是要注意一点,gap每轮乘 2,而归并排序要求每个区间长度都必须等于 gap ,因此需要数组的全部元素个数 刚好是 2的次方倍,区间才能以 长度为 gap ,进行平均分割,才不会出现越界的问题;
否则,上面这段代码就会出现越界情况
打印观察每个 gap 对应处理的区间
可以发现,数组 若一共 9 个元素,下标最多到 8 ,下面的打印运行过程中的区间数据,却出现了 严重的越界情况,就是因为 9 不是 2的次方倍,则肯定会越界
在这个代码下,只有 begin1 不可能越界,其他的都有可能越界,那么怎么处理边界问题呢?
🦐越界有三种情况
第一种:第一个区间的 end1 越界
则这一小区间就无需执行 本轮的归并排序了(不是以后都不归并,而是当前这一轮不用了),保留原数组数据即可(因为一个区间肯定保证是有序的)
第二种: 第二个区间的begin2 越界
说明第二个区间也不存在,则这一小区间也无需执行 本轮的归并排序了,保留原数组数据
第三种: 第二个区间的 end2 越界
当 end2 越界,(这里也就说明前面几项都没越界),需要修正end2:end2 直接等于右边界 end2 = R (因为越界的一定是最后一组)
则中间加一个判断
// 处理越界的逻辑
if (end1 >= n || begin2 >= n) break; // 第一第二种:当 end1 或 begin2 越界:直接 break,代表不用继续归并排序
if(end2 >= n) end2 = n - 1; // 第三种: 当 end2 越界:修正end2
同时 memcpy 也需要调整,即拷贝个数不一定是 2*gap 了
memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));
// 注意不能直接用 begin1 ,begin1 已经加加变化过了
🐻 非递归实现的 总代码
// 非递归 处理越界版本
void _MergeSort(int* a, int L, int R, int* tmp, int n) {
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = begin1 + gap - 1;
int begin2 = end1 + 1, end2 = begin2 + gap - 1;
// 处理越界的逻辑
if (end1 >= n || begin2 >= n) break;
if(end2 >= n) end2 = n - 1;
int j = begin1;
while (begin1 <= end1 && begin2 <= end2) {
if (a[begin1] < a[begin2]) {
tmp[j++] = a[begin1++];
}
else if (a[begin1] >= a[begin2]) { // 还有一种相等的情况,随便加在一个地方就行
tmp[j++] = a[begin2++];
}
}
// 当区间没走完,就把剩下的拷贝进数组
while (begin1 <= end1) {
tmp[j++] = a[begin1++];
}
while (begin2 <= end2) {
tmp[j++] = a[begin2++];
}
memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1)); // 注意不能直接用 begin1 ,begin1 已经加加变化过了
}
gap *= 2;
}
}
四、时间复杂度
从图中可以思考:归并排序的递归展开图是一棵满二叉树或完全二叉树,一共最多有 logN 层,每一个需要 归并排序共 N 次,则总共需要 NlogN 次,即 归并排序的时间复杂度为 O(NlogN)
五、性能对比
声明:下面我用代码随机生成 1000000 个数据,执行几个排序算法,显示的数据为 时间,以 ms 为单位
【下面用到的:随机数生成测试排序性能器的代码】
第一:对比归并排序的 递归法和非递归法
发现两者差异不大
第二:和其他几种排序的对比
快排和归并排序时间复杂度都是 O(nlogn) 的,但理论上 快排更快
🫡至此,第四章完结!
【若文章有什么错误或则可以改进的地方,非常欢迎评论区讨论或私信指出】