《数据结构》八大排序和拓展的排序(详细教学并提供多种版本、动态图分析)

本文聚焦数据结构中的排序算法,是校招常考知识点。介绍了排序概念、内外排序区别,详细讲解插入、选择、交换、归并、基数、计数等排序算法,包括原理、代码实现、复杂度分析和稳定性判断,还提及文件外排序、测试代码及校招考核范围。

今天,我将带来数据结构的排序算法,排序算法作为校招中常考知识点之一,我们必须要熟练的掌握它,对自己提出高要求,才能有高回报。





排序的概念和应用

排序的概念:

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

在生活中,排序的应用是非常的广泛的,比如在我们高考前,我们会在网上搜寻大学的排名,或者在双十一等日子,我们在淘宝挑着想要购买的电脑,它们都按照着一个关键字的大小来进行排序的,如大学排行榜按高考成绩,淘宝显示的电脑顺序按价格或者好评率。

大学排行榜
在这里插入图片描述

淘宝
在这里插入图片描述
由此可见,排序算法是多么的重要,那么,我们开始算法的教学吧。



内部排序和外部排序

概念:

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

简单来说,当我们需要排序的数据远远大于内存所能存放的最大空间,我们只能通过某种方式直接对磁盘存储的数据进行排序,称之为外部排序。如果,我们需要排序的数据量小,可以拿到内存进行排序,称之为内部排序



排序算法需要掌握的知识

1.在学习完八大排序后,我们必须熟练的掌握它们的思想,并且能够熟悉它们的代码实现。
2.我们必须要理解它们对应的时间复杂度和空间复杂度。
至于时间复杂度和空间复杂度的讲解,我在前面的文章已经提到,下面是
传送门
时间复杂度和空间复杂度(以题目的方式来介绍和分析)
3.我们必须掌握每个排序算法的稳定性。

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

如:我们需要排序序列,1 2 3 2,黑色字体的2在浅色字体2的后面。

如果我们排序后,结果为1 2 2 3,即两个数字2的顺序没有被打乱,依然是黑色字体的2在浅色字体2的后面,那么该排序算法就是稳定的。

如果排序后的序列为1 2 2 3,即两个数字2的顺序被打乱,黑色字体的2在浅色字体2的前面,那么该排序算法就是不稳定的。



插入排序

1.直接插入排序

假设我要让一个乱序的数组变成升序,那么我将从第二个元素开始,依次跟前面的元素进行比较。

过程如下:
我选择第二个元素跟第一个元素进行比较,如果,第二个元素小于第一个元素,则进行交换,如果是第二个元素大于第一个元素或者相等,则不进行交换。
这一套下来,前两个元素已经升序了。
接下来,我选择第三个元素,依次跟第二、第一个元素进行比较,依然是第三个元素小,就进行交换。
直到比较完最后一个元素,那么数组就变成升序了。

以下是动图:
在这里插入图片描述

下面是代码实现:

#include<stdio.h>
void InsertSort(int* arr,int n)
{
   
   
	for (int i = 0; i < n - 1; i++) //考虑tmp最后要取到最后一个元素,即n-1,i最大为n-2,保证i+1最大为 n - 1
	{
   
   
		int end = i;
		int tmp = arr[end + 1];//保存取出来的值
		while (end >= 0)
		{
   
   
			if (arr[end] > tmp)
			{
   
   
				arr[end + 1] = arr[end]; //相当于前一个元素小,向后移
				end--;
			}
			else
			{
   
   
				break;
			}
		}
		arr[end + 1] = tmp;
	}
}
int main()
{
   
   

	int arr[] = {
   
   10,2,19,3,12,25,15,36,30,5};
	InsertSort(arr,sizeof(arr)/sizeof(arr[0]));
	return 0;
}

排序前:
在这里插入图片描述

排序后:
在这里插入图片描述

直接插入排序的时间复杂度分析
最坏的情况:当我们要对数组进行排为升序的时候,初始数组为降序;当我们要排降序的时候,初始数组为升序。
上面的两种情况导致了,我们对每一个元素都需要往前进行调整多次。

如:5 4 3 2 1
我们要把它排成升序,按照直接插入排序。
第一趟结果4 5 3 2 1,4往前调整1次
第二趟结果3 4 5 2 1,3往前调整2次
第三趟结果2 3 4 5 1,2往前调整3次
第三趟结果1 2 3 4 5,1往前调整4次

如果上面调整次数有点不清楚,可以看上面的动图。

在上面的直接插入排序的代码中,有for循环和while循环。
当for循环第一次进入时,即为第一趟排序,因为要排序前两个元素;第二次进入时,即为第二趟排序,因为要排序前三个元素,所以for循环即为该排序的趟数。

