数据结构之各种排序方法


下面的排序方法都默认排升序

插入排序

直接插入排序

基本思想

将待插入的数据依次插入一个有序序列中,每次插入后都要保证序列仍是有序的

现实模型:扑克牌摸牌理牌的过程

一趟排序的过程:将待插入的数据k从序列的末尾元素ai逐个往前比较,若k>ai则将k放在ai后面,比较停止,一趟排序结束;若k<ai则继续和前一个数比较,直至遇到上一种情况,或者已经到序列头部前面没有元素了,此时直接插入在开头即可。

由于序列一般是数组,所以插入在序列的头部或中间,需要将后面的元素向后移

示例:image-20230106113254840

这里定义的end是待插入数据的前一个元素的下标。若待插入数据小于前一个元素,则继续和再前一个元素比较,同时end–;若大于,则直接插入在end+1的位置

代码实现
void InsertSort(int* a, int n)
{
	//共n个元素,需要进行n-1趟排序
	for (int i = 0; i < n-1; i++)
	{
		//一趟排序
		int end = i ;						//代表有序序列的最后一个元素
		int tmp = a[end + 1];		//将所要插入序列的数据保存一下,用于比较和最后的插入
		while (end>=0)
		{
			//如果插入的数据小于当前位置的元素,则将当前位置的元素往后移一位,同时继续判断前一个元素
			if (tmp < a[end])
			{
				a[end + 1] = a[end];
				end--;
			}
			else
			{
				break;
			}
		}
		//此时end+1所在位置即为数据应该插入的位置
		a[end + 1] = tmp;
	}
}

说明:

  1. 跳出while循环只有两种情况:要么是break,说明找到了插入元素大于当前比较元素;要么就是序列到头了,说明插入元素最小,应该放在序列开头

以一个数组来看:image-20230106114455592

现在要对这个数组排序。直接插入排序就是先将第一个元素看成一个有序序列,然后依次将第二个、第三个,第……元素插入进这个有序序列。

需要注意的是,因为这是原地排序,所以在每趟插入前需要将end+1这个位置的元素保存起来(也就是即将插入序列的数据),以防因为前面元素向后移而导致数据没了,在代码中具体体现在int tmp=a[end+1]

此外,整个循环(元素插入的次数)的执行次数为n-1次,因为第一个元素不用插入。

注意i的取值,千万不能写成i<n否则会使a[end+1]越界

时间复杂度
  • 最坏:逆序 等差数列求和–O(N2)
  • 最好:顺序有序或接近顺序有序–O(N)

希尔排序

基本思想

希尔排序分为两大步:

  1. 预排序:将序列按一定的间隔gap分为若干组,组内先进行直接插入排序。间隔gap由大到小,预排序多次
  2. 直接插入排序:当间隔小到等于1时,最后进行一次直接插入排序,即可将序列排为有序序列

希尔排序的内核还是直接插入排序,只不过排序思路上进阶了

关于预排序

**为什么gap要由大到小呢?**因为:

gap越大,大的数可以更快的到后面,小的数可以更快的到前面,但此时序列越不接近有序

gap越小,数据跳动越慢,但序列越接近有序

所以先大后下,使得序列更快的接近有序。当gap>1时都是预排序,当gap=1时,序列已经接近有序了,此时再直接插入排序就会很快排好序列了

gap的取值

gap的初始值为待排序数据个数n

通常gap有两种取值方式:

  1. gap=gap / 2
  2. gap=gap / 3 + 1

一般是第二种用的比较多,这样预排序的次数不会太多。+1是为了使gap最终为1

示例:

image-20230107105846267

最后,gap=2/3+1=1,进行最后一次直接插入排序即可得到有序序列了

