【数据结构】万字详解八大排序,建议收藏

目录

插入排序

直接插入排序

希尔排序 

选择排序 

直接选择排序

堆排序

交换排序

冒泡排序

快速排序

hoare版本

挖坑法 

 前后指针法

 快排非递归

 三路划分

归并排序

递归写法

非递归写法

计数排序

总结补充


插入排序

直接插入排序

直接插入排序其原理类似于我们打扑克牌时整理牌的过程:将新拿到的牌与手中已经排列好的手牌进行逐一比较,找到合适的位置插入,使手牌始终保持有序。

思路:当插入第 i(i>=1) 个元素时,前面的 array[0],array[1],…,array[i-1] 已经排好序,此时用 array[i] 的数与array[i-1],array[i-2],…的数进行比较,找到插入位置即将 array[i] 插入,原来位置上的元素顺序后移
理清思路我们就可以进行代码实现了,首先这里因为对每个数据的处理方式是一样的,所以一定会用到循环,所以我们需要先整理出如何对某个数排序,然后套到循环中即可,也就是先写 单趟排序

 对单趟排序循环进行解析:

循环结束条件就是最极端的情况,我们对tmp的插入是从end向左进行的,所以最极端的情况就是如图三要插入的tmp值最小,此时end作为下标不断左移,指向-1,所以判断条件是end>=0.

再对while内部进行分析,我们要插入的tmp只有两种情况,要么比end指向的值大,要么比end指向的值小,如果tmp<a[end],就需要将此时end的值右移,同时end--,对前一个值继续判断,直到找到tmp>a[end]时,根据图中可知end始终比tmp要插入的位置小1,所以这里的if和else判断语句最后都要将tmp进行插入,而插入的情况也相同,所以这里的else可以直接break,退出循环时end一定指向tmp要插入的位置的前一个位置,所以a[end+1]=tmp

 到这单趟排序结束,我们对要排序的数组中的一个数排好了顺序,接下来就该思考如何对整体的数组进行排序

对整体排序很简单,只需要在单趟排序的外层再来一层循环,控制每次单趟排序的区间即可,通过观察上面直接插入排序的动图,我们不难发现,区间起始点始终是下标为0的位置,右端点则是假设要排第i个数,那么右端点就是i-1.

 到这直接插入排序就写好了:

void InsertSort(int* a, int n)
{
	for (int i = 1; i < n; i++)
	{
		int end=i-1;//有序数组最后一个元素的下标
		int tmp=a[i];//要插入的数
		//将tmp插入到[0,end]区间中保持有序
		while (end >= 0)
		{
			if (tmp < a[end])
			{
				a[end + 1] = a[end];
				end--;
			}
			else
				break;
		}
		a[end + 1] = tmp;
	}
}

我们简单测试一下:

下面我们分析一下直接插入排序的时间复杂度:先考虑最坏情况,也就是假如我们要排成升序,但是给的数据是降序排列,此时每个数据都要让循环走完(等差数列),所以此时时间复杂度为O(n^2);但是最好的情况下,也就是本来就是升序,它可以做到O(N)。

希尔排序 

希尔排序也是插入排序,它是在直接插入排序的基础上进行了优化,对给定的数组先进行预排序,使数组接近有序,然后使用直接插入排序。

所谓预排序就是将原数组中间隔为gap分为一组,对每组数据进行插入排序

 ok接下来就进行代码实现,我们依然还是按块进行,先写红色的这一组数据

因为红色作为最小的模块,它的代码是直接插入排序,所以只是在原来的基础上将间隔1改成gap 

 ok接下来就是对三组进行排序,也就是在红色代码模块的基础上,外层再套一层循环:

当然由于这里嵌套的循环有些多,所以我们还有一种写法可以减少一层循环:

 对比这两种写法其实效率是不会发生改变的,唯一不同的地方在于, 三层循环的最外层的循环意义是分组进行插入排序,也就是排序顺序为红蓝绿,减一层循环的意义是先对每一组的第一个数据进行修改,修改方式同三层循环,对三组第一个数修改完之后,再对第二个数进行修改,以此类推……所以这两种写法的区别只是修改数据的顺序,本质并无差别。

 我们这里取的gap是3,那么如何选取gap呢

