数据结构与算法 - 排序 #直接插入排序 #希尔排序 #直接选择排序 #堆排序 #冒泡排序 #快速排序(hoare、挖坑、lomuto) #非递归版本快速排序 #归并排序 #非递归版本归并 #计数排序

文章目录


前言

路漫漫其修远兮,吾将上下而求索;


一、插入排序

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

(一)、直接插入排序

1、思路

当插入第i(i>=1) 个元素的时候,前面的array[0] , array[1],……array[i-1] 已经排好了(已然为有序),此时用array[i] 的排序码与array[i-1],array[i-2] ,……的排序码进行比较,找到插入的位置便可以将array[i]插入,将原来位置上的数据往后移动;

简单来说就是将待排序的数据往有序的序列中插入,将该数据与序列中的数据依次比较,在序列中找到合适的位置并挪动数据将这个位置空出来,然后再将该待排序数据放到该位置上;

思路图:

图解如下:

2、参考代码:

//直接插入排序
void InsertSort(int* a, int n)
{
	for (int i = 0; i < n - 1; i++)// i+1<n -->i<n-1
	{
		//变量end 指向有序序列的末尾
		int end = i;
		int tmp = a[end + 1];
		while (end >= 0)
		{
			if (a[end] > tmp)
			{
				a[end + 1] = a[end];
				//迭代
				end -= 1;
			}
			else//符合顺序便退出循环
			{
				break;
			}
			
		}
		//单趟的走完了,需要将tmp 放到end+1 的位置上去.因为end 永远指向尾数据的上一个数据
		a[end + 1] = tmp;
	}
}

3、复杂度计算:

直接插入排序的时间复杂度为 O(N^2)

直接插入排序什么时候直接插入排序的时间复杂度为O(N^2)?

  • 当所要排序的序列的数据为倒序(最坏的情况)的时候,那么每次内部循环均是以 end<0 而结束的,此时时间复杂度为最差;

直接插入排序的时间复杂度和冒泡排序的时间复杂度均为 O(N^2),它们二者的效率一样吗?

  • 但是在实际当中,直接插入排序表并不是每一次均会比较到end 越界,也就是说所要排序的数据不一定全是倒序,即直接直接插入排序的时间复杂度一般是小于O(N^2)的;而冒泡排序在最好的情况(待排数据本身就是升序或者降序)时间复杂度为O(N) ,而一旦数据不是目标的顺序,冒泡排序的时间复杂度一定为O(N^2);

为什么当待排序列为目标顺序的时候,冒泡排序的时间时间复杂度为O(N)?

  • 因为我们在写冒泡排序的时候定义了一个变量 flag 来标识判断当前所排序列是否为目标顺序(升序或者降序);如果待排序列为目标顺序,那么该数组中的数据在走了一趟的循环的时候其中的数据一次也没有进行交换,即flag 还是为初始值;此时只需要判断flag 是否为初始值便可以判断当前待排序列是否为目标顺序;一旦flag 为初始值,便会break 退出循环,此时的时间复杂度为 O(N);

综上,虽然直接插入排序的时间复杂度与冒泡排序的时间复杂度看上去一样均为O(N^2),但是实际上这两种排序算法的效率是不一样的;

直接插入的时间复杂度一般达不到O(N^2) ,但是由于也接近O(N^2) ,也算不上是一个很好的算法;

有没有什么方法可以优化直接插入排序呢?

  • 分组;下面所述的希尔排序就是针对直接插入排序缺点优化的算法

(二)、希尔排序

1、思路

我们先来思考一下,当数组为降序的时候,如何优化直接插入排序?

外部循环的次数是定的,优化的切入点应该从内部循环下手;

减少内部的比较次数;

如上图所示,通过分组,可以用较小的代价将大数据往后挪动,小数据向前挪动,相较于原始版本的直接插入排序,效率高了很多;这就是希尔排序;

