手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂

目录

前言

排序算法简介

直接插入排序

‍️算法思想:

⚠️实现代码实现注意的点:

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

希尔排序

‍️算法思想:

⚠️实现代码实现注意的点:

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️ 

直接选择排序

‍️算法思想:

⚠️实现代码实现注意的点:

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

堆排序

‍️算法思想:

⚠️实现代码实现注意的点: 

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

冒泡排序

‍️算法思想:

⚠️实现代码实现注意的点:

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

快速排序

‍️算法思想:

⚠️实现代码实现注意的点:

方法一:挖坑法

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

方法二:前后指针法

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

方法三:左右指针法

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

⬇️⬇️⬇️快速排序具体代码实现⬇️⬇️⬇️

快速排序的优化:

⬇️⬇️⬇️优化一具体代码实现⬇️⬇️⬇️

⬇️⬇️⬇️优化二具体代码实现⬇️⬇️⬇️

快速排序的非递归实现

‍️算法思想:

⚠️实现代码实现注意的点:

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

归并排序

 ‍️算法思想:

⚠️实现代码实现注意的点:

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

归并排序的非递归实现

‍️算法思想:

⚠️实现代码实现注意的点:

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

计数排序

‍️算法思想:

⚠️实现代码实现注意的点:

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

不同排序的时间/空间复杂度以及稳定性

总结:

写在最后:


前言

本文是作者第一篇与另一位优秀Java领域的博主共同完成的博客
(合作主要有动图的制作、算法实际实现的代码细节等)

在本文中,我将会从以下几个部分详解常用的八种排序算法:

            排序动图➡️排序算法思想的简化叙述➡️代码实现过程中易错的点➡️实际代码的实现➡️相应的排序算法的优化➡️排序算法的时间/空间复杂度以及稳定性的剖析

全方位手撕八种常用的排序✅✅✅

创作不易~如果觉得对你有帮助的话,麻烦点个赞啦~

特别鸣谢:

爪哇土著、JOElib

ps:热爱手撕数据结构与算法的小伙伴来交个朋友⑧~                                                         

本文源码为C/C++适用版,想康康Java实现的排序算法是啥样的戳这里 ➡️


排序算法简介

手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第1张图片

 排序算法真的很多~有兴趣可以多去了解一下~

但平常比较常用的排序有以下几种:

直接插入排序;希尔排序;直接选择排序;堆排序;冒泡排序;快速排序;归并排序和计数排序


直接插入排序

图解:

最开始的有序区间里只有5这个数,故是默认有序的,然后是将1、4、3依次插入到有序序列中

‍️算法思想:

往一个有序序列里(每次)插入一个数,使得这个有序序列仍保持有序。

⚠️实现代码实现注意的点:

向一个有序数组中插入一个数,使原数组仍然有序(即[0, end]有序)

即意为将end+1位置上的值插入到[0, end]后,使[0, end+1]仍然有序

要点1:每次要控制的即为end所在的位置,end所在的位置,即表示着当前的有序序列范围是多少,即意为要用end控制整个有序区间的范围

要点2:对数组进行排序时,因为每次进行排序都是要将end+1位置上的值插入[0,end]的有序区间里。最后一次end如果走到n-1,则end+1即为n,此时访问n即造成了越界(数组的下标是从“0”开始),所以end只能走到n-2

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

void insert_sort(int* arr, int n)
{
	//最后一次end如果走到n-1则end+1即为n,
    //此时访问就造成了越界,所以end只能走到end-2,故i= 0)
		{
			if (tmp < arr[end])//升序
			{
				arr[end + 1] = arr[end];//大小不符合要求,则将end位置的值往后移
				end--;
			}
			else//如果上述if语句未进入,说明当前要插入的值已经在正确的位置了
				break;
		}
		arr[end + 1] = tmp;//将end+1位置上的值插入到有序数组[0,end]
	}
}

希尔排序

手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第2张图片

tips:以23、10、4、1位步长的希尔排序 

‍️算法思想:

希尔排序其实就是对插入排序的优化,主要思想即是利用多组预排序(本质就是多组预排序,只是每次不是将后面有序区间后面的一个数开始,而是从有序区间后的gap个位置开始),该过程将序列变为接近有序的序列,然后再对该接近有序的序列进行排序。

(插入排序在对接近有序的序列排序时效率是接近O(N)的)

⚠️实现代码实现注意的点:

先进行多组预排序,让数组接近有序。然后再进行直接插入排序

(gap即该次预排序里分的每一部分有gap个元素,gap为1,即每一部分一个数,也就是一个数一个数往后走)

每次插入的数是end+gap位置上的数
gap为1即end+1,也就是直接插入排序

预排序:分组进行插入排序(一次比较是越过gap个位置后插入)
预排序的优势:这样能够让更大的数据能够更快沉到数组尾部

gap越大,大的数可以更快的到后面;小的数可以更快到前面
但是gap越大,预排完越不接近有序,相反越小,预排完越接近有序
当gap == 1时即为直接插入排序

要点1:end即每次与tmp的值进行比较,并相应的向前移动gap个位置,当找到位置了则将tmp值插入到end+gap的位置(tmp == arr[end+gap])

要点2:gap的取值不能写死,要与要排序的数组长度进行关联,保证其预排序能有一定的效率,并且多组排序每次的gap都不同(最后一次gap要为1)

要点3:end最后只能走到n-gap-1的位置,同直接插入排序一样,如果end走到了n-gap,去访问end+gap位置的时候即访问的是n位置的值,则造成了越界访问内存

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️ 

void my_shell_sort(int* arr, int n)
{
    //gap与数组长度相关,则可更好的提高效率
	int gap = n;
	while (gap > 1)
	{
		//gap每次除2时则循环要进行log2N次
		gap /= 2;//每次都先将gap除2,最后gap一定会到1
		//gap很大时,预排序大概要交换O(N)次
        //(因为gap很大,每次沉底的很快,一个数只用交换1-2次)
		//gap很小时,此时数组已经接近有序,预排序也大概要进行O(N)次
		
		//同时对多组进行预排序
		for (int i = 0; i < n - gap; i++)
		{
			//以下代码整体思路上即为直接插入排序
			int end = i;
			int tmp = arr[end + gap];
			while (end >= 0)
			{
				if (tmp < arr[end])
				{
					arr[end + gap] = arr[end];//end位置的值移动到end+gap的位置上
					end -= gap;//每次end移动gap个位置
				}
				else
					break;
			}
			arr[end + gap] = tmp;
		}
	}
}