gap越大,跳的越快,越不接近有序;

gap越小,跳的越慢,越接近有序

所以这里的gap其实不应该是一个确定的值,因为假如给定的数组元素有10000个,那gap取3就不合适,太小了,但如果数组元素有10个,gap取5又有些太大。

所以我们将gap设置为一个变化的值,原代码就可做如下修改:

 这里稍作解释,假设原来的数据很大,对应的gap也很大,快速进行调整,第二次循环时,gap再减小一点,进一步做有序调整,以此类推,直到最后gap一定会减为1,也就是直接插入排序。

我们这里依然测试一下: 

希尔排序的时间复杂度

我们以gap/=2为例分析复杂度:                      看最外层while循环,可以知道它的量级时logN,因为这个循环调整gap时每次/=2。          接下来我们先考虑最极端的两种场景,首末状态:                                                                  最开始时gap很大,gap=n/2,由于gap=n/2,所以这里的i<n/2,所以这个for循环的量级时N,进入for循环里的while,此时while的量级是一个常数级,因为最开始的gap很大就意味着每个组跨度很大,最开始跨度为2。                    最后gap=1,此时就是直接插入排序,按照直接插入排序,此时的复杂度应该是N^2,但是我们这里认为它的量级还是N,因为此时经过了前面的预排序,它已经很接近有序了,所以按最好情况分析。

综上,我们发现首尾状态,外层while 循环内部的时间复杂度为O(N),所以加上while循环,整体的时间复杂度为O(N*logN),但是它准确的复杂度并不是O(N*logN),因为中间过程并不是N,它是一个先上升,再下降到N的过程                                                                                       

选择排序 

直接选择排序

直接选择排序原理很简单,看图即可理解:先将第一个数定为最小,然后在区间内遍历找比第一个数还小的数,如果找到,则交换,区间内所有元素都遍历完成后,区间左端点右移,也就是将排好序的位置固定,继续按上面的操作对剩余数进行遍历交换,直到区间内只剩一个数。

直接选择排序通过观察就可以知道它的时间复杂度为O(N^2),可见并不高效,所以我们一般会进行优化,遍历一次,我们可以同时找到最小值和最大值,然后将它们交换到左右两端,然后缩小区间,继续相同操作。

下面就是相关代码:

void Swap(int* p1, int* p2)
{
	int tmp = *p1;
	*p1 = *p2;
	*p2 = tmp; 
}
void SelectSort(int* a, int n)
{
	int left = 0, right = n - 1;
	while (left < right)
	{
		int mini = left, maxi = left;
		for (int i = left + 1; i <= right; i++)
		{
			if (a[i] < a[mini])
			{
				mini = i;
			}
			if (a[i] > a[maxi])
			{
				maxi = i;
			}
		}
		Swap(&a[left], &a[mini]);
		//如果left和maxi重叠,交换后修正一下
		if (left == maxi)
		{
			maxi = mini;
		}
		Swap(&a[right], &a[maxi]);
		++left;
		--right;
	}
	
}

 测试一下:

 直接选择排序即使是在最好的情况,时间复杂度依然是O(N^2),因为无论原数据是怎样的,他都会选出最小和最大的数,也就是两个for循环还会走完。

堆排序

堆排序我们在堆的相关知识里提到过了,这里就不赘述了,有疑惑的小伙伴可以看我堆相关的博客:

https://mp.csdn.net/mp_blog/creation/editor/130452170

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[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}
void HeapSort(int* a, int n)
{
	//排升序建大堆
	//向上调整建堆
	//for (int i = 1; i < n; i++)
	//{
	//	AdjustUp(a, i);
	//}

	//向下调整建堆
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
		//这里解释一下不直接写成n-2的原因:n-1代表最后一个数据的下标,再-1除2是找出最后一个数据的父亲
	{
		AdjustDown(a, n, i);
	}

	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[end], &a[0]);
		AdjustDown(a, end, 0);
		--end;
	}
}

这里我们依然测试一下:

 为了更好的对比各种排序的性能,我们这里给出一种测试性能的代码:

// 测试排序的性能对比
void TestOP()
{
	srand(time(0));
	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);
	for (int i = 0; i < N; ++i)
	{
		a1[i] = rand();
		a2[i] = a1[i];
		a3[i] = a1[i];
		a4[i] = a1[i];
		a5[i] = a1[i];
		a6[i] = a1[i];
	}
	int begin1 = clock();
	InsertSort(a1, N);
	int end1 = clock();
	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();
	int begin3 = clock();
	SelectSort(a3, N);
	int end3 = clock();
	int begin4 = clock();
    HeapSort(a4, N);
	int end4 = clock();
	int begin5 = clock();
	QuickSort(a5, 0, N - 1);
	int end5 = clock();
	int begin6 = clock();
	MergeSort(a6, N);
	int end6 = clock();
	printf("InsertSort:%d\n", end1 - begin1);
	printf("ShellSort:%d\n", end2 - begin2);
	printf("SelectSort:%d\n", end3 - begin3);
	printf("HeapSort:%d\n", end4 - begin4);
	printf("QuickSort:%d\n", end5 - begin5);
	printf("MergeSort:%d\n", end6 - begin6);
	free(a1);
	free(a2);
	free(a3);
	free(a4);
	free(a5);
	free(a6);
}

有了这段代码,我们就可以对比一下堆排序,插入排序,希尔排序的性能:

 

这里数字的单位是毫秒,测试的数据为100000个 

交换排序

冒泡排序

冒泡排序可以说是我们接触的第一个排序,所以这里直接给出代码了: 

 就上边的代码,如果是最坏情况,时间复杂度是O(N^2),最好情况也是O(N^2),但如果进行如下优化,那么最好情况就变成了O(N)(加了一个是否交换的判断语句)

void BubbleSort(int* a, int n)
{
	for (int j = 0; j < n; j++)
	{
		bool exchange = false;
		for (int i = 1; i < n-j; i++)
		{
			if (a[i - 1] > a[i])
			{
				Swap(&a[i - 1], &a[i]);
				exchange = true;
			}
		}
		if (exchange == false)
			break;
	}
}

优化后,我们拿冒泡和直接插入对比一下:

由此可见,虽然它们时间复杂度相同,但是差异还是很大的,原因就在于时间复杂度只能表示量级,二者还是有很大差异的。 

快速排序

概念:任取待排序元素序列中的某元素作为基准值key(一般取最左端或最右端的数作为key),按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

hoare版本

 我们一般取左边为key时,右边的小孩(右指针)先移动,找比key小的值的位置,左边的小孩右移找比key大的值的位置,找到之后,交换二者对应的值,然后重复该过程,直到二者相遇。此时相遇位置的值一定比key小,交换该位置的值与key。【这部分你可能存在一些疑问:为什么取左为key要右指针先走?为什么相遇位置的值一定比key小?】

 上图就是一次单趟排序的过程,结果是将原数据处理成[比key小的数,key)key(key,比key大的数]这样的三个区间,key的最终位置确定,然后再对key左右区间做相同处理即可成功排序。

  

根据单趟排序的思路,我们可以写出这样的代码,但通过调试不难发现,这段代码是不能行的。

优化1:因为初始状态keyi=left,所以while循环里的第二个while循环是没有考虑二者相等的情况的。这里你可能会说,那我在一开始就先让左指针移动一步不就行了,虽然可行,但是避免不了有坑。

优化2:针对优化1中先让左指针右移一步,如第二图,加入++left,假设原来的数据是:6 1 2 6 9 3 4 6 10 8,那么两个指针走到两个6的位置会死循环,此时在判断语句基础上加一个=就可以通过,因为如果这个位置的数跟key相同,那么没必要交换。

优化3:在优化2的基础上还是存在问题,假如原来的数据是1 2 3 4 5 6,有序,此时进行while循环内的第一个while循环时会出现right移动到left左边的情况,所以需要对判断条件再次限定left<right