当while循环第一次进入时,即为某一趟的第一次调整,第二次进入时,即为某一趟的第二次调整,所以while循环即为该排序的调整次数。

综上,排序第几趟与for循环第几次相关,调整次数与while循环次数相关。

在上面的排序数组5 4 3 2 1中,我们还可以发现,排序第一趟,调整一次,排序第二趟,调整两次,即第几趟排序,就调整几次。综上,for循环第几次进入,就相应的进行几次while循环。

在这里插入图片描述

总共循环次数为1 + 2 + 3 + …… + n = n^2/2 + n /2

由大O渐表示法可得,最坏的情况的时间复杂度为:O(N^2)。

值得注意的是,上面的for循环是第几次进入,所以计算循环次数即为每进入一次for循环后,进行的while循环个数。

最好的情况:当我们要排序升序时,初始数组是升序或者接近升序;当我们要排序降序时,初始数组是降序或者是接近降序。

此时,我们的每趟排序的调整次数都是0或者接近于都是0,即while循环大多数都是进入,然后直接break出来,即接近于都是循环1次。

在这里插入图片描述
循环次数:n次。

由大O渐表示法可得,最好的情况的时间复杂度是:O(N)。

值得注意的是,上面的for循环是第几次进入,所以计算循环次数即为每进入一次for循环后,进行的while循环个数。

时间复杂度和空间复杂度(以题目的方式来介绍和分析)的文章可以得知,时间复杂度都是按最坏的情况,所以直接插入排序的时间复杂度是:O(N^2)。

直接插入排序的空间复杂度分析
由于直接插入排序的开辟的空间为常量级,所以空间复杂度为O(1)。

直接插入排序的稳定性分析
稳定性:稳定。

原因如下:我们在排序数组时,可以让数字在比对的过程中,如果相等就不要替换,直接插入到该数字的后面的位置,保证该排序的稳定性。

如:1 2 3 2
第一次调整,1 2 2 3 后
两个数字2相等,但是我们不要进行交换,保证直接插入排序算法的稳定性。

总结:
直接插入排序中:
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:稳定



2.希尔排序

希尔排序跟直接插入排序相比,增加了一个预排序的过程,来达到优化直接插入排序的效果。

在预排序中,增加了一个gap值,这个gap值是用来分组的。如下:

假设一个数组有10个元素,我们要排为升序,gap值为4,那么相同组中的元素,中间隔4个其他组的元素,如下图:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
上面的图片就已经是分组完成的了,那么分组有什么用呢?接下来,我来解答这个问题。

在上面的分组完成的图片中,我们可以确定有5组,每组有2个元素,那么在比对的过程中,我们只让组中元素进行比对,比如蓝色组只在蓝色组内比较,红色组只在红色组内比较,如下图:
在这里插入图片描述
那么,我们什么时候比对完成呢,毫无疑问,当比对掉所有的组,即比对完黄色的那一组,我们就已经比对完了。

比对完的结果如下:
在这里插入图片描述
在此次的预排序中,我们让大的元素一下跳跃几步到后面,小的元素一下跳跃几步到前面,最典型的还是数值2和数值3,只一步就跳到了前面。为直接插入排序做好了准备,防止某些元素在直接插入排序中,移动过多,拉低了整体排序的效率。

上面的讲解中,我们已经知道了预排序的gap是用来分组的,并且搞懂了是如何分组的,还有知道了是在组内元素进行比对的,还有搞懂了预排序中组内比对的好处。

接下来,我们就要搞懂gap的整个取值过程。
在最开始的过程中,gap初始化为n(数组元素个数),注意初始化后是为了公式求值,而不是gap的第一次值就是n。
接下来,gap = gap / 3 + 1
每取到一个gap值,就进行一次分组,组内比对,比对完,按公式改变gap的值,直到gap的值为1时,预排序结束,直接插入排序开始,这就是希尔排序的过程。

以下是动图
在这里插入图片描述
代码如下:

#include<stdio.h>
void ShellSort(int* arr, int n)
{
   
   
	int gap = n;
	while (gap > 1)//当gap等于2进入循环,就可以取到1了,循环条件改为大于等于1,会死循环
	{
   
   
		gap = gap / 3 + 1;
		for (int i = 0; i < n - gap; i++)//当gap为1时,n - gap的值是n - 1,满足后面的直接插入排序对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;
		}
	}
}
int main()
{
   
   
	int arr[] = {
   
   10,3,5,25,2,12,19,30,15,16};
	ShellSort(arr,sizeof(arr)/sizeof(arr[0]));
	return 0;
}

排序前:
在这里插入图片描述

排序后:
在这里插入图片描述

希尔排序的时间复杂度分析

希尔排序的时间复杂度的分析较为困难,下面截至一本书。