代码实现
void ShellSort(int* a, int n)
{
	int gap = n;				//先定义一下分组间隔
	while (gap>1)	
	{
		gap = gap / 3 + 1;		//一开始组间距比较大,越往后的预排序组间距越小,到最后为1。当gap=1时,即为最后整个序列的直接插入排序
		
		//对于预排序来说,这里是多组同时进行预排序
		//就是说,第一组先排一个,然后第二组再排一个,每一组都排完一           个后,再回过头来排第一组
		for (int i = 0; i < n - gap; i++)
		{
			//一趟排序
			int end = i;		   //代表有序序列的最后一个元素
			int tmp = a[end +gap]//将所要插入序列的数据保存一下
			while (end >= 0)
			{
				//如果插入的数据小于当前位置的元素,则将当前位置的                  元素往后移gap位,同时继续判断前gap个元素
				if (tmp < a[end])
				{
					a[end + gap] = a[end];
					end-=gap;
				}
				else
				{
					break;
				}
			}
			//此时end+gap所在位置即为数据应该插入的位置
			a[end + gap] = tmp;
		}
	}
}

说明:

  • 上面代码的预排序是多组同时进行的。就是说,第一组先排一个,然后第二组再排一个,每一组都排完一个后,再回过头来排第一组
  • 注意循环判断条件,一定是gap>1
  • for循环的结束条件是i<n-gap。我们知道三个数只需要直接插入排序2次就可以有序了。这样对比去理解
时间复杂度

希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算

希尔排序的时间复杂度在最开始排序和快结束排序时,我们可以大致得出为O(N),但中间排序的复杂度很难得到,因此希尔排序的时间复杂度很难得到。这里给个大致的结论为O(N1.3)

最开始时,每组数据少,所以排起来简单

快结束时,序列已经接近有序,所以也好排

选择排序

直接选择排序

基本思想

原思想:遍历序列,每次找出最大的元素或者最小的元素放在序列的末尾或开头。

现实模型:体育课体育老师按高矮排队

这里我们讲一下进阶的思想:遍历序列,每次同时找出最大元素和最小元素放在序列的末尾和开头

代码实现
//交换数据
void Swap(int* i, int* j)
{
	int tmp = *i;
	*i = *j;
	*j = tmp;
}

void SelectSort(int* a, int n)
{
	int begin = 0;		//表示序列的开头
	int end = n - 1;	//表示序列的末尾

	while (begin < end)
	{
		//一趟排序
		int mini = begin;	//表示本趟排序中最小元素的下标
		int maxi = begin;//表示本趟排序中最大元素的下标

		for (int i = begin + 1;i<=end;i++)
		{
			//找出序列中最大的元素的下标
			if (a[i] > a[maxi])
			{
				maxi=i;
			}
			//找出序列中最小的元素下标
			if (a[i] < a[mini])
			{
				mini=i;
			}
		}
		
		//将本趟最大的元素换到序列的末尾
		Swap(&a[end], &a[maxi]);
		//修正一下:防止序列末尾的元素恰巧是本趟最小的元素而影响下面的交换
		if (mini == end)
		{
			mini = maxi;
		}
		//将本趟最小的元素换到序列的开头
		Swap(&a[begin], &a[mini]);

		begin++;
		end--;
	}
}

说明:

  • 定义end和begin两个下标用来每趟排序后放元素,每趟放好后end–,begin++。当begin>=end时,说明排序完成
  • 注意每趟排序保存的是最大元素和最小元素的下标!!!
  • 注意代码末尾的修正
时间复杂度

普通的和进阶的直接选择排序任何情况下时间复杂度都是O(N2),包括有序情况下

但直接选择排序还是有区别的。两个都是等差数列求和,不同的是,普通的公差为1,进阶的公差为2

堆排序

基本思想

先将序列建为大堆,再重复下面的步骤:

  1. 将堆顶元素与序列末尾元素交换位置
  2. 序列长度-1,即将新末尾元素踢出待排序序列
  3. 将新堆顶元素向下调整,使序列重新成为大堆
代码实现
//交换数据
void Swap(int* i, int* j)
{
	int tmp = *i;
	*i = *j;
	*j = tmp;
}

