【初阶数据结构】排序

目录

一、排序的概念及其运用

1.1排序的概念

1.2常见的排序算法

二、常见排序算法的实现

2 .1插入排序

2 .1.1基本思想:

2.1.2直接插入排序:

算法复杂度:

最坏情况:

最好的情况:

直接插入排序的特性总结:

2.1.3 希尔排序( 缩小增量排序 )

算法复杂度:

希尔排序的特性总结:

2.2 选择排序

2.2.1基本思想:

2.2.2 直接选择排序:

算法复杂度:

直接选择排序的特性总结:

2.2.3 堆排序

直接选择排序的特性总结

2.3 交换排序

2.3.1冒泡排序

算法复杂度:

最坏情况:

最好的情况:

冒泡排序的特性总结:

2.3.2 快速排序递归版本

1. hoare版本​

算法复杂度:

存在问题:

快速排序优化

关于hoare版本相遇位置的补充说明(为什么左边做key,右边先走,可以保证相遇位置比key小)

2. 挖坑法

3. 前后指针版本​

快速排序的特性总结:

2.3.3快速排序非递归版本

2.3.4快速排序的深入优化探讨

1.快排性能的关键点分析:

2.三路划分算法解析:

三种快排单趟排序运行结果分析:

3.introsort算法解析:

2.4 归并排序

基本思想:

递归版本:

归并排序的特性总结:

归并排序非递归版本:

2.5 非比较排序

计数排序:

计数排序的特性总结:

桶排序:

三、排序算法复杂度及稳定性分析

四、选择题练习

题目

答案

五、外排序之文件归并排序实现

1.外排序介绍

​编辑

2.文件归并排序实现

2.1创建随机数据文件的代码

2.2文件归并排序思路分析

2.3.文件归并排序代码实现


一、排序的概念及其运用

1.1排序的概念

1.排序:所谓排序,就是使一串记录或者数据,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。

2.内部排序:数据元素全部放在内存中的排序。

3.外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不断地在内外存之间移动数据的排序。

1.2常见的排序算法

// 排序实现的接口
// 插入排序
void InsertSort(int* a, int n);
// 希尔排序
void ShellSort(int* a, int n);
// 选择排序
void SelectSort(int* a, int n);
// 堆排序
void AdjustDwon(int* a, int n, int root);
void HeapSort(int* a, int n);
// 冒泡排序
void BubbleSort(int* a, int n)
// 快速排序递归实现
// 快速排序hoare版本
int PartSort1(int* a, int left, int right);
// 快速排序挖坑法
int PartSort2(int* a, int left, int right);
// 快速排序前后指针法
int PartSort3(int* a, int left, int right);
void QuickSort(int* a, int left, int right);
// 快速排序 非递归实现
void QuickSortNonR(int* a, int left, int right)
// 归并排序递归实现
void MergeSort(int* a, int n)
// 归并排序非递归实现
void MergeSortNonR(int* a, int n)
// 计数排序
void CountSort(int* a, int n)
// 测试排序的性能对比
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() + i;//rand只能产生大概3万重复值,+i消除部分重复值
		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);
}

注:以下排序算法测试使用相同数据的测试结果(C的rand大概只能产生3万不同结果,通过+变化的值消除部分重复值,但是仍然会有影响,仅供感受)。

排序OJ(可使用各种排序跑这个OJ)OJ链接

二、常见排序算法的实现

2 .1插入排序

2 .1.1基本思想:

直接插入排序是一种简单的插入排序法 ,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列

实际中我们玩扑克牌时,就用了插入排序的思想,我们将牌按照从小到大的顺序排列,得到新牌时将它与从右往左的值依次比较大小,比当前牌小继续往前比,比当前牌大就插在当前牌的后面。

2.1.2直接插入排序:

基本思想:当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与 array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移。如下图,对于只有一个数字3来说,它就是有序的,这时如果我们要插入44,则跟前面已经排好的3比较,44大于3,44插入在3的后面,排序完成,要插入38,38跟前面的数据比较,38小于44,44往后挪,33大于3,33插入在3的后面,数据有序,排序结束,后面同理。

	//先插入排序一个数据
// [0,end]有序 end+1位置的值插入[0,end],保持有序
		int end;
		int tmp = a[end + 1];
		while (end >= 0)
		{
			if (tmp < a[end])
			{
				a[end + 1] = a[end];
				--end;
			}
			else
			{
				break;
			}
		}
		a[end + 1] = tmp;

我们创建end来记录已经有序一组数据的尾部位置,0到end之间都是有序数据,end+1就是待插入的数据,我们创建tmp记录。为了方便控制逻辑,我们按照从局部到整体的思路,首先我们先来考虑一个数据插入的情况,我们将带插入数据与尾端数据比较,如果小于,则将当前尾端数据往后挪,end--,这时end就是剩下未比较的数据的尾端位置,tmp继续与前一个数据比较,如果大于,就跳出循环,并在当前尾端位置下一位a[end + 1] 插入新数据,重复上述过程,待比较数据不断减少,当end--到-1说明数据都比较完成,跳出循环。

需要注意的是a[end + 1] = tmp将新数据插入的操作一般来说是应该写在else内的,但是如果出现待插入数据过小,我们将所有数据都比较一遍,仍然找不到比新数据小的数据,那么end减到-1,跳出循环。

这时候我们要将数据插入到数组开始的位置,如果我们将a[end + 1] = tmp写在else内,对于这种情况我们还需要if条件语句专门处理,因此,为了方便我们直接将a[end + 1] = tmp写在循环外,这样一但跳出循环就在当前尾端位置下一位插入新数据。

// 插入排序
void InsertSort(int* a, int n)
{
	//  [0, n-1]
	for (int i = 0; i < n-1; i++)
	{ 
		// [0,end]有序 end+1位置的值插入[0,end],保持有序
		int end = i;
		int tmp = a[end + 1];
		while (end >= 0)
		{
			if (tmp < a[end])
			{
				a[end + 1] = a[end];
				--end;
			}
			else
			{
				break;
			}
		}
		a[end + 1] = tmp;
	}
}

再然后我们套上一层循环来控制尾端end的位置,进而实现一组数据的插入排序,因为数组最后的数据是插入到已经有序的数据,所以end要小于n-1(数组最后一个有效数据在n-1),即end最大为n-2。这样最后一个数据插入完,整个数组就有序了。

算法复杂度:

对于直接插入排序来说,从后向前来比较来确定新数据插入的位置,比较的越多,时间消耗就越多。

最坏情况:

最坏的情况是数据成逆序排序,比如我们需要排1、2、3、4、5、6、7、8、9,但是数据是9、8、7、6、5、4、3、2、1,这时我们插入二个数据就需要挪动一次,插入第三个数据就需要挪动两次,插入第三数据就要挪动4次,以此类推,当n个数逆序排列,算法复杂度ji就是是O(N^{2})