《数据结构(C语言版)》— 严蔚敏
在这里插入图片描述
在希尔排序的时间复杂度分析中,还进行了大量的实验,我们直接记住结论就行。

希尔排序的时间复杂度:O(N^1.3)。

希尔排序的空间复杂度分析
由于希尔排序开辟的空间是常量级的,所以希尔排序的时间复杂度是:O(1)。

希尔排序的稳定性
由于在预排序中,相同的数字可能不在同一组中,那么进行组内交换时可能会改变相同数字的位置,所以希尔排序是不稳定的。

希尔排序的稳定性:不稳定。

总结:
希尔排序中:
时间复杂度:O(N^1.3)
空间复杂度:O(1)
稳定性:不稳定



选择排序

1.直接选择排序

直接选择排序是查找到序列中最小的值和最大的值,然后如果要排序升序的话,就将最小值和第一个元素交换,将最大值和最后一个元素交换。然后排除掉第一个元素和最后一个元素,继续寻找最小值和最大值,分别与第二个元素和倒数第二个元素进行交换,依次下去,直到数组有序。

比如,我要在一个数组中排序为升序,并且排序为升序,该数组的长度为n。(我们采用begin和end来表示需要排序的范围,比如最开始时,begin等于0,end等于数组元素减一,排序范围为全部的元素,当进行第一趟排序后,begin等于1,end等于n-2,排序掉第一个元素和倒数第一个元素)
在这里插入图片描述
下面是动图
在这里插入图片描述
下面是代码实现:

void swap(int* a,int* b)
{
   
   
	int tmp = *a;
	*a = *b;
	*b = tmp;
}
void SelectSort(int* arr,int n)
{
   
   
	int begin = 0, end = n - 1;
	while (begin < end)
	{
   
   
		int Mini = begin, Maxi = end;
		for (int i = begin; i <= end; i++)
		{
   
   
			if (arr[i] < arr[Mini])
			{
   
   
				Mini = i;
			}
			if (arr[i] > arr[Maxi])
			{
   
   
				Maxi = i;
			}
		}
		swap(&arr[begin],&arr[Mini]);
		if (begin == Maxi)    //调整,最大值因为上面的交换由begin下标所在的位置变为了Minx下标所在的位置
			Maxi = Mini;
		swap(&arr[Maxi],&arr[end]);
		begin++;           
		end--;
	}
}
int main()
{
   
   
	int arr[] = {
   
    10,3,5,25,2,12,19,30,15,16 };
	SelectSort(arr, sizeof(arr) / sizeof(arr[0]));
	return 0;
}

排序前:
在这里插入图片描述

排序后:
在这里插入图片描述

直接选择排序的时间复杂度分析
观察代码可以得知,有两层循环,但是我们不能认为认为两层循环,该排序的时间复杂度就是:O(N^2),有时候结果不是这样。

假设排序数组中,数组的元素个数是n。
第一趟排序中,begin的值为0,end的值为n-1,那么在寻找最大值和最小值的比较次数是n。
第二趟排序中,begin的值为1,end的值为n-2,那么在寻找最大值和最小值的比较次数是n-2。
第三趟排序中,begin的值为2,end的值为n-3,那么在寻找最大值和最小值的比较次数是n-4。
……
第n/2趟排序中,begin的值为n/2-1,end的值为n/2,那么在寻找最大值和最小值的比较次数是2。

第几趟排序即为代码中while循环中的第几次循环,比较次数即为for循环的第几次循环。
在这里插入图片描述
所以总共循环(比较)n + (n-2)+ (n-4) + …… + 1 = n^2

直接选择排序的时间复杂度是:O(N^2)。

直接选择排序的空间复杂度
直接选择排序开辟的空间为常量级,所以直接选择排序的空间复杂度是:O(1)。

直接选择排序的稳定性
直接选择排序的稳定性:不稳定。因为在选择最值时,直接往begin或者end位置替换的时候,会打乱相同数值的顺序。

比如:1 4 4 3排序升序,在第一趟的排序中,4做为从左往右找到的第一个最大值,直接与end位置交换,即与3进行交换,结果为1 3 4 4,那么,两个数字4的位置就乱了。

总结:
直接选择排序中:
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:不稳定



2.堆排序

在堆排序中,我们首先要进行建堆,建堆算法在前面的文章中我已经进行分析了,下面是传送门。
<<数据结构>>向上调整建堆和向下调整建堆的分析(特殊情况,时间复杂度分析,两种建堆方法对比,动图)
在上面的文章中,我已经对向上建堆和向下建堆进行了对比,所以,在这里的堆排序中的建堆算法,我选择向下调整建堆。

排序升序,建大堆
排序降序,建小堆

