【数据结构的排序算法4】归并排序与计数排序详解

🙊 归并排序🙊

💖 算法思想

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

假设有一个数组,如果其左半区间和右半区间有序,将其进行归并,则整个数组就都有序了。如果是链表的话,可以取两段有效区间,一次比较取小的进行尾插,但是如果是数组的话就会有差异,因为数组的空间是连续的,而且不能在原数组进行归并,会导致数据覆盖的问题。所以数组需要一个额外的空间,将数据尾插到新数组最后再拷贝给原数组。

在这里插入图片描述
如何让数组的左半区间和右半区间有序?也是利用了递归分割的方法,图示如下:

在这里插入图片描述
注意这里并不是开辟一块一块的数组进行排序,而是在一个新建的组中进行排序,排序完成将新建数组中的数据拷贝给原数组。

在这里插入图片描述

💖 动态图示

可以参照下面的动图了解归并排序的思想:

在这里插入图片描述

💖 归并排序的递归实现

首先动态开辟一个数组,由于需要递归实现,所以还需要写一个子函数用于实现归并的递归。而子函数实现的功能是某段区间的归并,所以我们这里定义包括左右区间在内的 4 个参数。

代码如下:

void _MergeSort(int* a, int begin, int end, int* tmp)
{
	//如果区间只有一个数或者区间不存在就结束
	if (begin >= end)
		return;
	//分割成左右两个区间
	int mid = (begin + end) / 2;
	// [begin, mid] [mid+1, end] 递归让子区间有序

	//递归分解成子问题让左右区间都有序
	_MergeSort(a, begin, mid, tmp);
	_MergeSort(a, mid + 1, end, tmp);

	// 归并[begin, mid] [mid+1, end]
	//...

	//两段区间开始归并,有一个区间结束就结束了
	int begin1 = begin, end1 = mid;
	int begin2 = mid + 1, end2 = end;
	int i = begin;
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (a[begin1] <= a[begin2])
		{
			tmp[i++] = a[begin1++];
		}
		else
		{
			tmp[i++] = a[begin2++];
		}
	}
	//以上执行完代表有一个区间已经走完了,直接将另一个区间剩下的元素放入tmp数组
	//如果是左区间没走完,就执行这个while
	while (begin1 <= end1)
	{
		tmp[i++] = a[begin1++];
	}
	//如果是右区间没走完,就执行这个while
	while (begin2 <= end2)
	{
		tmp[i++] = a[begin2++];
	}
	//归并好了将tmp拷贝回原数组
	memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}

void MergeSort(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail");
		exit(-1);
	}

	_MergeSort(a, 0, n - 1, tmp);


	free(tmp);
	tmp = NULL;
}

递归展开图如下:

在这里插入图片描述

💖 归并排序递归实现的时间复杂度

由于每一层都是 N 个数据,一共有 logN 层,所以这里的时间复杂度为严格的 O(N*logN)。 由于额外开辟了 N 个数据的空间,高度为 logN 递归的时候建立了 log N 个栈帧,所以这里的空间复杂度为 O(N + logN)

在这里插入图片描述

💖 归并排序的非递归实现

注意归并排序每次都是二分,分到最后一定是 1 个数和 1 个数为一组,再将其进行归并,归并成 2 个数为一组的有序,再将 2 个数为一组归并成 4 个数为一组的有序,以此类推,所以这里只需要控制单个区间的大小即可。

1、通过 rangeN 来控制区间的大小。rangeN 指的是一个区间的数据个数,第一次归并是单个数为一组,将两组数为归并成 2 个有序的数。并将归并完成的数据拷贝给原数组。

在这里插入图片描述
2、进行第二次归并,让 rangeN * 2,这个时候变成 2 个数为一组。归并成 4 个数为一组的有序,再拷贝给原数组。

在这里插入图片描述
3、再继续进行归并,此时 4 个数为一组,将两组数归并成 8 个数为一组的有序,再将其拷贝给原数组。

在这里插入图片描述
如上就完成了 8 个数的归并排序。

但是需要注意,如果有 10 个数据,就会有越界问题,下面我们来进行越界情况的分析:

1、我们将每次两两为一组的两组数据划分左右区间,取变量 i 为每组首个元素的位置,每执行完一组的排序,i 就变为下一组的首元素位置