最好的情况:

最好的情况是数据成顺序排序,比如我们需要排1、2、3、4、5、6、7、8、9,数据是1、2、3、4、5、6、7、8、9,这时候数据只需要每个跟前面一个数据比较一次确认大小,因此时间复杂度是O(N)

不过时间复杂度考虑最坏情况,因此时间复杂度为O(N^{2})

时间复杂度只是体现了一种量级,即使是同一个量级的,也会有不小的差距。我们从算法的思想本身出发,对于插入排序,如果我们拿随机数测试,想要完全逆序的概率是非常低的,平均下来,新数据可能只要挪动一半的数据,想要达成坏的情况概率非常低,如果前面的数据已经有序,那么插入的数据运气好可能不要挪动,综合来说插入排序对各种情况的适应性非常好,所以实际上效率会在O(N)O(N^{2})之间,效率上比冒泡高很多。

10万相同随机数据的测试结果
直接插入排序的特性总结:

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

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

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

2.1.3 希尔排序( 缩小增量排序 )

直接插入排序的思想本身很好,但是我们发现上述直接插入排序一旦遇到像逆序的这种情况,性能会退化的非常严重,对于这种情况希尔大佬研究出希尔排序。

希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后取,重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。

希尔排序的大致思路是先进行预排序(让大的数据在靠后的位置,小的数据在靠前的位置),再进行直接插入排序。

比如下图,我们先假设步长间隔gap是3,我们将相邻gap的数据划为一组,如9、5、8、5,1、7、6为一组,2、4、3为一组(如下图,5为9同一组的下一个数,所以9到5之间的数就是每组开头的数,9、5之间相距gap步,因此9到5之间有9、1、2gap个数,数组被分成gap组,所以步长gap为多少,n个数就被分成多少组),我们对每一组进行直接插入排序。这样我们发现较大的数就到了后面,较小的数就到了前面,数据就相对有序了,之后进行直接插入排序消耗会小很多。 

我们首先先排红色的这一组,对于同一组中的一个数数,我们使用直接插入排序的逻辑。然后为了排每一组,我们需要外层套个循环,如上文所说n个数中每个组的开头都会在第一组第二个数之前,步长gap就有gap组,因此外层循环gap次,将gap组依次排序。需要注意是每次走gap长,为了避免下一步越界,我们需要确保当前位置加gap小于n,即下图i小于n-gap;

上述的​的排序,有的人觉得上面一组一组排的三层循环看上去不太好,因此,后来想出了将不同组并着排序的做法。通过++i,每一组除最后一个待插入的数,其他都可以走到,i<n-gap防止越界。不过这种做法,该比较的数一个都没少,算法本身的效率并未改变。

我们考虑gap为3的情况之后,读者可能会思考为什么gap是3?gap取其他值会怎么样?如何取到合适的值?

希尔排序的优化就在于通过预排序来使得数据达到相对有序的状况。在预排序中gap值越大,大的数据可以越快跳到后面,小的数可以可以越快跳到前面,但是数组越不接近有序(如同上面绿色组中的6,如果gap越大,那么一组数据中越大的数据越无法跳到相对靠后的位置);gap值越小,,越接近有序,但跳的越慢,消耗越大,优化越不明显,当gap==1就相当于插入排序了。

void ShellSort(int* a, int n)
{
	int gap = n;
	while (gap > 1)
	{
		// +1保证最后一个gap一定是1
		// gap > 1时是预排序
		// gap == 1时是插入排序
		gap = gap / 3 + 1;

		for (size_t i = 0; i < n - gap; ++i)
		{
			int end = i;
			int tmp = a[end + gap];
			while (end >= 0)
			{
				if (tmp < a[end])
				{
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			a[end + gap] = tmp;
		}
	}
}

gap的值我们一开始赋予n,每一次循环gap = gap / 3 + 1(保持最后一次gap为1,最后直接插入排序后,gap为1退出循环);这样当一开始数据可以移动到相对有序,之后gap变小,分出许多gap组,数据可以进一步排的接近有序,最后gap为1,直接进行插入排序。

gap的值gap = gap / 2 或者gap = gap / 4等都是可以的,没有明确的定论,但是有人做过测试,gap = gap / 3是相对来说较为合适的取值。

1百万个数据排序比较
算法复杂度:

希尔排序的时间复杂度非常难算,我们为了方便计算忽略掉+1,每组排序的消耗是每组比较次数*组数,如下图,计算时间复杂度,我们第一趟排序可以将其视为最坏情况计算,但是自从第一趟预排序之后,数据就不会处在最坏情况,而这个一趟排序下来,数据的有序情况比最坏情况有序多少,这是很难计算的。不过可以确定的是gap比较次数是一个先升后降的曲线。

《数据结构(C语言版)》--- 严蔚敏

《数据结构-用面相对象方法与C++描述》--- 殷人昆

​因为gap是按照Knuth提出的方式取值的,而且Knuth进行了大量的试验统计,我们暂时就按照: O(n^{1.25})O(1.6*n^{1.25})来算,日常取近似值O(N^{1.3})就行。

希尔排序的特性总结:

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

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

3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些书中给出的希尔排序的时间复杂度都不固定。

2.2 选择排序

2.2.1基本思想:

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

2.2.2 直接选择排序:

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

​直接选择排序一次选一个的效率不高,笔者提供一个优化版本的,我们创建begin和end来记录数组未排序数据的边界,在一次排序中,我们循环遍历整个数组,选出当前最大和最小的数据,将其与首尾的数据交换位置,然后begin--,end--缩小待排序的范围,重复上述操作,如果begin>=end说明数组数据全部排完,跳出循环。

void Swap(int* p1, int* p2)
{
	int tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

// O(N^2)
void SelectSort(int* a, int n)
{
	int begin = 0, end = n - 1;

	while (begin < end)
	{
		int mini = begin, 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[begin], &a[mini]);
		if (begin == maxi)
			maxi = mini;

		Swap(&a[end], &a[maxi]);
		++begin;
		--end;
	}
}

需要注意的是如果一次找两个数,那么当mini指向最小值,maxi指向最大值的时候,排序就会出错,即将最小值交换到最前面,交换后mini指向最大值,maxi指向最小值,对于这种特殊情况,我们需要处理一下,我们判定一下,如果交换之后,maxi指向的并不是最大值,那么我们将maxi挪动到mini位置。

5万个数排序比较
算法复杂度:

对于直接选择排序来说,不管数据怎么样,它都需要挨个选、比较然后交换。效率很差。

直接选择排序的特性总结:

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

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

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

2.2.3 堆排序

堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。

由于是基于完全二叉树的,涉及过多,加之笔者之前专门写过这一方面,这里笔者不再过多赘述,这里附上笔者之前写的文章堆排序

直接选择排序的特性总结

1. 堆排序使用堆来选数,效率就高了很多。

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

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

2.3 交换排序

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

2.3.1冒泡排序

冒泡排序的的基本思想是从第一个数据开始,将第一个数据与下一个数据比较,如果前一个数据大,就将前一个数据交换到后面,如果后一个数据大就选取后一个数,将后一个数据与下一个数据比较,重复上一个过程,这样不断比较,我们最后就将最大的数据交换到最后的位置,接着我们排下一个数据,需要注意的是通过上一趟排序,最后一个数据是排好的,确定最大的,因此这一趟排序最后一个数据不需要比较,我们比较到前一个数据既可以停止了。因此待比较数据区间的尾端一直递减,一直到与第一个数据重合,这样所以的数据我们就排好了。此外为了避免造成多余的浪费,我们做一个优化,如果一趟排序下来,都没有发生交换,说明每一个后面的数都比前面的数小,数据已经有序了,这是我们直接结束排序。

// O(N^2) 最坏
// O(N)   最好
void BubbleSort(int* a, int n)
{
	for (int j = 0; j < n; j++)
	{
		// 单趟
		int flag = 0;
		for (int i = 1; i < n - j; i++)
		{
			if (a[i - 1] > a[i])
			{
				Swap(&a[i - 1], &a[i]);
				flag = 1;
			}
		}

		if (flag == 0)
		{
			break;
		}
	}
}
算法复杂度:
最坏情况:

最坏的情况是每个数据都要通过冒泡完一趟才能有效,时间复杂度为O(N)

最好的情况:

最好的情况是数据冒泡个一趟没有发生交换,或者冒泡几次就有序了,时间复杂度为O(N)

冒泡排序的特性总结:

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

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

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

从算法的思想本身出发,我们发现冒泡是比较两个数的大小,将最大的换到后面,原来的大的数据还留在中间,这样之后的过程当中交换的发生是非常容易达成的,也就是说冒泡的最坏的情况非常容易发生,相对的是数据直接有序或者冒泡个几次之后不需要冒泡了这些最好的情况都是非常不容易达成的。因此冒泡的时间效率非常低下,没有实践意义,只有教学意义。

10万相同随机数据的测试结果

2.3.2 快速排序递归版本

快速排序是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);
}

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

