数据结构《排序》

发布于:2024-09-05 ⋅ 阅读:(11) ⋅ 点赞:(0)

在之前数据结构之算法复杂度章节中我们学习了复杂度相关的概念,这就使得懂得如何来区分算法的好坏,在之前C语言专题中在指针的学习时我们了解了冒泡排序,之后再数据结构的二叉树章节中我们又学习了堆排序,其实排序不止这两种,还有直接插入排序、快速排序等,在本篇中我们就会来相信学习各种的排序,还会对这些排序方法进行复杂度的分析来分析出哪些是好的排序方法,接下来就开始排序篇章的学习吧!!  !

863c19ad485f4fe98d645d580158df5c.jpeg


1.排序概念及运用

在学习各种排序之前我们先来了解排序的相关概念和在现实中的运用

1.1概念

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

1.2 运用

排序广泛运用在各种场景中,就例如计算机中的文件系统的文件排序就是根据时间、文件名、大小、修改日期等属性进行排序,以下的文件就是按照时间排序的

d6dde7825ef849c0973c3a1d9ac862c4.png

再比如在购物网站中也非常频繁的用到排序,就比如在购物网站要购买电脑时就可以看到会出现按评论数排序、按价格排序等的排序方法;有了这些不同的排序就可以让购物者更快速的选择到心仪的商品
f0519dae0d974855a7b732e410ed5fc8.png

1.3 常见排序算法

在以上了解的排序的概念和运用后接下来我们就来了解常见的排序算法有哪些
7ca382be180d4656a39e8dfeb2daa09a.png

通过以上的图示就可以看出排序可以分为4大类,在这些排序当中我们之前了解过了冒泡排序和堆排序,接下来我们就来深入理解其他排序

2. 实现常见排序算法

2.1插入排序 

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

de35b2c478fa497aabafe390619cbc1c.png

就比如在打扑克时,在整理牌的时候就用到了插入排序的思想,我们拿到一张牌后就会将这张牌插入到合适的位置

2.1.1直接插入排序  

直接插入排序的思想:
当插入第 i(i>=1) 个元素时,前面的 array[0],array[1],…,array[i-1] 已经排好序,此时用array[i] 的排序码与 array[i-1],array[i-2],… 的排序码顺序进行比较,找到插入位置即将 array[i] 插入,原来位置上的元素顺序后移

32befbafc87f4deabbf2a0fe8fae1a79.gif

例如在以下的示例当中要将其排序为升序要如何实现呢?
e862db5ff8fd437c8a68ea649f1ca2ba.png

要将以上的数据排为升序先定义一个变量end一开始实现第一个数据,定义变量tmp存储下一个数据的值,如果end指向的数据大于tmp就将end指向的数据值赋值给end+1的位置,之后将end-1继续将该位置的数据与tmp比较重复以上的操作;若不大于就让end+1,将tmp也改变为end+1指向的数据的值

例如以上示例中在将4插入到前面的数据中时会进行以下操作

0cb48ffc00b847eca7f82c63d13e03f0.png

abc80e00a85d4864b748d53076baa6bc.png

之后其他数据的插入也是按照以上的方法来实现

代码实现

完成了直接插入排序方法的分析后接下来就来实现该排序算法的代码实现

先在Sort.h内完成函数的声明

//直接插入排序
void InsertSort(int* arr, int n);

将函数命名为InsertSort,函数的参数有两个,第一个是数组名,第二个是数组的元素个数  

之后就是在Sort.c内进行函数的定义

以下使用for循环来遍历数组,在此让变量tmp=arr[end+1]这样就可以使得tmp的值为end下标下一位的值,在此由于end+1的值最大只能到n-2,否则就造成数组的越界访问,因此在for循环的判断条件为i<n-1

在for循环内再定义一个while循环来实现tmp内的数据与之前已排序好的数据进行比较,在出了while循环后就将tmp内的数据插入到数组下标为end+1的位置

