归并排序:递归与非递归全解析

目录

前言

归并排序

一、归并排序的思想及动图展示

二、归并排序的核心步骤逻辑分析

三、归并排序的代码实现

1、典型错误写法

2、改进方法

四、归并的非递归版本

1、非递归思路讲解

2、数组中数据个数为2^n的代码实现

[2.1 11归并的代码实现](#2.1 11归并的代码实现)

[2.2 整体代码实现](#2.2 整体代码实现)

3、数组中数据个数任意的代码实现

结束语


前言

在上一篇文章数据结构之排序-选择排序&交换排序中我们详细讲解了直接选择排序以及快速排序,到此排序我们就剩下一个归并排序了,本篇文章就着重讲解归并排序的相关知识。当我们把排序讲完后初阶数据结构我们也就告一段落了,之后我们就会开始步入C++语言的学习,希望大家能多多支持!

归并排序

一、归并排序的思想及动图展示

归并排序 (MERGE-SORT)是建立在归并操作 上的一种有效的排序算法 ,该算法是采用分治法 (Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并 ,得到完全有序的序列;即先使每个子序列有序 ,再使子序列段间有序 。若将两个有序表合并成一个有序表 ,称为二路归并

二、归并排序的核心步骤逻辑分析

归并排序的整体思路就如上图所示,首先归并排序 其实有点类似我们在C语言学习中遇到的一个问题:两个有序数组合并成一个有序数组 。之所以类似就是因为图中的合并过程就与其如出一辙,所以归并排序是需要借助一个新数组用于存放调整排序后的数据

但是合并过程是在分解过程之后完成的,所以我们看一下分解过程是什么意思?由于我们说了归并排序和上面的问题类似,所以我们需要将数组均分成两个部分 ,如果这两个部分各自是有序的话,我们就可以通过下图的逻辑借助第三方新数组进行存放数据:

这样两个各自有序的部分就可以合并成一个数组,但是这两个部分如果不是有序 的呢?那是不是继续分成两个部分 ,看看这两个部分是不是有序的,如果有序则就排序合并 ,如果不是有序则继续分 。这样是不是递归的思路就出来。

但我们想一下如果按照刚刚的逻辑是不是每次递归都需要先判断当前数组是否已经有序,这样其实会显的麻烦,所以我们考虑直接递归到底,也就是递归到两个部分只有一个数为止,也就如上图所示。

而且会发现当我们递归到底 后返回才开始进行合并排序 ,所以这个逻辑就类似于二叉树的后序遍历 ;然后我们再回想上一篇文章中讲解的快速排序 。快速排序的逻辑是先找到key的位置 使其左边都小于本身,右边都大于本身,然后再将数组分成三个部分进行递归 ,所以我们会发现快速排序的逻辑是类似于二叉树的前序遍历的。

三、归并排序的代码实现

1、典型错误写法

cpp 复制代码
//Sort.h
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
//打印数组
void PrintArray(int* arr, int n);
//归并排序
void MergeSort(int* arr, int n);

//Sort.c
void _MergeSort(int* arr, int* tmp, int begin, int end)
{
	if (begin == end) //递归到只有一个数时则不用归并排序,递归返回
	{
		return;
	}

	int mid = (begin + end) / 2;
	//将数组区域划分为:[begin, mid - 1] [mid, end]
	_MergeSort(arr, tmp, begin, mid - 1); //先递归再归并,逻辑类似后序遍历
	_MergeSort(arr, tmp, mid, end);       //因为归并的前提是左右子区间各自为有序状态

	////归并
	int begin1 = begin;
	int end1 = mid - 1;
	int begin2 = mid;
	int end2 = end;
	int i = begin; //i用于第三方数组tmp在对应区间进行有序存值

	while (begin1 <= end1 && begin2 <= end2) //有一个数组先走完则跳出循环
	{
		if (arr[begin1] < arr[begin2])
		{
			tmp[i++] = arr[begin1++];
		}
		else
		{
			tmp[i++] = arr[begin2++];
		}
	}
	//跳出循环则说明一定有一个数组已经走完,则另一个数组剩余数据直接接着存放到tmp即可
	while (begin1 <= end1)
	{
		tmp[i++] = arr[begin1++];
	}
	while (begin2 <= end2)
	{
		tmp[i++] = arr[begin2++];
	}
	//当第三方数组tmp对应区间的数据有序存放好后需要拷贝回arr数组中
	memcpy(arr + begin, tmp + begin, (end - begin + 1) * sizeof(int));
    //这个尤其要注意:arr和tmp是整个数组的大小,不加begin则表示在数组最开头拷贝,这是不行的
    //所以如果要在对应区域拷贝时需要加 begin
}

//归并排序
void MergeSort(int* arr, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n); //开辟第三方数组存放排好序的区间
	assert(tmp != NULL);

	_MergeSort(arr, tmp, 0, n - 1); //排序用子函数进行操作,目的:避免多次创建数组

	free(tmp);
	tmp = NULL;
}

//Test.c
#include "sort.h"

void Test()
{
	int arr[] = { 6, 1, 2, 7, 9, 3, 4, 5, 10, 8 };
	PrintArray(arr, sizeof(arr) / sizeof(arr[0]));
	MergeSort(arr, sizeof(arr) / sizeof(arr[0]));
	PrintArray(arr, sizeof(arr) / sizeof(arr[0]));
}

int main()
{
	Test();
	return 0;
}

大致看了一下上面的代码逻辑好像符合我们刚刚所讲的归并排序逻辑,但是当我们运行程序时就会出现下面这个情况:

看到这个异常如果看了上一篇文章数据结构之排序-选择排序&交换排序的朋友应该就找到这个栈溢出 的问题。

可是为什么会有栈溢出的问题呢?感觉上面的代码没有什么有问题的地方啊,所以我们需要将上述的代码通过图表示出来:

我们会发现栈溢出 问题的出现就是因为中间值下标mid取的有问题 ,我们知道整型的除法是会取整的,所以奇数的取中是会有问题的;而且当递归只剩下两个数 的时候,下标之和一定为奇数 的,这样就会导致如图所示**[mid, end]** 这个区间会一直不变 导致无限递归的情况,而递归一次就需要在栈区开辟新的空间,无限的递归就会导致栈溢出的问题。

2、改进方法

由于问题来自于只有两个数的情况,也就是下标和为奇数,所以如果我们将 mid 再加1就可以解决问题了:

cpp 复制代码
//Sort.c
void _MergeSort(int* arr, int* tmp, int begin, int end)
{
	if (begin == end)
	{
		return;
	}

	int mid = (begin + end) / 2 + 1;
	//将数组区域划分为:[begin, mid - 1] [mid, end]
	_MergeSort(arr, tmp, begin, mid - 1);
	_MergeSort(arr, tmp, mid, end);

	////归并
	int begin1 = begin;
	int end1 = mid - 1;
	int begin2 = mid;
	int end2 = end;
	int i = begin;

	while (begin1 <= end1 && begin2 <= end2)
	{
		if (arr[begin1] < arr[begin2])
		{
			tmp[i++] = arr[begin1++];
		}
		else
		{
			tmp[i++] = arr[begin2++];
		}
	}
	
	while (begin1 <= end1)
	{
		tmp[i++] = arr[begin1++];
	}
	while (begin2 <= end2)
	{
		tmp[i++] = arr[begin2++];
	}
	/
	memcpy(arr + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}

//归并排序
void MergeSort(int* arr, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n); //开辟第三方数组存放排好序的区间
	assert(tmp != NULL);

	_MergeSort(arr, tmp, 0, n - 1); //排序用子函数进行操作,目的:避免多次创建数组

	free(tmp);
	tmp = NULL;
}

//Test.c

void Test()
{
	int arr[] = { 6, 1, 2, 7, 9, 3, 4, 5, 10, 8 };
	PrintArray(arr, sizeof(arr) / sizeof(arr[0]));
	MergeSort(arr, sizeof(arr) / sizeof(arr[0]));
	PrintArray(arr, sizeof(arr) / sizeof(arr[0]));
}

int main()
{
	Test();
	return 0;
}

四、归并的非递归版本

对于归并排序来说利用递归其实是比较容易实现的,因为大体思路和前面的快速排序类似,但如果不用递归来实现归并排序就会很有点难度了,主要在于非常多细节需要讲解。

1、非递归思路讲解

首先我们思考一下如果不用递归怎么去实现归并排序的效果,没有递归的话是不是就没有分解这一部分了,所以如下图所示:

所以此时我们不再把一个数组看成一个整体,由于一次归并排序是需要两个有序数组,所以我们将第一排两两为一组进行归并排序,第一排也叫做11归并 ;当第一排完成归并排序后就如第二排所示,此时由于一个数组此时存放两个数,则相当于4个数为一组进行归并排序,所以第二排也叫做22归并 ;同理下面的逻辑也是如此,则我们就能不用递归来实现归并排序。

我们会发现每完成一排的归并数组的数据量 都进行了翻倍 ,但是我们知道归并排序需要两个头指针 同时进行比较排序 ,所以这两个头指针之间的距离 是会发生变化 的,也就是说我们需要一个变量来控制这个距离,我们就定义为gap
gap 指的就是当前一排中一个数组所存放的数据个数。当 gap = 1时相当于第一排,此时每两个数组为一组进行归并排序,全部完成后则gap = 2相当于第二排,此时再每两个数组为一组进行归并排序,以此类推。

2、数组中数据个数为2^n的代码实现

为什么我们先实现个数为2^n的数组排序呢?就由上面的图所示,我们能保证每两个数组都能成为一组进行归并排序,如果数组个数不是2^n,比如假设为10个数据,则到第二排的数组个数为5个,无法做到每两个数组为一组进行归并排序,这种类似情况我们等会再进行讲解。

2.1 11归并的代码实现

在实现整个数组的排序之前,我们可以先写第一层的归并排序也就是11归并 ,所以此时的gap = 1,具体的各个细节在代码注释中已经详细解释了,具体代码如下:

cpp 复制代码
//Sort.c
void MergeSortNonR(int* arr, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	assert(tmp != NULL);

	int gap = 1; //11归并
	for (int i = 0; i < n; i += 2 * gap)
    //i += 2*gap表示的是一组归并完成后到下一组,由于一组有两个数组所以为2*gap
	{
		int begin1 = i;//当前一组的第一个数组开头下标
		int end1 = i + gap - 1; 
        //由于 i+gap 为第二个数组的开头下标,所以第一个数组的结尾下标为i+gap-1
		int begin2 = i + gap;
		int end2 = i + 2 * gap - 1; 
        //end1 = begin1 + gap - 1 --> end2 = begin2 + gap - 1 = i + 2 * gap - 1
		int j = i; // i 用于后续的数据拷贝,不能让i发生改变
		//一组进行归并排序
		while (begin1 <= end1 && begin2 <= end2)
		{
			if (arr[begin1] < arr[begin2])
			{
				tmp[j++] = arr[begin1++];
			}
			else
			{
				tmp[j++] = arr[begin2++];
			}
		}
		while (begin1 <= end1)
		{
			tmp[j++] = arr[begin1++];
		}
		while (begin2 <= end2)
		{
			tmp[j++] = arr[begin2++];
		}
		memcpy(arr + i, tmp + i, sizeof(int) * (2 * gap)); 
        //每次拷贝的数据个数:end2 - begin1 + 1 = 2 * gap
	//为什么每归并完一组就进行数据拷贝而不是for循环结束也就是把当前一层全部归并完再全部拷贝arr?
		//如果数据个数为2^n则没问题,但如果不是则会出现后面部分数组无法归并的情况
		//而tmp是把归并好的组拷贝到arr,就会导致部分数据丢失的问题
	}

	free(tmp);
	tmp = NULL;
}

//Test.c
void Test()
{
	int arr[] = { 10, 6, 7, 1, 3, 9, 4, 2 };
	PrintArray(arr, sizeof(arr) / sizeof(arr[0]));
	MergeSortNonR(arr, sizeof(arr) / sizeof(arr[0]));
	PrintArray(arr, sizeof(arr) / sizeof(arr[0]));
}

int main()
{
	Test();
	return 0;
}

2.2 整体代码实现

我们通过打印结果发现所写11归并的代码的确没问题,每两个数为一组是各自有序的。那接下来我们就需要对gap进行改变 了,上面我们提到了每完成一层的归并排序下面一层的数组数据个数是上面一层的两倍 ,所以每次完成一层归并排序我们的gap = 2 * gap

cpp 复制代码
//Sort.c
void MergeSortNonR(int* arr, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	assert(tmp != NULL);

	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += 2 * gap)
		{
			int begin1 = i;
			int end1 = i + gap - 1;
			int begin2 = i + gap;
			int end2 = i + 2 * gap - 1;
			int j = i;

			while (begin1 <= end1 && begin2 <= end2)
			{
				if (arr[begin1] < arr[begin2])
				{
					tmp[j++] = arr[begin1++];
				}
				else
				{
					tmp[j++] = arr[begin2++];
				}
			}
			while (begin1 <= end1)
			{
				tmp[j++] = arr[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[j++] = arr[begin2++];
			}
			memcpy(arr + i, tmp + i, sizeof(int) * (2 * gap));
		}
		gap = 2 * gap;
	}

	free(tmp);
	tmp = NULL;
}

//Test.c
void Test()
{
	int arr[] = { 10, 6, 7, 1, 3, 9, 4, 2 };
	PrintArray(arr, sizeof(arr) / sizeof(arr[0]));
	MergeSortNonR(arr, sizeof(arr) / sizeof(arr[0]));
	PrintArray(arr, sizeof(arr) / sizeof(arr[0]));
}

int main()
{
	Test();
	return 0;
}

这样数组的数据个数为2^n的非递归代码实现就完成了。

3、数组中数据个数任意的代码实现

之所以我们先讲数据个数为2^n的特殊情况原因就是这种情况能保证每层的每两个数组都能进行归并排序 ,在代码逻辑上就比较简单;但是如果此时数据个数不为2^n,则一定会出现某一层不能保证每两个数组为一组进行归并排序 ,这种细节我们需要重新进行讲解。

如果假设一个数组有10个数据,此时我们还是按照上面的代码实现的话,我们在每次归并排序前将归并的范围进行打印,就会出现下面的情况:

我们会发现按照上面的代码运行则会出现划红线的范围,这些范围在数组中其实是不存在的,但由于受到 gap = 2 * gap 的控制,导致每一组的数据个数保持为2^n。虽然我们知道比如4个数据和2个数据进行归并或者8个数据和2个数据进行归并,但上面的代码不可能出现这种情况 ,所以我们基于划红线的部分 要进行修改逻辑

cpp 复制代码
//Sort.h
//非递归的归并排序
void MergeSortNonR(int* arr, int n);

//Sort.c
//非递归的归并排序
void MergeSortNonR(int* arr, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	assert(tmp != NULL);

	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += 2 * gap)
		{
			int begin1 = i;
			int end1 = i + gap - 1;
			int begin2 = i + gap;
			int end2 = i + 2 * gap - 1;

			if (begin2 >= n) 
            //第二个数组整体越界(也包括第一个数组的end1越界),则不需要进行归并,将数据保留即可
			{
				break;
			}

			if (end2 >= n) //第二个数组部分越界,则对其尾部进行修正即可
			{
				end2 = n - 1;
			}

			int j = i;
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (arr[begin1] < arr[begin2])
				{
					tmp[j++] = arr[begin1++];
				}
				else
				{
					tmp[j++] = arr[begin2++];
				}
			}
			while (begin1 <= end1)
			{
				tmp[j++] = arr[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[j++] = arr[begin2++];
			}
			//memcpy(arr + i, tmp + i, sizeof(int) * (2 * gap));
            //每次拷贝的数据个数:end2 - begin1 + 1 = 2 * gap
            //为什么每归并完一组就进行数据拷贝而不是for循环结束也就是把当前一层全部归并完再全部拷贝到arr?
			//如果数据个数为2^n则没问题,但如果不是则会出现后面部分数组无法归并的情况
			//而tmp是把归并好的组拷贝到arr,就会导致部分数据丢失的问题
			memcpy(arr + i, tmp + i, sizeof(int) * (end2 - i + 1));
			//如果数据个数不为2^n则传的字节数不能为sizeof(int) * (2 * gap),
			//因为最后一层归并一定会导致 2*gap > n 的情况,
			//本质原因就是最后一层end2进行了修正,此时不再是i + 2 * gap - 1而是n - 1
		}
		printf("\n");
		gap = 2 * gap;
	}

	free(tmp);
	tmp = NULL;
}