将区间按照基准值划分为左右两半部分的常见方式有

1. hoare版本​

hoare版本的基本思想是先取最左边的数作为基准值key,然后右边的人先走,一找到比基准值小的数就停下,否则就继续往左边走,右边的人找到比key小的数停下,然后左边的人继续往右边走,找比key大的数,找不到就继续往后走,找到就停下,将左右两人站立的位置的数据交换,然后右边的人先走,重复上述过程,如果两个人相遇,相遇位置的值一定比key小,再将相遇位置的值与key交换,这样key就到了靠近中间的位置,最后key左边都是比key小的值,右边都是比key大的值。但是左右区间还是无序的,为了让左右区间有序,我们需要快排一下左区间,然后快排一下右区间,这样数据就有序了。快排左右区间的操作是通过不断递归调用函数本身实现的,我们假设我们要封装的这个函数已经好了,然后用这个函数去排左右区间,排的实际流程就是key值处的位置不动,左区间重复上述过程,继续划分出两个区间,然后左区间的左区间继续往下划分,直到只有一个数(可以视为有序)或者区间不存在无法继续往下划分,然后返回上一层,继续划分右区间,右区间往下划分完,再返回上一层…………,通过这种类似于二叉树的中序或者后序遍历的递归划分,每次key排在中间的位置,key左边是比key小的值,右边是比key大的值,最终数据就有序了。

因为排这些数的过程中,我们不断将大区间化为小区间,先排好大区间,再排大区间,这些过程都是发生在一个数组上的,为了实现不断划分区间的效果以及之后排key,不断调用函数之前我们需要将对应区间的边界作为参数传递。

五百万的数据排序时间消耗
算法复杂度:

上述流程途中每次排好一个key,相当于二分当前区间,这样不断递归划分下去,就形成类似完全二叉树的结果,整个递归的深度就有logN层,每一层上的每一个区间都需要遍历每个数才能排好key,我们将每一层上的数据加起来,就有n个数,因此整个算法的时间复杂度就是N*logN。

存在问题:

1.我们发现当key的值是不大不小的中间值,形成类似完全二叉树的遍历效果,排序的性能是最高的,但是假如数据是顺序的情况,右边就会找小找不到,遍历所有数,直到与左边的人重合,然后接下来选第二个数作为key,右边的数同样会遍历所有数然后与左边的人重合…………整体排序的复杂度会退化至O(N^{2})

2.以只有5个数的区间为例,我们发现为例排好这些数,往下递归加上往上回退共计12次,这样的代价似乎有点大,并且hoare版本的快排流程类似完全二叉树结构,越往下划分出的小区间占总数的比例越高,这样越往下的时间消耗是非常巨大的。

快速排序优化

1. 三数取中法选key

针对上面问题一,我们在第一个数、中间的数、最后一个数中选出一个中间值,然后将中间值与数据区间第一个数据交换,这样之后,我们要选出整个区间内最大或最小的数概率非常低,快排最坏的情况几乎不会发生。

