• 归并排序(MergeSort)


    思路分析

    两个有序数组的归并

    现在给你两个有序数组,让你进行归并成一个大的有序数组。

    nums1 = [1,2,3]
    nums2 = [2,5,6]
    => nums = [1,2,2,3,5,6]
    
    • 1
    • 2
    • 3

    先定义一个新数组接收归并后的大数组。

    拿两个指针分别指向两个数组中的首元素,然后遍历数组,

    每次比较两个指针指向的元素大小,将小的那一个尾插到大数组中,

    当其中一个指针走到结束时直接将另一个数组的剩余部分依次尾插即可:

    动画

    根据思路直接写出来代码:

    void Merge(int* nums1, int* nums2, int size1, int size2, int* nums)
    {
    	int begin1 = 0, begin2 = 0;
    	int count = 0;
    	while (begin1 < size1 && begin2 < size2)
    	{
    		if (nums1[begin1] <= nums2[begin2])
    			nums[count++] = nums1[begin1++];
    		else
    			nums[count++] = nums2[begin2++];
    	}
    	while (begin1 < size1)
    		nums[count++] = nums1[begin1++];
    	while (begin2 < size2)
    		nums[count++] = nums2[begin2++];
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    时间复杂度为O(N)


    一个无序数组的拆分和归并

    有了上面的基础,我们可以思考一个问题。

    如果将一个数组平均分成左右两个区间,如果左右两个区间都是升序有序,那么对左右区间进行归并,整个数组就变得有序了。这其实就是将上面的两个数组合并成一个。

    但是如果左右两个区间不有序呢?

    再分。将左区间再分为两个小区间,如果两个小区间都有序,归并得到的大区间就是有序的。

    如果小区间还是不有序呢?

    那就一直分,分到不可再分为止:

    归并排序2

    然后再从下至上归并回去:

    归并排序3

    经过上面的步骤,我们就完成了无序数组的归并排序。

    既然归并可以完成排序,那怎么实现呢?


    代码实现

    下面的讲解模式是直接将完整代码贴出来,然后对代码进行讲解。

    递归实现
    void _MergeSort(int* a, int left, int right, int* tmp) {
        //递归出口
    	if (left >= right)
    		return;
        
        //分割区间
    	int mid = left + (right - left) / 2;
    	_MergeSort(a, left, mid, tmp);
    	_MergeSort(a, mid + 1, right, tmp);
    
    	//开始归并
    	int begin1 = left, end1 = mid;
    	int begin2 = mid + 1, end2 = right;
    	int i = left;
    	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++];
    
    	//归并完成,进行拷贝
    	for (int j = left; j <= right; ++j)
    		a[j] = tmp[j];
    }
    
    void MergeSort(int* a, int n) {
    	int* tmp = (int*)malloc(sizeof(int) * n);
    	if (tmp == NULL) {
    		perror("MergeSort");
    		exit(-1);
    	}
    
    	_MergeSort(a, 0, n - 1, tmp);
    
    	free(tmp);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41

    首先我们需要新开一个跟原数组大小相同的临时数组用于接收归并后的数组,因为没法对原数组进行分割归并操作。

    这里借助一个子函数完成递归过程,子函数的参数分别是原数组、需要排序的区间、临时数组。

    递归的出口就是区间不可再分,即 left >= right

    首先用mid = left + (right - left) / 2将数组分割成两个区间[left, mid][mid + 1, right],默认两个小区间是无序的。

    由于小区间是无序的,需要先将小区间分割归并成有序的,递归开始。

    这里很显然是一个深度优先,分割归并实现逻辑上与上面的动图有些许冲突,下面会再做一个。

    两个区间有序后,对两个小区间进行归并,注意这里是归到临时数组 tmp 中。

    每归并一段小区间后,再将这段小区间拷回到原数组。

    下面的动图则演示了递归的执行逻辑:

    归并排序4

    这里只是演示了一下分割归并的递归过程,实际上每有一个函数结束就会有一次从临时数组 tmp 到 原数组 a 的一次拷贝。

    结合上面的动图和代码,可以更好地理解递归过程。


    非递归版本

    非递归的大思路不难,麻烦的是边界的处理。

    归并排序的核心是分割子区间,我们直接从只包含一个数据的区间开始,跟相邻的区间进行归并:

    归并排序5

    然后将区间长度扩大一倍,再来一轮:

    归并排序6

    如此区间长度再扩大一倍,再来一轮就完成了排序。

    先将这一过程的代码写出来:

    void MergeSortNonR(int* a, int n) {
    	int* tmp = (int*)malloc(sizeof(int) * n);
    	if (tmp == NULL)
    		exit(-1);
    
    	int gap = 1;
    	while (gap < n) {
    		for (int i = 0; i < n; i += gap * 2) {
    			int begin1 = i, end1 = i + gap - 1;
    			int begin2 = i + gap, end2 = i + gap * 2 - 1;
    
    			int count = begin1;
    			int j = begin1; //用于后面拷贝回去
    			while (begin1 <= end1 && begin2 <= end2) {
    				if (a[begin1] < a[begin2])
    					tmp[count++] = a[begin1++];
    				else
    					tmp[count++] = a[begin2++];
    			}
    			while (begin1 <= end1)
    				tmp[count++] = a[begin1++];
    			while (begin2 <= end2)
    				tmp[count++] = a[begin2++];
    
    			//归并完成,进行拷贝
    			for (; j <= end2; ++j)
    				a[j] = tmp[j];
    		}
    		gap *= 2;
    	}
        
        free(tmp);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33

    我们用 gap 来表示区间长度,beginend 分别代表区间的起始下标,

    通过 for 循环完成单趟遍历,通过 while 循环来控制每趟遍历后区间长度的扩大。

    很显然,上面这段代码针对数组元素个数为 2n 时是正好的。

    那么问题来了,如果数组元素个数随机呢?

    怎么控制可以让区间不越界呢?

    首先思考一下越界的几种情况。

    用下面的代码来测试:

    int main() {
    	int n = 8;  //表示数组元素个数
    	int gap = 1;
    	while (gap < n) {
    		for (int i = 0; i < n; i += gap * 2) {
    			int begin1 = i, end1 = i + gap - 1;
    			int begin2 = i + gap, end2 = i + gap * 2 - 1;
                //将每次归并的两个区间打印出来
    			printf("[%d, %d] [%d, %d]  ", begin1, end1, begin2, end2);
    		}
    		printf("\n");
    		gap *= 2;
    	}
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    n = 8 时:

    image-20220916160143143

    此时每次归并都没出现越界情况。

    n = 9 时:

    image-20220916160253617

    这里在第一轮归并就发生了越界。

    具体是 [begin1, end1] 正好,[begin2, end2] 中的 begin2 就越界了。

    这对应的越界情况就是两个需要归并的区间只有一个,此时这一个区间已经是有序的,不需要归并

    当然,假设第一轮归并越界的问题解决了,第二轮归并也同样发生了越界。

    具体是 [begin1, end1] 未越界,[begin2, end2] 中的 end2 越界了。

    这对应的越界情况是两个需要归并的区间第二个边界超了,此时只需要修正一下 end2 为数组最后一个元素的下标即可

    第三轮的越界情况又不一样了,此时是 [begin1, end1] 中的 end1 就越界了,此时还是只有第一个区间,与第一轮情况相似。

    第四轮的越界情况和第三轮相同。

    总结一下,

    [begin1, end1] 区间就已经越界时,不需要归并;

    [begin1, end1] 区间正常,[begin2, end2] 中的 begin2 越界时,也不需要归并;

    [begin1, end1] 区间正常,[begin2, end2] 中的 end2 越界时,只需要修正 end2 即可。

    其中前两种情况都不需要排序可以直接跳过,判断条件统一一下就是 begin2 >= n

    第三种情况只需要修改 end2 = n - 1,判断条件应为 end2 >= n

    所以非递归版本修正后的完整代码如下:

    void MergeSortNonR(int* a, int n) {
    	int* tmp = (int*)malloc(sizeof(int) * n);
    	if (tmp == NULL)
    		exit(-1);
    
    	int gap = 1;
    	while (gap < n) {
    		for (int i = 0; i < n; i += gap * 2) {
    			int begin1 = i, end1 = i + gap - 1;
    			int begin2 = i + gap, end2 = i + gap * 2 - 1;
                
    			if (begin2 >= n)
    				break;
    			if (end2 >= n)
    				end2 = n - 1;
                
    			int j = begin1;
    			int count = begin1;
    			while (begin1 <= end1 && begin2 <= end2) {
    				if (a[begin1] < a[begin2])
    					tmp[count++] = a[begin1++];
    				else
    					tmp[count++] = a[begin2++];
    			}
    			while (begin1 <= end1)
    				tmp[count++] = a[begin1++];
    			while (begin2 <= end2)
    				tmp[count++] = a[begin2++];
                
    			for (; j < end2 + 1; ++j)
    				a[j] = tmp[j];
    		}
    		gap *= 2;
    	}
    
    	free(tmp);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    复杂度和稳定性

    空间复杂度

    归并排序需要开辟一个和原数组同样大小的临时数组,

    所以空间复杂度就是 O(N)


    时间复杂度

    很明显,以平均分割为例,

    分到最小区间之后的形状就是一颗完全二叉树,

    层数就是 log2N

    而每层都要遍历一遍,

    所以总的时间复杂度就是 O(NlogN)

    而且归并排序的时间复杂度也很稳定,不会因为数据的原因而有最好最坏的情况。


    稳定性

    在上一篇讲解快速排序的文章中讲到了排序算法的稳定性问题。

    很明显归并排序是很稳定的,两个相同元素的相对位置不会在归并的过程中改变。

  • 相关阅读:
    软考高级-系统架构师-案例分析-数据库真题考点汇总
    五十二.PPO算法原理和实战
    认识matlab
    零基础想系统地学习金融学、量化投资、数据分析、python,需要哪些课程、书籍?有哪些证书可以考?
    基于Spring Boot的租房网站设计与实现
    Go实战学习笔记-1.3流程控制
    使用.NET简单实现一个Redis的高性能克隆版(一)
    航空科普VR大型体验馆设备VR航天主题乐园星际飞碟vr游乐设备
    【单片机毕业设计】【mcuclub-cl-008】基于单片机的出租车计价器的设计
    HTML5期末考核大作业 基于HTML+CSS+JavaScript仿王者荣耀首页 游戏网站开发 游戏官网设计与实现
  • 原文地址:https://blog.csdn.net/Ye_Ming_OUC/article/details/126899936