优化4:到这基于++left还是存在问题,还是优化3中的数据,加上优化3中的限定后,结束时左右指针都指向2位置,此时进行最后一行代码,交换2和keyi对应的值,变成了2 1 3 4 5 6,所以这就回到优化1中的++left是有坑的,正确的方式应该是去掉++left,将内部的两个while循环限制条件中对数值大小的比较变成大于等于和小于等于,这样之前存在的情况都解决了。最终优化结果如第三图。

 

单趟排序结束后就是key的左右区间分别递归进行同样的操作

接下来就进行递归代码的实现

测试一下:

 看一下它的性能

 100000数据量

 1000000数据量

 可见快排的效率是很高的,尤其数据量大的时候,差距很明显。

时间复杂度

单趟排序内,两个指针不断向中间移动,时间复杂度为O(N),递归过程中,深度最大为logN,每一层要排序的数是在变化的(key的位置确定,就不需要移动了),第一层为N,第二层为N-1,第三层为N-4,以此类推,但是,到最后N减去的值不会太大,可以看作是常数,【相当于这里减去的是一棵树的层数(指数爆炸),所以数据很多,但指数不大 】所以每一层还是可以看作N这个量级,所以时间复杂度还是O(N*logN)

快排最坏情况是数据本身就是有序的,无论顺序还是逆序,这时候这个递归形成的树状结构就成了一边倒的情况,全部是左子树或者全部是右子树,此时它每一层变成了N,N-1,N-2……以此类推到2,1,所以时间复杂度就成了O(N^2),这个时候可能会栈溢出

由此可见,决定它的性能的关键是keyi,如果keyi更接近中间,那么形成的递归树状结构就更接近满二叉树,效率更高。

所以我们就可以进行优化,不需要规定keyi一定是最左边或者最右边,我们随机选取keyi如下图:

 还有一种优化方法是三数取中,即选取开始,结束和中间三个数中不是最小和最大的那个数:

先写一个可以得到三个数中值不是最大和最小的那个数的函数,然后调用即可:

 三数取中方法针对相对有序的数据优化是很明显的

 接下来我们分析一下之前的问题:为什么取左为key要右指针先走?为什么相遇位置的值一定比key小?

取左为key,右指针先走,可以保证相遇位置比key小

因为相遇有两种情况:

1.R找到小,L找大没有找到,L和R相遇。那么此时R和L相遇在了比key小的位置

2.R找小没有找到,直接与L相遇,此时L所在的位置有两种情况,一种是L还没有移动过,在最初的位置,一种是L和R交换过,如果交换过,那么L所在的位置就是交换之后的值,这个值是小于key的,所以无论L是哪种情况,最差也是相遇在key的位置

所以,通过上述分情况分析,我们就知道了为什么取左为key,一定要右指针先走,则相遇位置的值一定比key小

挖坑法 

挖坑法我们就不做过多解释了,它的本质并没有发生太大变化,主要思想就是将key保存起来,将原来的位置看作是一个没有存储数据的坑,然后通过左右指针移动,填坑,产生新的坑,直到最后都落在坑(即两指针在坑相遇),将保存的key放入坑结束。

//挖坑法
void QuickSort(int* a, int left, int right)
{
	//判断条件
	if (left >= right)
	{
		return;
	}
	int begin = left, end = right;

	//三数取中
	int midi = GetMidNumi(a, left, right);
	Swap(&a[left], &a[midi]);

	int key = a[left];
	int hole = left;
	while (left < right)
	{
		//右边找小
		while (left < right && a[right] >= key)
		{
			--right;
		}
		a[hole] = a[right];
		hole = right;
		//左边找大
		while (left < right && a[left] <= key)
		{
			++left;
		}
		a[hole] = a[left];
		hole = left;
	}
	a[hole] = key;
	//递归
	QuickSort(a, begin, hole - 1);
	QuickSort(a, hole + 1, end);
}

 前后指针法

 

 分析一下图:

1.cur找到比key小的值,++prev,cur和prev位置的值交换,++cur

2.cur找到比key大的值,++cur

说明:

1.prev紧跟cur(prev下一个就是cur),下一步就是自身进行交换;