希尔排序法又称缩小增量法。希尔排序的基本思想先选定一个整数(通常是 gap=n/3+1), 将待排序序列分为多组,所有的距离相等的数据分在同一组中,并对每一组中内的数据进行排序;然后再gap=gap/3+1 将数组再分组(组会越来越少),并进行插入排序,当gap为1的时候,就相当于直接插入排序;

希尔排序是在直接插入排序额基础上进行改进而来的,综合来说希尔排序的效率是肯定要高于直接插入排序算法;

2、参考代码:

//希尔排序
void ShellSort(int* a, int n)
{
	int gap = n ;
	while (gap > 1)
	{
		//+1 是为了保证最后一次的gap 一定为1
		//gap>1 时是预排序
		//gap==1 时是插入排序
		gap = gap / 3 + 1;
		for (int i = 0; i < n - gap; i++)// i+gap<n --> i<n-gap
		{
			//内部就是直接插入的逻辑,只不过距离为gap 的数据为同一组数据
			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;
		}
	}
}

通过上述代码,希尔排序就是预排序+直接插入排序

为什么循环的判断条件是gap>1 而不是gap>=1?

  • 可见,由于gap=gap/3+1 是在循环内部的,外层循环的判断条件为 gap>1 便可,如若为gap>=1 还会多走一趟无效的比较;

 为什么gap=gap/3+1?

一般情况下是让gap 除3或者除2,如若除较大的值例如7或者8,如下:

gap 表示了分为同一组间数组之间的距离,也就是说gap 越大组越多,gap 越小组越少;

  • gap 越大,再前面的大的数据可以快速地跳转到后面,后面的小数据可以快速地跳转到前面
  • gap越小,跳转地越慢,数据地顺序会越来越接近有序,当gap==1 的时候相当于直接插入排序 ( 待排序列变为有序 )

如上图所示,当gap = gap/7+1;的时候得到的gap 的值相较于 gap = gap/3+1; 得到的gap 的值要小很多;也就是说划分的组数越少,那么在前面较大的数据就不能快速地跳转到后面,在后面较小的数据就不能快速地跳转到前面;故而,gap/2 或者 gap/3 是比较合适的;

为什么gap = gap/3 +1 , 最后要+1?

  • 因为存在gap/3 == 0 的情况,要保证最后一次希尔排序gap==1 ,故而需要加上1 以保证最后一次gap一定为1;

希尔排序分特性:

1、希尔排序是对直接插入排序的优化;

2、当gap>1 的时候是预排序,目的是让数组跟接近于有序。当gap==1 的时候,数组已经接近有序。

3、时间复杂度计算:

外层循环:循环的判断条件为:gap>1, 其时间复杂度为O(logN)

内部第二层for循环:for(int i = 0; i<n-gap;i++) 

内部第一层的while循环:while (end>=0) 

粗略一看希尔排序的时间复杂度为O(logN*N^2),但是实际不然;

内部的第二层循环与内部第一层循环受gap 的影响,如下图:

通过以上分析可以得到下图:

从上图中可以看出,希尔排序在最初和最后一次的排序其时间复杂度均为n , 即前一阶段排序次数是逐渐上升的状态,当到达某一点的时候排序的次数逐渐下降;

由于gap 的取值有很多(由数据量决定),不好计算这也就导致了希尔排序的时间复杂度不好计算,这也就是在不同书中给出的希尔排序的时间复杂度不一样的原因;

在严蔚敏老师的《数据结构(C语言版)》中:

可以将希尔排序的时间复杂度看作O(N^1.5) 或者O(N^1.3)

二、选择排序

选择排序的基本思想:

  • 每一次从待排序的数据中选出最小(或最大)的一个元素,存放在序列的起始位置(如若选最大的数据就要放在序列最后的位置上),直到所有待排序的数据均排完时结束;

(一)、直接选择排序

1、思路

  • 1、在待排序列( array[i]~array[n-1] )中选择最大或者最小的数据
  • 2、如果这个数据不是最后一个(或者第一个)的数据,那么便会与待排序列中最后一个(或者第一个)数据进行交换;
  • 3、在剩余的 array[i] ~ array[n-2] 集合中,重复上述步骤,直到待排序列中只有一个数据便结束;