//直接插入排序
void InsertSort(int* arr, int n)
{
	for (int i = 0; i < n - 1; i++)
	{
		int end = i;
		int tmp = arr[end + 1];
		while (end >= 0)
		{
			if (arr[end] > tmp)
			{
				arr[end + 1] = arr[end];
				end--;
			}
			else
			{
				break;//值不大于tmp内的值就跳出while循环
			}
		}
		arr[end + 1] = tmp;
	}
}
复杂度分析

在实现了直接插入排序的代码后接下来我们来分析这种算法的复杂度
670ee5bc17da4bfe9be1774ef21e1042.png
 

在外层循环为n-1次,内层循环在第一个外层循环时最坏情况循环一次;在第二个外层循环时最坏情况循环两次,因此内层的循环次数就为一个等差数列,总的while指向次数就是等差数列求和,最终算法的时间复杂度就为eq?O%28N%5E%7B2%7D%29,并且当要排序的数据一开始就是有序的,那么时间复杂度就为eq?O%28N%29

因此在直接插入排序中元素集合越接近有序,直接插入排序算法的时间效率越高

因为在该排序算法中没有申请新的内存空间,因此空间复杂度为eq?O%281%29

2.1.2希尔排序

在以上的直接插入排序中当要排序的数据一开始是降序的时候这时排序的效率就为最差,时间复杂度为eq?O%28N%5E%7B2%7D%29,那有什么方法能优化直接插入排序这种不足呢?

希尔排序就是直接插入排序优化后得来的,在排序过程中我们会加入以下操作:
先选定⼀个整数(通常是gap = n/3+1),把待排序文件所有记录分成各组,所有的距离相等的记录分在同⼀组内,并对每⼀组内的记录进行排序,然后gap=gap/3+1得到下⼀个整数,再将数组分成各组,进行插入排序,当gap=1时,就相当于直接插入排序。

注:在希尔排序中在gap为1之前的排序称为预排序 ,gap为1时就是直接插入排序

例如以下的示例使用希尔排序的思想过程就如下所示

第一趟排序中如果gap为5,那么就使得将距离为5的数据化为一组,将每组都排序好就变为以下形式
38c75afa327b427e882e86ce0e41652d.png

之后如将以上第一趟排序好的数据再按照gap为2进行第二趟排序,那么就使得距离为2的数据化为一组,将每组排序好就变为以下形式
376c4c538d4743448e0ce62757b5bc99.png 最后再完成以上的排序后相比原来的数据,以上排序之后就变为基本有序,最后再进行一次gap为1的排序就能将原数据变为升序

8e88f13d42684713a80a2c62986ee713.png

 完成了以上的分析就可以试着来实现希尔排序的代码了

代码实现

先在Sort.h内完成函数的声明

//希尔排序
void ShellSort(int* arr, int n);

将函数命名为ShellSort,函数的参数有两个,第一个是数组名,第二个是数组的元素个数  

之后就是在Sort.c内进行函数的定义

假设gap值为3,那么以下代码的第一个for循环就将原数组分为3部分,在第二个for循环内就进行每个部分的排序,在该部分中基本与直接插入排序基本相同,不过在希尔排序中每部分每个元素相距gap,因此以下原来直接插入排序中加减一的部分要改为加减gap