int GetMidi(int* a, int left, int right)
{
	int midi = (left + right) / 2;
	// left midi right
	if (a[left] < a[midi])
	{
		if (a[midi] < a[right])
		{
			return midi;
		}
		else if (a[left] < a[right])
		{
			return right;
		}
		else
		{
			return left;
		}
	}
	else // a[left] > a[midi]
	{
		if (a[midi] > a[right])
		{
			return midi;
		}
		else if (a[left] < a[right])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
}

2.小区间优化:既然递归到小的子区间时,继续使用快排消耗非常大,那我们就可以考虑使用插入排序继续小区间(希尔排序的优势要数据量大时才能体现,堆排序10个数排序还要建堆,消耗也较大,冒泡和直接选择排序效率比不上直接插入排序)。

一百万个数排序消耗
void QuickSort(int* a, int left, int right)
{
	if (left >= right)
		return;

	// 小区间优化,不再递归分割排序,减少递归的次数
	if ((right - left + 1) < 10)
	{
		InsertSort(a+left, right - left + 1);
	}
	else
	{
		// 三数取中
		int midi = GetMidi(a, left, right);
		Swap(&a[left], &a[midi]);

		int keyi = left;
		int begin = left, end = right;
		while (begin < end)
		{
			// 右边找小
			while (begin < end && a[end] >= a[keyi])//加上begin < end,防止找值时出现begin>end的情况
			{
				--end;
			}

			// 左边找大
			while (begin < end && a[begin] <= a[keyi])
			{
				++begin;
			}

			Swap(&a[begin], &a[end]);
		}

		Swap(&a[keyi], &a[begin]);
		keyi = begin;
		// [left, keyi-1] keyi [keyi+1, right]
		QuickSort(a, left, keyi - 1);
		QuickSort(a, keyi + 1, right);
	}
}

在begin与end分别找大与找小的过程中我们也需要注意合理的区间begin<=end,因此begin与end找值的逻辑控制条件我们需要加上begin<end.

关于hoare版本相遇位置的补充说明(为什么左边做key,右边先走,可以保证相遇位置比key小)

2. 挖坑法

​挖坑法的基本思想是选最左边的数做key,并且当前位置作为一个坑位,然后右边的R往左走找比key小的值,找到值了,将值放到刚刚的坑位中,将坑填上,当前位置作为新坑,然后左边的L往右走找大,找到大数值,将值放到刚刚的坑位上,当前位置变成新的坑位,然后R继续走,重复上述步骤,最后L和R在坑位上相遇,这时将key放入。同样key左边比key小,右边比key大,然后同样我们调用函数自身去将区间左边排有序,再将函数右边排有序(不断往深递归),这样整个数组就都有序了。

为了实现函数递归调用自身以及区间内排key,我们同样需要将左右区间作为区间传递。

// 快速排序挖坑法
void QuickSort(int* a, int left, int right) {
	if (left >= right)
		return;
	//小区间优化
	if (right - left + 1 <= 10) {
		InsertSort(a + left, right - left + 1);
		return;
	}

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

	int key = a[left];
	int begin = left;
	int end = right;
	while (begin < end) {
		while (begin < end && a[end] >= a[key]) {//加上begin < end,防止找值时出现begin>end的情况
			end--;
		}

		a[begin] = a[end];

		while (begin < end && a[begin] <= a[key]) {
			begin++;
		}

		a[end] = a[begin];
	}
	a[begin] = key;
// 左区间[begin, keyi-1] keyi 右区间[keyi+1, end]

	QuickSort(a, left, begin - 1);
	QuickSort(a, begin + 1, right);

}

与hoare版本相比,挖坑法并没有效率上的提升,但是因为最左边的数一开始设为坑,所以不用分析,左边做key,右边先走的问题,因为相遇位置是坑,也不用分析,相遇位置为什么比key小的原因。整体的思想理解难度比hoare版本小。

3. 前后指针版本​

基本思想是选左边最小的数作为基准值,创建两个指针prev、cur,prev的起始位置在最左边,cur在prev的下一位,我们将cur指向的值与key比较,如果cur指向值小于key,那么就将pcur后移,再将pcur与cur指向的数据交换,再cur++,如果大于key,cur++,当cur走到区间之外就结束,将pcur指向的值与key交换。然后同样我们调用函数自身去排函数左区间和函数右区间。同样我们将区间边界作为参数传递。

prev开始指向的是key,如果cur指向值小于key,那么prev和cur一起向后走,这时prev与cur并未拉开差距,如果cur指向值大于key,那么cur++,prev和cur拉开差距也就是说,prev和cur之间相差的都是比key大的值,如果cur指向值小于key,prev先往后走,这时prev指向的就是比prev大的数(或者就是cur,不过这个是自己跟自己交换,可以忽略),我们将prev于cur指向的值交换,那么大的数就到后面去了,小的数到了前面,prev指向key小的值。沿着这样的逻辑,当cur走出边界是,prev与cur之间就是比key大的值,prev指向的是比key小的值,我们再将key与prev交换,这样key左边就是都比它小的值,右边就是都比它大的值。

// 快速排序前后指针法
void QuickSort(int* a, int left, int right) {
	if (left >= right)
		return;

	//小区间优化
	if (right - left + 1 <= 10) {
		InsertSort(a + left, right - left + 1);
		return;
	}

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

	int key = left;
	int prev = left;
	int cur = prev + 1;

 	while (cur <= right) {
		if (a[key] > a[cur] && ++prev != cur) {
			Swap(&a[prev], &a[cur]);
		}
		++cur;
	}
	Swap(&a[prev], &a[key]);
// 左区间[left, prev-1] prev 右区间[prev+1, right]
	QuickSort(a, left, prev - 1);
	QuickSort(a, prev + 1, right);
}

if (a[key] > a[cur] && ++prev != cur)这一步中,条件加上++prev != cur,这样当a[key] <= a[cur],prev的值不会发生变化,如果a[key] > a[cur],++pcur之后顺便与cur比较,可以防止出现自己与自己交换的情况(当然交换的也没事,影响不大)。

快速排序的特性总结:

1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序,但是快排的优越是建立在后续不断的研究上的,目前上文介绍的快排的性能、对一些特殊情况的处理仍然不够好,属于较早阶段的快排。

2. 时间复杂度:O(N*logN)(经过三数取中,最坏情况发生概率非常低)。

3. 空间复杂度:O(logN)(不断递归会产生系统栈的消耗,消耗的最大的时候就是递归深度最大的时候)

2.3.3快速排序非递归版本

快速排序的版本由于使用递归,当遇到一些情况会出现深度太深导致栈溢出的问题,因此学习如何使用非递归版本的快排还是很有必要的。

以上是递归版本快速排序代码展开图,我们发现递归的过程中一份相同的代码被执行多次,发生变化只有传递的区间边界的数值,递归传递的实质似乎就是传递了排序过程中出现的区间的边界数值。

如下图,我们创建栈用来存放数据(系统上的栈很小容易出现栈溢出,而数据结构上的栈的空间是在系统的堆区申请的,堆区空间很大,不容易发生溢出的问题),首先我们开始将原区间的左右边界的数值(可以传结构体,但是消耗较大)按先右后左顺序先入栈9再入0(出栈取区间时顺序就可以是先0后9了。当然也可以是先入0再入左,只不过为了模拟上面递归的过程,就先入9再入0),之后我们取栈顶的数据,排key划分左右区间,然后将左右区间的边界数值入栈,我们按照从右往左的顺序,入9、6、4、0,之后我们取出0、4,排key划分左右区间,然后再将4、3、1、0入栈…………我们发现这样入栈出栈之后,就可以实现类似二叉树后序遍历的递归逻辑,我们的快排的非递归改写答题逻辑就完成了。

#pragma once
#include<stdlib.h>
#include<stdbool.h>
#include<assert.h>
#include<stdio.h>


// 支持动态增长的栈
typedef int STDataType;

typedef struct Stack
{
	STDataType* _a;
	int _top;		// 栈顶
	int _capacity;  // 容量 
}Stack;

// 初始化栈 
void StackInit(Stack* ps);

// 入栈 
void StackPush(Stack* ps, STDataType data);

// 出栈 
void StackPop(Stack* ps);

// 获取栈顶元素 
STDataType StackTop(Stack* ps);

// 获取栈中有效元素个数 
int StackSize(Stack* ps);

// 检测栈是否为空,如果为空返回0 ,如果不为空返回非零结果
bool StackEmpty(Stack* ps);

// 销毁栈 
void StackDestroy(Stack* ps);

// 快速排序 非递归实现
void QuickSortNonR(int* a, int left, int right) {
	Stack s1;
	StackInit(&s1);
	StackPush(&s1, right);
	StackPush(&s1, left);

	while (!StackEmpty(&s1)) {
		int left = StackTop(&s1);
		StackPop(&s1);
		int right = StackTop(&s1);
		StackPop(&s1);

		//小区间优化
		if (right - left + 1 <= 10) {
			InsertSort(a + left, right - left + 1);
			return;
		}

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

		int key = left;
		int begin = left;
		int end = right;
		while (begin < end) {
			while (begin < end && a[end] >= a[key]) {//加上begin < end,防止找值时出现begin>end的情况
				end--;
			}
			while (begin < end && a[begin] <= a[key]) {
				begin++;
			}
			Swap(&a[begin], &a[end]);
		}
		Swap(&a[key], &a[begin]);
// 左区间[begin, keyi-1] keyi 右区间[keyi+1, end]
		if (right > key + 1) {
			StackPush(&s1, end);
			StackPush(&s1, key + 1);
		}
		if (left < key - 1) {
			StackPush(&s1, key - 1);
			StackPush(&s1, left);
		}

	}
	StackDestroy(&s1);

}

我们根据栈是否为空来作为while大循环结束的条件,如果区间边界数值合理,那么就会一直有数据可以入栈出栈,反之栈为空,说明所有数据已排完,我们结束循环。这个大循环控制的就是整个模拟递归的进行。

在每一次排key划分完左右区间后,入左右区间之前我们需要判断一下区间释放存在。最后递归结束将栈释放。

注:使用队列来进行快排非递归的实现也是可以的,但是由于队列先进先出的特性,模拟的逻辑就会变成二叉树层序遍历的逻辑(广度优先遍历),与快排递归版本的二叉树后续遍历(深度优先遍历)不一样,但是最终效果还是一样的。

2.3.4快速排序的深入优化探讨

1.快排性能的关键点分析:

决定快排性能的关键点是每次单趟排序后,key对数组的分割,如果每次选key基本二分居中,那么快排的递归树就是颗均匀的满二叉树,性能最佳。但是实践中虽然不可能每次都是二分居中,但是性能也还是可控的。但是如果出现每次选到最小值/最大值,划分为0个和N-1的子问题时,时间复杂度为O(N^2),数组序列有序时就会出现这样的问题,我们前面已经用三数取中或者随机选key解决了这个问题,也就是说我们解决了绝大多数的问题,但是现在还是有一些场景没解决(数组中有大量重复数据时),类似一下代码。

// 数组中有多个跟key相等的值
int a[] = { 6,1,7,6,6,6,4,9 };
int a[] = { 3,2,3,3,3,3,2,3 };
// 数组中全是相同的值
int a[] = { 2,2,2,2,2,2,2,2 };

912. 排序数组 - 力扣(LeetCode)下面我们再来看看这个OJ题,这个OJ,当我们用快排的时候,传统的hoare和lomuto的方法,过不了这个题目。堆排序和归并和希尔是可以过的,其他几个O(N^2)也不过了,因为这个题的测试用例中不仅仅有数据很多的大数组,也有一些特殊数据的数组,如大量重复数据的数组。堆排序和归并和希尔不是很受数据样本的分布和形态的影响,但是快排会,因为快排要选key,每次key都当趟分割都很偏,就会出现效率退化问题。

2.三路划分算法解析:

当面对有大量跟key相同的值时,三路划分的核心思想有点类似hoare的左右指针和lomuto的前后指针的结合。核心思想是把数组中的数据分为三段【比key小的值】 【跟key相等的值】【比key大的
值】,所以叫做三路划分算法。结合下图,理解一下实现思想:
1. key默认取left位置的值。
2. left指向区间最左边,right指向区间最后边,cur指向left+1位置。
3. cur遇到比key小的值后跟left位置交换,换到左边,left++,cur++。
4. cur遇到比key大的值后跟right位置交换,换到右边,right--。
5. cur遇到跟key相等的值后,cur++。
6. 直到cur > right结束

在实现思想的这一步cur遇到比key小的值后跟left位置交换,换到左边(left指向比key小的值,cur指向key),left++,cur++,交换之后先将left后移,保持left还是指向key值,而cur指向下一个未知值。

cur遇到比key大的值后跟right位置交换,换到右边,right--。这一步我们将大的值往后甩,但是不能后移cur,因为我们无法确定交换之后的cur指向的值与key的大小关系,下一轮循环继续比较。

cur遇到跟key相等的值后,cur++,这样cur不断往后走,与key相等的值留在left与right之内。

这样不断交换之后,当cur>right时,说明right处的位置数值已经比较过了,一定比key小(数值比key小,与left交换,left++,cur++,right指向key值;如果数值比key大,cur与right交换值,right--,通过上文,我们知道cur与left之间能拉开距离,那这些值一定是与key相等的值,所以right--之后,一定指向key值)。left、right控制的区间就是与key相等的值。

但是这种算法为了处理大量重复数据的特殊情况,实际的效率也有一定的下降。

// 三路划分
KeyWayIndex PartSort3Way(int* a, int left, int right)
{
	int key = a[left];
	// left和right指向就是跟key相等的区间
	// [开始, left-1][left, right][right+1, 结束]
	int cur = left + 1;
	while (cur <= right)
	{
		// 1、cur遇到比key小,小的换到左边,同时把key换到中间位置
		// 2、cur遇到比key大,大的换到右边
		if (a[cur] < key)
		{
			Swap(&a[cur], &a[left]);
			++cur;
			++left;
		}
		else if (a[cur] > key)
		{
			Swap(&a[cur], &a[right]);
			--right;
		}
		else
		{
			++cur;
		}
	}
	KeyWayIndex kwi;
	kwi.leftKeyi = left;
	kwi.rightKeyi = right;
	return kwi;
}
三种快排单趟排序运行结果分析:

从下面的运行结果分析,无论是hoare,还是lomuto的前后指针法,面对key有大量重复时,划分都不是很理想。三数取中或随机选key,都不能很好的解决这里的问题。但是三路划分算法,把跟key相等的值都划分到了中间,可以很好的解决这里的问题。

运行结果:
6 1 7 6 6 6 4 9
4 1 6 6 6 6 7 9
hoare keyi:2
3 2 3 3 3 3 2 3
2 2 3 3 3 3 3 3
hoare keyi:6
2 2 2 2 2 2 2 2
2 2 2 2 2 2 2 2
hoare keyi:0
6 1 7 6 6 6 4 9
4 1 6 6 6 6 7 9
前后指针 keyi:2
3 2 3 3 3 3 2 3
2 2 3 3 3 3 3 3
前后指针 keyi:2
2 2 2 2 2 2 2 2
2 2 2 2 2 2 2 2
前后指针 keyi:0
6 1 7 6 6 6 4 9
1 4 6 6 6 6 9 7
3Way keyi:2,5
3 2 3 3 3 3 2 3
2 2 3 3 3 3 3 3
3Way keyi:2,7
2 2 2 2 2 2 2 2
2 2 2 2 2 2 2 2
3Way keyi:0,7

我们发现上面讲的三路划分仍然存在一些问题,比如如果运气一直不好,选出的key值一直不合适,那么效率还是会非常差的。

3.introsort算法解析:

因此下面介绍一个工业上使用的快排版本,也是C++ STL 中sort的使用版本。

我们发现上面算法中,我们就是担心选key出现问题导致递归深度太深,性能退化严重。

introsort是introspective sort采用了缩写(introsort是由David Musser在1997年设计的排序算法),他的名字其实表达了他的实现思路,他对于这个问题的思路就是进行自我侦测和反省,如果快排递归深度太深超过设定的数值(sgi stl中使用的是深度为2倍排序元素数量的对数值)那就说明在这种数据序列下,选key出现了问题,性能在快速退化,那么就不要再进行快排分割递归了,改换为堆排序(效率较高,空间消耗较小)进行排序。

/**
* Note: The returned array must be malloced, assume caller calls free().
*/
void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = 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[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}
void HeapSort(int* a, int n)
{
	// 建堆 -- 向下调整建堆 -- O(N)
	for (int i = (n - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown(a, n, i);
	}
	// 自己先实现 -- O(N*logN)
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[end], &a[0]);
		AdjustDown(a, end, 0);
		--end;
	}
}
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;
	}
}
void IntroSort(int* a, int left, int right, int depth, int defaultDepth)
{
	if (left >= right)
		return;
	// 数组长度小于16的小数组,换为插入排序,简单递归次数
	if (right - left + 1 < 16)
	{
		InsertSort(a + left, right - left + 1);
		return;
	}
	// 当深度超过2*logN时改用堆排序
	if (depth > defaultDepth)
	{
		HeapSort(a + left, right - left + 1);
		return;
	}
	depth++;
	int begin = left;
	int end = right;
	// 随机选key
	int randi = left + (rand() % (right - left));//随机数选key
	Swap(&a[left], &a[randi]);
	int prev = left;
	int cur = prev + 1;
	int keyi = left;
	while (cur <= right)
	{
		if (a[cur] < a[keyi] && ++prev != cur)
		{
			Swap(&a[prev], &a[cur]);
		}
		++cur;
	}
	Swap(&a[prev], &a[keyi]);
	keyi = prev;
	// [begin, keyi-1] keyi [keyi+1, end]
	IntroSort(a, begin, keyi - 1, depth, defaultDepth);
	IntroSort(a, keyi + 1, end, depth, defaultDepth);
}
void QuickSort(int* a, int left, int right)
{
	int depth = 0;
	int logn = 0;
	int N = right - left + 1;
	for (int i = 1; i < N; i *= 2)
	{
		logn++;
	}
	// introspective sort -- 自省排序
	IntroSort(a, left, right, depth, logn * 2);
}
int* sortArray(int* nums, int numsSize, int* returnSize) {
	srand(time(0));
	QuickSort(nums, 0, numsSize - 1);
	*returnSize = numsSize;
	return nums;
}

