数据结构--6.排序

在我们数据结构的学习中,排序无疑是一个非常重要的算法,具体应该描述为算法,而非数据结构,而在排序中又会有许多排序算法,这些排序算法各有优劣,集前人的很多智慧而设计出来的,里面有许多优秀的思想值得我们学习,让我们一起站在巨人的肩膀上,来了解下8大排序算法吧

本文借鉴 @2021dragon 大神的八大排序算法(C语言实现)中的部分优秀图片

基础概念

排序的概念
排序 :所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性 :假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j] ,且 r[i] r[j] 之前,而在排序后的序列中, r[i] 仍在 r[j] 之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序 :数据元素全部放在内存中的排序。
外部排序 :数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。

 最后在加入一个非交换类排序的计数排序一共8中排序

在此,我们对这八种排序算法分别进行剖析

直接插入排序

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

在这里插入图片描述

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

 根据上面的分析我们可以看到,直接插入排序的精髓,就是从第二个数开始,与第一个数进行比较,若小,则第一个数后移,第二个数插到前面去,向后迭代.....在上图的例子中就是,数组向后迭代过程中,2比5小,5后移,2插到5前面,4比5小,5后移,4插到5前面,6比5大,不动,向后迭代,1比6小,比5小,比4小,比2小,依次后移,1插入到2前面,3比6小,比5小,比4小,插入到4前面,完成排序

下面我们对其进行代码实现

void InsertSort(int* a, int n)
{
	for (int i = 0; i < n - 1; i++){
		int end = i;//定义有序序列的最后一位下标
		int tmp = a[end + 1];//将end后一个值存在tmp里(要插入的值)
		while (end >= 0)//当end还在数组中
		{
			if (tmp < a[end])//如果tmp值比end小(需要向前插)
			{
				a[end + 1] = a[end];//将end下标的值后移覆盖原先tmp的位置
				--end;//end向前继续检查
			}
			else{
				break;//找到要插入的位置
			}
		}//走到这里:1.end已经到-1位置,tmp比前面所有的数都小,tmp插入到0号下标位置
                    2.--end途中,end减到了比tmp小的值,找到插入的地方
		a[end + 1] = tmp;//将tmp存入的数据插入到end的后一位
	}
}

总的来说,就是定义一个end下标,一个tmp变量存储要插入的值,下标负责检查位置是否正确,找到位置了就让tmp的值放到正确位置,具体操作是,外层end依次后移从前往后遍历,内层end从后往前遍历,a[end]与tmp比较,tmp小,a[end]=a[end]值覆盖tmp位置,end--向前继续查找,tmp小,继续向后覆盖,完成后移操作,最后碰到tmp大,停止循环,将tmp插入空位,外层依次end后移tmp后移插入下一个数完成插入排序

 这是最后break找到位置时end与tmp的相对位置图,当end前移未达到此相对位置时完成的操作则是end与tmp间的数字整体后移一位,最终当end走到数组倒数第二位,也就是n-1时排完,这就是我们的插入排序

直接插入排序的特性总结:
1. 元素集合越接近有序,直接插入排序算法的时间效率越高
2. 时间复杂度: O(N^2)
插入排序最坏情况O(N^2),当整个数组逆序时,则需要将每个遍历的要插入的值插到最前面,每一次插入都需要依次挪动tmp到0位置的值,第一次挪动1个,第二次2个...每次都需要挪动,所以此时执行次数就为N*(N-1+N-2+N-3...0)最后时间复杂度便为N^2
插入排序最好情况O(N):顺序情况
3. 空间复杂度: O(1) ,它是一种稳定的排序算法
4. 稳定性:稳定

希尔排序

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

 当我们完成对插入排序的分析,我们会发现,插入排序在接近顺序时时间复杂度会降低为O(N)情况,当插入排序接近逆序时时间复杂度为O(N^2),所以希尔在对于插入排序的研究中,模拟先对其预排序,尽可能使其接近有序,这时排序效率便会大幅度提升,由此,希尔发明了希尔排序,希尔排序实际是插入排序的一种优化,是一种非常优秀的排序算法