如下图所示:

此处的直接选择排序只选择了最大或最小,即为单指针;倘若想优化可以使用双指针;简单来说就是在待排序列中找最大值、最小值,找到之后将最大值放在待排序列的最后,最小值放在待排序列的最前;

如下图:

2、参考代码

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

//直接选择排序 
void SelectSort(int* a, int n)
{
	int begin = 0;
	int end = n - 1;
	while (begin < end)
	{
		int mini = begin, maxi = begin;
		for (int i = begin + 1; i <= end; i++)
		{
			if (a[i] < a[mini])
			{
				mini = i;
			}
			else if (a[i] > a[maxi])
			{
				maxi = i;
			}
		}
		//出循环便代表mini 找到了待排序列中最小的,maxi 找到了待排序列中最大的
		//将mini 中的数据与begin 中的数据进行交换,将maxi 中的数据与end 中的数据进行交换
		if (maxi == begin)
		{
			maxi = mini;
		}
		Swap(&a[mini], &a[begin]);
		Swap(&a[maxi], &a[end]);

		begin++;
		end--;
	}
}

上述代码中还处理了一个特殊情况:由于写代码的时候是先交换mini 中与 begin 中的数据如果maxi 找到的数据放在begin 中,那么下一轮maxi 与end 中的数据进行交换的时候就是mini 中的数据与end 中的数据进行交换;如下图:

3、时间复杂度计算

外层循环时间复杂度:O(N) * 内层循环时间复杂度:O(N)

--> 直接选择排序的时间复杂度为:O(N^2)

(二)、堆排序

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

在讲二叉树的时候涉及过堆排序,此处就不再赘述,可以参考此链接:数据结构与算法 - 树 #数的概念 #二叉树 #堆 - 堆的实现/堆排序/TOP-K问题-优快云博客

三、交换排序

交换排序的基本思想:

所谓交换就是根据序列中两个记录键值的比较结果来对换两个记录在序列中的位置

交换排序的特点:将键值大的记录向序列的尾部移动,键值较小的记录向序列的前部移动;

简单来说就是通过交换的方式将大数据放在后面,将小数据放在前面

(一)、冒泡排序

冒泡排序的思想是一种最为基础的交换排序;而之所以叫做冒泡排序是因为每一个数据都可以像小气泡一样,根据自身的大小一点点向数组的一侧移动;

参考代码:

//冒泡排序 排升序
void BubbleSort(int* a, int n)
{
	//n个数据需要走 n-1 趟冒泡排序
	for (int i = 0; i < n - 1 ; i++)
	{
		int flag = 0;
		//每一趟会确定一个数据的最终位置,每一趟比较的数据的个数是不一样的
		for (int j = 0; j < n - 1 - i; j++) //j+1 < n -->j<n-1
		{
			if (a[j] > a[j + 1])
			{
				flag = 1;
				Swap(&a[j], &a[j + 1]);
			}
		}
		//判断待排数据是否本身就是有序的标志
		if (flag == 0)
			break;
	}
}

冒泡排序的时间复杂度为O(N^2);

(二)、快速排序

注:快排涉及的内容有点多,单独分了一篇文章单独来总结~

【快速排序 #hoare版本 #挖坑法 #lomuto前后指针 #三数取中 #三路划分 #introsort自省排序】

四、归并排序

归并排序的内容也比较多,单独开了一篇文章来写归并排序~

【#递归版本 #非递归版本 #文件归并】

五、计数排序

上述的排序均有一个特征:需要去比较值的大小才能进行的排序;

当然还有非比较排序 (无需让数据之间进行比较也可以进行排序) —— 计数排序

计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用

思路如下:

  • 1、统计相同元素出现的次数
  • 2、根据统计的结果将序列回收到原来的序列中;

简单来说,就是借助于数组以及数组下标与待排序数据产生联系