直接选择排序

‍️算法思想:

遍历整个序列未排序的部分,每次都找到序列中最小的值,然后将其与序列最左端的值进行交换

该排序是效率最低下的排序之一,对其做一定的优化:
即每次遍历整个序列,找到序列中的最大值与最小值,然后将最大值与序列的最右端的值进行交换;最小值与序列的最左端的值进行交换

⚠️实现代码实现注意的点:

因为该种直接选择排序的优化方式是每次找两个数,往左右两端处丢,序列每次都是左右两端同时向中间缩小。

那么就可能出现一种情况,就是当左边端点下标位置的值是最大值时,如果先将最小值与左端点位置的值进行交换,那么此时最大值的下标就指向了该最小值,如果再将该“最大值”交换到最右端则明显是不正确的

故要防止最大的数的下标(maxi)可能和最左端的下标(begin)重叠

当其重叠时,最小的数被交换到了begin(左端点处),此时maxi对应的数值即为最小的数,此时如果再将maxi换到右边(end)的位置则是将最小的数换过去了。
故此应进行一定的调整:当最小值交换到begin处时,且maxi和begin重叠时,要修正一下最大值的下标,即:maxi = mini

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

void select_sort(int* arr, int n)
{
	int begin = 0, end = n - 1;
	while (begin < end)
	{
		int maxi = begin, mini = begin;
		//遍历一遍数组找最大值和最小值的下标
		for (int i = begin; i <= end; i++)
		{
			if (arr[maxi] < arr[i])
				maxi = i;
			if (arr[mini] > arr[i])
				mini = i;
		}
		//将最大值放到最左边,最小值放到最右边
		swap(arr[begin], arr[mini]);
		//如果最大值的下标与begin重叠则会造成最大值的下标上的数是最小值,故需进行调整
		if (maxi == begin)
			maxi = mini;
		swap(arr[maxi], arr[end]);
		begin++;
		end--;
	}
}

堆排序

‍️算法思想:

首先其本质的思想是选择排序,不过选数的方式有所改变,不再是遍历整个数组选数,而是通过数据结构中的堆来进行选数(借助堆的特性来进行选数)

首先我们先来了解一下堆(下图即为大堆)(旁边的索引即数组里对应的下标)手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第3张图片

  • 什么是堆?
    堆其实是一个抽象的数据结构(即是逻辑上的一种数据结构),并不在物理上真正存在。常见的实现方式即是使用一棵完全二叉树来进行实现的(只是这棵完全二叉树只存在于你的心中)。(见上图)
  • 下标与父子节点的关系
    leftChild = parent*2+1
    rightChild = parent*2+2
    parent = (child-1)/2
  • 堆的结构性
    堆的逻辑结构是一棵完全二叉树,物理结构是一个数组,即实现堆是通过数组来实现的
  • 堆的有序性

    • 大顶堆
      任意节点的关键字(值)是其子树所有节点中的最大值
      排升序即建大堆

    • 小顶堆
      任意节点的关键字(值)是其子树所有节点中的最小值
      排降序即建小堆

⚠️实现代码实现注意的点: 

了解了堆之后,我们该如何进行堆的建立呢?这里则涉及到了堆排序的核心:建堆

如何建堆?
这里则涉及一个算法:向下调整算法

首先使用该算法的前提:建小堆时,左右子树都是小堆;建大堆时,左右子树都是大堆

算法思想:(建小堆)

从根节点开始,选出左右孩子较小的那个与父亲比较

如果比父亲小则与父亲进行交换,然后继续往下调整,直到调整到叶子结点为止。
或者当左右孩子较小那个也比父亲小则也说明调整完毕,也跳出循环。

要点1:
叶子节点:即孩子结点的下标如果越界了,则说明到了叶子结点
要点2:
因为孩子结点进行选择时,首先默认为左孩子,此时可能会有右孩子不存在的情况,所以当child+1(右孩子)没有越界时才需进行选出较小那个孩子与父亲进行比较的步骤


但使用这个向下调整算法的前提是左右子树都需要已经是堆结构,那一个数组给你大多时候都是不可能是左右子树都是堆结构的状态的,那这个算法如何使用呢?

这其实就涉及了两个方向建堆的事儿了,一个就是从上往下建堆,另一个就是从下往上建堆

从上往下建堆很明显就是适用于已经建好的堆,不过堆顶的数据发生了变化而其它位置都没变化的情况。

从下往上建堆即是我们堆排序时主要使用的方式。

因为将数组看作一棵完全二叉树的话,那它倒数第一个非叶子结点的左右子树一定已经是堆结构(如下图)(因为叶子结点的左右子树都为空,即可以看作其为一个堆)
所以从倒数第一个非叶子结点开始,即可使用向下调整算法。
一路往上调整,调整到数组首元素(根节点)时,则左右子树已经都已成堆

建堆过程如下(建小堆):

手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第4张图片

要点1:
最后一个结点的下标为n-1,倒数第一个非叶子结点为最后一个结点的父亲,即(n-1-1)/2即为倒数第一个非叶子结点的下标(下图标红的结点即为第一个非叶子结点)

手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第5张图片

依次向上调整,最后即调整成如下的大堆结构

手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第6张图片

通过上述步骤建好堆后,下一步即为选数的步骤
我们上面时不时就提到了,排升序,要建大堆;排降序则要建小堆。这即是为了选数时的便捷

首先为什么排升序要建大堆,而降序要建小堆呢?

首先就假设我们要排升序,大部分第一次接触堆排序的小伙伴都会想着排升序的话,应该建小堆吧,因为堆顶的数据是左右子树最小的嘛。