//堆的向下调整
//建大堆。就是将父亲和左右孩子中较大的孩子比较,若小于较大的则交换
void AdjustDown(int* a, int n, int parent)
{
	//假设左孩子大
	int child = parent * 2 + 1;
	while (child<n)
	{
		//如果右孩子存在且大于左孩子
		if (child + 1 < n && a[child + 1] > a[child])
		{
			child++;
		}
		//如果父亲小于孩子
		if (a[parent] < a[child])
		{
			Swap(&a[parent], &a[child]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

void HeapSort(int* a,int n)
{
	//建大堆
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, n, i);
	}

	int end = n - 1;	//记录序列的末尾下标
	while (end > 0)
	{
		//交换堆顶与序列末尾的元素
		Swap(&a[0], &a[end]);
		//序列长度减一
		end--;
		//对堆重新向下调整
		AdjustDown(a, end + 1, 0);
	}
}
时间复杂度

堆排序与直接选择排序同为选择排序。但区别在于选择方式不同,堆排序借助堆,使得效率提升到了O(logN),在加上对n个数排序,所以最终时间复杂度为O(N*logN)

交换排序

冒泡排序

基本思想

从序列的头部开始,第一个元素和第二个元素比较,若第一个元素比第二个元素大则交换,然后它变成第二个元素再和第三个元素比较;若小则不交换,但还是继续第二个元素和第三个元素比较……最终可以将最大的元素放在序列的最后。这就是一趟排序。一共要进行n-1趟

第一趟n个数要比较n-1次,第二趟n-1个数要比较n-2次……

想象序列开头是湖底,序列末尾是湖面,那么整个排序的过程就像泡泡上升的过程

当然冒泡排序也可以进行优化:原本的代码是必须要进行n-1趟冒泡。但有可能序列原本就是有序的,但也只能进行n-1趟冒泡。这时我们可以加个检验变量,判断这一趟冒泡,有没有发生过数据交换,如果没有说明已经排序好了,后面不需要再进行冒泡了,这时直接break就可以了

但这种优化意义不大。除非是序列开头的一段是有序的,才有点用。否则中间或者后面一段有序啥用没有

代码实现
void BubbleSort(int* a, int n)
{
	//n个数据排序,需要n-1趟
	for (int i = 0; i < n; i++)
	{
		//一趟排序
		int exchange = 0;	//用来检验此趟排序是否发生交换,若没发生说明序列已经有序,没必要再进行后续的排序
		for (int j = 0; j < n - i-1; j++)
		{	
			if (a[j] > a[j + 1])
			{
				Swap(&a[j], &a[j + 1]);
				exchange = 1;
			}
		}
		if (exchange == 0)
		{
			break;
		}
	}
}
时间复杂度

等差数列求和–O(N2)

快速排序

快速排序是由霍尔提出的一种二叉树结构的交换排序方法,是一个前序过程。

基本思想

任取待排序元素序列中的某元素作为基准值key,按照该基准值将待排序集合分割成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列分别重复上述分割过程得到新的左右子序列,直到所有元素都排列在相应位置上为止

这里提出两个问题:

  1. 取哪个元素作为基准值key
  2. 如何使左子序列全部小于key,右子序列全部大于key
思想解释

先针对上面的两个问题:

  1. 通常选取序列的第一个(也可以是最后一个)元素作为key

  2. 设置左右下标分别指向序列的开头和末尾。右下标先走,遇到比key小的元素停下;左下标再走,遇到比key大的停下,然后交换左右下标指向的元素。直至左右下标相遇,此时将当前指向的元素与key指向的交换即可。

    需要注意的是:设第一个元素为key,则右下标先走;若设最后一个元素为key,则左下标先走

    解释:以设第一个元素为key为例

    为了保证相遇位置比就一定比key要小呢?

    因为相遇就两种情况:

    一是R停止,L遇到R,此时相遇位置就是R停止的位置,而R是停在比key小的上面的。

    第二种就是L停止,R遇到L,此时相遇位置就是L停止的位置,而此时的L一定是与上一个R停止的位置换过元素了,此时L停止的位置也一定是小于key的。就算我L一直没动过,那最终也只是key自己与自己换

    那我非要设第一个元素为key,还有左下标先走呢?其实也可以,只不过有很多细节需要限制,比较麻烦

此外单趟排序的作用:

  1. 分割出了左右区间,左区间比key小,右区间比key大
  2. key已经落到它的正确位置(即排序后的最终位置)

第一趟排序完,剩下的就是要将左右子序列变成有序即可。那么要使左右区间有序就是原问题的子问题了,然后左右区间还可以继续分子问题。所以这是一个二叉树递归

image-20221217141945350

代码实现
void QuickSort(int* a, int begin, int end)
{
	//当区间中只有一个数据或没有数据时,则不用排
	if (begin >= end)
	{
		return;
	}

	//一趟快排
	int left = begin, right = end;	//本趟所排序列的首末下标
	int keyi = left;
	while (left < right)
	{
		//先右下标走。找比key小的
		while (left < right && a[right] >= a[keyi])
		{
			right--;	//进入循环中说明没找到,继续往前找
		}
		//再左下标走。找比key大的
		while (left < right && a[left] <= a[keyi])
		{
			left++;	//进入循环说明没找到,继续往后找
		}

		Swap(&a[right], &a[left]);
	}

	//此时left与right相遇
	Swap(&a[left], &a[keyi]);
	keyi = left;		//交换后的关键字的新下标

	//对关键字的左右区间分别快排
	//左区间
	QuickSort(a, begin, keyi - 1);
	//右区间
	QuickSort(a, keyi+1, end);
}

说明:

  • 这里基准值没有选具体的某个元素,而是选取的某个元素的下标。这是为了在Swap(&a[left], &a[keyi]);交换时,可以实现数组的改变。

    假如基准值选的是某个具体的元素,即int key = a[left],那么在交换时Swap(&a[left],&key)交换的仅仅是局部变量,没有对数组改变

  • image-20230109200422665

    这里加left<right和加上=号的目的是:防止越界和序列中有与key一样的值而造成的死循环

  • 在key交换后,一定要重置key的新下标。以此为基础才能划分左右子序列keyi = left;

时间复杂度

快排的递归过程是一个二叉树

理想状态下(也就是个满二叉树,树两侧孩子数量差不多),快排差不多要进行logN趟,而每趟的时间复杂度为O(N),所以最终是O(N*logN)

为什么每趟的时间复杂度为O(N)呢?

虽然代码中是两层循环,但实际只遍历了序列一遍

image-20230109153002132

但在最坏情况下(也就是二叉树只有一边有孩子,另一边没有。如序列原本就有序时)这时候快排要进行N趟,每趟O(N),所以最终是O(N2)

image-20230109153014818

空间复杂度:O(logN)。因为快排需要建立LlogN层的函数栈帧,每层栈帧的空间消耗为1,所以最终空间复杂度为logN

快排的优化
优化一:三数取中

针对在计算时间复杂度时的最坏情况,可以通过“三数取中”的方法来优化

具体操作:

  • 原本key要么第一个元素,要么最后一个元素。这样在有序的情况下会使效率低很多。若我们想在有序的情况下也变成理想状态的话,可以选取序列中间的元素作为基准值,这样得到的左右子序列长度就差不多了
  • 所以我们分别用begin、mid、end三个下标指向序列的开头、中间、末尾,取出这三个下标所指元素的中间大小的元素作为基准值。
  • 为了原代码不变,我们可以将选出来的基准值换到序列的开头即可

具体代码:

//三数取中
int GetMidIndex(int* a, int begin, int end)
{
	int mid = (begin + end )/ 2;
	if (a[begin] < a[mid])
	{
		if (a[mid] < a[end])
		{
			return mid;
		}
		else if (a[begin] > a[end])
		{
			return begin;
		}
		else
		{
			return end;
		}
	}
	else // a[begin] > a[mid]
	{
		if (a[mid] > a[end])
		{
			return mid;
		}
		else if (a[begin] < a[end])
		{
			return begin;
		}
		else
		{
			return end;
		}
	}
}

void QuickSort(int* a, int begin, int end)
{
	//当区间中只有一个数据或没有数据时,则不用排
	if (begin >= end)
	{
		return;
	}

	//三数取中优化
	int mid = GetMidIndex(a, begin, end);
	Swap(&a[mid], &a[begin]);

	//一趟快排
	int left = begin, right = end;	//本趟所排序列的首末下标
	int keyi = left;
	while (left < right)
	{
		//先右下标走。找比key小的
		while (left < right && a[right] >= a[keyi])
		{
			right--;	//进入循环中说明没找到,继续往前找
		}
		//再左下标走。找比key大的
		while (left < right && a[left] <= a[keyi])
		{
			left++;	//进入循环说明没找到,继续往后找
		}

		Swap(&a[right], &a[left]);
	}

	//此时left与right相遇
	Swap(&a[left], &a[keyi]);
	keyi = left;		//交换后的关键字的新下标

	//对关键字的左右区间分别快排
	//左区间
	QuickSort(a, begin, keyi - 1);
	//右区间
	QuickSort(a, keyi+1, end);
}

这样一来,快排几乎不会出现最坏情况了。所以快排的时间复杂度可以认为是O(N*logN)

优化二:小区间优化

当快排往下分序列时,越往下序列越来越短,短到序列中只有15个元素了(15只是个泛值,差不多都可以),此时不再用快排排序,而是用直接插入排序。

好处:减少百分之八十五左右的递归调用,从而减少建立栈帧所带来的消耗

解释:假设一个序列中有10个元素,对这10个元素进行快排,子序列也得分出个3、4层。而最后一层递归次数就是整个递归次数的一半,所以少最后的3、4层,可以减少很多递归次数

这个优化很有价值。官方库包括qsort都在用这种小区间优化

这种优化在debug版本下较明显,在release版本下不明显。因为release版本下对于建立栈帧的本身就已经优化许多

具体代码:

void QuickSort(int* a, int begin, int end)
{
	//当区间中只有一个数据或没有数据时,则不用排
	if (begin >= end)
	{
		return;
	}

	//小区间优化
	if ((end-begin+1)<15)
	{
		//注意这里数组千万不能只写a。一定是当前序列的开头
		InsertSort(a + begin, end - begin + 1);
	}
	else
	{
		//三数取中优化
		int mid = GetMidIndex(a, begin, end);
		Swap(&a[mid], &a[begin]);

		//一趟快排
		int left = begin, right = end;	//本趟所排序列的首末下标
		int keyi = left;
		while (left < right)
		{
			//先右下标走。找比key小的
			while (left < right && a[right] >= a[keyi])
			{
				right--;	//进入循环中说明没找到,继续往前找
			}
			//再左下标走。找比key大的
			while (left < right && a[left] <= a[keyi])
			{
				left++;	//进入循环说明没找到,继续往后找
			}

			Swap(&a[right], &a[left]);
		}

		//此时left与right相遇
		Swap(&a[left], &a[keyi]);
		keyi = left;		//交换后的关键字的新下标

		//对关键字的左右区间分别快排
		//左区间
		QuickSort(a, begin, keyi - 1);
		//右区间
		QuickSort(a, keyi + 1, end);
	}
}
单趟排序的不同方法

之前我们讲的快排的单趟的排法,是霍尔提出的。下面我们在讲两个别的方法

挖坑法

思路:

  1. 设第一个元素为基准值key,将这个基准值保存在变量中,同时将这个这一个位置挖个坑

  2. 设左右下标从序列开头和末尾相向而走。右下标先走,遇到比key小的就将其放到坑中,然后这个位置变成新的坑;再左下标走,遇到比key大的,就将其放入坑中,然后这个位置成为新的坑

  3. 重复上述操作,直至左右下标相遇。此时则将key放到坑中即可

    左右下标相遇的地方一定也是坑。因为坑不是在左下标处,就是在右下标处

挖坑法其实是霍尔法的通俗版本,更容易理解

具体代码:

//挖坑法
int SinglePassSort2(int* a, int begin, int end)
{
	//三数取中
	int mid= GetMidIndex(a, begin, end);
	Swap(&a[mid], &a[begin]);

	int left = begin, right = end;		//定义左右下标
	int key = a[left];							//保存基准值
	int hole = left;								//挖坑

	while (left < right)
	{
		//右下标先走,找比key小的
		while (left < right && a[right] >= key)
		{
			right--;
		}
		//右下标找到了,则将该处元素移到坑里
		a[hole] = a[right];
		hole = right;

		//左下标走,找比key大的
		while (left<right && a[left]>key)
		{
			left++;
		}
		//左下标找到了
		a[hole] = a[left];
		hole = left;
	}

	//left和right相遇了。则将key放入坑中即可
	a[hole] = key;
	//返回本趟基准值的下标
	return hole;
}
前后下标法

思路:

  1. 先设第一个元素为基准值key
  2. 定义两个下标,下标prev指向第一个元素,下标cur指向第二个元素
  3. cur先往后走,遇到比key小的,则cur停下
  4. 此时prev先++,再交换prev处和cur处的元素,然后cur再往后走
  5. 当cur走出序列后,则直接将key位置与prev位置的值交换

也就是cur是不管遇到比key大的还是小的,要一直往后走

而prev只有在cur遇到小的时候,才走一步

具体代码:

//前后指针版本
int SinglePassSort3(int* a, int begin, int end)
{
	//三数取中
	int mid = GetMidIndex(a, begin, end);
	Swap(&a[begin], &a[mid]);

	int prev = begin, cur = begin + 1;		//定义前后指针
	int keyi = begin;			//定义关键字下标

	while (cur <= end)
	{
		//当cur遇到比key小的元素则交换。如果prev和cur指向同一个元素则不用交换了
		if (a[cur] < a[keyi] && ++prev != cur)
			Swap(&a[cur], &a[prev]);
		cur++;
	}

	//交换prev和key
	Swap(&a[prev], &a[keyi]);
	//返回本趟基准值的下标
	return prev;
}
快速排序的非递归

凡是递归的程序,都存在一个缺陷:当递归深度太深的话,容易发生栈溢出

所以凡是递归的代码,我们都要能改成非递归的

简单的递归改非递归直接改成循环

复杂的就需要借助栈或队列实现

思路:利用栈实现非递归

  1. 第一次将整个序列的左右区间依次入栈。下面开始进行循环

  2. 将右左区间依次出栈,进行单趟排序。排序完得到左右子序列。当子序列的长度大于1时,则将其的左右区间入栈

    因为栈后进先出,所以左右进,右左出。

    若想模仿上面的递归写法的思路,先左序列后右序列的话,则左序列后进栈

  3. 当栈空时,则结束

具体代码:

//非递归版快速排序
void QuickSortNonR(int* a, int begin, int end)
{
	//创建一个栈
	ST st;
	StackInit(&st);

	//第一次将整个序列的左右区间依次入栈
	StackPush(&st, begin);
	StackPush(&st, end);

	while (!StackEmpty(&st))
	{
		//因为右区间后入栈,所以右区间先出栈
		int right = StackTop(&st);
		StackPop(&st);
		//左区间再出栈
		int left = StackTop(&st);
		StackPop(&st);

		//进行单趟排序
		int keyi = SinglePassSort3(a, left, right);

		//如果右子序列的长度大于1则将右子序列的左右区间入栈
		if (right - keyi > 1)
		{
			StackPush(&st, keyi+1);
			StackPush(&st, right);
		}
		//如果左子序列的长度大于1则左子序列的左右区间入栈
		if (keyi - left > 1)
		{
			StackPush(&st, left);
			StackPush(&st, keyi-1);
		}
	}

	StackDestroy(&st);
}

说明:

  1. 一次出栈两个数字,分别是序列的头尾

  2. 代码最后的两个if的顺序谁在前谁在后都无所谓。按照上述代码的话,右子序列的区间先入栈,左子序列的区间后入栈。那等后面出栈,单趟排序时就是左子序列的左右区间先出,也就是左子序列先排序

但其实用队列也能实现非递归

用栈的话,就是整个过程和递归是一样的,先处理左序列,再处理右序列

用队列的话,就是一层一层的处理

归并排序

基本思想

要想整个序列有序:可以将整个序列分成左右两个子序列,使这两个子序列分别有序,然后再将两个子序列合并为一个有序序列即可

使子序列有序可以继续套用这个思路,所以归并排序是一个递归排序,递归过程是一个二叉树结构,是一个后序过程。

步骤图:image-20230111125501219

注意:

  1. 虽然说是将整个序列分成左右两个子序列,且图上也是这么画的,但这只是一个理解过程,实际还是在原数组中操作的,借助左右区间就可以实现分解
  2. 两个有序子序列合并为一个有序序列是需要借助额外空间的,在额外空间中合并完后拷贝回去即可。

代码实现

void _MergeSort(int* a, int begin, int end, int* tmp)
{
	//当序列中只有一个数或没有数则返回
	if (begin >= end)
		return;

	int mid = (begin + end) / 2;

	//左右子序列分别排序
	_MergeSort(a, begin, mid, tmp);
	_MergeSort(a, mid + 1, end, tmp);

	//两个子序列进行合并
	//定义两个子序列的左右区间
	int begin1 = begin, end1 = mid;
	int begin2 = mid + 1, end2 = end;

	int i = begin1;		//额外空间中的下标
	//两个序列中都有数据
	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++];
	}

	//每次子序列合并完则拷贝至原数组
	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;
}