2.prev跟cur之间间隔着比key的值大的一段值区间,下一步的交换,就可以看作是这一段比key的值大的值区间向右反转的过程(把比key大的值往右翻,比key小的值往左翻,整体呈现的结果就是这一段大的值区间向右移动了一个数的距离)

下面进行代码实现: 

 

快排优化:

快排递归的过程画出递归展开图可以看成是一个树状结构,接近于满二叉树,数据大的时候,效果很明显,但是当它递归到分支很小的时候,比如递归成每个区间只有3-5个数的时候,对这几个数继续递归排序其实效果并不好,很繁琐因为递归到没有数或者只有一个数的时候才递归结束,所以我们可以对这部分进行优化。

当数据量小于一定区间时,我们可以考虑不再使用递归进行排序,而是选择插入排序。

void QuickSort(int* a, int left, int right)
{
	//判断条件
	if (left >= right)
	{
		return;
	}
	//小区间优化----小区间使用插入排序
	if ((right - left + 1) > 10)
	{
		int begin = left, end = right;

		//三数取中
		int midi = GetMidNumi(a, left, right);
		Swap(&a[left], &a[midi]);

		int keyi = left;
		int prev = left;
		int cur = left + 1;
		while (cur <= right)
		{
			if (a[cur] < a[keyi] && ++prev != cur)
			{
				Swap(&a[cur], &a[prev]);
			}
			++cur;
		}
		Swap(&a[prev], &a[keyi]);
		keyi = prev;

		//递归
		QuickSort(a, begin, keyi - 1);
		QuickSort(a, keyi + 1, end);
	}
	else//小区间插入排序(每个区间数据量小于等于10)
	{
		InsertSort(a + left, right - left + 1);
	}
	
}

 快排非递归

递归改成非递归无非就是改成循环,这里快排改成非递归需要借助栈辅助完成循环

分析:

快排递归过程中改变的是区间,那么我们改成非递归的过程中栈保存的也是区间,假设要排的数是0-10,那么先将0-10这个区间入栈,出栈进行单趟排完之后,选出key为5,分成左右两个区间,然后让右区间6-9入栈,再让左区间0-4入栈(这样做的目的是先让左区间出栈,再让右区间出栈,与递归顺序相同),然后0-4区间出栈单趟排序,同上边步骤继续入栈出栈,直到子区间只剩一个值或者没有值停止入栈。(类比二叉树的前序遍历)

//非递归
void QuickSortNonR(int* a, int left, int right)
{
	ST st;
	STInit(&st);
	STPush(&st, right);
	STPush(&st, left);

	while (!STEmpty(&st))
	{
		int begin = STTop(&st);
		STPop(&st);
		int end = STTop(&st);
		STPop(&st);

		//三数取中
		int midi = GetMidNumi(a, begin, end);
		Swap(&a[begin], &a[midi]);

		int keyi = begin;
		int prev = begin;
		int cur = begin + 1;
		while (cur <= end)
		{
			if (a[cur] < a[keyi] && ++prev != cur)
			{
				Swap(&a[cur], &a[prev]);
			}
			++cur;
		}
		Swap(&a[prev], &a[keyi]);
		keyi = prev;
		//[begin, keyi] keyi [keyi, end]
		if (keyi + 1 < end)
		{
			STPush(&st, end);
			STPush(&st, keyi+1);
		}
		if (begin < keyi-1)
		{
			STPush(&st, keyi-1);
			STPush(&st, begin);
		}
	}
	STDestroy(&st);
}

 注意这里需要提前将我们栈的相关代码拷贝过来,代码详情请看栈的相关文章:

https://blog.csdn.net/m0_73737172/article/details/129907308?spm=1001.2014.3001.5501

 三路划分

快排最后一个点是如果给的数据出现大量重复,比如全是同一个数时,上边的各种快排方法性能都会大大降低,它的时间复杂度会下降到O(N^2),针对这个问题,我们给出三路划分这个方法。

什么是三路划分?我们之前的方法可以看作是两路划分,也就是单趟排序分成小于等于和大于等于两个区间,那么三路划分就是单趟排序分成小于,大于,等于key三个区间。然后对左区间递归,右区间递归,中间区间不需要处理。 

分析:

核心思想:c作为一个“中转站”,负责将比key大的数翻转到右边,比key小的数翻转到左边,与key相等的数推到中间。 

//三路划分
void QuickSort(int* a, int left, int right)
{
	//判断条件
	if (left >= right)
	{
		return;
	}
		int begin = left, end = right;

		//三数取中
		int midi = GetMidNumi(a, left, right);
		Swap(&a[left], &a[midi]);
		int key = a[left];
		int cur = left + 1;
		while (cur <= right)
		{
			if (a[cur] < key)
			{
				Swap(&a[cur], &a[left]);
				cur++;
				left++;
			}
			else if (a[cur] > key)
			{
				Swap(&a[cur], &a[right]);
				right--;
			}
			else
			{
				cur++;
			}
		}
		//[begin, left-1][left, right][right+1, end]
		//递归
		QuickSort(a, begin, left - 1);
		QuickSort(a, right + 1, end);
}

归并排序

先将原序列进行分解,直到每个子序列有序,再使同级子序列有序合并(二路归并)
两个有序区间归并:数据依次比较,小的数尾插到新的数组空间。

 时间复杂度:归并排序的树状结构很规整,它的高度时logN,每一层的归并整体是N,所以整个过程的时间复杂度是O(N*logN)

归并排序的递归合并需要借助一个临时数组,用于存放每一次合并的数据,也就是合并的过程在临时数组中进行,每次合并结束后,将每次合并的结果覆盖到原数组对应的位置即可。临时数组的大小就是数据总量N,最后一次拷贝回去之后,将临时数组空间释放。

所以从这里我们可以看出归并排序的空间复杂度是O(N)

递归写法

因为我们需要开辟临时数组空间,还要进行递归排序,所以我们需要进行分块处理,不然每次递归都要动态开辟空间。按照这个思路我们就可以写成如下框架:

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++];
	}
	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\n");
		return;
	}
	_MergeSort(a, 0, n - 1, tmp);
	free(tmp);
}

 (这里递归排序部分如果有问题建议画递归展开图)

非递归写法

归并的非递归不能像快排的非递归一样借助栈进行操作。

因为快排可以类比二叉树的前序遍历(根-左子树-右子树),先处理整体的区间,然后分割成左右两个区间(分割方法就是取key)后,先将右区间压栈保存,再对左区间做相同的处理,依然是继续分割成左右两区间,右区间压栈保存,左区间分割,一直分割到不可再分,此时左区间的数可以有序的存入数组中,且此时不再有元素继续压栈(因为不分割就意味着没有右区间入栈了),然后取栈顶两个元素(即相邻的右区间),如果该区间可分割,则分割成左右区间,同上操作,如果不可分割,则将数字保存到数组中对应的位置。快排非递归可以使用栈的关键点是,栈可以优先处理当前区间分割得到的左区间并且保存分割后的右区间,使得每一步都能按照类似于根-左-右 的顺序进行操作。

而归并类似于二叉树的后序遍历(左子树-右子树-根),它需要先将原数据一步步划分成一个数为一个区间,才进行有序化,如果使用栈的话,就意味着需要先将每个区间都按顺序压栈,然后再一个区间一个区间的出栈进行有序化,这样操作起来很复杂,困难之处在于你如何将各个区间压入栈中。如下图:

 你需要像图中这样将各个区间以这样的顺序放入栈中,代码是很麻烦的,所以这是一个不选择栈进行非递归实现的原因。

另外归并排序的核心操作是合并两个有序数组,递归并不是归并排序的核心操作,所以不是用栈不能实现,而是引入了额外空间却并没有效率提升,而且提升了代码的复杂度,没有必要。

所以我们这里改成非递归的方式是将其直接改成循环的方式,结构如图:

 思路:

 取gap记录每次递归时每组数据的个数,先一一归并,然后二二归并,然后四四归并,直到全部数据有序。思路很简单,但是每组的区间把握是难点。

以gap=2进行分析,进行两组数据合并,按照递归中的写法首先需要确定begin1,end1,begin2,end2,由图分析可得:begin1 = i, end1 = i + gap - 1,begin2 = i + gap,end2 = i + 2 * gap - 1