希尔排序的思想是,先分组进行插入排序, 因为在升序中,大数挪向后面位置需要进行很多覆盖后移操作,一个一个移动太慢,所以其引入了gap变量,让大数据跨越gap个单位直接插入排序后移,这就大大的降低了一个一个移动所带来的消耗,对于上图而言就是先让9,4为一组,1,8为一组,等等,分别对其进行插入排序,一轮完毕数组更接近有序了,而后再次分组,缩小gap值,让4,2,5,8,7,5为一组,1,3,9,6,7为一组,分别再次插入排序,排完之后更接近有序,最后再次缩小gap,使gap为1,此时数组已经很大程度的接近有序了,再对数组进行插入排序,则完成排序

 我们可以发现

当gap越大,大的和小的数可以更快地挪到对应的方向去,gap越大,越不接近有序

当gap越小,大的和小的数可以更慢的挪到对的应方向去,gap越大,越接近有序

gap为1时,就是我们的插入排序,所以我们需要先使gap尽量的大,最后使gap小,最后到1

下面是我们对其的代码实现

void ShellSort(int *a, int n)
{
	int gap = n;//对gap设立初始值
	while (gap > 1)//当gap大于1时为预排序
	{
		gap = (gap / 3 + 1);//这里我们选/3进行缩减
	}
	for (int i = 0; i < n - gap; i++)//开始依次排序,i每+1就换下一组相距gap的数排序,直到走到n-gap的位置走完
	{
		int end = i;
		int tmp = a[end + gap];
		while (end >= 0)
		{
			if (tmp < a[end])
			{
				a[end + gap] = a[end];
				a -= gap;
			}
			else
			{
				break;
			}
		}
		a[end + gap] = tmp;
	}
}

对于这个代码而言,我们的下层循环内的部分,将gap改为1便是我们的插入排序,我们在外层做的操作是,每增加1个i,数组就会切到下一分组来进行排序

 正如上图一样,i=1时,插入排序9,4,i=2时,插入排序1,8,直到i走到n-gap=5号位,下标为4时停止,完成了一轮插入排序,当gap重新设定,又重新对更多的数据插入排序,最后gap为1完成希尔排序