//Test.c
void Test()
{
	int arr[] = { 6, 1, 2, 7, 9, 3, 4, 5, 10, 8, 11 };
	PrintArray(arr, sizeof(arr) / sizeof(arr[0]));
	MergeSortNonR(arr, sizeof(arr) / sizeof(arr[0]));
	PrintArray(arr, sizeof(arr) / sizeof(arr[0]));

    int arr2[] = { 5, 9, 1, 6, 3, 8, 2 };
    PrintArray(arr, sizeof(arr) / sizeof(arr[0]));
    MergeSortNonR(arr, sizeof(arr) / sizeof(arr[0]));
    PrintArray(arr, sizeof(arr) / sizeof(arr[0]));
}

int main()
{
	Test();
	return 0;
}

结束语

到此排序中第四类的归并排序我们就讲解完了,这样大体排序的知识就全部讲解完了。但我们知道这些排序中最重要也是最难的两个排序就是快速排序以及归并排序了,虽然我们知识大致讲解完了但这两个排序之所以最重要就在于其应用远不止这些,后面我还会为大家对这两个排序的拓展应用进行讲解,希望大家多多支持!

这样我们的初级数据结构也就可以告一段落了,接下来我们就要开始正式步入C++语言的学习当中了,而高阶数据结构也是需要借助C++的知识才能继续学习。希望这篇文章对大家学习排序能有所收获!

相关推荐
@卞43 分钟前
高阶数据结构 --- 单调队列
数据结构·c++·算法
Live&&learn8 小时前
算法训练-数据结构
数据结构·算法·leetcode
胡萝卜3.09 小时前
掌握C++ map:高效键值对操作指南
开发语言·数据结构·c++·人工智能·map
风筝在晴天搁浅11 小时前
代码随想录 509.斐波那契数
数据结构·算法
落落落sss11 小时前
java实现排序
java·数据结构·算法
fei_sun11 小时前
【数据结构】2018年真题
数据结构
SundayBear12 小时前
C语言复杂类型声明完全解析:从右左原则到工程实践
c语言·开发语言·数据结构·嵌入式
立志成为大牛的小牛13 小时前
数据结构——四十四、平衡二叉树的删除操作(王道408)
数据结构·学习·程序人生·考研·算法
hweiyu0016 小时前
数据结构:数组
数据结构·算法