这是前两组数据,根据图中还有两组数据,那么循环时i的调整语句就是i+=2*gap.

根据图中可知每次调整gap*=2.

由上述分析我们可以写出代码:

void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail\n");
		return;
	}
	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += 2 * gap)
		{
			int begin1 = i, end1 = i + gap - 1;
			int begin2 = i + gap, end2 = i + 2 * gap - 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, tmp, sizeof(int) * n);
		gap *= 2;
	}
	
	free(tmp);
}

测试一下:

 

如果我们在原数据的基础上加一个数呢?

 出错啦!

我们分析一下哪里出问题了,我们把区间全部打印出来看看问题:

 我们发现除了begin1,其他三个端点都存在越界情况,那么我们就分情况处理:

1.end1越界:剩下的数据不需要归并,但是仍然需要拷贝到tmp,这里涉及到一个问题,就是你的拷贝函数写在哪里,我上边的代码写在了for循环外面,也就是所有数据都排好之后统一拷贝,所以这里即使不归并也需要拷贝一次,否则原数据会被tmp数组覆盖。当然如果拷贝函数写在for循环里即每归并一次就拷贝一次则不用考虑这种情况

 2.end1没有越界,begin2越界:跟1同样的处理

3.end1和begin2没有越界,end2越界:继续归并,但是需要修正end2

上述就是所有情况,根据上述情况,进行修改:

 上面就是拷贝函数写在for循环之外的修改方式,如果拷贝函数在for循环内,则可以写出下面的代码:

void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail\n");
		return;
	}
	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += 2 * gap)
		{
			int begin1 = i, end1 = i + gap - 1;
			int begin2 = i + gap, end2 = i + 2 * gap - 1;

			if (end1 >= n||begin2 >= n)
			{
				break;
			}
		    if (end2 >= n)
			{
				end2 = n - 1;
			}
			printf("[%d,%d][%d,%d]", begin1, end1, begin2, end2);

			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));
		}
		printf("\n");
		gap *= 2;
	}
	free(tmp);
}

解释一下修正部分:当end1>=n||begin2>=n时,剩下的那部分数据不需要归并拷贝,所以直接break跳出循环,只有当end2>=n时才需要修正右端,进行归并拷贝。

至于拷贝函数部分的修改,则可以自行对比图理解,这里不做过多解释,简单说就是末端减去首端加一,得到拷贝的长度。 

计数排序

计数排序同前七种排序相比,它是非比较排序,它的方法是统计每个数据出现的次数,根据统计的结果将原数据进行覆盖排序。

如下图例子:

 遍历一遍原数组:O(N)

遍历一遍计数数组:O(MAX)(该数组所开大小与原数组中最大的数的值有关)

也就是说,如果给定的数据量不大,那么完全可以使用计数排序实现O(N)

当然这里可以进行优化,因为存在一些不合理的地方,比如如果给的数据都比较大,都落在一个数值比较大的区间比如落在了100-150区间,按照上边的方式建立的计数数组就得在100-150范围内,但是0-100这个区间根本没有值,就会浪费,所以优化的方式就是由原来的绝对映射(数据是多少下标就是多少)改成相对映射(每个值减去最小值)

当然,这样依然存在问题:假如数据集中在两个区间0-100和10000+,100-10000之间没有数据,那么优化后的方式依然存在很大的空间浪费。

所以由此我们不难看出计数排序有着很大的局限性:

计数排序适合对范围集中,且范围不大的整型数组进行排序。

不适合范围分散或者非整型(字符串、浮点数、结构体)的排序。

所以它是一种小众排序

void CountSort(int* a, int n)
{
	int max = a[0], min = a[0];
	for (int i = 0; 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\n");
		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++)
	{
		while (countA[i]--)
		{
			a[j++] = i + min;
		}
	}
	free(countA);
}

时间复杂度:O(N+range)

空间复杂度:O(range)

总结补充

 

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

通俗讲就是排序之后相同数据的相对顺序是否改变。

 码字不易,看到这的小伙伴点个关注,给博主一些支持呗~

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值