说明:

  1. 额外空间只需要开辟一次即可,所以不能放在递归函数中。额外空间的长度即为待排序列的长度

时间复杂度

这里的递归过程是一个二叉树结构,共logN层,每一层循环N趟,所以时间复杂度为O(N*logN)

空间复杂度是O(N)

其实原本空间复杂度为O(N+logN)

logN是递归建立栈帧的空间消耗。层栈帧的空间消耗为1,所以最终空间复杂度为logN。但由于logN比起N可以忽略,所以最终为O(N)

归并排序的非递归

思路:设定一个rangeN从1开始,表示归并时每个序列的数据个数,因为1个数可以认为是有序的。每归并一次,rangeN就×2

这是原数组的变化情况

这是原数组的子序列在额外空间中合并,然后再从额外空间中拷贝回来

image-20230111135902777

代码实现:

void MergeSortNonR(int* a, int n)
{
	//开辟额外空间
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail");
		exit(-1);
	}

	int rangeN = 1;		//设定一开始序列中数据个数
	//当rangeN大于等于序列数据总数,则说明排好了
	while (rangeN < n)
	{
		//进行当前rangeN下的子序列合并
		//i表示进行合并的两个子序列中左子序列的左区间
		//i+=2*rangeN表示一次跳过两个子序列,来到下一个子序列进行合并
		for (int i = 0; i < n; i += 2 * rangeN)
		{
			//两个子序列进行合并
	        //定义两个子序列的左右区间
			int begin1 = i, end1 = i + rangeN - 1;
			int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;

			//由于序列长度并不一定是2^n,所以会存在越界的情况
			//下面对越界情况进行修正
			//end1越界,说明只有1个左子序列,且序列长度小于rangeN,那么就没必要进行合并了
			if (end1 >= n)
			{
				break;
			}
			else if (begin2 >= n)//begin2越界,说明只有一个左子序列,且序列长度恰好等于rangeN,那么也没必要进行合并了
			{
				break;
			}
			else if (end2 >= n)//end2越界,说明左右两个子序列都有,但右子序列的长度小于rangeN,所以要对end2进行修正后将两个序列合并
			{
				//将end2修正到序列末尾
				end2 = n - 1;
			}

			int j =i;		//额外空间中的下标
			//两个序列中都有数据
			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;
	}
}