但其实不然,如果建小堆,即取堆顶的数据排序,那排好序后堆顶的数据就已经在正确位置了(即已经不能再算作堆里的数据了,毕竟因为堆是用来选数的嘛,已经选出来了肯定不能再算作堆里的数据了)。

然后剩下的数其实已经不再是堆的结构了(根都无了),会出现什么问题呢?

首先选下一个数时,左右子树该选哪一个呢?小堆里左右子树的数据都比根大,但根节点的数据已经被选走了,剩下左右子树各自的数值并不能确定哪个子树的数值更大,所以哪个做根我们并不能确定。故此如果要选出下一个数,又得重新建一次堆(从下向上重新进行调整算法),这样反复重新建堆来选数其实时间效率是很低的。

所以排升序不能建小堆,而要建大堆

排升序建大堆,那具体如何选数呢?

首先我们要保证选数过程中,整体的堆结构不能有大的变化 ,那我们这样选数:

建好大堆后,第一个数(堆顶)与最后一个数进行交换,然后最后一个数不看作堆内的数,再从堆顶进行向下调整(要调整n-1个数),选出次大的数,再跟倒数第二个数进行交换,以此类推进行升序排序。

将堆顶数据与最后一个数交换,因为是大堆,即是将最大的数放到了数据末端,然后其不看作堆里的数据,那么堆的整体大结构就没有被破坏(根节点的左右子树都)。下一次选数只需要从根节点开始,而不是需要全部推翻,重新建堆。

好了,说了这么多,相信大家应该了解整体的思想了,来看看具体的代码吧

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