2.4 归并排序

基本思想:

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

归并排序核心步骤:

递归版本:

void _MergeSort(int* a, int* tmp, int begin, int end)
{
	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;
	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, (end - begin + 1) * sizeof(int));
}

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

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

	free(tmp);
	tmp = NULL;
}

如上图,归并排序过程中将小数组归成大数组,我们不能直接在原数组上修改会导致数据丢失,因此这里我们创建一个数组tmp来存放临时的排完序的结果,但是归并排序使用递归实现,我们不能递归一次就创建一个申请一个新数组,因此,这里我们实现一个子函数,我们将递归的主要逻辑交由子函数实现。

子函数内,我们要排第一层的数组,那我们就需要先使用归并排序的子函数先将第二层两个数组(第一层数组左右两个区间)排序成有序(归并排序的逻辑:我们要排第一层的数组,那我们就需要第二层两个数组有序再归并成一个数组,二层的数组要先有序,就需要第三层的数组有序归并成第二层数组,同理第三层有序需要第四层数组有序归并成第三层,而第四层每一组数组只剩下一个数,可以视为有序。归并成第三层,第三层有序再归并排序成第二层,第二层再归并层第一层),接着我们归并第二层,创建begin1指向第一个数组,begin2指向第二个数组,我们比较begin1与begin2指向的位置,我们将较小的数据放到tmp中,对应begin++,继续比较,如果有一个数组内数据都放入tmp内,我们将另一个数组内数据都放入到tmp内。