这个数组究竟该创建多大的呢?

  • 可能首先是想到遍历原数组,找出最大值max,那么所要创建的空间的大小就是max+1 ; 这个思路可行,但是会浪费许多空间,还有一种就是:遍历待排序列中的数据找到最大值、最小值,根据最大值与最小值的差值来出创建数组;

如何让数组(假设创建的数组名叫做 count)下标与待排序列中的数据产生联系呢?

  • 让最小的数据(min) 存放在下标为0的空间中,最小的数据与0之间的差值(实际上为min )就是待排序列中数据与数组下标之间的关系;简单来说就是将待排序列中的数据映射到创建的数组count之中

如何将数组count 中的数据放回到原空间中?

  • 定义一个变量 i 来遍历count 数组,那么count[i] 则是表示数据 "i+min" 出现了 count[i] 次;遍历count 数组并且按照次数拷贝放回原空间便可;

图解如下:

代码如下:

//计数排序
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;//左闭右闭区间计算个数相减需要加1
	//创建数组 并且初始化
	int* count = (int*)calloc(range , sizeof(int));
	if (count == NULL)
	{
		perror("malloc fail");
		exit(-1);
	}
	//统计次数
	for (int i = 0; i < n; i++)
	{
		//待排序列中数据与count 数组下标之间的关系: count下标 = 待排数据 - min
		count[a[i] - min]++;
	}
	//将count 数组中的数据放回到原空间中
	int j = 0; 
	for (int i = 0; i < range; i++)
	{
		while (count[i]--)
		{
			a[j++] = i + min;
		}
	}

	//释放动态开辟的空间
	free(count);
	count = NULL;
}

注:一定要记得将创建的数组每个字节初始化为0!!!!!

六、测试各种排序的效率

排序总阶如下:

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

//希尔排序
void SheelSort(int* a, int n);

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

//堆排序
void HeapSort(int* a, int n);

//冒泡排序
void BubbleSort(int* a, int n);

//快速排序
void QuickSort1(int* a, int left, int right);

//快排的优化 - 三路取中
void QuickSort2(int* a, int left, int right);