希尔排序的特性总结:
1. 希尔排序是对直接插入排序的优化。
2. gap > 1 时都是预排序,目的是让数组更接近于有序。当 gap == 1 时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
3. 希尔排序的时间复杂度不好计算,需要进行推导,推导出来平均时间复杂度: O(N^1.3—N^2
4. 稳定性:不稳定

选择排序

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

在这里插入图片描述

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

选择排序的思想其实很简单,就上对整个数组遍历一遍挑选最小的放到第0号下标位,再对剩下n-1个元素遍历挑选最小的放到第1号下标位,以此类推,直到外层遍历完整个数组

 对于上述数组就是,整个数组7到1遍历一遍,找出最小的1,让1与0号下标位的7互换,再对4到7遍历,找出最小的2,与1号下标位的4互换,,,最后遍历9到8,找出小的8与6号下标位的9互换,完成排序

下面我们对其进行代码实现

//选择排序(一次选一个数)
void SelectSort(int* a, int n)
{
	int i = 0;
	for (i = 0; i < n; i++)//i代表参与该趟选择排序的第一个元素的下标
	{
		int start = i;
		int min = start;//记录最小元素的下标
		while (start < n)
		{
			if (a[start] < a[min])
				min = start;//最小值的下标更新
			start++;
		}
		Swap(&a[i], &a[min]);//最小值与参与该趟选择排序的第一个元素交换位置
	}
}
void SelectSort(int *a, int n)(一次两个数)
{
	int left = 0; int right = n - 1;//设立左右两端最大最小标志位
	while (left < right)
	{
		int minIndex = left, maxIndex = right;
		for (int i = left; i <= right; i++)//对中间非最大最小的区间扫描
		{
			if (a[i] < a[minIndex])//找出最小值,将其下标存在minIndex中
				minIndex = i;
			if (a[i] > a[maxIndex])//找出最大值,将其下标存在maxIndex中
				maxIndex = i;
		}
		Swap(&a[left], &a[minIndex]);//将最小值的与left交换
		if (left == maxIndex)//排除因为最大值在left号位时,因为上步与min进行交换后,max有了改变,而后影响下面的交换的情况
		{
			maxIndex = minIndex;
		}
		Swap(&a[right], &a[maxIndex]);//
		++left;
		--right;
	}
}

注意,我们这里进行的代码实现是将最大值与最小值同时进行排序,而非仅对最小值排序,可以大大提高效率,但是最后在交换处有个额外需要注意的点,需要对left==max这种情况单独处理,下面我们展示不处理的结果

 我们可以看到,当left==max时,此时left为0号下标,min为4号下标,right为8号下标,max也为0号下标,那么这时left先和min交换,0号下标变为了4号下标的数字,而后right与max交换,8号下标变成了0号下标的数字,但是此时0号下标的数字已经在前一步被换成了4号下标的数字,所以并不再是最大值,max在前一步已经被改变,所以当我们碰到这种情况,将min赋再给max,将原本最大的重新赋回去,即可解决问题

直接选择排序的特性总结:
1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
2. 时间复杂度: O(N^2)
3. 空间复杂度: O(1)
4. 稳定性:不稳定

堆排序

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

在我们了解堆排序之前,我们得先来了解建堆过程的一个重要算法,向下调整算法,向下调整算法顾名思义,向下调整,拿建大堆举例子

1.前提:两边子树都为大堆,但根节点不符合大堆

在这里插入图片描述

 2.做法:扫描堆,若父节点小于左右两叶子节点,则将左右两子树中大的与父节点交换,递归向下,直到原先的父结点到叶子节点或者递归过程中碰到左右两子树都小于他的情况,则中止调整,调整完毕

在这里插入图片描述

 3.效果:这个算法完成的操作就是将左右子树为大堆的堆,调整为整个堆都为大堆的堆,将根节点调整到了属于他的位置,并且保持了堆的完整性

下面我们进行代码实现

void AdjustDown(int *a, int n, int parent)//数组指针,数组元素个数,父节点变量
{
	int child = parent * 2 + 1;//定义左孩子与父亲的关系(右孩子为child+1)
	while (child < n){//当孩子结点在数组内时(当孩子不存在时父亲就到了叶子节点)
		if (child + 1 < n&&a[child + 1] < a[child]){//右孩子在存在且右孩子较小(原先默认左孩子小)
			++child;//那么让小的孩子变为右孩子
		}
		if (a[child] < a[parent]){//当孩子小于父亲(注意这里并不区分左右孩子,因为最后在计算中相除左右孩子自动向下取整得到的结果是一样的)
			Swap(&a[parent],&a[child]);//交换孩子与父亲
			parent = child;//孩子成为新的父亲
			child = parent * 2 + 1;//恢复孩子与父亲关系,
		}
       else{
              break;//若满足大堆,则无需调整,直接退出
        }
	}
}

我们可知,堆的向下调整算法,将一个堆调整完毕,所需的时间复杂度最多为树高,所以其时间复杂度为O(logN),是一种很优秀的算法

那么在我们了解了向下调整算法之后,知道这个算法只能将两子树为大堆的堆调整为完全的堆,不是针对任意完全二叉树的,那么怎么才能将任意完全二叉树调整为堆呢,这时,我们便需要对其从下向上,依次调整

在这里插入图片描述

 也就是如这图一样,从下向上,依次去调整,最后可以调整为一个标准的大堆

下面我们对这一步进行代码实现

for (int i = (n - 1 - 1) / 2; i >= 0; i++)//找第一个非叶子节点
	{
		AdjustDown(a, n, i);
	}

这边是我们针对任意完全二叉树建堆的过程

下面我们进入我们的正题,堆排序

堆排序就是利用堆这个数据结构,对一组数据进行排序,当我们需要对一组数据进行升序排序时,我们需要建立大堆,此时最大的数在最上面的根节点,然后将根节点与最后一个节点进行交换,交换完毕后忽略处在最后一个节点位置上的最大的数,对其他的数而言,又是一个两子数都为大堆的完全二叉树,此时再对除最大数之外的其他数进行建堆,就会得到一个新的堆,而后再将根节点与最大数交换,重复上述过程

 就像这图一样,不断的对白色部分进行建堆,找出最大的数忽略,再建堆,再忽略,最后即可完成堆排序

下面我们对堆排序整体代码进行展示

void Swap(int *a, int *b)//交换函数
{
	int temp = *a;
	*a = *b;
	*b = temp;
}
void AdjustDown(int *a, int n, int parent)//数组指针,数组元素个数,父节点变量
{
	int child = parent * 2 + 1;//定义左孩子与父亲的关系(右孩子为child+1)
	while (child < n){//当孩子结点在数组内时(当孩子不存在时父亲就到了叶子节点)
		if (child + 1 < n&&a[child + 1] < a[child]){//右孩子在存在且右孩子较小(原先默认左孩子小)
			++child;//那么让小的孩子变为右孩子
		}
		if (a[child] < a[parent]){//当孩子小于父亲(注意这里并不区分左右孩子,因为最后在计算中相除左右孩子自动向下取整得到的结果是一样的)
			Swap(&a[parent],&a[child]);//交换孩子与父亲
			parent = child;//孩子成为新的父亲
			child = parent * 2 + 1;//恢复孩子与父亲关系,
		}
       else{
              break;
        }
	}
}
void HeapSort(int* a, int n)//数组指针,数组元素个数
{
	//建堆
	for (int i = (n - 1 - 1) / 2; i >= 0; --i)//从下往上建堆
	{
		AdjustDown(a, n, i);
	}
	int end = n - 1;
	while (end > 0)//不断从后往前将数放入该放的位置
	{
		Swap(&a[0], &a[end]);
		//选出次大的
		AdjustDown(a, end, 0);//从end向下调整,忽略排好的数,其他数重新调整
		end--;
	}
}
直接选择排序的特性总结:
1. 堆排序使用堆来选数,效率就高了很多。
2. 时间复杂度: O(N*logN)
3. 空间复杂度: O(1)
4. 稳定性:不稳定

冒泡排序

在这里插入图片描述

 对于冒泡排序而言,我们曾经已经算是比较熟悉了,就是如同水中的泡泡一样,总是大泡泡先出来,一个接一个

 下面我们对其代码实现

void BubbleSort(int* a, int n)
{
	int end = 0;//第一个有序数的下标
	for (end = n - 1; end >= 0; end--)
	{
		int exchange = 0;//记录该趟冒泡排序是否进行过交换
		for (int i = 0; i < end; i++)
		{
			if (a[i]>a[i + 1])
			{
				Swap(&a[i], &a[i + 1]);
				exchange = 1;
			}
		}
		if (exchange == 0)//该趟冒泡排序没有进行过交换,已有序
			break;
	}
}
冒泡排序的特性总结:
1. 冒泡排序是一种非常容易理解的排序
2. 时间复杂度: O(N^2)
3. 空间复杂度: O(1)
4. 稳定性:稳定

快速排序

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

对于快速排序而言,其精髓就是选定一个中间值,使中间值左边的数小于他,右边的数大于他,而后对左右区间再次选定中间值,左右划分,最后直到排序完成、

将区间按照基准值划分为左右两半部分的常见方式有:
1. hoare 版本
2. 挖坑法
3. 前后指针版本

Hoare法

Hoare法的主要步骤就是先设定左右两指针,而后让左右两指针分别向中间移动,当左指针遇到比中间值大的数时停止,右指针遇到比中间值小的停止,而后将两个停止的指针的数进行交换,再次向中间靠拢,重复上述过程,直到两指针相遇,最后将中间值与两指针相遇的位置的值进行交换完成左边数都小于中间值,右边数都大于中间值的操作

在这里插入图片描述

 当我们完成单趟排序之后,只需要对排完序key左右两边重复这个操作就好了,最后直到排序个数为一或者走到一端结束

Hoare法快速排序递归代码实现

void QuickSort1(int* a, int begin, int end)//数组指针,起始位置,终止位置
{
	if (begin >= end)//当数组仅有一个元素或不存在时退出
		return;
	int left = begin;//初始化left指针
	int right = end;//初始化right指针
	int key = left;//初始化key指针
	while (left < right)//开始循环
	{
		while (left < right&&a[right] >= a[key])//right先走,找比key小的
		{
			right--;//right左移
		}
		while (left < right&&a[left] <= a[key])//left后走,找比key大的
		{
			left++;//left右移
		}
		if (left < right)//当left位置值比key大,right位置值比key小
		{
			Swap(&a[left], &a[right]);//交换
		}
	}
	int meet = left;//完成循环后记录相遇时的下标
	Swap(&a[key], &a[meet]);//交换
	QuickSort1(a, begin, meet - 1);//对左边递归快排
	QuickSort1(a, meet + 1, end);//对右边递归快排
}

挖坑法

在这里插入图片描述

 对于挖坑法而言,其主要思想就是先将key的值储存起来,而后left指针与right指针像Hoare法一样,分别去寻找对应范围的数字,但是挖坑法不一样的是,当right指针找到小数时,就将小数填到我们开始key的位置,坑变为right此时的位置,其次让left走,找到大数,就将大数填到此时的坑(right位置),而后left位置变为坑,以此循环,直到相遇,最后将key填到相遇时的坑中即可

这样的方法达到的效果也是key左侧值都小于key,右侧值都大于key

下面是挖坑法的代码实现

void QuickSort2(int* a,int begin, int end)
{
	if (begin >= end)
		return;
	int left = begin;
	int right = end;
	int key = a[left];//将最左端的值存入key(挖坑)
	while (left < right)
	{
		while (left < right&&a[right] >= key)
		{
			right--;
		}
		a[right] = a[left];//将小值填入坑中
		while (left < right&&a[left] <= key)
		{
			left++;
		}
		a[right] = a[left];//将大值填入坑中
	}
	int meet = left;//将相遇点赋为meet
	a[meet] = key;//将key填入坑位

	QuickSort2(a, begin, meet - 1);
	QuickSort2(a, meet + 1, end);
}

前后指针法

在这里插入图片描述

 之前我们采用的都是左右两边分别向中间移动的策略,而这个前后指针法则是都从left开始走,保证prev到cur中间部分都是大于key,left到prev都是小于key,直到cur走出数组

我们可以看到,初始时prev与cur一前一后间隔1,cur先走,当cur指向的值比key小时,prev++与cur交换,大时不交换,最后直到cur走出数组时,所有的小于key的值都在left到prev中间,大于key的值都在cur与prev中间

下面我们对其进行代码演示

int QuickSort3(int* a, int left, int right)
{
	int midIndex = GetMidIndex(a, left, right);//三数取中得到中间值
	Swap(&a[left], &a[midIndex]);//将中间值赋给left
	int key = left;//初始化key指针
	int prev = left, cur = left + 1;//初始化prev与cur指针
	while (cur < right)//开始循环
	{
		if (a[cur] < a[key])//当cur值比key小时
		{
			Swap(&a[cur], &a[prev++]);//cur与prev++交换
		}
		++cur;
	}
	Swap(&a[key], &a[prev]);//最后将中间值与prev交换
}

我们在书写这个方法时,用了一个优化方法,三数取中,下面我们来介绍快速排序的两种优化方法

快排优化

对于我们快速排序而言,key值的选定,在很大程度上影响着排序的效率,当key值取得偏向中间时,快排的执行次数就偏向logN*N,然而当偏向两边时,效率就会大打折扣趋近N*N

 当key值趋近中间中位数时,递归深度为logN,越接近二分,效率越高

 当趋近于两边时,深度就趋近于N了,时间复杂度会退化为冒泡

三数取中

三数取中,顾名思义,三个数中取中间那个,我们不妨设想一下,当一个数组中key的值取到了最小或最大的那个值,意味着剩下n-1个数重新进行排序,效率是最低的,所以我们为了避免key取到端点值,所以我们引入了三数取中的优化方法

三数取中的思想是去在数组中取第一个,最后一个,以及中间的一个值,从这三个值中选不大不小的,大小为中间的那个值,返回其下标

下面我们进行代码演示

int GetMidIndex(int* a, int left, int right)
{
	int mid = (left + right) >> 1;
	if (a[left] < a[mid])//当left比mid小时
	{
		if (a[mid] < a[right])
		{
			return mid;
		}
		else if (a[left] > a[right])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
	else//当left比mid大时
	{
		if (a[mid] > a[right])
		{
			return mid;
		}
		else if (a[left] < a[right])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
}

其实对于三数取中算法,就是我们的取三个数,找中间大小那个,比较简单

小区间优化

我们在递归快排时,当递归深度很深,每一个递归区间的值很少时,想要对其完成排序,仍然需要调用很多次递归才能完成,而递归又是一种消耗较大的算法,那么我们可不可以在递归内元素数量降到一定数量时,停止递归,转而对剩下并不多的数利用其它排序算法进行排序,不再去调用递归,可以提高一定的效率,不过效率提升没有三数取中大

//优化后的快速排序
void QuickSort0(int* a, int begin, int end)
{
	if (begin >= end)//当只有一个数据或是序列不存在时,不需要进行操作
		return;

	if (end - begin + 1 > 20)//可自行调整
	{
		//可调用快速排序的单趟排序三种中的任意一种
		//int keyi = PartSort1(a, begin, end);
		//int keyi = PartSort2(a, begin, end);
		int keyi = PartSort3(a, begin, end);
		QuickSort(a, begin, keyi - 1);//key的左序列进行此操作
		QuickSort(a, keyi + 1, end);//key的右序列进行此操作
	}
	else
	{
		//HeapSort(a, end - begin + 1);
		ShellSort(a, end - begin + 1);//当序列长度小于等于20时,使用希尔排序
	}
}

我们可以对最后取消递归区间剩余数量进行控制,也可以对最后使用的其他排序算法进行控制来提高效率

目前来讲,加上优化算法的快速排序是所有排序算法中相对最快的一个

快排的非递归实现

我们知道,当一个递归函数过深,栈空间就可能会出现空间不足的情况了,我们此时就需要非递归的写法来完成快排的实现,非递归写法也称迭代写法,迭代的效率在大多数情况都是比递归要优的,虽然相对复杂,但也是我们所必须掌握的

void QuickSortNonR(int* a, int begin, int end)
{
	Stack st;//创建栈
	StackInit(&st);//初始化栈
	StackPush(&st, begin);//待排序列的L
	StackPush(&st, end);//待排序列的R
	while (!StackEmpty(&st))
	{
		int right = StackTop(&st);//读取R
		StackPop(&st);//出栈
		int left = StackTop(&st);//读取L
		StackPop(&st);//出栈
		//该处调用的是Hoare版本的单趟排序
		int keyi = PartSort1(a, left, right);
		if (left < keyi - 1)//该序列的左序列还需要排序
		{
			StackPush(&st, left);//左序列的L入栈
			StackPush(&st, keyi - 1);//左序列的R入栈
		}
		if (keyi + 1 < right)// 该序列的右序列还需要排序
		{
			StackPush(&st, keyi + 1);//右序列的L入栈
			StackPush(&st, right);//右序列的R入栈
		}
	}
	StackDestroy(&st);//销毁栈
}

事实上,对于任何递归改迭代,我们都需要借用栈,而后去循环计算递归的起始点与中止点,将起始点与中止点入栈,而后中止点与起始点出栈,调用快排,再计算两边起始点中止点,读取,出栈,重复操作,也就是将递归边界,用栈的形式保存再读出使用

1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫 快速 排序
2. 时间复杂度: O(N*logN)

 

3. 空间复杂度: O(logN)
4. 稳定性:不稳定

归并排序

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

在这里插入图片描述

 归并排序,采用分而治之的思想,先对大区间进行分离,而后对每个小区间进行排序,最后合并,其具体操作如下图,先将数组一层一层分开,再两两合并,四四合并,最后合并为一个大区间,在合并途中完成排序

 下面我们对其进行递归代码实现

void _MergeSort(int* a, int left, int right, int* tmp)
{
	if (left >= right)//当区间无元素时返回
		return;

	int mid = (left + right) >> 1;//找区间中间值
	// [left, mid][mid+1,right]区间需要排序
	_MergeSort(a, left, mid, tmp);
	_MergeSort(a, mid+1, right, tmp);

	// 两段有序子区间归并tmp,并拷贝回去
	int begin1 = left, end1 = mid;//左子区间
	int begin2 = mid+1, end2 = right;//右子区间
	int i = left;//总区间初始下标
	while (begin1 <= end1 && begin2 <= end2)//将小的区间放入tmp
	{
		if (a[begin1] < a[begin2])
			tmp[i++] = a[begin1++];
		else
			tmp[i++] = a[begin2++];
	}

	while (begin1 <= end1)//将两区间剩下的值拷入tmp
		tmp[i++] = a[begin1++];

	while (begin2 <= end2)
		tmp[i++] = a[begin2++];

	// 归并完成以后,拷贝回到原数组
	for (int j = left; j <= right; ++j)
		a[j] = tmp[j];
}

void MergeSort(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int)*n);//创建临时数组
	if (tmp == NULL)
	{
		printf("malloc fail\n");
		exit(-1);
	}

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

	free(tmp);//释放临时数组
}

主要思想就是将两区间都拷入tmp顺便排序,最后再将tmp中的数据拷回原数组即可

那么我们的非递归写法呢

非递归归并排序

我们在归并排序中,归并区间长度是在有规律的不断缩小的过程,而最后一定会合为数组长,所以对于我们的非递归实现归并排序,不需要借助栈,只需控制归并区间长度即可

 而当我们使用这种区间长固定的方法时,则不可避免地还需要考虑剩下三种情况

 

 

 我们需要对其进行单独处理

下面是我们的迭代写法

void _Merge(int* a, int* tmp, int begin1, int end1, int begin2, int end2)
{
	int j = begin1;
	int i = begin1;
	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++];

	// 归并完成以后,拷贝回到原数组
	for (; j <= end2; ++j)
		a[j] = tmp[j];
}