说明:

  1. 这里有两种拷贝方式:注意是同一个rangeN下,两种不同的拷贝方式

    1. 每次两个子序列合并完则拷贝至原数组,即归并一部分就拷贝一部分
    2. 等所有子序列都合并完了再拷贝至原数组
  2. 关于区间越界的修正

    下面是三种越界情况:

    image-20230111173311605

    关于越界的修正有两种方式:

    1. 对于情况1和情况2,由于只有一个序列,那么可以不用合并,直接break即可;对于情况3,由于有两个序列,是需要进行合并的,但需要对右子序列的右区间做一下修正

    2. 对于情况1和情况2不break,也是像情况3一样做修正,注意修正时需要让右子序列的end2大于begin2,即右子序列没有没有数据。然后到下面进行判断,然后自然结束本次序列归并

      if(end1>=n)
      {
          end1=n-1;
          begin2=n+1;
          end2=n;
          //begin2和end2可以随便取,只要保证begin2大于end2即区间不存在即可
      }
      else if(begin2>=n)
      {
          begin2=n+1;
          end2=n;
      }
      else if(end2>=n)
      {
          end2=n-1;
      }
      

    注意:

    当选用修正方法a时,拷贝时只能用拷贝方法a。

    解释:

    用拷贝方法a是你合并的哪些区间,就拷贝哪些区间;而拷贝方法b是将整个序列都拷贝回去。假如遇到情况1和2,那么拷贝方法b会将额外空间中没有进行合并的那块区间也拷贝回去,而那块区间放的是随机值

    选用修正方法b时,两种拷贝方法都可以用

    两种修正方法我更倾向于用修正方法a,既然只有左子序列,那就直接跳出即可,没必要在去下面进行判断然后正常结束循环

  3. 总结:更倾向于使用修正方法a和拷贝方法a