//introsort 自省排序
void QuickSort3(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((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);
	int* a8 = (int*)malloc(sizeof(int)*N);
	int* a9 = (int*)malloc(sizeof(int)*N);
	int* a10 = (int*)malloc(sizeof(int)*N);
	int* a11 = (int*)malloc(sizeof(int)*N);
	int* a12 = (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];
		a8[i] = a1[i];
		a9[i] = a1[i];
		a10[i] = a1[i];
		a11[i] = a1[i];
		a12[i] = a1[i];
	}

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

	int begin2 = clock();
	SheelSort(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();
	BubbleSort(a5, N);
	int end5 = clock();

	int begin6 = clock();
	QuickSort1(a6, 0 , N-1);
	int end6 = clock();

	int begin7 = clock();
	QuickSort2(a7, 0, N - 1);
	int end7 = clock();

	int begin8 = clock();
	QuickSort3(a8, 0, N - 1);
	int end8 = clock();

	int begin9 = clock();
	QuickSortNonR(a9, 0, N - 1);
	int end9 = clock();

	int begin10 = clock();
	MergeSort(a10, N);
	int end10 = clock();

	int begin11 = clock();
	MergeSortNonR(a11, N);
	int end11 = clock();

	int begin12 = clock();
	CountSort(a12, N);
	int end12 = clock();

	printf("InsertSort:%d\n", end1 - begin1);
	printf("SheelSort:%d\n", end2 - begin2);
	printf("SelectSort:%d\n", end3 - begin3);
	printf("HeapSort:%d\n", end4 - begin4);
	printf("BubbleSort:%d\n", end5 - begin5);
	printf("QuickSort1:%d\n", end6 - begin6);
	printf("QuickSort2:%d\n", end7 - begin7);
	printf("QuickSort3:%d\n", end8 - begin8);
	printf("QuickSortNonR:%d\n", end9 - begin9);
	printf("MergeSort:%d\n", end10 - begin10);
	printf("MergeSortNonR:%d\n", end11 - begin11);
	printf("CountSort:%d\n", end12 - begin12);

	//释放数组
	free(a1);
	free(a2);
	free(a3);
	free(a4);
	free(a5);
	free(a6);
	free(a7);
	free(a8);
	free(a9);
	free(a10);
	free(a11);
	free(a12);
}

测试10万个数据的排序速度如下:

(最主要是看排序算法之间计算时间的比较)

七、复杂度以及稳定性的分析

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

简而言之就是待排序列中存在重复数据,如果经过排序之后,这些重复数据的相对位置没有发生改变便说明这个算法的稳定性好;

排序方法时间复杂度 - 平均时间复杂度 - 最好时间复杂度 - 最坏空间复杂度稳定性
直接插入排序O(N^2)O(N)O(N^2)O(1)稳定
希尔排序O(NlogN) ~ O(N^2)O(N^1.3)O(N^2)O(1)不稳定
直接选择排序O(N^2)O(N^2)O(N^2)O(1)不稳定
堆排序O(NolgN)O(NlogN)O(NlogN)O(1)不稳定
冒泡排序O(N^2)O(N)O(N^2)O(1)稳定
快速排序O(NlogN)O(NlogN)O(N^2)O(logN) ~O(N)不稳定
归并排序O(NlogN)O(NlogN)O(NlogN)O(N)稳定

八、排序算法思想总结

直接插入排序:

向有序的序列中插入待排序的数据,需要将此待排序数据将有序序列中的数据进行比较以找到合适的位置;

找合适位置的过程

希尔排序:是针对当待排序数据为倒序时,其时间复杂度为O(N^2) 的优化;通过分组的方式,让排在后面的小数据快速地跳转到前面,排在前面较大的数据快速地跳转到后面;

直接选择排序:首先是遍历待排序序列中的数据找到最大值、最小值,然后将最大值、最小值放在最后、最前。再调整待排序数据的范围;

堆排序:借助于“堆”结构的思想;(排升序,建大堆;排降序,建小堆);首先是需要对待排序数据进行建堆(根据目标顺序选择建大堆或者建小堆),然后让堆根中的数据与待排数据尾进行交换,对堆根中的数据进行向下调整,调整待排序的数据范围……重复上述做法……

冒泡排序:相邻的数据进行比较,交换位置。不需要用到额外的空间,时间复杂度为:O(1);时间复杂度为O(N^2);

快速排序:找基准值,然后根据基准值的下标将待排序列进行划分;

归并排序:(分治思想)分别取两个待排序列中的数据,然后依次将较小值放到另外一个空间(tmp)之中……最后将tmp 中的数据拷贝放回原数组中;需要额外的空间,空间复杂度为O(N) ;时间复杂度为:O(N*logN)

计数排序:找待排序数据与新创建的数组count 下标之间的关系,在新创建的数组count 中存放该数据出现的次数;需要创建额外的空间,空间复杂度为O(N)


总结

1、直接插入排序:将待排序的数据往有序的序列中插入,将该数据与序列中的数据依次比较,在序列中找到合适的位置并挪动数据将这个位置空出来,然后再将该待排序数据放到该位置上;

2、希尔排序:又称缩小增量法先选定一个整数(通常是 gap=n/3+1), 将待排序序列分为多组,所有的距离相等的数据分在同一组中,并对每一组中内的数据进行排序;然后再gap=gap/3+1 将数组再分组(组会越来越少),并进行插入排序,当gap为1的时候,就相当于直接插入排序

3、直接选择排序:在待排序列中找最大值、最小值,找到之后将最大值放在待排序列的最后,最小值放在待排序列的最前;

4、计数排序借助于数组以及数组下标与待排序数据产生联系

  • 1、统计相同元素出现的次数
  • 2、根据统计的结果将序列回收到原来的序列中;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值