void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int)*n);//开辟tmp数组
	if (tmp == NULL)
	{
		printf("malloc fail\n");
		exit(-1);
	}

	int gap = 1;//设立区间长初始值
	while (gap < n)//开始循环
	{
		for (int i = 0; i < n; i += 2 * gap)//每次跳跃到下一个区间
		{
			// [i,i+gap-1][i+gap, i+2*gap-1] 归并(当i增加时,区间个数也随着增加,这两个式子总能表示前一个区间与后一个区间)
			int begin1 = i, end1 = i + gap - 1, begin2 = i + gap, end2 = i + 2 * gap - 1;

			// 如果第二个小区间不存在就不需要归并了,结束本次循环
			if (begin2 >= n)
				break;

			// 如果第二个小区间存在,但是第二个小区间不够gap个,结束位置越界了,需要修正一下
			if (end2 >= n)
				end2 = n - 1;

			_Merge(a, tmp, begin1, end1, begin2, end2);//对区间进行归并排序
		}

		gap *= 2;//每次区间扩大二倍
	}

	free(tmp);//释放空数组
}
1. 归并的缺点在于需要 O(N) 的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
2. 时间复杂度: O(N*logN)
3. 空间复杂度: O(N)
4. 稳定性:稳定

计数排序

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

我们之前的排序都是比较排序,通过比较两数大小得出顺序,而计数排序是一种非比较排序,利用数组本来的下标来记录数据