static void AdjustDown(int* arr, int n, int root)
{
	//建大堆
	int parent = root;
	int child = parent * 2 + 1;
	while (child < n)
	{
		//找出左右孩子大的那一个与父亲进行比较
		if (child + 1 < n && arr[child + 1] > arr[child])
			child += 1;
		//建大堆,即堆顶比左右子树的每个节点的值都大
		if (arr[parent] < arr[child])
		{
			swap(arr[parent], arr[child]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
			break;
	}
}

void heap_sort(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--;
	}
}

冒泡排序

‍️算法思想:

从第一个数开始,往后每两个数进行两两比较,若比其大,则进行交换。依次进行,最大的值则沉底,最小的值则冒到前面。

⚠️实现代码实现注意的点:

冒泡排序,典型的两个循环,注意一下边界控制即可,不要越界了

第一个循环控制要进行冒泡的趟数,第二个循环即是每个数要进行几次比较,从第一趟开始往后每趟冒泡都少比较一次(因为上一次冒到最后的那个数已经有序)

手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第7张图片

冒泡的思想比较容易理解,这里也对基础冒泡排序做一定的优化:

当一趟排序完毕,如果每次数据进行两两比较后并没有进行交换,则说明该序列已经有序了,此时就没必要进行接下来的每一趟排序了,可以直接跳出循环,退出冒泡排序。 

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

void bullet_sort(int* arr, int n)
{
	for (int i = 0; i < n; i++)
	{
        //默认已经有序
		int flag = 1;
		for (int j = 0; j < n - i - 1; j++)
		{
			if (arr[j] > arr[j + 1])
			{
				swap(arr[j], arr[j + 1]);
                //如果进行交换,说明其还这一趟并没有都有序了
				flag = 0;
			}
		}
        //如果没有进入交换,则说明当前序列已经有序了
		if (flag)
			break;
	}
}

快速排序

‍️算法思想:

整体思想是采用了分治算法的思想。具体如何实现分治呢?

首先选出一个基准值(key),一般默认都是序列最左端的那个数,然后每次进行快排后,这个基准值都被放到了正确位置(该基准值左侧都比其本身小,基准值右侧都比其本身大)。

那么以基准值为轴,则将原序列分为了左序列与右序列,再分别在左序列和右序列中选出一个基准值,进行快排过程,完成后依旧是基准值左侧都是比其本身小,右侧都比其本身大。

一直周而复始下去,将序列分至只有一个数时,则说明该序列已经有序。

手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第8张图片

 ps:动图选取的基准值是中间那个数,原理是一样的,每次排序完毕后,左边都是比基准值小,右边都是比基准值大

⚠️实现代码实现注意的点:

首先实现快排的方法有挺多种的,本质都一样,但思想不太相同。
即不同的思想都能做到一样的结果

要点一:

挖坑法和左右指针法都要注意,当基准值(key)与正在遍历遇到的元素相等时,指针仍旧要移动,不然则会导致死循环。如:

while (begin < end && arr[end] >= key)        end--;

(详情见代码)

方法一:挖坑法

思想: 

始终在选定排序区域内选定最左边的数字为key值,再先从右边开始先找比key值小的,填入左边的坑中(最左边的数已经被保存为key值了,所以可以直接被覆盖);

之后再从左边开始找比key值大的,填入右边的坑中(因为最右边的数已经放到左边了,所以可以直接被覆盖),依次进行,单趟遍历完成后,最后再将key值填入最后停止的位置,完成单趟后,key值左边都比key值小,右边都比key值大,此时就分为了左右两部分。

再对两部分按相同的上述规则来进行递归,最后两侧递归至只剩一个元素即结束,整体数组也已经有序。

手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第9张图片

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

//挖坑法
int part_sort_1(int* arr, int left, int right)
{
	int index = get_mid_index(arr, left, right);
	swap(arr[index], arr[left]);
	int begin = left, end = right;
    //左边先为坑
	int pivot = begin;
    //并将区域内最左边的值作为key值
	int key = arr[begin];
	while (begin < end)
	{
		//begin= key)
			end--;
        //当右边找到比key值小的则填入左边的坑中
		arr[pivot] = arr[end];
        //被填入的数值的原位置则变成坑
		pivot = end;
        //再从左边开始找比key值大的
		while (begin < end && arr[begin] <= key)
			begin++;
        //当左边找到比key值大的则填入右边的坑中
		arr[pivot] = arr[begin];
        //被填入的数值的原位置则变成坑
		pivot = begin;
	}
    //最后begin和end相遇或相交的位置即为key值最后要填入的坑的位置
	pivot = begin;
    //填入key值
	arr[pivot] = key;
    //返回坑,即序列被分割的位置
	return pivot;
}

方法二:前后指针法

思想:给定两个指针cur和prev同时从左边开始,keyi也是默认最左边的值的下标,cur往右边找小于keyi的值,当找到了则将prev++,再交换cur和prev所指向的值,没找到则cur自增,最后再将keyi的值与prev所指向的值交换,返回prev即可。

ps:该种方法边界控制比较简单

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

//前后指针法
int part_sort_2(int* arr, int left, int right)
{
	int index = get_mid_index(arr, left, right);
	swap(arr[index], arr[left]);

	int cur = left + 1, prev = left;
	int keyi = left;
	while (cur <= right)
	{
		//++prev与cur相等时,就相当于自己与自己交换,没有意义,故加上该条件
		if (arr[cur] < arr[keyi] && ++prev != cur)
			swap(arr[prev], arr[cur]);
		cur++;
	}
	swap(arr[prev], arr[keyi]);
    //返回后指针的位置,即序列被分割的位置
	return prev;
}


方法三:左右指针法

思想:
还是将左边的值作为key值,然后有指针先往左边找小,找到后再从左指针往右边找大,最后将这两个值进行交换,最后左指针和右指针相遇后,相遇的位置上的值再与key值交换。

tips:一些特殊情况时边界没控制好会容易产生bug,如从右往左找小时,与key值相等时的情况右指针也要进行自减,从左往右找大也同理,不然左右指针所指向的值相同时则会导致左右指针始终停留原地,最后即死循环

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

//左右指针法
int part_sort_3(int* arr, int left, int right)
{
	int index = get_mid_index(arr, left, right);
	swap(arr[index], arr[left]);

	int begin = left, end = right;
	int keyi = begin;
	while (begin < end)
	{
		//arr[keyi]<=arr[end]的等号如果不加可能会导致死循环
		//不加等号则导致两个值都相等,end和begin一直都不进行自增,那begin始终小于end,则死循环
		while (begin < end && arr[keyi] <= arr[end])
			end--;
		while (begin < end && arr[keyi] >= arr[begin])
			begin++;
		swap(arr[begin], arr[end]);//找到了大的值和小的值则进行交换
	}
	swap(arr[keyi], arr[begin]);
    返回最后左右指针相遇的位置,即序列被分割的位置
	return begin;
}

⬇️⬇️⬇️快速排序具体代码实现⬇️⬇️⬇️

void quick_sort(int* arr, int left, int right)
{
	//当区间不存在时,即不需要再继续进行递归
	if (left > right)
		return;
    //part_sort_1即上面已经写到的挖坑法
	int keyIndex = part_sort_1(arr, left, right);
    //keyIndex即分割区间的标志
    //递归左区间[left,keyIndex-1]
	quick_sort(arr, left, keyIndex - 1);
    //递归右区间[keyIndex+1,right]
	quick_sort(arr, keyIndex + 1, right);
}

快速排序的优化:

其实快排在大多情况下都是效率很高的,但在一些特殊情况下,快排的效率也会变低。

如要排序的序列已经接近有序的情况下,每次选取左边的数据作为基准值(key值),那每次进行快排后都不能将序列一分为二,等于n个数,每次分割都是分割成1和n-1两个部分,那整体效率就会立马低了下来。 

所以我们要对基准值的选取做一个优化

如何优化呢?我们很容易想到,既然每次都选左边的数,可能导致这样的情况,那我们选中间的数不就可以了。其实不然,中间的数也可能是序列中最小的数据,那进行快排后,与选左边的数作为基准值(key值)也没有什么区别。

所以我们这里采用三数取中的思想:
即每次的基准值都是选取最左边,最右边和最中间这三个数数值大小排在中间的那个数
这样可以保证每次的基准值都不会是最小值或最大值

可能有的小伙伴会浅浅杠一下:那你选的数不是最小的数据,但也可能是次小的呀,那你选数的消耗就带来了一点点的性能优化呀。但其实不然,每次都三数取中选取基准值,更多的时候都是能将序列相对好的分割成两个序列的。实例为证:(未加三数取中)

手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第10张图片

手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第11张图片


加了三数取中:

手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第12张图片

手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第13张图片


这个程序是用来测试排序的性能的(测试程序的代码我会放到最后,这里只是用于说明三数取中的效率问题,所以就不过多赘述)

我们可以看到,未加三数取中在对已经有序的序列进行排序时效率甚至是不如一般的选择排序(有的编译器在运行测试排序的性能的程序时,对已经排好序的序列调用快速排序甚至会造成栈溢出,即递归的深度过深,我的就是这样QAQ)。所以可见,三数取中可以说是填补了快速排序的最后一块短板。

那么如何进行三数取中呢?其实很简单,就是一个简单的coding问题,注意一下考虑到所有情况即可。

⬇️⬇️⬇️优化一具体代码实现⬇️⬇️⬇️

static int get_mid_index(int* arr, int left, int right)
{
	int mid = (left + right) >> 1;
	if (arr[left] < arr[mid])
	{
		if (arr[mid] < arr[right])
			return mid;
		else if (arr[left] > arr[right])
			return left;
		else
			return right;
	}
	else//left > mid
	{
		if (arr[mid] > arr[right])
			return mid;
		else if (arr[left] < arr[right])
			return left;
		else
			return right;
	}
}

还有什么可以优化的呢?

我们来看,假设每次快排后,基准值恰好都将序列一分为二(理想情况)
那么即是一棵完全二叉树的形态,如下:手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第14张图片

我们可以看到,当递归到最后一层时,其实是递归调用的次数最多的。

假设有100w个数,那由二叉树的性质(结点个数=2^h-1)可得,该树大约就有20层左右(2^20=1024*1024≈100w)那第20层则有(2^19)50w个结点,19层则有(2^18)25w个结点。这里的每个结点即意为着一次递归调用。我们可以看到最后几层,占用了总调用次数(总结点个数)的大部分。所以我们这里的优化即是将最后几层的递归调用给消除。

小区间优化
当递归调用到最后几层时,我们不再进行快排,而是改用其它排序(这里选择的是插入排序,因为插入排序在接近有序的时候效率相对更高)

如何做呢,很简单,直接看代码即可

⬇️⬇️⬇️优化二具体代码实现⬇️⬇️⬇️

void my_quick_sort(int* arr, int left, int right)
{
	//当区间不存在时,即不需要再继续进行递归
	if (left > right)
		return;

	int keyIndex = part_sort_1(arr, left, right);

	//进行小区间优化
	if (keyIndex - 1 - left > 10)
		my_quick_sort(arr, left, keyIndex - 1);//递归左区间
	else//到一定小的范围则改用插入排序
		insert_sort(arr + left, keyIndex - 1 - left + 1);
	if (right - (keyIndex + 1) > 10)
		my_quick_sort(arr, keyIndex + 1, right);//递归右区间
	else
		insert_sort(arr + keyIndex + 1, right - (keyIndex + 1) + 1);
}

简单解释一下代码:

当剩下的个数只有十个数时(十个数又被分为5个数,5个数又被分为2个数,2个数又被分为1个数),大概递归调用就是三次(就是对应着最后几层递归调用)
c++官方给出的小区间是大约10~13个数的范围

所以当分割的区间还大于十个数时则进行正常的递归调用,当小于十个数时则改用插入排序继续进行排序


快速排序的非递归实现

快速排序本质即是利用递归分治解决问题。但是只要是递归,因为其是在栈空间上进行的,栈空间是相对来说比较有限的,所以当数据量大时,总会可能导致栈溢出的错误。所以我们就有学习一下快速排序的非递归实现了。

非递归实现即利用数据结构中的栈模拟系统的栈进行。在程序里实现一个数据结构的栈,相当于在堆区建立了一个模拟栈,堆区的空间可远远大于栈,所以普适性更强一些。

‍️算法思想:

首先我们将首元素以及最后一个元素的下标入栈,栈不为空作为循环终止条件。
栈中存放的数据都是要进行排序的序列区间。当区间仍存在则一直将区间入栈,直到区间不存在了,即栈为空了,退出循环。

⚠️实现代码实现注意的点:

tips:要模拟真实的快排递归的过程,一般都是先处理左区间,再处理右区间
所以入栈的时候先将右区间的末位元素下标、首元素的下标入栈,再入左区间的末位元素和首元素的下标。

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

(其实比递归相对还好理解一些⑧~)

void quick_sort_nonR(int* arr, int n)
{
	stack s;
	//将数组首尾坐标先入栈
	s.push(n - 1);
	s.push(0);
	while (!s.empty())
	{
		//栈不为空说明排序区间仍存在
		int left = s.top();
		s.pop();
		int right = s.top();
		s.pop();
		int keyIndex = part_sort_1(arr, left, right);//进行单趟快排
		if (keyIndex + 1 < right)//区间未分到最后,继续入栈
		{
			s.push(right);
			s.push(keyIndex + 1);
		}
		if (left < keyIndex - 1)
		{
			s.push(keyIndex - 1);
			s.push(left);
		}
	}
}

归并排序

                                               手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第15张图片


 ‍️算法思想:

将要排序的序列先分为左半和右半两个区间,当左半区间有序并且右半区间有序,则可以进行归并算法,归并到一个临时数组,然后拷贝回原数组,原数组即有序了

当左右两个区间都为无序时,则需要将左右区间不断的一分为二(递归),直到只剩一个值则可认为其有序了,此时再依次进行归并即可,归并后将临时数组里的值拷贝回原数组即可

​tips:归并排序相较于其它排序来说多了一些空间上的消耗(空间复杂度为O(N)) 

总体思想图如下:

手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第16张图片

⚠️实现代码实现注意的点:

要点一:
别忘记将临时数组中的内容拷贝回原数组了~ 


⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

static void _merge_sort(int* arr, int left, int right, int* tmp)
{
	if (left >= right)
		return;
    //将数组区间一分为二
	int mid = (left + right) >> 1;
    //递归进行左区间的分割
	_merge_sort(arr, left, mid, tmp);
    //递归进行右区间的分割
	_merge_sort(arr, mid + 1, right, tmp);
	//进行归并
    //左区间的起始下标
	int begin1 = left, end1 = mid;
    //右区间的起始下标
	int begin2 = mid + 1, end2 = right;
    //临时数组的下标
	int index = left;
	while (begin1 <= end1 && begin2 <= end2)
	{
		//将小的那个值拷贝到临时数组中
		if (arr[begin1] < arr[begin2])
			tmp[index++] = arr[begin1++];
		else
			tmp[index++] = arr[begin2++];
	}
	//将未归并完毕的数组剩下的值拷贝到临时数组里
	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 merge_sort(int* arr, int n)
{
	//临时数组
	int* tmp = (int*)malloc(sizeof(int) * n);
	_merge_sort(arr, 0, n - 1, tmp);
	free(tmp);
}

归并排序的非递归实现

学习归并排序的非递归实现的理由也与快排一样,因为都是通过递归来分割问题,解决问题,所以都可能造成栈溢出的问题。

归并排序的非递归思想也很简单,但是代码实现较为复杂

‍️算法思想:

即在原数组中进行归并(对控制边界的能力就要求更高一些了~)

第一个元素是有序的,然后第一个和第二个进行归并;
然后跳过第一个和第二个,对第三个和第四个进行归并

以此类推,这是作为第一组。

然后第一、二个和第三、四个进行归并;跳过这四个,第五、六个和第七、八个进行归并。

以此类推,每次都是对已经有序的区间进行归并

⚠️实现代码实现注意的点:

(⚠️⚠️⚠️复杂警告⚠️⚠️⚠️

再解释一遍算法思想:

由归并排序的思想可以知道:当最后只剩一个数字时即为有序,即可开始递归合并

若是非递归,则同理可得到:先一个一个为一组进行归并,两个数有序后再两个两个为一组进行归并,四个数有序后再四个四个为一组进行归并

故我们可以给一个gap作为几个一组的标识

当gap为1,即[0,0]和[1,1]进行归并,[2,2]和[4,4]进行归并;gap为2时,即[0,1]和[2,3]进行归并,[4,5]和[6,7]进行归并。以此类推。

遍历一遍数组即完成一组的归并,完成后gap*=2即可进行下一组的归并
当gap*2>n即说明归并完毕

需要注意的点(对于边界的控制):

1、数组长度n可能不为2的倍数,归并过程中左区间可能存在,但右区间不存在,所以按上述逻辑进行则会造成越界

2、每次都进行gap*2,此时左右区间可能存在,但左右区间的数据个数可能不对称,右区间的数少于左区间

3、归并过程中,可能右半区间不存在,左半区间也不够一组的数据(例如4个和4个进行归并,一共有11个数据,前面8个成一对,后面只剩下3个作为左半区间的数据,不足4个)

针对情况1:当右半区间不存在时,则不需要归并了,直接跳出该组归并的循环即可(因为右半区间不存在的情况都是在数组归到末尾位置才会出现)

针对情况2:当右半区间存在但数据个数少于左区间时,则需修正右半区间的范围,即则将右半区间的范围大小修正到最后一个数据之前即可

针对情况3:当右半区间不存在或者左半区间数据不够一组时,其实都不需要进行归并(左半区间数据不够一组时,右半区间肯定不存在,那直接就没有进行归并退出了本次循环),又因为其在原数组内的那一组里本来就有序,就直接放在原数组即可,无需拷贝到临时数组。所以可以将临时数组拷贝到原数组的操作放到归并的小循环内,当左半区间数组不够一组时(右半区间不存在)既没有将该部分归并下来(因为情况1进行了修正)
(并且此时临时数组最后的一部分则为随机值)

所以也没有将临时数组内的随机值拷贝到原数组中。

说了这么多,多少还是有点抽象(雀食抽象),康康代码吧

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

void merge_sort_nonR(int* arr, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += 2 * gap)
		{
			int begin1 = i, end1 = i + gap - 1;
			int begin2 = i + gap, end2 = i + 2 * gap - 1;
			int index = i;
			//归并过程中右半区间不存在即可退出本次循环
			if (begin2 >= n)
				break;
			//归并过程中右半区间的数据个数少于左半区间,则需修正右半区间的范围
			if (end2 >= n)
				end2 = n - 1;
			while (begin1 <= end1 && begin2 <= end2)
			{
				//将小的那个值拷贝到临时数组中
				if (arr[begin1] < arr[begin2])
					tmp[index++] = arr[begin1++];
				else
					tmp[index++] = arr[begin2++];
			}
			//将未归并完毕的数组剩下的值拷贝到临时数组里
			while (begin1 <= end1)
				tmp[index++] = arr[begin1++];
			while (begin2 <= end2)
				tmp[index++] = arr[begin2++];
			//归并一部分拷贝一部分,未归并的部分则不拷贝
            //即归并一组,拷贝一组
			for (int j = i; j <= end2; j++)
				arr[j] = tmp[j];
		}
		gap *= 2;

	}
}

(仔细看看对应着代码实现的思想看代码还是可以理解的~)


计数排序

‍️算法思想:

非比较排序的思想类似于哈希表的映射,虽然只能对整数进行排序,但优在效率较高

首先介绍两种计数方式

  • 绝对映射
    当待排序的数据都小于10时,其可以对应数组下标依次放入
  • 相对映射
    当待排序数组的数据都较大时,可以先求出该数据中的最大值与最小值,然后相减得出要开辟的空间大小以及相对映射的相对大小
    (尽管有额外空间消耗,但是消耗对整体效率影响不大)

将待排序序列中的每个数,按照映射方式依次映射到计数数组的相应位置中,然后再从映射数组的首元素开始,依次将读出每个数字并取出放进新数组中,待数组遍历完毕则新数组中的序列即已经有序。

⚠️实现代码实现注意的点:

要点一:

获取映射的范围大小时要+1
//如“[0-9]”0到9差9个数,但实际有十个数,所以范围要+1才能保证所有数都有映射的位置

⬇️⬇️⬇️具体代码实现⬇️⬇️⬇️

void count_sort(int* arr, int n)
{
	int max = arr[0], min = arr[0];
	for (int i = 0; i < n; i++)
	{
		if (max < arr[i])
			max = arr[i];
		if (min > arr[i])
			min = arr[i];
	}
    //max-min是该序列中最大值与最小值的差值
    //即意为该序列的相对映射的最大范围的大小
    //如“[0-9]”0到9差9个数,但实际有十个数,所以范围要+1才能保证所有数都有映射的位置
	int range = max - min + 1;
    //进行计数的数组
	int* count = (int*)malloc(sizeof(int) * range);
	memset(count, 0, sizeof(int)*range);//初始化为0
	//进行计数
	for (int i = 0; i < n; i++)
        //要插入的数字减去最小值,即得到相对映射的位置
		count[arr[i] - min]++;
	//进行排序
	int j = 0;
	for (int i = 0; i < range; i++)
	{
		while (count[i]--)
			arr[j++] = i + min;
	}
}

不同排序的时间/空间复杂度以及稳定性

时间复杂度和空间复杂度的定义就不再赘述,简单来说​:
时间复杂度计算的是算法中,基本操作执行的次数,而并非程序运行的时间长短等
空间复杂度计算的是临时开辟的辅助空间的大小,即临时占用存储空间的变量的个数

稳定性

假定在待排序的记录序列中,存在多个具有相同的关键字记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的,否则成为不稳定的。
(即排序前后,两个相同的数据的相对位置没有发生改变)

稳定性的意义何在?简单举个栗子⑧

例如考试时,两个同学在不同的完成时间下提交了试卷,成绩相同则先提交的应该排名在前面,若用不稳定的排序则可能排名在后面


首先我们来总览一下各个排序的时间/空间复杂度以及稳定性: 

手撕八大排序,这一篇文章足矣(C++与Java) 堆排、快排、归并、希尔、计数、冒泡、插入、选择等一文搞掂_第17张图片

我们来结合具体代码来分析其每一个的复杂度以及稳定性

冒泡排序:

void bullet_sort(int* arr, int n)
{
	for (int i = 0; i < n; i++)
	{
		int flag = 1;
		for (int j = 0; j < n - i - 1; j++)
		{
			if (arr[j] > arr[j + 1])
			{
				swap(arr[j], arr[j + 1]);
				flag = 0;
			}
		}
		if (flag)
			break;
	}
}

平均时间复杂度: T(n) = O(n²)
最坏时间复杂度: T(n) = O(n²):当输入的数据是反序时
最好时间复杂度: T(n) = O(n):当输入的数据已经有序时,只需遍历一遍用于确认数据已有序。
空间复杂度: O(1):没有借助额外的辅助空间
稳定性: 稳定:当两个数相等时并不进行交换

选择排序:

void select_sort(int* arr, int n)
{
	int begin = 0, end = n - 1;
	while (begin < end)
	{
		int maxi = begin, mini = begin;
		//遍历一遍数组找最大值和最小值的下标
		for (int i = begin; i <= end; i++)
		{
			if (arr[maxi] < arr[i])
				maxi = i;
			if (arr[mini] > arr[i])
				mini = i;
		}
		//将最大值放到最左边,最小值放到最右边
		swap(arr[begin], arr[mini]);
		//如果最大值的下标与begin重叠则会造成最大值的下标上的数是最小值,故需进行调整
		if (maxi == begin)
			maxi = mini;
		swap(arr[maxi], arr[end]);
		begin++;
		end--;
	}
}

该排序是本文中的八大排序里最差劲的一个排序,因为无论是否有序,每次都是要进入遍历找最大值和最小值。

时间复杂度计算:n+(n-2)+(n-4)+...+1/0(即等差数列求和,得出O(n²))

平均时间复杂度: T(n) = O(n²)
最坏时间复杂度: T(n) = O(n²)
最好时间复杂度: T(n) = O(n²)
空间复杂度: O(1):没有借助额外的空间
稳定性: 不稳定:左右分别有相同的最小值,右边的一交换过去自然就交换了次序了

插入排序:

void insert_sort(int* arr, int n)
{
	//最后一次end如果走到n-1则end+1即为n,此时访问就造成了越界,所以end只能走到end-2,故i= 0)
		{
			if (tmp < arr[end])//升序
			{
				arr[end + 1] = arr[end];//大小不符合要求,则将end位置的值往后移
				end--;
			}
			else
				break;
		}
		arr[end + 1] = tmp;//插入到有序数组
	}
}