2、设置 begin1 = iend1 = i + rangeN -1 为进行两两为一组归并排序的第一组区间的左端点和右端点

3、设置 begin2 = i + rangeN, end2 = i + 2 * rangeN - 1 为进行两两为一组归并排序的第二组区间的左端点和右端点。

第一种越界情况: end1begin2end2 越界

在这里插入图片描述
第二种越界情况: begin2end2 越界

在这里插入图片描述
第三种越界情况: end2 越界

在这里插入图片描述
所以在写代码的时候要加入对以上三种情况的判断,有两种思路。

思路一: 修正的写法

因为当越界的时候,我们可以将其区间修改为不存在,这样就不会进入到下一次循环,不存在区间的值也就不会放到 tmp 数组中。只将存在的区间里的数据存到 tmp 数组中,再拷贝给原数组。

在这里插入图片描述
思路二: 不修正的写法

第二个思路就是如果区间不存在就 break 出去,前面已经将排好的数拷贝回原数组,说明原数组中区间存在的数已经有序了,而 break 后区间不存在的那一组数不会放入 tmp 数组中,也不会拷贝回原数组,所以原数组也排序完成。

在这里插入图片描述

修正写法代码如下:

void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail");
		exit(-1);
	}

	// 归并每组数据个数,从1开始,因为1个认为是有序的,可以直接归并
	//先让rangeN为1,进行归并
	int rangeN = 1;

	//rangeN 小于 n 个数就继续,否则就结束
	while (rangeN < n)
	{
		//for循环里实现一组一组的归并
		//期望每次都是一组一组的归并,一组为rangeN个,所以下次i需要跳到下一组,即跳2*rangeN个位置
		for (int i = 0; i < n; i += 2 * rangeN)
		{
			// [begin1,end1][begin2,end2] 归并
			//第一组左右区间
			int begin1 = i, end1 = i + rangeN - 1;
			//第二组左右区间
			int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;
			printf("[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);
			int j = i;

			// 如果第1组数不存在,将其修正成为不存在的区间,循环就不会进去,后面归并过程正常
			if (end1 >= n)
			{
			   //修正的写法(不存在的区间)
				end1 = n - 1;
				begin2 = n;
				end2 = n - 1;
			}
			//
			else if (begin2 >= n)
			{
				//修正的写法(不存在的区间)
				// 不存在区间
				begin2 = n;
				end2 = n - 1;
			}
			else if (end2 >= n)
			{
				//修正的写法(不存在的区间)
				end2 = n - 1;
			}

			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] <= a[begin2])
				{
					tmp[j++] = a[begin1++];
				}
				else
				{
					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));
		}
		//也可以整体归并完了再拷贝
		//memcpy(a, tmp, sizeof(int) * n);
		rangeN *= 2;
	}

	free(tmp);
	tmp = NULL;
}

不修正写法代码如下:

代码如下:

```cpp
void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail");
		exit(-1);
	}

	// 归并每组数据个数,从1开始,因为1个认为是有序的,可以直接归并
	//先让rangeN为1,进行归并
	int rangeN = 1;

	//rangeN 小于 n 个数就继续,否则就结束
	while (rangeN < n)
	{
		//for循环里实现一组一组的归并
		//期望每次都是一组一组的归并,一组为rangeN个,所以下次i需要跳到下一组,即跳2*rangeN个位置
		for (int i = 0; i < n; i += 2 * rangeN)
		{
			// [begin1,end1][begin2,end2] 归并
			//第一组左右区间
			int begin1 = i, end1 = i + rangeN - 1;
			//第二组左右区间
			int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;
			printf("[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);
			int j = i;

			// 如果第1组数不存在,将其修正成为不存在的区间,循环就不会进去,后面归并过程正常
			if (end1 >= n)
			{	
				//不修正的写法,直接break
			   break;
			}
			//
			else if (begin2 >= n)
			{
				//不修正的写法,直接break	
				break;
			}
			else if (end2 >= n)
			{
				//此种情况只能修正
				end2 = n-1;
			}

			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] <= a[begin2])
				{
					tmp[j++] = a[begin1++];
				}
				else
				{
					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));
		}
		rangeN *= 2;
	}
	free(tmp);
	tmp = NULL;
}

注意:

以上是部分拷贝的分析情况,部份拷贝是排完一部分就拷贝回原数组,而还有整体拷贝的分析情况,整体拷贝就是所有数都在 tmp 里面排完了后,再一起拷贝给原数组。整体拷贝的实现就是将 memcpy 函数放在 while 循环外即可,其次整体拷贝只能采取修正的方式而不能采取直接 break 的方式。

🙊计数排序🙊

💖 算法思想

计数排序的算法思想是统计每个数据出现的次数,就是用一个数组来记录原数组中每个位置的数据出现的次数。

💖 举例说明

💖 绝对映射

有一个需要排序的数组,它的排序过程如下:

在这里插入图片描述
1、新建一个数组,将其所有位置的数据初始化为 0,此数组为计数数组,存的是原下标对应值出现的次数

在这里插入图片描述
2、原数组第一个位置为 9,所以第二个数组的 9 号位置 ++

在这里插入图片描述
3、原数组的第二个位置为 6,所以第二个数组的 6 号位置 ++

在这里插入图片描述
4、直到统计完原数组中所有数据出现的次数

在这里插入图片描述
5、然后按照每个数据出现的次数将其写到第一个数组中,完成排序的过程。

在这里插入图片描述

💖 相对映射

在使用绝对映射的时候,会出现一个问题:如果数据有复数或者数值太大,这个计数数组需要开多大?

这个时候我们不能采取上面介绍的绝对映射方式,而是采取相对映射的方式,相对映射的第二个数组开辟的空间大小为第一个数组中元素范围。图示如下:

在这里插入图片描述

原数组中最大数据为 max,最小数据为 min,计数数组需要开辟 max - min+1 个空间,数组元素的值 - min 就是存到计数数组中的位置。

💖 代码实现

代码如下:

// 时间复杂度:O(N+range)
// 空间复杂度:O(range)
// 适合数据范围集中,也就是range小
// 只适合整数,不适合浮点数、字符串等
void CountSort(int* a, int n)
{
	//找出数组中最大数和最小数
	int max = a[0], min = a[0];
	for (int i = 1; i < n; ++i)
	{
		if (a[i] > max)
		{
			max = a[i];
		}

		if (a[i] < min)
		{
			min = a[i];
		}
	}
	//算出计数数组的范围
	int range = max - min + 1;
	// 统计次数
	int* countA = (int*)malloc(sizeof(int) * range);
	if (countA == NULL)
	{
		perror("malloc fail");
		return;
	}
	memset(countA, 0, sizeof(int) * range);

	//用相对映射统计次数
	for (int i = 0; i < n; ++i)
	{
		countA[a[i] - min]++;
	}

	// 排序
	int j = 0;
	//遍历计数数组
	for (int i = 0; i < range; ++i)
	{
		//计算计数数组每个位置的值,如果大于0,就将其的相对映射对应的值写到原数组
		//且每写完一次计数数组此位置的值就-1,当减到0就停止
		while (countA[i]--)
		{
			a[j] = i + min;
			++j;
		}
	}

	free(countA);
}

💖 复杂度说明

1、计数排序的时间复杂度其实是由 rangeN 的关系来衡量的,因为找出数组中最大数和最小数是 N,而遍历计数数组是第一层 for 循环是 range,而 while 循环里面是将原数组重新排列,其实走了 N 次,所以实际上时间复杂度是 N + range

2、当我们不确定 rangeN 的大小时,我们可以认为 计数排序的时间复杂度为 O(max(N,range)) ,取较大的一个

3、因为只开辟了计数数组,所以空间复杂度则是 O(range)

4、计数排序的时空复杂度都较优,时间复杂度可以认为近似 O(N) ,并且空间复杂度也不会太大。

5、但是计数排序也有一些局限性,如只适用于整型,如浮点数类型等就不能使用计数排序。

6、对于范围分散,跨度大的序列也不适合用计数排序,因此计数排序的适用范围有限。

🙊数据结构排序算法总结🙊

结合下面两张图和之前介绍的讲解,可以简单对数据结构的几种排序算法进行比较总结。

在这里插入图片描述
在这里插入图片描述

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值