void ShellSort(int* arr, int n)
{
	int gap = 3;
	for(int j=0;j<gap;j++)
	{
		for (int i = 0; i < n - gap; i+=gap)
		{
			int end = i;
			int tmp = arr[end + gap];
			while (end >= 0)
			{
				if (arr[end] > tmp)
				{
					arr[end + gap] = arr[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			arr[end + gap] = tmp;
		}
	}
}

虽然以上代码确实符合希尔排序,但是以上代码是用3个循环嵌套的,是否能优化这点不足呢?

在之前实现希尔排序的代码中我们是将分组后各个部分的排序分开进行,其实可以直接将不同部分的排序同时进行,这就只需要将以上代码内的第一个for循环删除再将第二个for循环内的调整部分改为i++,并且在这些代码外层再包含一层while循环,该循环来控制gap的值,在此gap在/3后再加一是为了保证最后一次gap一定为1

以下就是将以上代码修改之后的希尔排序最终的代码

//希尔排序
void ShellSort(int* arr, int n)
{
	int gap = n;
	while (gap>1)
	{
		gap = gap / 3 + 1;//保证gap最后一次一定为1
		for (int i = 0; i < n-gap; i++)
		{
			int end = i;
			int tmp = arr[end + gap];
			while (end >= 0)
			{
				if (arr[end] > tmp)
				{
					arr[end + gap] = arr[end];
					end-=gap;
				}
				else
				{
					break;
				}
			}
			arr[end + gap] = tmp;
		}
	}
}

在以上实现了希尔排序的代码后你可能会想希尔排序是优化直接插入排序的不足,那么希尔排序怎么相比直接插入排序还多了一层循环,那么时间复杂度不应该提高吗?

其实不是这样的,希尔排序相比直接插入排序的时间复杂度是减少很多的,原因是希尔排序在预排序中就会基本让数组中数据大的在前,小的在后,这就会使得数据基本有序在预排序结束之后直接插入排序时排序的次数会大大减少,这就会使得时间复杂度大大减低
 

复杂度分析

在实现了希尔排序的代码后接下来就来细致的分析希尔排序的复杂度

首先来分析希尔排序外层循环的时间复杂度,在第一次进入循环时gap=n/3,之后第二次就变为n/3/3,之后不断进入循环直到gap为1时就跳出该while循环,因此进入循环的公式就可以的出3^{x}=n,进入循环次数就为\log_{3}n 

分析了外层循环接下来继续来分析内存循环

如以上示例当n为9时如果gap为3,那么每组就为3个元素,将这3个元素排序好最坏的情况次数就为1+2 ,移动总数就为9

假设⼀共有n个数据,合计gap组,则每组为n/gap个;在每组中,插入移动的次数最坏的情况下为
1 + 2 + 3 + .... +(\frac{n}{gap}-1)⼀共是gap组,因此:
总计最坏情况下移动总数为: gap *(1+2+3+....+(\frac{n}{gap}-1))
gap取值有(以除3为例):n/3 n/9 n/27 ...... 2 1

• 当gap为n/3时,移动总数为:\frac{n}{3}*(1+2)=n
• 当gap为n/9时,移动总数为:\frac{n}{9}*(1+2+3+....+8)=\frac{n}{9}*\frac{8(1+8)}{2}=4n
• 最后⼀躺,gap=1即直接插入排序,内层循环排序消耗为n

通过以上的分析,可以画出这样的曲线图:

因此,希尔排序在最初和最后的排序的次数都为n,即前⼀阶段排序次数是逐渐上升的状态,当到达某⼀顶点时,排序次数逐渐下降至n,而该顶点的计算暂时无法给出具体的计算过程 

希尔排序时间复杂度不好计算,因为 gap 的取值很多,导致很难去计算,因此很多书中给出的希尔排序的时间复杂度都不固定。《数据结构(C语言版)》--- 严蔚敏书中给出的时间复杂度为:

 

2.2选择排序 

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

2.2.1直接选择排序

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

例如在以下的示例当中要将其排序为升序要如何实现呢?

首先创建两个变量begin和endbegin为一开始数组第一个元素得下标,而end一开始为数组最后的下标再创建一个变量min来表示数组中最小值的下标,并且一开始让其初始化时与begin值相同,之后通过遍历数组找到数组中得最小值将元素下标赋值给min,之后交换下标begin和下标min的数据,完成以上操作后将begin加一。一直重复以上操作直到begin值和end相同时

 完成了以上的分析就可以试着来实现希尔排序的代码了

代码实现

先在Sort.h内完成函数的声明

//直接选择排序
void SelectSort(int* arr, int n);

将函数命名为SelectSort函数的参数有两个,第一个是数组名,第二个是数组的元素个数  

 之后就是在Sort.c内进行函数的定义

//直接选择排序
void SelectSort(int* arr, int n)
{
	int begin = 0;
	int end = n - 1;
	while (begin < end)
	{
		int min = begin;
		for (int i = begin + 1; i <= end; i++)
		{
			if (arr[i] < arr[min])
			{
				min = i;
			}
		}
		Swap(&arr[min], &arr[begin]);
		++begin;
	}
}

以上代码确实可以实现直接选择排序,但以上的算法在一次循环中只能找到最小值其实可以对以上的算法进行优化让其在一次循环能找到最大值max和最小值min,这时只需要将完成找大和找小后交换下标end和下标max的数据,在完成操作后将begin的加一同时将end减一

以下就是优化后的代码 

注意使用这种算法时会有一种特殊的情况要处理,就是当在使用Swap交换前min下标和begin相同;max和end下标相同,例如以下示例
 

在以上的数组中如果是使用之前的算法就需要将下标为begin的元素和下标为下标为min的元素交换,这时进行完之后就变为了1 2 3已经变为了升序,当之后下标为end的元素和下标为下标为max的元素交换这就会使得数组又变为3 2 1

通过以上的示例就可以发现当begin和max相等且end和min相等时,交换两次就会使得这次交换失效,在此解决方法是当begin等于max时;将max移到min的位置也就是将min赋值给max

注:在以上特殊情况判断时如果在if判断部分写的是max==begin,那么在if语句内部就是max=min如果在if判断部分写的是min==end,那么在if语句内部就是min=max,如果将这两种交叉使用就会在排序结果无法预测

//直接选择排序
void SelectSort(int* arr, int n)
{
	int begin = 0;
	int end = n - 1;
	while(begin<end)
	{
		int min = begin;
		int max = begin;
		for (int i = begin + 1; i <= end; i++)
		{
			if (arr[i] < arr[min])
			{
				min = i;
			}
			if (arr[i] > arr[max])
			{
				max = i;
			}
		}
		if (max ==begin)
		{
			max = min;
		}
		Swap(&arr[min], &arr[begin]);
		Swap(&arr[max], &arr[end]);
		++begin;
		--end;
	}
}

复杂度分析 

在实现了直接选择排序的代码后接下来就来细致的分析直接选择排序的复杂度

通过以上代码的分析就可以看出该算法代码是由两个循环嵌套的,第一次内层循环次数为n-2次,第二次为n-4次,直到最后2次,因此总的内层循环执行次数就为等差数列求和,最终可求出直接选择排序的时间复杂度为O(N^{2})

该排序由于没有申请新的内存空间,因此空间复杂度为O(1)

2.2.2堆排序

堆排序相关的介绍和讲解在数据结构之《二叉树》(中)因此在本篇就不再对该算法进行讲解

2.3交换排序

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

2.3.1冒泡排序

冒泡排序相关的介绍和讲解在C语言的深入理解指针(2),冒泡排序的复杂度讲解在算法复杂度,因此在本篇就不再对该算法进行讲解

2.3.2快速排序

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

在快速排序中过程就如以上图示是不断的在找几尊值,当被分到每个区间都只存在基准值时就将数据排序好了。通过以上图示还可以看出快速排序在不断找基准值过程中展现出的结构就如二叉树一样

了解了快速排序的大体执行流程后,那么快排的代码该如何实现呢?

接下来我们就来分析看看首先是快速排序函数的参数和之前实现的排序有所不同,快速排序一开始是在整个数组中找一个基准值当由于之后要继续划分多个区间因此需要有区间的左右值,因此快速排序的参数除了数组名外还有有数组的起始和结束对应的下标

//快速排序
void QuickSort(int* arr, int left, int right);

快速排序实现主框架:

在快速排序中我们使用的是递归的方法来实现各区间中基准值的寻找,递归的限制条件是当left大于right,也就是当左区间大于等于右区间时就停止递推

在此QuickSort中的_QuickSort是该函数的子方法,是用于找基准值

//快速排序
void QuickSort(int* arr, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	//_QuickSort⽤于按照基准值将区间[left,right)中的元素进⾏划分
	//找基准值
	int keyi=_QuickSort3(arr, left, right);
	//[left,keyi-1]  [keyi+1,right]
	QuickSort(arr, left, keyi - 1);
	QuickSort(arr, keyi + 1, right);
}

在快速排序中找基准值的方法有多种,也就是_QuickSort的实现方法有多种,接下来将一一介绍

 hoare版本

算法思路 :
(1)创建左右指针,确定基准值
(2)从右向左找出比基准值小的数据,从左向右找出比基准值大的数据,左右指针数据交换,进入下次循环

例如以下示例: 

 

在以上数组当中使用hoare版本找基准值的方法流程如下所示
首先先定义变量keyi为基准值下标,一开始将keyi初始化值为left的值,之后将left+1,之后left向右找比5大的值下标,right向左找比基准值小的值下标,之后交换left下标和right下标的元素

 

之后继续重复以上操作最终left等于right就停止操作,这时再将下标keyi的值和下标right的值交换,这时下标为right的元素就为基准值 

该过程完整流程如下所示:

完成了hoare版本找基准值的示例分析接下来就试着来实现代码

//找基准值——hoare版本
int _QuickSort(int* arr, int left, int right)
{
	int keyi = left;
	left++;
	while (left <= right)
	{
		while (left <= right && arr[left] < arr[keyi])
		{
			left++;
		}
		while (left <= right && arr[right] > arr[keyi])
		{
			right--;
		}
		if (left <= right)
		{
			Swap(&arr[left++], &arr[right--]);
		}
	}
	Swap(&arr[right], &arr[keyi]);
	return right;
}

对于以上的代码你可能会有以下的疑问

问题1:为什么left 和 right指定的数据和key值相等时也要交换?

要解答这个以上这个问题来看下面这个示例

在以上的数组当中如果在找基准值时left和right的数据相等时不交换就会使得在right从右向左找小过程中right一直到key的位置,这时基准值就是用来key下标的数据

这时就会使得每次找到基准值都为最左侧的元素,这回就会使得找基准值进行n次,这时程序的效率就会很低 

那如果找基准值时left和right的数据相等时交换又会是什么样的情况呢?
 

这种情况时left和right第一次移动后图示如下

最终找到基准值的图示如下所示

这时按照这种方法之后找基准值的次数一定不会在为n次,以上示例解释为什么left和right指定的数据和key值相等时也要交换

问题2:为什么跳出循环后right位置的值⼀定不大于key?

要解答这个以上这个问题来看下面这个示例

在以上的示例当中若代码当中判断部分都是left<right那么最终基准值就为6,但这时就出现问题了
6的左边有大于基准值的也有小于基准值的这就不符合对于取基准值的要求 

在来看代码当中判断部分都是left<=right以上示例最终基准值就变为以下形式

这时在基准值6的左边都是小于基准值的,在基准值6的右边都是大于基准值的这就符合我们的预期了

通过以上的示例就可以解释为什么当left等于right时还要再进入循环一次

挖坑法

思路:
创建左右指针。首先从右向左找出比基准小的数据,找到后立即放入左边坑中,当前位置变为新的"坑",然后从左向右找出比基准大的数据,找到后立即放入右边坑中,当前位置变为新的"坑",结束循环后将最开始存储的分界值放入当前的"坑"中,返回当前"坑"下标(即分界值下标)

 例如以下示例: 

 

当我们使用挖坑法来找以上数组的基准值时,先将坑位hole初始化为数组首元素下标,并且保存该坑位的值5到变量keyi中,之后right从右往左找比5小的,找到后将找到的值赋值给hole并且之后将坑位改变,之后left从左往右找比5大的,找到后将找到的值赋值给hole并且之后将坑位改变,之后一直重复以上操作直到left等于right时就停止

 完成了hoare版本找基准值的示例分析接下来就试着来实现代码

//找基准值——挖坑法
int _QuickSort2(int* arr, int left, int right)
{
	int hole = left;
	int keyi = arr[left];
	while (left < right)
	{
		if (left<right && arr[right]>keyi)
		{
			right--;
		}
		arr[hole] = arr[right];
		hole = right;

		if (left < right && arr[left] < keyi)
		{
			left++;
		}
		arr[hole] = arr[left];
		hole = left;

	}
	arr[hole] = keyi;
	return hole;
}

对于以上的挖坑法的代码你可能又会有疑惑为什么不同于hoare版本的代码在此判断又变成left<right,在left等于right时不再继续移动

以上是由于在挖坑法中left和right的移动是单独进行的,而hoare版本中left和right的移动是同时进行的

lomuto前后指针 

算法思路 :
创建前后指针,从左往右找比基准值小的进行交换,使得小的都排在基准值的左边。

以下是该算法的示例: 

  完成了前后指针法找基准值的示例分析接下来就试着来实现代码

//找基准值——前后指针法
int _QuickSort3(int* arr, int left, int right)
{
	int prev = left;
	int pcur = left + 1;
	int keyi = left;
	while (pcur<=right)
	{
		if (arr[pcur] < arr[keyi] && ++prev != pcur)
		{
			Swap(&arr[prev], &arr[pcur]);
		}
		++pcur;
	}
	Swap(&arr[keyi], &arr[prev]);
	return prev;
}
复杂度分析 

在以上我们实现了快速排序,那么接下来就对其复杂度进行分析

在快速排序中函数递归次数为\log n,而且每层都要遍历n个元素,因此快速排序时间复杂度为O(n\log n)

空间复杂度为O(\log n)

2.3.3快速排序非递归版本

在以上我们实现的快速排序是用递归的方式来实现的,但当递归的深度很深时就会使得创建的函数栈帧非常多,就任意导致栈溢出,因此在快速排序中我们除了使用递归的方式还有什么其他的方法能实现快速排序呢?

在此非递归版本的快速排序需要借助数据结构:栈

在实现非递归版本的快速排序代码前先来了解是如何借助栈来实现快速排序的

例如以下示例:

 首先先将数组最右侧和最左侧的的下标向后入栈

之后取两次栈顶元素后出两次栈,取出两次栈根据取出的两个栈顶元素得到相应的基准值,再划分数组区间 

该数组第一次基准值数组下标为3,因此就将数据划分为[0,2]和[4,6]两个区间,之后再将右区间的两个下标入栈,再将左区间的两个下标入栈。

之后再取两次栈中的的栈顶元素再出栈两次,再根据基准值划分区间

在此就得到 [0,-1]和[1,2]两个区间,[0,-1]为无效区间就不再入栈,[1,2]入栈

 

之后再取两次栈中的的栈顶元素再出栈两次,再根据基准值划分区间

在此就得到 [1,0和[2,2]两个区间,[1,0]为无效区间就不再入栈,[2,2]内只有一个数据不入栈,因此之后再取两次栈中的的栈顶元素再出栈两次,再根据基准值划分区间

在此就得到 [4,3]和[5,6]两个区间,[4,3]为无效区间就不再入栈,[5,6]入栈

之后再取两次栈中的的栈顶元素再出栈两次,再根据基准值划分区间

在此就得到 [5,4和[5,5]两个区间,[5,4]为无效区间就不再入栈,[5,5]内只有一个数据不入栈,在此之后栈为空就完成了整个的操作

通过以上的示例就了解了如何借助栈来完成非递归版本的快速排序接下来就来实现代码

//快速排序(非递归法)
void QuickSortNonR(int* arr, int left, int right)
{
	stack st;
	StackInit(&st);
	StackPush(&st, right);
	StackPush(&st, left);

	while (!StackEmpty(&st))
	{
		int begin = StackTop(&st);
		StackPop(&st);
		int end = StackTop(&st);
		StackPop(&st);

		//找基准值
		int prev = begin;
		int pcur = begin + 1;
		int keyi = begin;
		while (pcur <= end)
		{
			if (arr[pcur] < arr[keyi] && ++prev != pcur)
			{
				Swap(&arr[prev], &arr[pcur]);
			}
			++pcur;
		}
		Swap(&arr[prev], &arr[keyi]);
		keyi = prev;

		//划分区间
		//[begin,keyi-1] [keyi+1,end]

		if (keyi + 1 < end)
		{
			StackPush(&st, end);
			StackPush(&st, keyi + 1);
		}
		if (begin<keyi-1)
		{
			StackPush(&st, keyi - 1);
			StackPush(&st, begin);
		}
		
	}
	StackDestory(&st);
}

2.4归并排序

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

归并排序核心步骤: 

在归并排序的分解过程就结构像是二叉树一样,也像是递归过程中的递推过程,在该过程中不断对数组进行二分,直到每个区间都只有一个元素时,这时每个序列就都是有序的;再将两个相对应的序列合并,在此两个有序序列的合并的算法思想就和之前顺序表算法题篇章中合并两个有序数组算法题相同

在合并两个有效序列过程中由于要合并的序列都是在同一个数组内的,这就使得合并的过程会较为繁琐,因此再创建一个大小于原数组大小相同的数组来临时存储有效序列合并后的值,之后再在每一次合并后再将值重新赋值给原数组

例如以下示例归并过程:

在以上示例中tmp是我们创建的临时数组,以上tmp数组的是将原数组分解后各个序列进行第一次合并后得到的形式,之后再将数组内的值赋值给原数组

之后再进行第二次有序序列的合并,tmp数组以及原数组就变为以下形式

之后再进行第三次有序序列的合并,tmp数组以及原数组就变为以下形式

通过以上的多次合并原数组就变为有序

代码实现

通过以上的示例就了解了归并排序的整个流程接下来就来实现代码

在以上代码中 _MergeSort是MergeSort的一个子方法,这样在递归原数组时就不需要多次创建tmp数组

//归并排序
void _MergeSort(int* arr, int left, int right, int* tmp)
{
	if (left >= right)
	{
		return;
	}

	int mid = (left + right) / 2;
	//[left,mid] [mid+1,right]
	_MergeSort(arr, left, mid, tmp);
	_MergeSort(arr, mid + 1, right,tmp);

	//合并
	int begin1 = left;
	int end1 = mid;
	int begin2 = mid + 1;
	int end2 = right;
	int index = begin1;//临时数组下标起始值
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (arr[begin1] > arr[begin2])
		{
			tmp[index++] = arr[begin2++];
		}
		else
		{
			tmp[index++] = arr[begin1++];
		}
	}
	while (begin1 <= end1)
	{
		tmp[index++] = arr[begin1++];
	}
	while (begin2 <= end2)
	{
		tmp[index++] = arr[begin2++];
	}
	for  (int i = left;i <= right;i++)//将临时数组内数据传给原数组
	{
		arr[i] = tmp[i];
	}
}


void MergeSort(int* arr, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	_MergeSort(arr, 0, n - 1, tmp);
	free(tmp);
	tmp = NULL;
}
复杂度分析

在实现了归并排序后接下来就来分析其的复杂度

首先是归并排序递归的深度为\log n,接下来归并排序中的子方法_MergeSort的时间复杂度

在以上函数中就可以看出在第一个循环中当是在对最多元素进行合并时,假设这时合并的两个序列元素个数都为n/2,该循环的执行次数就为n/2次, 之后的两个循环执行次数相较该循环就可以忽略不记,最后一个for循环在当是在对最多元素进行合并时将tmp数组内n个元素赋值给原数组;因此该for循环执行次数就为n次。通过以上分析就可以得出单次递归时间复杂度为O(N)因此该排序算法总的时间复杂度就为O(N\log N)

而由于创建了n大小的数组tmp,外加归并排序递归的深度为\log n因此该排序算法总的空间复杂度就为O(N)

2.5测试代码:排序性能对比

在以上我们已经了解了常见的排序算法,我们大体得出各个排序算法的时间复杂度,但是还是无法直观对比各个排序的时间效率,为了解决以上的不足,下面这段测试代码就能使我们直观的得出每个排序的快慢

在此这段代码是先随机生成100000个数据,再将这些数据都拷贝到各个数组当中,这样就可以让每个排序算法排序时的数据都是一样的,之后再通过得出各个排序排序前后的时间,再相减就得到了各个排序算法的运行时间,通过运行时间就可以得出运行效率 

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);
	int* a7 = (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];
		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();
	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();
	int begin7 = clock();
	BubbleSort(a7, N);
	int end7 = 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);
	printf("BubbleSort:%d\n", end7 - begin7);
	free(a1);
	free(a2);
	free(a3);
	free(a4);
	free(a5);
	free(a6);
	free(a7);
}

运行以上代码输出结果如下所示: 

 

通过排序的时间来看希尔排序、堆排序、快速排序、归并排序的时间效率都非常的不错,而剩余的排序算法的效率不高

2.6 非比较排序

在之前的排序算法当中都会使用数据的比较,那么接下来我们要学习的比较排序就是不再使用比较的方法也能实现排序,在本篇中非比较排序我们学习的是计数排序

2.6.1 计数排序

首先来了解计数排序的原理:

计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。 操作步骤:
(1)统计相同元素出现次数
(2)根据统计的结果将序列回收到原来的序列中

计数排序中是先将待排序中各元素出现次数统计,之后再创建一个数组这时将之前出现的元素当作数组下标,将元素出现的次数放在对应数组下标内 

例如以下示例:

在此就会有一个问题了,在新创建的数组的大小是如何确定的呢?这时你可能会想到根据原来数据中最大的数据来确定不就可以了吗,就比如以上示例当中原数据最大为9那么新创建数组的大小就为9,在以上这个示例当中这种想法确实没问题,但是如果原数组是以下这种情况呢?
 这时如果我们按照以上的想法原数据都集中到100~109之间,但是我们申请的数组空间为109个,这时就会造成大量的空间浪费,因此以上这种根据原来数据中最大的数据来确定新数组大小是想不通的

在此对于该问题最佳的解决方法就是让新数组的大小为原数据中最大值减去最小值再加一
就例如以上的例子当中使用这种方法相比我们之前的方法就大大减少空间的浪费

代码实现

通过以上的示例就了解了计数排序的整个流程接下来就来实现代码

//计数排序
void CountSort(int* arr, int n)
{
	int max = arr[0], min = arr[0];
	for (int i = 1; i < n; i++)
	{
		if (arr[i] > max)
		{
			max = arr[i];
		}
		if (arr[i] < min)
		{
			min = arr[i];
		}
	}
	int counts = max - min + 1;
	int* tmparr = (int*)calloc(counts, sizeof(int));
	if (tmparr ==NULL)
	{
		perror("calloc fail");
		exit(1);
	}
	memset(tmparr, 0, counts * sizeof(int));
	for (int i = 0; i < n; i++)
	{
		tmparr[arr[i] - min]++;
	}
	int index = 0;
	for (int i = 0; i < counts; i++)
	{
		while (tmparr[i]--)
		{
			arr[index++] = i + min;
		}
	}
}

 复杂度分析

在实现了计数排序的代码后接下来来分析计数排序的复杂度

通过以上代码就可以看出在第一个for循环执行次数为n次,第二个for循环执行次数也为n次,第三个for循环执行次数为n+counts次,因此代码的时间复杂度为O(N+counts)

在代码中我们创建大小为counts的数组Count,因此代码空间复杂度为O(counts) 

在计数排序中当待排数据很集中时排序的效率会非常的高,但是计数排序在一些场景下会无法使用,就比如计数排序只能对整数进行排序无法对小数排序,因此计数排序会有以下的特征

计数排序的特性:
计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。

3.排序算法复杂度及稳定性分析

在本本篇中我们学习了许多种排序算法,那么接下来就对这些排序算法做一个总结,在此之前我们要来了解排序算法的稳定性是说明

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

排序方法 平均情况 最好情况 最坏情况 辅助空间 稳定性
冒泡排序 O(n^{2}) O(n) O(n^{2}) O(1) 稳定
直接选择排序 O(n^{2}) O(n^{2}) O(n^{2}) O(1) 不稳定
直接插入排序 O(n^{2}) O(n) O(n^{2}) O(1) 稳定
希尔排序 O(n\log n)~O(n^{2}) O(n^{1.3}) O(n^{2}) O(1) 不稳定
堆排序 O(n\log n) O(n\log n) O(n\log n) O(1) 不稳定
归并排序 O(n\log n) O(n\log n) O(n\log n) O(n) 稳定
快速排序 O(n\log n) O(n\log n) O(n^{2}) O(n\log n)~O(n) 不稳定

以上就是数据结构——排序章节所有的内容了,希望能得到你的点赞收藏


网站公告

今日签到

点亮在社区的每一天
去签到