平均时间复杂度: T(n) = O(n²)
最坏时间复杂度: T(n) = O(n²):输入数组按降序排列(完全逆序),每个数都要进行交换
最好时间复杂度: T(n) = O(n):输入数组按升序排列(基本有序)
空间复杂度: O(1):没有借助额外的辅助空间
稳定性:稳定:可以控制相同的数则跳过,不进行插入

希尔排序:

//整体时间复杂度为O(N*logN)
void my_shell_sort(int* arr, int n)
{
	int gap = n;//gap与数组长度相关,则可更好的提高效率
	while (gap > 1)
	{
		//gap每次除2时循环进行log2N次
		gap /= 2;//每次都先将gap除2,最后gap一定会到1
		//gap很大时,预排序大概要交换O(N)次
        //(因为gap很大,每次沉底的很快,一个数只用交换1-2次)
		//gap很小时,此时数组已经接近有序,预排序也大概要进行O(N)次
		
		//同时对多组进行预排序
		for (int i = 0; i < n - gap; i++)
		{
			//以下代码整体思路上即为直接插入排序
			int end = i;
			int tmp = arr[end + gap];
			while (end >= 0)
			{
				if (tmp < arr[end])
				{
					arr[end + gap] = arr[end];//end位置的值移动到end+gap的位置上
					end -= gap;//每次end移动gap个位置
				}
				else
					break;
			}
			arr[end + gap] = tmp;
		}
	}
}

  • 平均时间复杂度:T(n) = O(N^1.5):具体咋算的也不清楚
  • 最坏时间复杂度:T(n) = O(NlogN):详情见代码注释
  • 空间复杂度: O(1):为借助额外的空间
  • 稳定性: 不稳定,由于多次插入排序,我们可以让一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同组的插入排序过程中,一次跳过了gap个数,在预排序时相同的数就可能被分到了不同的组,则造成了稳定性的破坏。