排序的稳定性

稳定性:保证相同的数在排完序后相对顺序不变,那么此排序就是稳定的

注意:所谓稳定,是你努努力就可以保证该排序算法是稳定的;所谓不稳定,就是你怎么努力都不能保证该排序算法是稳定的

如果你执意想让一个原本稳定的排序算法不稳定那是很轻松的,有时改个符号即可

  • 直接插入排序:稳定

    小于的数才到前面,大于等于的数就在后面,这样就可以保证稳定

  • 希尔排序:不稳定

    如果预排序时,相同的数据分到了不同的组,那么就不能保证稳定性了

  • 直接选择排序:不稳定

    我们看普通版本的直接选择排序

    image-20230112150341417

    看这种情况下,当max和end交换了,那么4的稳定性就不能保证了

  • 堆排序:不稳定

    image-20230111174845828

    这种情况下,8的稳定性就不能保证了

  • 冒泡排序:稳定

    只有大于后面的数时才交换,这样就可以保证稳定性了

  • 快速排序:不稳定

    image-20230112150949147

    这种情况下,4的稳定性就不能保证了

  • 归并排序:稳定的

    只要两个子序列合并时,遇到相同的数,左子序列先合并,就可以保证稳定性了

总结比较

上述排序都是内排序(在内存中排序),但归并也可以是外排序(在磁盘中排序。当数据太大时,内存中放不下)