如果我们要对一个数组进行堆排序,将这个数组排为了升序,那么我们应该先建为大堆,此时,堆顶就是最大的元素,我们将堆顶的元素与堆尾部的元素交换,然后排除掉堆尾部,重新调整堆,此时,这个堆最大的元素就在后面了,持续下去。在这种方法下,大的元素将一直往后面移动,直到把所有的元素排序完成。

同理,如果我们要对一个数组进行堆排序,将这个数组排为了降序,那么我们应该先建为小堆,此时,堆顶就是最小的元素,我们将堆顶的元素与堆尾部的元素交换,然后排除掉堆尾部,重新调整堆,此时,这个堆最小的元素就在后面了,持续下去。在这种方法下,小的元素将一直往后面移动,直到把所有的元素排序完成。

下面,我以排序升序为例子。
在这里插入图片描述
如上面数组中,我将该数组排为升序(采用向下建堆),下面是动图
在这里插入图片描述
下面是代码:

void swap(int* a, int* b)
{
   
   
	int tmp = *a;
	*a = *b;
	*b = tmp;
}
//向下调整
void AdjustDown(int* arr, int n ,int parent)
{
   
   
	int child = parent * 2 + 1;
	while (child < n)
	{
   
   
		if (child + 1 < n && arr[child + 1] > arr[child])//注意child + 1 < n
		{
   
   
			child++;
		}
		if (arr[child] > arr[parent])
		{
   
   
			swap(&arr[child],&arr[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
   
    
			break;
		}
	}
}
void HeapSort(int* arr, int n)
{
   
   
	//向下建堆算法
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
   
   
		AdjustDown(arr,n,i);
	}

	int end = n - 1;
	while (end > 0)
	{
   
   
		swap(&arr[0],&arr[end]);
		AdjustDown(arr,end,0);
		end--;
	}
}
int main()
{
   
   
	int arr[] = {
   
    12,19,5,25,36,10,3,30,15,2,14,20,30,43,30 };
	HeapSort(arr, sizeof(arr) / sizeof(arr[0]));
	return 0;
}

排序前:
在这里插入图片描述

排序后:
在这里插入图片描述

堆排序的时间复杂度
在这里插入图片描述
可能有人认为,有的结点调整的高度次数远小于我们所计算的高度次数,导致计算结果过大,但是,时间复杂度本身就不是一个准确的计算,如大O渐进表示法就规定省略一些计算表达式中的细节。

堆排序的空间复杂度
堆排序开辟的空间为常量级,所以空间复杂度是:O(1)。

堆排序的稳定性
不稳定,堆顶和堆底元素交换,向下调整,这些都可能打乱相同数字的顺序。

总结:
堆排序中:
时间复杂度为:O(N*logN)
空间复杂度为:O(1)
稳定性:不稳定



交换排序

1.冒泡排序

冒泡排序是我们在编程学习中的老相识了,我就直接上动图了,如果还是不熟悉的,可以看看这篇文章,下面是传送门
冒泡排序(详细)
如果感觉可以的话,那么就直接往下看吧。

下面是动图。
在这里插入图片描述
下面是代码:

void swap(int* a, int* b)
{
   
   
	int tmp = *a;
	*a = *b;
	*b = tmp;
}
void BubbleSort(int* arr, int n)
{
   
   
	for (int i = 0; i < n; i++)
	{
   
   
		for (int j = 0; j < n - 1 - i; j++)
		{
   
   
			if (arr[j] > arr[j + 1])
			{
   
   
				swap(&arr[j],&arr[j + 1]);
			}
		}
	}
}
int main()
{
   
   
	int arr[] = {
   
    12,19,5,25,36,10,3,30,15,2,14,20,30,43,30 };
	BubbleSort(arr, sizeof(arr) / sizeof(arr[0]));
	return 0;
}

排序前:
在这里插入图片描述

排序后:
在这里插入图片描述

如果需要代码注释,可前往上面冒泡排序的传送门,那篇文章有详细的代码注释。

冒泡排序的时间复杂度
在这里插入图片描述
循环次数满足等差数列,总共循环(n-1) + (n-2) + (n-3) + … + 2 + 1 = (n^2 + n) / 2
由大O渐进表示法可得,冒泡排序的时间复杂度是:O(N^2)。

冒泡排序的空间复杂度
由于冒泡排序开辟的空间为常量级,所以冒泡排序的空间复杂度是:O(1)。

冒泡排序的稳定性
稳定,因为我们可以控制在比对的过程中,如果两个数相等,就不交换它们的位置,来保证相同数字的位置不会变换。

总结:
冒泡排序中:
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:稳定



2.快速排序

快速排序的知识点还是相对比较多的,它有三个版本,分别是hoare版本,挖坑法,前后指针法,并且我们还需要掌握它的非递归版本,并且还有三种优化方法,分别是三数取中,小区间优化,三目并排。

不着急,我依次深入地进行讲解。

评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值