数据结构--排序

排序的概念

所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减排列起来的操作。内部排序就是数据元素全部放在内存中的排序。外部排序就是数据元素太多不能同时·放在内存中,根据排序过程的要求不断地在内外存之间移动数据的排序。

稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[ i ] == r[ j ],且r[ i ]在r[ j ]之前,而在排序后的序列中,r[ i ]仍在r[ j ]之前,则称这种排序算法是稳定的;否则称为不稳定的。

排序的应用

在计算机科学中,排序算法是一类重要的算法,用于将一组数据按照一定规则进行排序。在实际应用中,排序算法广泛应用于数据处理、搜索引擎、数据库管理等领域。在日常生活中排序也有较为广泛的应用,例如:我们在淘宝中挑选商品时可以按销量、评论数、上架时间和价格等来对商品进行排序;中国的全部大学排行等等,这些都需要通过排序来操作。

常见的排序算法

常见的排序算法包括插入排序(直接插入排序和希尔排序)、选择排序(直接选择排序和堆排序)、交换排序(冒泡排序和快速排序)、归并排序。

各位可以通过Comparison Sorting Visualization (usfca.edu)本链接来观看各种排序的动态演示。

插入排序

插入排序是一种简单的排序算法,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。日常生活中我们玩扑克牌时,就用到了插入排序的思想。

直接插入排序

基本思想:当插入第 i 个元素时,前面的 i - 1 个元素已经排好序,此时,用array[ i ]的排序码与前 i - 1 个元素的排序码进行比较,找到插入位置后,将array[ i ]插入,原来位置上的元素顺序后移。

个人解释:直接插入排序是一个有实践意义的排序,以升序为例,首先假设[0,end]是有序的(0 <= end < sz - 1),将下标为end+1的元素值保存到tmp中,向前比较,如果下标为end的元素值大于tmp,就将下标为end的元素值后移,- -end继续比较,直到end元素值小于tmp或者end<0时,我们将tmp存入到数组下标end+1的位置,一趟循环结束,i++,继续向后循环,直到 i = sz-1。

特性总结:

1.元素集合接近有序,直接插入排序算法的时间效率越高。

2.时间复杂度:O(N^2)

3.空间复杂度:O(1)。

4.稳定性:稳定。

代码演示:

cpp 复制代码
//插入排序:有实践意义
void InsertSort(int* a, int sz)
{
	for (int i = 0; i < sz - 1; i++)
	{
		//单趟排序
		int end = i;
		//假设[0 ,end]有序,将第end+1个数插入进去,保持有序。
		int tmp = a[end + 1];
		while (end >= 0)
		{
			if (tmp < a[end])
			{
				a[end + 1] = a[end];
				--end;
			}
			else
			{
				break;
			}
		}
		a[end + 1] = tmp;
	}
}

希尔排序

希尔排序法又称为缩小增量法,顾名思义,希尔排序是一种增量不断缩小的排序方法。

基本思想:先选定一个整数,把待排序文件中所有记录分成 gap 个组,所有距离为 gap 的记录分在同一组内,并对每一组内的记录进行排序。然后缩小gap,重复上述分组和排序的工作。当gap==1时所有记录在组内就已经排好序了。

个人解释:希尔排序的原理非常玄妙,我们设gap为同一组内两个元素之间的距离,起始为sz/3-1,因为当gap较大时,大数可以更快地换到后面去,小数可以更快的换到前面去,但是这样排出来的数组是更不有序的,所以我们在每排完一趟序后就要将 gap / 3 - 1 ,使数组越来越有序,因为前一步已经将大数和小数进行了交换,所以在后面每趟的排序中的工作效率会大大提升;当gap变为1时进行最后一趟排序,其实gap为1的希尔排序就是直接插入排序。内部的工作原理和直接插入排序差不多,这里不在多说。

希尔排序特性总结

1.希尔排序是对直接插入排序的优化。

2.当gap > 1时,都是预排序,目的是让数组更接近有序,当gap == 1 时,数组已经是接近有序的了,这一趟遍历可以很快就完成。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。

3.希尔排序的时间复杂度不好计算,因为gap的取值方法有很多,导致很难去计算,因此在好多书中给出的时间复杂度都不一致。由于本文中是按照Kunth提出的方式进行取值的,而且Knuth进行了大量的试验统计,我们暂时就按照O(n^1.25)到O(1.6 * n^1.25)来算。

4.稳定性:不稳定。

代码演示:

cpp 复制代码
//希尔排序:O(N^1.3)
void ShellSort(int* a, int sz)
{
    //设置gap的减小倍数
	int gap = sz / 3 + 1;
    //gap为1时退出循环
	while (gap > 1)
	{
        //等于1时需要进行一趟排序:直接插入排序
		gap = gap / 3 + 1;
        //也可以写成两层循环的方式,但是没必要
		for (int i = 0; i < sz - gap; i++)
		{
			int end = i;
			int tmp = a[end + gap];
			while (end >= 0)
			{
				if (a[end] > tmp)
				{
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			a[end + gap] = tmp;
		}
	}
}

希尔排序拓展知识

值得一提的是希尔排序的时间复杂度大约是O(N^1.3),略............

选择排序

基本思想:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到待排序的元素排完。

直接选择排序

在元素集合array[ i ] - array[ n-1 ]中选择关键码最大(小)的数据元素。若它不是这组元素中的最后一个(第一个),则将它与这组元素中的最后一个(第一个)交换。在剩余的array[ i ] - array[ n-2 ](array[ i+1 ] - array[ n-1 ])集合中重复上述步骤,直到集合剩余1个元素。

个人解释

直接选择排序的过程比较简单,一端选择即单次循环只寻找最大或最小的元素与对应位置进行交换,直到排序结束;两端选择即单词循环寻找最大和最小的元素,与对应位置进行交换,可以提高效率。需要注意的一点是两端循环可能在部分待排序数组中存在bug,因为在选定最大值和最小值时,若最大值在开头的位置上,在进行交换时先交换最小值会导致排序位置发生错乱,因此在交换之前需要对最大值的下标进行处理,当最大值在开头时,把最小值的下标赋值给最大值的下标,因为在最小值交换后,最大值被交换到了最小值下标的位置,因此做以上处理。

直接选择排序的特性总结

1.直接选择排序思考非常好理解,但是效率不是很好,实际中很少使用。

2.时间复杂度O(N^2)。

3.空间复杂度:O(1)

4.稳定性:不稳定。

代码演示:

cpp 复制代码
//选择排序:最没用的排序
//一端选择
void SingleSelectSort(int* a, int sz)
{
	int mini;
	for (int i = 0; i < sz; i++)
	{
		mini = i;
		for (int j = i+1; j < sz; j++)
		{
			if (a[mini] > a[j])
			{
				mini = j;
			}
		}
		Swap(&a[i], &a[mini]);
	}
}
//两端选择
void DoubleSelectSort(int* a, int sz)
{
	int begin = 0, end = sz-1;
	int mini, maxi;
	while(begin < end)
	{
		mini = begin;
		maxi = begin;
		for (int j = begin + 1; j <= end; j++)
		{
			if (a[mini] > a[j])
			{
				mini = j;
			}
			if (a[maxi] < a[j])
			{
				maxi = j;
			}
		}
		if (a[maxi] == a[begin])
			maxi = mini;
		Swap(&a[begin], &a[mini]);
		Swap(&a[end], &a[maxi]);
		begin++;
		end--;
	}
}

堆排序

具体步骤在:数据结构--二叉树-CSDN博客,这里不在重复解释。

代码演示:

cpp 复制代码
//交换函数
void Swap(int* p1, int* p2)
{
	int tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

//向下调整建(大)堆
void AdjustDown(int* a,int sz,int parent)
{
	int child = parent * 2 + 1;
	//保证孩子下标不大于数组长度
	while (child < sz)
	{
		//找较大的孩子
		if (child + 1 < sz && a[child] < a[child + 1])
		{
			child++;
		}
		//比较大孩子和父亲的大小
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

//堆排序
void HeapSort(int* a, int sz)
{
	//排升序,建大堆
	//建大堆
	for (int i = (sz - 1 -1) / 2; i >= 0; i--)
	{
		//向下调整建堆
		AdjustDown(a,sz,i);
	}
	//排升序
	int end = sz - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end - 1, 0);
		end--;
	}
}

交换排序

基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来交换两个记录在序列中的位置,交换排序的特点是:将键值较大的记录想序列尾部移动,键值较小的记录项序列的前部移动。

冒泡排序

冒泡排序是最常见的排序了,大多数人最早学的排序都是冒泡排序。虽然在实践中冒泡排序没有什么意义,但是作为入门排序一个算法,还是很成功的。

基本思想:在待排序的一组数中,将相邻的两个数进行比较,若前面的数比后面的数大就交换两数,否则不交换;如此下去,直至最终完成排序

冒泡排序的特性总结:

1.冒泡排序是一种非常容易理解的排序。

2.时间复杂度:O(N^2)。

3.空间复杂度:O(1)。

4.稳定性:稳定。

cpp 复制代码
//冒泡排序:教学意义,实践中没有意义
void BubbleSort(int* a, int sz)
{
	for (int i = 0; i < sz - 1; i++)
	{
		int flag = 0;
		for (int j = 0; j < sz - 1 - i; j++)
		{
			if (a[j] > a[j + 1])
			{
				int tmp = a[j];
				a[j] = a[j + 1];
				a[j + 1] = tmp;
				flag = 1;
			}
		}
		if (flag == 0)
		{
			break;
		}
	}
}

快速排序(递归)

快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于该基准值,然后在左右子序列中重复该过程,直到所有元素都排列在相应位置上为止。

实现框架如下:
// 假设按照升序对 array 数组中 [left, right) 区间中的元素进行排序
void QuickSort ( int* array , int left , int right )
{
if ( right - left <= 1 )
return ;
// 按照基准值对 array 数组的 [left, right) 区间中的元素进行划分
int div = partion ( array , left , right );
// 划分成功后以 div 为边界形成了左右两部分 [left, div) 和 [div+1, right)
// 递归排 [left, div)
QuickSort ( array , left , div );
// 递归排 [div+1, right)
QuickSort ( array , div + 1 , right );
}

上述为快速排序递归实现的主框架,发现与二叉树前序遍历规则非常像,各位在写递归框架时可想想二叉树前序遍历规则即可快速写出来,后序只需分析如何按照基准值来对区间中数据进行划分的方式即可。

Hoare版本

基本思路:

首先我们把数组看成一个从0 ~ sz-1(left ~ right)的一个区间,取区间最左端的值下标为key,定义begin和end分别为从两端向中间遍历的下标变量,以key的元素值为标准,先通过end在右边开始遍历,找到比key的元素值小的元素的下标,再通过begin找到比key的元素值大的元素的下标,随即将两个数组中的元素进行位置交换,期间begin要始终保持小于end,当begin=end时,将key与begin和end相交的位置的元素值进行交换,完成了一趟排序。

这里可能会有人觉得疑惑,为什么最后要把key和相遇点交换,我们来讲一下:既然是排序,我们就需要确保相遇点的值小于key的元素值,我们可以把这里分为两部分理解,第一部分是end向左移动时与begin相遇,这里分为两种情况:一种是begin在之前没有开始遍历,这种情况下相遇是在key下标位置,所以交不交换都一样,另一种是begin在之前遍历到了大于key元素值的点,并且与end发生了元素交换,这种情况下,begin的值小于key的元素值,因此成立;第二部分是begin向右移动时与end相遇,这里只有一种情况,就是end找到了小于key的值,begin与end相遇,和key交换。从这里我们就可以看出key的值一定大于相遇点的值。

这时我们把key的值改为其相交位置的下标,以修改后的key为分段点,将区间分为了三部分,[left, key-1] key [key+1, right],接下来就是通过递归将左右两个子区间进行新一轮的排序。直到分出的子区间内只有一个值,或区间不存在就返回。这样就完成了简单的快速排序。

代码如下:

cpp 复制代码
//快速排序
void QuickSort(int* a, int left, int right)
{
	//区间为1个数或不存在时退出
	if (left >= right)
	{
		return;
	}
	//单趟
	int key = left;
	int begin = left, end = right;
	while (begin < end)
	{
		//右边找小
		while (begin < end && a[end] >= a[key])
		{
			--end;
		}
		//左边找大
		while (begin < end && a[begin] <= a[key])
		{
			++begin;
		}
		Swap(&a[begin], &a[end]);
	}
	Swap(&a[key], &a[begin]);
	//关键值下标赋值给key
	key = begin;
	//子区间递归
	//[left, key-1] key [key+1, right]
	QuickSort(a, left, key - 1);
	QuickSort(a, key + 1, right);
}

但是写到这里算法还有一定的缺陷,当所给数据有序的情况下,该算法会出现栈溢出的情况,原因是有序的情况下,单趟排序只能排出key和[key+1, right]两个区间导致递归深度过大从而栈溢出。

为了避免有序情况下的效率退化,我们采用以下方法:

1、三数取中:取left、(left+right)/2、right中的中间值做key。

代码演示:

cpp 复制代码
//三数取中
int GetMid(int* a, int left, int right)
{
	int mid = (left + right) / 2;
	//left mid right
	if (a[left] < a[right])
	{
		if (a[left] > a[mid])
		{
			return left;
		}
		else if (a[right] < a[mid])
		{
			return right;
		}
		else
		{
			return mid;
		}
	}
	else  //(a[left] >= a[right])
	{
		if (a[left] < a[mid])
		{
			return left;
		}
		else if (a[right] > a[mid])
		{
			return right;
		}
		else
		{
			return mid;
		}
	}
}
//快速排序
void QuickSort(int* a, int left, int right)
{
	//区间为1个数或不存在时退出
	if (left >= right)
	{
		return;
	}
	//三数取中,交换到最左端
	int mid = GetMid(a, left, right);
	Swap(&a[left], &a[mid]);

	//单趟
	int key = left;
	int begin = left, end = right;
	while (begin < end)
	{
		//右边找小
		while (begin < end && a[end] >= a[key])
		{
			--end;
		}
		//左边找大
		while (begin < end && a[begin] <= a[key])
		{
			++begin;
		}
		Swap(&a[begin], &a[end]);
	}
	Swap(&a[key], &a[begin]);
	//关键值下标赋值给key
	key = begin;
	//子区间递归
	//[left, key-1] key [key+1, right]
	QuickSort(a, left, key - 1);
	QuickSort(a, key + 1, right);
}

了解了三数区中之后,我们再来看小区间优化:

我们在上面已经了解到快速排序的函数递归调用与返回是通过二叉树的结构实现的也就是说函数栈帧创建的数量也是随递归的深度增加而成倍增加的,那我们应该如何减少一定的函数栈帧的创建从而达到优化排序效率的问题呢?这就是我接下来要将的小区间优化,这种方法是当函数在某次递归后区间内的元素数量小于10(一个较小的常数),我们就放弃使用递归,而是使用更为简单的排序方法来提高排序效率,我们没减少一层函数递归就可以少开辟一半的函数栈帧,大大优化了排序效率。由于前面讲的希尔排序和堆排序在大量数据中才能显现其高效,所以这里不予采用,在直接插入排序、直接选择排序和冒泡排序中直接插入排序是同一数量级中最优的排序,所以我们决定使用它。

代码如下:

cpp 复制代码
//三数取中
int GetMid(int* a, int left, int right)
{
	int mid = (left + right) / 2;
	//left mid right
	if (a[left] < a[right])
	{
		if (a[left] > a[mid])
		{
			return left;
		}
		else if (a[right] < a[mid])
		{
			return right;
		}
		else
		{
			return mid;
		}
	}
	else  //(a[left] >= a[right])
	{
		if (a[left] < a[mid])
		{
			return left;
		}
		else if (a[right] > a[mid])
		{
			return right;
		}
		else
		{
			return mid;
		}
	}
}

//快速排序:Hoare法
void QuickSort(int* a, int left, int right)
{
	//区间为1个数或不存在时退出
	if (left >= right)
	{
		return;
	}
	//小区间优化
	if (right - left + 1 < 10)
	{
        //对a+left开始的right-left+1个数排序
		InsertSort(a + left, right - left + 1);
	}
	else
	{
		//三数取中,交换到最左端
		int mid = GetMid(a, left, right);
		Swap(&a[left], &a[mid]);
	}
	//单趟
	int key = left;
	int begin = left, end = right;
	while (begin < end)
	{
		//右边找小
		while (begin < end && a[end] >= a[key])
		{
			--end;
		}
		//左边找大
		while (begin < end && a[begin] <= a[key])
		{
			++begin;
		}
		Swap(&a[begin], &a[end]);
	}
	Swap(&a[key], &a[begin]);
	//关键值下标赋值给key
	key = begin;
	//子区间递归
	//[left, key-1] key [key+1, right]
	QuickSort(a, left, key - 1);
	QuickSort(a, key + 1, right);
}

挖坑法版本

基本思路:挖坑法的思路和hoare版本大同小异,只是从前面的找到两个相对位置的值再交换变成了,找到一个值就交换。直到begin和end相遇,最后把key的值赋给相遇点这个坑位。

代码如下:

cpp 复制代码
//三数取中
int GetMid(int* a, int left, int right)
{
	int mid = (left + right) / 2;
	//left mid right
	if (a[left] < a[right])
	{
		if (a[left] > a[mid])
		{
			return left;
		}
		else if (a[right] < a[mid])
		{
			return right;
		}
		else
		{
			return mid;
		}
	}
	else  //(a[left] >= a[right])
	{
		if (a[left] < a[mid])
		{
			return left;
		}
		else if (a[right] > a[mid])
		{
			return right;
		}
		else
		{
			return mid;
		}
	}
}
//快速排序:挖坑法
void DigQuickSort(int* a, int left, int right)
{
	//区间为一个数或不存在时返回
	if (left >= right)
	{
		return;
	}
	//三数取中,交换到最左端
	int mid = GetMid(a, left, right);
	Swap(&a[left], &a[mid]);

	//存储分割值
	int keyvalue = a[left];
	//坑位
	int dig = left;
	//两端点
	int begin = left, end = right;
	while (begin < end)
	{
		while (begin < end && a[end] >= keyvalue)
		{
			--end;
		}
		if (begin < end && a[end] < keyvalue)
		{
			a[dig] = a[end];
			dig = end;
		}
		while (begin < end && a[begin] <= keyvalue)
		{
			++begin;
		}
		if (begin < end && a[begin] > keyvalue)
		{
			a[dig] = a[begin];
			dig = begin;
		}
	}
	a[dig] = keyvalue;
	//dig即区间分割点,直接进行子区间递归
	DigQuickSort(a, left, dig - 1);
	DigQuickSort(a, dig + 1, right);
}

前后指针版本

基本思路:前后指针法的思路也比较简单,利用两个指针的位置关系,每次循环++一次cur,如果当前cur下标的元素值小于key的元素值,就++prev,若prev和cur此时没有重合就交换prev和cur,直到走出数组,将prev和key交换,并把prev赋值给key,然后就是子区间递归了。

代码演示:

cpp 复制代码
//三数取中
int GetMid(int* a, int left, int right)
{
	int mid = (left + right) / 2;
	//left mid right
	if (a[left] < a[right])
	{
		if (a[left] > a[mid])
		{
			return left;
		}
		else if (a[right] < a[mid])
		{
			return right;
		}
		else
		{
			return mid;
		}
	}
	else  //(a[left] >= a[right])
	{
		if (a[left] < a[mid])
		{
			return left;
		}
		else if (a[right] > a[mid])
		{
			return right;
		}
		else
		{
			return mid;
		}
	}
}
//快速排序:前后指针法
void PointQuickSort(int* a, int left, int right)
{
	//区间为1个数或不存在时退出
	if (left >= right)
	{
		return;
	}
	//小区间优化
	if (right - left + 1 < 10)
	{
		InsertSort(a + left, right - left + 1);
		return;
	}
	else
	{
		//三数取中,交换到最左端
		int mid = GetMid(a, left, right);
		Swap(&a[left], &a[mid]);
	}

	int key = left;
	int	prev = left;
	int cur = prev + 1;
	while (cur <= right)
	{
		if (a[key] > a[cur])
		{
			prev++;
			Swap(&a[prev], &a[cur]);
		}
		cur++;
	}
	Swap(&a[key], &a[prev]);
	//关键值下标赋值给key,作为区间分割点
	key = prev;
	//子区间递归
	//[left, key-1] key [key+1, right]
	PointQuickSort(a, left, key - 1);
	PointQuickSort(a, key + 1, right);
}

快速排序(非递归)

基本思想:循环每走一次,相当于之前的一次递归。取栈顶区间(两个元素),单趟排序,先右后左子区间入栈,其他的和霍尔快排一致,整个递归过程类似二叉树的前序遍历。

这里调用的是之前文章里栈的代码,数据结构--栈和队列-CSDN博客

代码演示:

cpp 复制代码
//这里调用的是之前文章里栈的代码
void HoareQuickSortNonR(int* a, int left, int right)
{
	//使用栈模拟实现递归
	Stack st;
	stackInit(&st);
	stackPush(&st, right);
	stackPush(&st, left);
	while (!stackIsEmpty(&st))
	{
		//从队列中获取区间
		int lefti = stackTop(&st);
		stackPop(&st);
		int righti = stackTop(&st);
		stackPop(&st);


		//区间为1个数或不存在时退出
		if (lefti >= righti)
		{
			return;
		}
		//三数取中,交换到最左端
		int mid = GetMid(a, lefti, righti);
		Swap(&a[lefti], &a[mid]);

		//单趟
		int key = lefti;
		int begin = lefti;
		int end = righti;

		while (begin < end)
		{
			//右边找小
			while (begin < end && a[end] >= a[key])
			{
				--end;
			}
			//左边找大
			while (begin < end && a[begin] <= a[key])
			{
				++begin;
			}
			Swap(&a[begin], &a[end]);
		}
		Swap(&a[key], &a[begin]);
		//关键值下标赋值给key,作为区间分割点
		key = begin;

		if (key + 1 < righti)
		{
			stackPush(&st, righti);
			stackPush(&st, key + 1);
		}
		if (key - 1 > lefti)
		{
			stackPush(&st, key - 1);
			stackPush(&st, lefti);
		}
	}
}

快速排序特性总结

1.快速排序正题的综合性能和使用场景都是比较好的,所以才敢叫快速排序。

2.时间复杂度O(N^2 ~ N*logN)。快排的时间复杂度随需要排序的数组的有序程度而变化。

3.空间复杂度:O(logN):开辟了LogN的函数栈帧。

4.稳定性:不稳定。

归并排序

基本思想:

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

递归法代码思路讲解:

归并排序的第一步是先开辟一个与排序数组空间大小相等的数组,用于合并两区间的数组,并拷贝回原数组,从而实现了归并排序。为了让代码逻辑清晰可见,我选择在子函数中进行递归操作,这样不仅可以提前确定好函数的参数,而且数组也只需要开辟一次即可。归并与快排的递归逻辑不同,快排类似于二叉树的前序遍历,而归并排序类似二叉树的后序遍历,因此要符合左右根的顺序,先递归后执行操作。在子函数中,首先我们需要定义一个mid来分割数组区间,然后进行递归,这里要注意区间需要分割成[begin, mid] [mid+1, end],不可以分割成[begin, mid-1]和[mid, end],原因是定义为后者会导致栈溢出。

我们通过一个简单的例子来看一下如果我们需要排序的数组中有十个元素,那么begin=0,end=9,mid=(0+9)/2=4,按后者来算,区间分为[0, 3] [4, 9],再往下分,左区间就是[0,0] [1,3],左区间只剩一个数,我们选择返回,右区间继续,分为[1,1] [2,3],左区间返回,有区间继续分[2,1] [2,3],你会发现这里产生了一个不存在的区间和母区间,导致递归无法结束,造成栈溢出,因此后者方法行不通,各位可以尝试一下前者,我们继续。

接下来就是合并数组操作,这部分比较简单,在C语言题目中各位可能就见到过,所以这里就不再详细讲述了,唯一要注意的是我们为tmp数组定义的下标 i 是从begin开始走的,因为拷贝操作是有对应空间位置的,这里不能随意设置起始位置。

最后memcpy函数是string.h头文件中的一个函数,如果想详细了解可以看这篇博客:C语言中内存函数的使用-CSDN博客

代码演示:

cpp 复制代码
//归并排序子函数
void _MergeSort(int* a, int* tmp, int begin, int end)
{
	//区间内元素个数为1时退出
	if (begin == end)
	{
		return;
	}

	int mid = (begin + end) / 2;
	//优先递归子区间:[begin, mid] [mid+1, end] (类似后序遍历)
	_MergeSort(a, tmp, begin, mid);
	_MergeSort(a, tmp, mid+1, end);

	//合并数组
	int begin1 = begin, end1 = mid;
	int begin2 = mid + 1, end2 = end;
	//保持tmp数组和a数组元素位置一致
	int i = begin;
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (a[begin1] < a[begin2])
		{
			tmp[i++] = a[begin1++];
		}
		else
		{
			tmp[i++] = a[begin2++];
		}
	}
	//将剩余数组元素,补充到tmp数组尾部
	while (begin1 <= end1)
	{
		tmp[i++] = a[begin1++];
	}
	while (begin2 <= end2)
	{
		tmp[i++] = a[begin2++];
	}

	//拷贝函数
	memcpy(a + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}
//归并排序
void MergeSort(int* a, int sz)
{
	//提前申请数组,为子函数拷贝使用
	int* tmp = (int*)malloc(sizeof(int) * sz);
	if (tmp == NULL)
	{
		perror("malloc fail!");
		return;
	}
	//子函数实现部分功能
	_MergeSort(a, tmp, 0, sz - 1);

	//释放数组空间
	free(tmp);
	tmp = NULL;
}

非递归法代码思路讲解:

归并排序的非递归也可以用栈来实现,但是由于其逻辑类似二叉树的后序遍历,而非前序遍历,因此效率并不高,所以我们另寻他法。我们的思路同样是从一 一归并向后递增,所以我们创建一个gap,起始为1,通过每次循环gap都增长为原来的2倍,直到gap不再小于sz,循环结束,外部框架就写好了,内部与递归类似,但是需要把两组begin和end的值稍作修改,我们通过for循环让其内部数据两两一组进行排序,起始的end是begin+gap-1,gap-1是因为gap是区间内数据个数,而这个区间内的最后一个数据下标是gap-1。接下来的操作与上面基本一致,个别地方稍作变量代换。

还有一个地方需要注意:由于我们设置的gap<sz,但是2个gap大小的空间排序可能会导致数组越界,因此我们需要确保归并区间存在,这里我们可以把需要调整的区间分为两部分,一部分是需要归并的第二区间不存在,这种直接跳出即可;另一部分是第二区间的右区间不存在,这种需要修改右区间范围。

代码演示:

cpp 复制代码
void MergeSortNonR(int* a, int sz)
{
	//提前申请数组,为子函数拷贝使用
	int* tmp = (int*)malloc(sizeof(int) * sz);
	if (tmp == NULL)
	{
		perror("malloc fail!");
		return;
	}
	//需要归并的数据的数据个数,从1开始2倍递增。
	int gap = 1;
	while (gap < sz)
	{
		for (int j = 0; j < sz; j += 2 * gap)
		{
			//合并数组
			int begin1 = j, end1 = j + gap - 1;
			int begin2 = j + gap, end2 = j + gap * 2 - 1;

			//确保归并区间存在
			if (begin2 >= sz)//需要归并的第二区间不存在,直接跳出
			{
				break;
			}
			if (end2 >= sz)	//第二区间的右区间不存在,修改右区间范围
			{
				end2 = sz - 1;
			}

			//保持tmp数组和a数组元素位置一致
			int i = j;
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] < a[begin2])
				{
					tmp[i++] = a[begin1++];
				}
				else
				{
					tmp[i++] = a[begin2++];
				}
			}
			//将剩余数组元素,补充到tmp数组尾部
			while (begin1 <= end1)
			{
				tmp[i++] = a[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[i++] = a[begin2++];
			}
			//拷贝函数
			memcpy(a + j, tmp + j, (end2 - j + 1) * sizeof(int));
		}
		gap *= 2;
	}

	//释放数组空间
	free(tmp);
	tmp = NULL;
}

归并排序特性总结

1.归并排序的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。

2.时间复杂度O(N*logN)。

3.空间复杂度O(N)。

4.稳定性:稳定。

下面我们听过一组测试用例来检验一下各种排序的效率:

随机生成十万个数,进行排序,测算时间,单位ms。

cpp 复制代码
//复杂度效率测试
void TestOP()
{
	srand((unsigned int)time(NULL));
	const int N = 100000;
	int* a1 = (int*)malloc(sizeof(int) * N);
	int* a2 = (int*)malloc(sizeof(int) * N);
	int* a3 = (int*)malloc(sizeof(int) * N);
	int* a4 = (int*)malloc(sizeof(int) * N);
	int* a5 = (int*)malloc(sizeof(int) * N);
	int* a6 = (int*)malloc(sizeof(int) * N);
	int* a7 = (int*)malloc(sizeof(int) * N);

	for (int i = 0; i < N; ++i)
	{
		a1[i] = rand()+i;
		a2[i] = a1[i];
		a3[i] = a1[i];
		a4[i] = a1[i];
		a5[i] = a1[i];
		a6[i] = a1[i];
		a7[i] = a1[i];
	}

	int begin1 = clock();
	InsertSort(a1, N);
	int end1 = clock();

	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();

	int begin3 = clock();
	//SingleSelectSort(a3, N);
	DoubleSelectSort(a3, N);
	int end3 = clock();

	int begin4 = clock();
	HoareQuickSort(a4, 0, N - 1);
	int end4 = clock();

	int begin5 = clock();
	HeapSort(a5, N);
	int end5 = clock();

	int begin6 = clock();
	MergeSort(a6, N);
	int end6 = clock();

	int begin7 = clock();
	BubbleSort(a7, N);
	int end7 = clock();

	printf("InsertSort:%d\n", end1 - begin1);
	printf("ShellSort:%d\n", end2 - begin2);
	printf("SlectSort:%d\n", end3 - begin3);
	printf("QuickSort:%d\n", end4 - begin4);
	printf("HeapSort:%d\n", end5 - begin5);
	printf("MergeSort:%d\n", end6 - begin6);
	printf("BubbleSort:%d\n", end7 - begin7);

	free(a1);
	free(a2);
	free(a3);
	free(a4);
	free(a5);
	free(a6);
	free(a7);
}

总结:

从图中我们可以看出希尔排序、快速排序、堆排序和归并排序是第一梯队的排序,第二梯队中直接插入排序略好,直接选择排序和冒泡排序基本无用。

排序拓展

计数排序

基本思想:计数排序又称鸽巢原理,是对哈希直接定址法的变形应用。

操作步骤:

1.统计相同元素的出现次数。

2.根据统计的结果将序列回收到原序列中
代码演示:

cpp 复制代码
//计数排序
void CountSort(int* a, int sz)
{
	//寻找最大值和最小值,用于确定区间范围和后续计数
	int min = a[0], max = a[0];
	for (int i = 0; i < sz; i++)
	{
		if (min > a[i])
		{
			min = a[i];
		}
		if (max < a[i])
		{
			max = a[i];
		}
	}
	//范围
	int range = max - min + 1;
	//calloc = malloc + memset
	int* count = calloc(range, sizeof(int));
	if (count == NULL)
	{
		perror("calloc fail!");
		return;
	}
	//计数
	for (int i = 0; i < sz; i++)
	{
		count[a[i] - min]++;
	}
	//排序
	int j = 0;
	for (int i = 0; i < sz; i++)
	{
		while (count[i]--)
		{
			a[j++] = i + min;
		}
	}

	//释放开辟的数组
	free(count);
}

计数排序的特性总结:

  1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
  2. 时间复杂度: O(MAX(N, 范围 ))
  3. 空间复杂度: O( 范围 )
  4. 稳定性:稳定

排序算法复杂度及稳定性汇总

典型例题

  1. 快速排序算法是基于( )的一个排序算法。
    A 分治法
    B 贪心法
    C 递归法
    D 动态规划法
    解:A
  2. 对记录( 54 , 38 , 96 , 23 , 15 , 72 , 60 , 45 , 83 )进行从小到大的直接插入排序时,当把第 8 个记录 45插入到有序表时,为找到插入位置需比较( )次?(采用从后往前比较)
    A 3
    B 4
    C 5
    D 6
    解:C
  3. 以下排序方式中占用 O ( n )辅助存储空间的是
    A 选择排序
    B 快速排序
    C 堆排序
    D 归并排序
    解:D
  4. 下列排序算法中稳定且时间复杂度为 O ( n2 ) 的是( )
    A 快速排序
    B 冒泡排序
    C 直接选择排序
    D 归并排序
    解:B
  5. 关于排序,下面说法不正确的是
    A 快排时间复杂度为 O ( N * logN ) ,空间复杂度为 O ( logN )
    B 归并排序是一种稳定的排序 , 堆排序和快排均不稳定
    C 序列基本有序时,快排退化成冒泡排序,直接插入排序最快
    D 归并排序空间复杂度为 O ( N ), 堆排序空间复杂度的为 O ( logN )
    解:D
  6. 下列排序法中,最坏情况下时间复杂度最小的是( )
    A 堆排序
    B 快速排序
    C 希尔排序
    D 冒泡排序
    解:A
  7. 设一组初始记录关键字序列为 ( 65 , 56 , 72 , 99 , 86 , 25 , 34 , 66 ) ,则以第一个关键字 65 为基准而得到的一趟快
    速排序结果是()
    A 34 , 56 , 25 , 65 , 86 , 99 , 72 , 66
    B 25 , 34 , 56 , 65 , 99 , 86 , 72 , 66
    C 34 , 56 , 25 , 65 , 66 , 99 , 86 , 72
    D 34 , 56 , 25 , 65 , 99 , 86 , 72 , 66
    解:A
相关推荐
CV工程师小林41 分钟前
【算法】BFS 系列之边权为 1 的最短路问题
数据结构·c++·算法·leetcode·宽度优先
Navigator_Z1 小时前
数据结构C //线性表(链表)ADT结构及相关函数
c语言·数据结构·算法·链表
还听珊瑚海吗1 小时前
数据结构—栈和队列
数据结构
Aic山鱼1 小时前
【如何高效学习数据结构:构建编程的坚实基石】
数据结构·学习·算法
sjsjs112 小时前
【数据结构-一维差分】力扣1893. 检查是否区域内所有整数都被覆盖
数据结构·算法·leetcode
Lzc7742 小时前
堆+堆排序+topK问题
数据结构·
cleveryuoyuo2 小时前
二叉树的链式结构和递归程序的递归流程图
c语言·数据结构·流程图
湫兮之风3 小时前
c++:tinyxml2如何存储二叉树
开发语言·数据结构·c++
zhyhgx3 小时前
【算法专场】分治(上)
数据结构·算法
Joeysoda4 小时前
Java数据结构 时间复杂度和空间复杂度
java·开发语言·jvm·数据结构·学习·算法