我们将同时间复杂度的进行比较。

直接插入排序、直接选择排序、冒泡排序

  • 它们三者在最坏情况下的时间复杂度都是O(N2)
  • 对于直接插入排序:它的适应性很强,最坏的情况下要全挪动;但有时候只要挪动一半的序列;最好的情况下,不用挪动序列。所以在序列有序或接近有序的情况下,它的时间复杂度为O(N)
  • 对于直接选择排序:适应性差,始终O(N2)。但经过优化,一次选两个,还行
  • 对于冒泡排序:癌症晚期了,优化也没用。除非是从头开始有序,否则优化也没用

总结:直接插入>直接选择>冒泡

希尔排序、堆排序、快速排序、归并排序

当快速排序进过三数取中的优化后,除了希尔排序外的排序的时间复杂度都是O(N*logN),希尔排序为O(N1.3)。时间复杂度上四种排序差不多

空间复杂度上,希尔和堆排是O(1),快排是O(logN),归并排序是O(N)

总的来说四种方法没有谁好谁坏,都可以。

快速排序和归并排序

两种都是递归排序(这里不谈两种的非递归),递归过程都是二叉树结构

不同的是:

  • 快排的过程是一个前序二叉树,先确定key,然后分割出左子序列,右子序列,再解决左子树、右子树
  • 归并的过程是一个后序二叉树,先搞定左子树、右子树,再合并搞定根

直接插入排序和其他排序

当序列有序或接近有序时,直接插入排序是最快的,时间复杂度为O(N)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值