对于上图的数据,计数排序利用另一个新数组,对原数组进行扫描,记录各个相同数据的个数,0出现了两次,则新数组0号下标存的就是个数2,1没有出现,则1号下标存的是0……最大值5出现了一次,则新数组最后一个5号下标位置存1,而后再将新数组中的元素*其各自对应的元素个数拷回原数组,即可完成排序

而这种1对应1号下标,2对应2号下标的方式叫做绝对映射

那么除过这种,如果要对下列数组进行排序呢

 他们都是大于10,小于15的数,此时我们就需要多开辟一个格子,因为最小值为10,最大值为15,所以我们开大小为7的数组,最后6号下标位用来存储10,代表着这些数字都是需要加上10才是他们本来的大小,这种映射方式就叫相对映射,区别在于还加了一个最大公共单元10

下面我们对这种排序方式进行代码实现

// 时间复杂度:O(N+range)
// 只适合,一组数据,数据的范围比较集中. 如果范围集中,效率是很高的,但是局限性也在这里
// 并且只适合整数,如果是浮点数、字符串等等就不行了
// 空间辅助度:O(range)
void CountSort(int* a, int n)
{
	int max = a[0], min = a[0];//初始化max,min
	for (int i = 0; i < n; ++i)//扫描数组得出max,min
	{
		if (a[i] > max)
			max = a[i];

		if (a[i] < min)
			min = a[i];
	}

	int range = max - min + 1;//得出需要开辟新数组的大小
	int* count = malloc(sizeof(int)*range);//开辟新数组
	memset(count, 0, sizeof(int)*range);//初始化
	for (int i = 0; i < n; ++i)//扫描原数组将元素个数拷到新数组
	{
		count[a[i] - min]++;
	}

	int i = 0;
	for (int j = 0; j < range; ++j)//将新数组的值拷回原数组
	{
		while (count[j]--)
		{
			a[i++] = j + min;
		}
	}

	free(count);//释放新数组
}
1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
2. 时间复杂度: O(MAX(N, 范围 ))
3. 空间复杂度: O( 范围 )
4. 稳定性:稳定

排序总结

 

 当我们需要判断排序算法的稳定性时,我们只需要回想各个排序算法的排序过程中有无跳跃性的数字交换即可,当两个相同的数字在排序前后可以保证相对位置关系的不变,就稳定

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值