需要注意的是我们创建i来控制数据放入tmp内的位置,我们不能直接将i赋为0,而是赋值为传入的begin,因为虽然我们上文我们是以第二层归第一层示范的,但在这之前我们使用分治的思想先将第二层排有序了,分治的过程中会有许多小区间归并成大区间的过程,每一次归并数组的起始数据在原数组a内的位置并不相同。memcpy(a+ begin, tmp+ begin, (end - begin + 1) * sizeof(int));归并完之后将有序的结果拷贝回原数组内,a+ begin, tmp+ begin,也是因为每次归并数据的起始位置不同。并且这里是每归并完一段就将tmp内对应位置的数据拷贝回去,及时将相关数据修正。

一百万个数
归并排序的特性总结:

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

2. 时间复杂度:O(N*logN),归并每一次每一个数组都划分成两个小数组,最终整个递归的深度就类似于完全二叉树,高为logN,每一层每两个数组归并成一个更大的数组,每一层都要遍历完原数组n个数,时间复杂度就是n*logn。

3. 空间复杂度:O(N),需要跟原数组一样大的数组来存放归并后的临时结果。

归并排序非递归版本:

我们发现上述递归过程中是不断通过小数组归并成大数组实现有序的,归并的逻辑都是相同只是每次归并的数组数据量不同,因此在实现非递归版本时,我们创建gap来控制一次归并的数组的大小,一开始我们尝试每一个数为一组归并,之后每两个数为一组归并…………(归并时时每两个为一组归并,因此,我们这里控制小数组数据个数成2次方增长)这样我们也就实现类似分治划分区间的效果。

但是因为边界是跟gap有关的,gap又是成2的次方增长的,所以区间边界会出现错误,我们需要修正一下。

观察打印的边界数值,我们发现存在三种越界的情况,可能只有end2越界,可能begin2、end2都越界,可能end1、begin2、end2都越界,其中后两种情况可以归为一类,因为这一类情况只有前面begin1、end1控制的这一组有有效数据,因此这一类情况出现我们就不用归并,如果是只有end2越界了,我们就需要将end2修正为n-1。