堆排序:

//向下调整的时间复杂度为每层的结点个数*每个结点向下调整的次数
//即n-log(n+1)->故时间复杂度为O(N)
static void AdjustDown(int* arr, int n, int root)
{
	//建大堆
	int parent = root;
	int child = parent * 2 + 1;
	while (child < n)
	{
		//找出左右孩子大的那一个与父亲进行比较
		if (child + 1 < n && arr[child + 1] > arr[child])
			child += 1;
		//建大堆,即堆顶比左右子树的每个节点的值都大
		if (arr[parent] < arr[child])
		{
			swap(arr[parent], arr[child]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
			break;
	}
}
//整体的时间复杂度为O(N*logN)
void heap_sort(int* arr, int n)
{
	//从第一个非叶子结点开始进行调整
	//建堆时间复杂度为O(N)
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
		AdjustDown(arr, n, i);
	int end = n - 1;
	//选数的时间复杂度为O(logN)
	//向下调整算法最多调整高度次(logN次)
	while (end >= 0)
	{
		swap(arr[0], arr[end]);
		AdjustDown(arr, end, 0);
		end--;
	}
}

调整堆:O(logN)(h=logN,h为树的高度)
建堆:O(n)
循环调堆:O(NlogN)
总运行时间T(n) = O(nlogn) + O(n) = O(nlogn)。对于堆排序的最好情况与最坏情况的运行时间,因为最坏与最好的输入都只是影响建堆的运行时间O(1)或者O(n),而在总体时间中占重要比例的是循环调堆的过程,即O(nlogn) + O(1) =O(nlogn) + O(n) = O(nlogn)。因此最好或者最坏情况下,堆排序的运行时间都是O(nlogn)。
而且堆排序还是 原地算法(in-place algorithm) 

具体计算详情来源见注释

  • 平均情况:T(n) = O(nlogn)
  • 最差情况:T(n) = O(nlogn)
  • 最佳情况:T(n) = O(nlogn)
  • 空间复杂度:O(1):为借助额外的辅助空间
  • 稳定性:不稳定:进行堆的调整的时候,左子树和其根节点相同,但右子树比左子树的值大,进行大堆调整时,根节点则会调整到右子树,那次序就改变了

归并排序: 

static void _merge_sort(int* arr, int left, int right, int* tmp)
{
	if (left >= right)
		return;
	int mid = (left + right) >> 1;//将数组区间一分为二
	_merge_sort(arr, left, mid, tmp);//递归进行左区间的分割
	_merge_sort(arr, mid + 1, right, tmp);//递归进行右区间的分割
	//进行归并
	int begin1 = left, end1 = mid;//左区间的起始下标
	int begin2 = mid + 1, end2 = right;//右区间的起始下标
	int index = left;//临时数组的下标
	while (begin1 <= end1 && begin2 <= end2)
	{
		//将小的那个值拷贝到临时数组中
		if (arr[begin1] < arr[begin2])
			tmp[index++] = arr[begin1++];
		else
			tmp[index++] = arr[begin2++];
	}
	//将未归并完毕的数组剩下的值拷贝到临时数组里
	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 merge_sort(int* arr, int n)
{
	//临时数组
	int* tmp = (int*)malloc(sizeof(int) * n);
	_merge_sort(arr, 0, n - 1, tmp);
	free(tmp);
}

由归并排序的特性可看出,每次都是一分为二的递归,则可以看作一棵完全二叉树进行时间复杂度的计算。

第一层要分N次,第二层为(N/2+N/2=N)次,第三层要分(N/2+N/2+N/2+N/2=N)次,每一层都是要分N次,一共有log(N+1)层,故时间复杂度为O(N*logN)

  • 平均情况:T(n) = O(nlogn)
  • 最差情况:T(n) = O(nlogn)
  • 最佳情况:T(n) = O(n)
  • 空间复杂度: O(n),归并排序需要一个与原数组相同长度的数组做辅助来排序
  • 稳定性: 稳定

快速排序:

int part_sort_3(int* arr, int left, int right)
{
    //三数取中
	int index = get_mid_index(arr, left, right);
	swap(arr[index], arr[left]);

	int cur = left + 1, prev = left;
	int keyi = left;
	while (cur <= right)
	{
		//++prev与cur相等时,就相当于自己与自己交换,没有意义,故加上该条件
		if (arr[cur] < arr[keyi] && ++prev != cur)
			swap(arr[prev], arr[cur]);
		cur++;
	}
	swap(arr[prev], arr[keyi]);
	return prev;
}

void my_quick_sort(int* arr, int left, int right)
{
	//当区间不存在时,即不需要再继续进行递归
	if (left > right)
		return;

	int keyIndex = part_sort_3(arr, left, right);

	//进行小区间优化
	if (keyIndex - 1 - left > 13)
		my_quick_sort(arr, left, keyIndex - 1);//递归左区间
	else//到一定小的范围则改用插入排序
		insert_sort(arr + left, keyIndex - 1 - left + 1);
	if (right - (keyIndex + 1) > 13)
		my_quick_sort(arr, keyIndex + 1, right);//递归右区间
	else
		insert_sort(arr + keyIndex + 1, right - (keyIndex + 1) + 1);
}
  • 最佳情况:T(n) = O(nlogn),快速排序最优的情况就是每一次取到的元素都刚好平分整个数组,那跟归并排序的计算方式类似
  • 最差情况:T(n) = O(n²),最差的情况就是每一次取到的元素就是数组中最小/最大的,这种情况其实就是冒泡排序了(每一次都排好一个元素的顺序),不过加了三数取中,基本不可能会有最坏的情况。
  • 平均情况:T(n) = O(nlogn)
  • 稳定性:不稳定:快速排序的key值如果有多个相同的值分别在序列的两端,那最后key值放到了正确的位置时,则很即导致稳定性被破坏

计数排序: 

void count_sort(int* arr, int n)
{
	int max = arr[0], min = arr[0];
	for (int i = 0; i < n; i++)
	{
		if (max < arr[i])
			max = arr[i];
		if (min > arr[i])
			min = arr[i];
	}
    //max-min是该序列中最大值与最小值的差值
    //即意为该序列的相对映射的最大范围的大小
    //如“[0-9]”0到9差9个数,但实际有十个数,所以范围要+1才能保证所有数都有映射的位置
	int range = max - min + 1;
    //进行计数的数组
	int* count = (int*)malloc(sizeof(int) * range);
	memset(count, 0, sizeof(int)*range);//初始化为0
	//进行计数
	for (int i = 0; i < n; i++)
        //要插入的数字减去最小值,即得到相对映射的位置
		count[arr[i] - min]++;
	//进行排序
	int j = 0;
	for (int i = 0; i < range; i++)
	{
		while (count[i]--)
			arr[j++] = i + min;
	}
}

N为元素个数,K为数据横跨的范围,即代码里的range(序列相对映射的最大范围)

遍历N个数,同时将数据计数;然后再遍历计数的数组,将K个数依次读出排序

  • 平均情况:T(n) = O(N+K)
  • 最差情况:T(n) = O(N+K)
  • 最佳情况:T(n) = O(N+K)
  • 空间复杂度: O(N+K)
  • 稳定性: 稳定:相同的数就按次序先后读入,也是按顺序先后读出

总结:

其实八种排序学习下来,会明白它们各自有各自的优劣,要深刻理解每一个排序算法的各自的优劣,才能在实际运用时能够更好的对症下药~

本文大致向大家展现了这八大常用排序,希望能够让大家在算法的道路上浅浅再进一步吧~

这个排序算法的博客总算是肝完了,其实排序算法学了这么久,一直没有机会去总结,受哪位hxd的影响,总算也是系统的把常用的这几大排序给总结了一番
(没想到写着写着就两万字了,这里感慨一下!!) 

写在最后:

如果对你有帮助,点个赞,收个藏再走呗~对作者是莫大的鼓励哩

你可能感兴趣的:(数据结构与算法入土,c++,c语言,算法,排序算法,数据结构)