void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail");
		return;
	}
	
	// gap每组归并数据的数据个数
	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += 2 * gap)
		{
			// [begin1, end1][begin2, end2]
			int begin1 = i, end1 = i + gap - 1;
			int begin2 = i + gap, end2 = i + 2 * gap - 1;

			printf("[%d,%d][%d,%d] ", begin1, end1, begin2, end2);

			// 第二组都越界不存在,这一组就不需要归并
			if (begin2 >= n)
				break;

			// 第二的组begin2没越界,end2越界了,需要修正一下,继续归并
			if (end2 >= n)
				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));
		}

		printf("\n");

		gap *= 2;
	}

	free(tmp);
	tmp = NULL;
}

2.5 非比较排序

计数排序:

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

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

2. 根据统计的结果将序列回收到原来的序列中

计数排序本质上是利用数组的自然序号排序,为了避免空间浪费,我们首先遍历一遍数组,选出最大max与最小min的数,然后max-min+1得出要创建数组的大小,之后创建数组,并且数组每个元素要初始化为0,之后做相对映射,min对应数组count[0]的位置,之后每个数-min就是其在数组内的位置,我们再次遍历数组,每遍历一个元素,将其对应在count数组位置上的数加1。最后,count数组内每个元素就是对应原数组元素的出现次数,从前往后遍历count数组,将对应位置的数据放回原数组内,每放一次,对应统计次数减1,统计次数减到0,则继续放下一个位置对应数据。

void CountSort(int* a, int n)
{
	int min = a[0], max = a[0];
	for (int i = 1; i < n; i++)
	{
		if (a[i] < min)
			min = a[i];

		if (a[i] > max)
			max = a[i];
	}

	int range = max - min + 1;
	//printf("%d\n", range);

	int* count = (int*)calloc(range, sizeof(int));
	if (count == NULL)
	{
		perror("calloc fail");
		return;
	}

	// 统计次数
	for (int i = 0; i < n; i++)
	{
		count[a[i] - min]++;
	}

	// 排序
	int j = 0;
	for (int i = 0; i < range; i++)
	{
		while (count[i]--)
		{
			a[j++] = i + min;//因为是相对映射,i+min才能变回对应原值
		}
	}

	free(count);
}
一千万个数据排序消耗
计数排序的特性总结:

1. 计数排序在数据范围集中时,效率很高(数据分散,最值差的大,数组开的大,会造成时间与空间上的双重浪费),但是适用范围及场景有限(可以排字符、整数,但是不能排小数,带小数位的数与最值相减会得出小数,无法与count数组的自然序号对应)。

2. 时间复杂度:O(MAX(N,范围))或者O(N+range),先遍历原数组,再遍历计数的数组(计数数组大小是原数组内max-min+1的范围)

3. 空间复杂度:O(范围),根据最大值和最小值确定范围,额外使用范围大小的数组来统计数据出现的个数。

桶排序:

 桶排序(Bucket sort)或所谓的箱排序,是一个排序算法,工作的原理是将数组分到有限数量的桶里。每个桶再个别排序。桶排序只适用于位数都相同的情况,如都是两位数或者都是三位数,桶排序永远只建立十个桶(指针数组)。如上图,排两位数时,我们根据十位上的数字将其插到对应的桶内,桶内的数据还要进行排序。同时对数据也有要求适合能够比较均匀分布在各个链表桶内的数据,如果数据只在某一桶内,那么其实用其他排序会更好。总的来说桶排序的实际价值非常低。

桶排序

基数排序(Radix Sort)是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表示字符串(如电话号码或单词),所以基数排序也不限于整数。

基数排序先比小位再比大位(先比小位,那么再比较大位上的数时,如果该位上的数相同,就可以区分出大小),如下图,先比较个位上的数,再比较十位上的数,最后再比较百位上的数。这个算法相对计数排序来说复杂不少,要求数据的最好相同,并且不能排浮点数,负数也不可以排,较为鸡肋。

三、排序算法复杂度及稳定性分析

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

一般来说考虑单个数据的稳定性没什么意义,但是对于包含多种信息结构体,如考试成绩,当同学总分一样是,为了进一步筛选,我们会进行不同数据的比较,为了不使上一步比较之后的顺序丢失,这时候就需要考虑稳定性了。

对于直接插入排序,一个数与数组末尾的数比较,比它小就往前挪,与它相等可以选择挪也可以选择不挪,我们选择不挪,那么相同数的相对位置就不变。所以这个排序是稳定的。

对于希尔排序,相同的数预排序时,有可能分到不同的组,所以这个排序是不稳定的。

对于简单选择排序,虽然选择数时为了控制,我们可以统一选择相同数中最左边的那个,但是如果出现下面这种情况那是无法控制的,所以这个排序是不稳定的。

对于堆排序, 如下图,如果数据内全是二,当将2放到数组尾部相对位置一下就变了,所以这个排序是不稳定的。

对于冒泡排序,在将数据比较交换过程中,我们可以选择相同时不交换,相对位置不会发生变化,所以这个排序是不稳定的。

对于快速排序,如下图,当将key交换到中间,相对位置一下就变了,所以这个排序是不稳定的。

对于归并排序,当比较数据,如果数据相同,我们可以选择将位置靠前的数先放入临时数组中,所以相对位置可以不变,所以这个排序是不稳定的。

四、选择题练习

题目

1. 快速排序算法是基于( )的一个排序算法。

A 分治法

B 贪心法

C 递归法

D 动态规划法

2.对记录(54,38,96,23,15,72,60,45,83)进行从小到大的直接插入排序时,当把第8个记录

45插入到有序 表时,为找到插入位置需比较( )次?(采用从后往前比较)

A 3

B 4

C 5

D 6

3.以下排序方式中占用O(n)辅助存储空间的是

A 选择排序

B 快速排序

C 堆排序

D 归并排序

4.下列排序算法中稳定且时间复杂度为O(n2)的是( )

A 快速排序

B 冒泡排序

C 直接选择排序

D 归并排序

5.关于排序,下面说法不正确的是

A 快排时间复杂度为O(N*logN),空间复杂度为O(logN)

B 归并排序是一种稳定的排序,堆排序和快排均不稳定

C 序列基本有序时,快排退化成冒泡排序,直接插入排序最快

D 归并排序空间复杂度为O(N), 堆排序空间复杂度的为O(logN)

6.下列排序法中,最坏情况下时间复杂度最小的是( )

A 堆排序

B 快速排序

C 希尔排序

D 冒泡排序

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

答案

1.A

2.C

3.D

4.B

5.D

6.A

7.A

五、外排序之文件归并排序实现

1.外排序介绍

外排序(External sorting)是指能够处理极大量数据的排序算法。通常来说,外排序处理的数据不能一次装入内存,只能放在读写较慢的外存储器(通常是硬盘)上。外排序通常采用的是一种“排序-归并”的策略。在排序阶段,先读入能放在内存中的数据量,将其排序(其他效率较高的排序,使用归并排序还需要额外的O(N)的空间复杂度)输出到一个临时文件,依此进行,将待排序数据组织为多个有序的临时文件。然后在归并阶段将这些临时文件组合为一个大的有序文件,也即排序结果。
跟外排序对应的就是内排序,我们之前讲的常见的排序,都是内排序,他们排序思想适应的是数据在内存中,支持随机访问。归并排序的思想不需要随机访问数据,只需要依次按序列读取数据,所以归并排序既是一个内排序,也是一个外排序。

2.文件归并排序实现

2.1创建随机数据文件的代码

// 创建N个随机数,写到文件中
void CreateNDate()
{
	// 造数据
	int n = 1000000;
	srand(time(0));
	const char* file = "data.txt";
	FILE* fin = fopen(file, "w");
	if (fin == NULL)
	{
		perror("fopen error");
		return;
	}
	for (int i = 0; i < n; ++i)
	{
		int x = rand() + i;
		fprintf(fin, "%d\n", x);
	}
	fclose(fin);
}

2.2文件归并排序思路分析

1. 读取n个值排序后写到file1,再读取n个值排序后写到file2
2. file1和file2利用归并排序的思想,依次读取比较,取小的尾插到mfile,mfile归并为一个有序文件
3. 将file1和file2删掉,mfile重命名为file1
4. 再次读取n个数据排序后写到file2
5. 继续走file1和file2归并,重复步骤2,直到文件中无法读出数据。最后归并出的有序数据放到了
file1中。

注:这里我们不使用将待排序数据组织为多个有序的临时文件,然后在归并阶段将这些临时文件组合为一个大的有序文件的做法,因为就使用C语言来说,给数量较多的文件取不同的名字非常难受

2.3.文件归并排序代码实现

#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
void Swap(int* p1, int* p2)
{
	int tmp = *p1;
	*p1 = *p2;
	*p2 = 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[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}
void HeapSort(int* a, int n)
{
	// 建堆 -- 向下调整建堆 -- O(N)
	for (int i = (n - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown(a, n, i);
	}
	// O(N*logN)
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[end], &a[0]);
		AdjustDown(a, end, 0);
		--end;
	}
}
// file1文件的数据和file2文件的数据归并到mfile文件中
void MergeFile(const char* file1, const char* file2, const char* mfile)
{
	FILE* fout1 = fopen(file1, "r");
	if (fout1 == NULL)
	{
		printf("打开文件失败\n");
		exit(-1);
	}
	FILE* fout2 = fopen(file2, "r");
	if (fout2 == NULL)
	{
		printf("打开文件失败\n");
		exit(-1);
	}
	FILE* fin = fopen(mfile, "w");
	if (fin == NULL)
	{
		printf("打开文件失败\n");
		exit(-1);
	}
	// 这里跟内存中数组归并的思想完全类似,只是数据在硬盘文件中而已
	// 依次读取file1和file2的数据,谁的数据小,谁就往mfile文件中去写
	// file1和file2其中一个文件结束后,再把另一个文件未结束文件数据,
	// 依次写到mfile的后面
	int num1, num2;
	int ret1 = fscanf(fout1, "%d\n", &num1);
	int ret2 = fscanf(fout2, "%d\n", &num2);
	while (ret1 != EOF && ret2 != EOF)
	{
		if (num1 < num2)
		{
			fprintf(fin, "%d\n", num1);
			ret1 = fscanf(fout1, "%d\n", &num1);
		}
		else
		{
			fprintf(fin, "%d\n", num2);
			ret2 = fscanf(fout2, "%d\n", &num2);
		}
	}
	while (ret1 != EOF)
	{
		fprintf(fin, "%d\n", num1);
		ret1 = fscanf(fout1, "%d\n", &num1);
	}
	while (ret2 != EOF)
	{
		fprintf(fin, "%d\n", num2);
		ret2 = fscanf(fout2, "%d\n", &num2);
	}
	fclose(fout1);
	fclose(fout2);
	fclose(fin);
}
// 返回读取到的数据个数
int ReadNNumSortToFile(FILE* fout, int* a, int n, const char* file)
{
	int x = 0;
	// 读取n个数据放到file
	int i = 0;
	while (i < n && fscanf(fout, "%d", &x) != EOF)
	{
		a[i++] = x;
	}
	// 一个数据都没有读到,则说明文件已经读到结尾了
	if (i == 0)
		return i;
	// 排序
	HeapSort(a, i);
	FILE* fin = fopen(file, "w");
	if (fout == NULL)
	{
		printf("打开文件%s失败\n", file);
		exit(-1);
	}
	for (int j = 0; j < i; j++)
	{
		fprintf(fin, "%d\n", a[j]);
	}
	fclose(fin);
	return i;
}
// MergeSortFile的第二个是每次取多少个数据到内存中排序,然后写到一个小文件进行归并
// 这个n给多少取决于我们有多少合理的内存可以利用,相对而言n越大,更多数据到内存中排序后,
// 再走文件归并排序,整体程序会越快一些。
void MergeSortFile(const char* file, int n)
{
	FILE* fout = fopen(file, "r");
	if (fout == NULL)
	{
		printf("打开文件%s失败\n", file);
		exit(-1);
	}
	int i = 0;
	int x = 0;
	const char* file1 = "file1";
	const char* file2 = "file2";
	const char* mfile = "mfile";
	// 分割成一段一段数据,内存排序后写到,小文件,
	int* a = (int*)malloc(sizeof(int) * n);
	if (a == NULL)
	{
		perror("malloc fail");
		return;
	}
	// 分别读取前n个数据排序后,写到file1和file2文件
	ReadNNumSortToFile(fout, a, n, file1);
	ReadNNumSortToFile(fout, a, n, file2);
	while (1)
	{
		// file1和file2文件归并到mfile文件中
		MergeFile(file1, file2, mfile);
		// 删除file1和file2
		if (remove(file1) != 0 || remove(file2) != 0)
		{
			perror("Error deleting file");
			return;
		}
		// 将mfile重命名为file1
		if (rename(mfile, file1) != 0)
		{
			perror("Error renaming file");
			return;
		}
		// 读取N个数据到file2,继续走归并
		// 如果一个数据都没读到,则归并结束了
		if (ReadNNumSortToFile(fout, a, n, file2) == 0)
		{
			break;
		}
	}
	printf("%s文件成功排序到%s\n", file, file1);
	fclose(fout);
	free(a);
}
// 创建N个随机数,写到文件中
void CreateNDate()
{
	// 造数据
	int n = 1000000;
	srand(time(0));
	const char* file = "data.txt";
	FILE* fin = fopen(file, "w");
	if (fin == NULL)
	{
		perror("fopen error");
		return;
	}
	for (int i = 0; i < n; ++i)
	{
		int x = rand() + i;
		fprintf(fin, "%d\n", x);
	}
	fclose(fin);
}
int main()
{
	//CreateNDate();
	MergeSortFile("data.txt", 100000);
	return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ZLRRLZ

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值