【数据结构(初阶)】听说你还没有学会快速排序?快来!万字详解手把手带你撸快速排序!!!

在这里插入图片描述

⛳️博主主页:
草莓base(hacker核心组织成员)
🎥收录专栏:
💬 苟日新,日日新,又日新。
欢迎大家⛺点赞、📂收藏、🌝评论、👀关注鼓励四连

📜前言

🚀 快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,运用到了分治思想
🚀 其基本的过程可以总结为任取待排元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两个子序列左子序列中所有元素均小于基准值右子序列中所有元素均大于基准值然后就是左右子列重复该过程,直到所有元素都排列在相应位置上为止。
🚀本章内容将会介绍快速排序的四个版本,即 Hoare版本挖坑法前后指针法非递归方法,以及对快速排序的优化方法:三数取中小区间优化

📜Hoare版本

📅思路分析

在这里插入图片描述

Hoare版本的过程可以简单分成如下几个步骤:
🔔将首位数设置为key值,把left设置为首位数的下标,把right设置为最后一位数的下标。

🔔然后,left寻找比key值小的后停下,right寻找比key值大的后停下(老铁们注意,这里需要让right先走,至于原因我们后面会解释。)

🔔交换这里的light位置的数和right位置的数

🔔继续移动rightleft

🔔直到leftright相遇,停下,将这个位置的值与key交换。

如此,便实现了key前的值都比key小,后面的值都比key大,并且key到了排好序要放的位置这个目的。

📅代码实现

void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}

//快速排序(hoare版本)
//注意是二叉树的分治思想
int PartSort1(int* a, int left,int right)
{
	int keyi = left;
	while (left < right)
	{
		//R找比key小的
		while (a[right] >= a[keyi] && left<right)
		{
			right--;
		}
		//L找比key大的
		while (a[left] <= a[keyi] && left<right)
		{
			left++;
		}
		Swap(&a[left], &a[right]);
	}
	//相遇了,要交换key和相遇点
	Swap(&a[keyi], &a[left]);
	return left;
}

📅注意

1.为什么我们还需要再用一个left<right来约束中间的循环呢?
这里就是对极端情况的考虑:如果整个数组的值均大于key,就会导致right一直向前移动,直到指向了原数组的前面,相当于“野指针”。为了避免这样的情况,便加上了这个条件进行约束。下一个循环同理。
2.中间循环的约束条件,我们可不可以用a[right]<a[keyi] a[left]>a[keyi]
这里是不可以的。这里如果不加上等号,就会导致死循环的产生。我们来考虑一个极端情况,如果一个数组中的数组全部相同,那么left和right将不会移动,使程序陷入死循环。
3.在交换的时候,我们能不能将key值存到临时变量当中,然后在交换时,使用Swap(&key,&a[left])
这里也是不可以的,key此时存放在临时变量当中,而我们想实现的是数组的首个位置和left进行交换,所以此时交换key的临时变量,是无法实现数组首个位置与left进行交换这个条件的。

📅Hoare版本中常见的问题

相遇位置的值为什么可以直接和a[keyi]交换,如果相遇位置的值比key大呢?
这里先给出结论:是由于right先走这个重点步骤,确保了相遇位置的值一定小于key

接下来我们具体讲解一下:
首先,我们要明白,leftright并不是同时移动的,而是一个停下后,另一个再开始走,所以leftright相遇,其实无非就是两种情况:

  • 第一种情况是right不动,left移动走到right的位置,两者相遇;
  • 第二种就是left不动,right移动走到left的位置,两者相遇。

我们接着分析:

如果是第一种情况,他们相遇的位置就是原本right的位置,而由于right先走,遇到比key小的停下了,所以此处的值肯定是小于key的;

如果是第二种情况,他们相遇的位置就是原本left的位置,这时要注意,left开始走了,说明right已经开始走了一次了,所以这个位置的值已经在上一次发生了交换了,所以此时left位置的数依然是比key小的.

📜挖坑法

📅思路分析

大家看完上面的讲解之后,是不是觉得Hoare版本的快速排序有太多的坑了?这时,一些大佬们想着改进快速排序的实现方法,便出现了:挖坑法
在这里插入图片描述

大家认真观察上图,有没有明白挖坑法的具体过程?
🔔其实就是现将首位置的值放到临时变量key当中,此时,就形成了一个坑位;
🔔我们还是仿照Hoare的版本,设置rightleftright先走,遇到比key小的便停下,将该值移放到坑中,这时right变成了新的坑;
🔔接着移动left,遇到比key大的便停下,将该值移放到坑中,这时left变成了新的坑;
🔔直到leftright相遇后,就停止循环,再将原本存放在临时变量中的key放进坑位当中。

如此一来,我们便不用像Hoare版本一样考虑很多种情况,便可以直接实现快速排序。

📅代码实现

//挖坑法
int PartSort2(int* a, int left, int right)
{
	int hole = left;
	int keyi = a[left];
	while (left < right)
	{
		//R找比key小的
		while (a[right] > keyi && left < right)
		{
			right--;
		}
		a[hole] = a[right];
		hole = right;
		//L找比key大的
		while (a[left] < keyi && left < right)
		{
			left++;
		}
		a[hole] = a[left];
		hole = left;		
	}
	a[hole] = keyi;
	return hole;
}

📜前后指针

接下来,我们再来讲解一个实现快速排序的方式——前后指针法。前后指针法其实和双指针法有着相似的地方,比起Hore版本的实现更加方便理解。
在这里插入图片描述

下面我们来总结一下前后指针法的步骤:
🔔开始:prev指针设立在首位,cur指针设立在第二位;
🔔接着cur指针移动,寻找比key小的值;
🔔cur遇到比key小的值之后停下,prev+1之后的位置和cur进行交换;
🔔接着cur找比key小的值,直到cur数组越界,循环停下
🔔最后,将prev的位置的值和key进行交换。

📅思路分析

这里prevcur的关系无非是以下两种情况:

  • 第一种情况:在cur还没有遇到比key还要大的值之前,prev是紧跟着cur的,而此时的交换则是原地交换,不改变数组的排序;
  • 第二种情况,cur遇到了比key大的数据,这时cur自己向前走,寻找比key小的数据,prev不动,而prev+1的数据就是比key大的数据,所以当cur遇到比key小的数据的时候,交换curprev+1即可实现大的在后,小的在前。

📅代码实现

//前后指针版本
int PartSort3(int* a, int left, int right)
{
	int keyi = left;
	int prev = left;
	int cur = prev+1;
	while (cur <=right)
	{
		if (a[cur] < a[keyi]) 
		{
			//这里注意是++prev,如果是prev++,那么返回值依然是原来的prev
			Swap(&a[++prev], &a[cur]);
		}
		cur++;
	}
	Swap(&a[keyi], &a[prev]);
	return prev;
}

📜递归形式

思路分析

刚才,我们已经讲解了每一次单趟排序的过程以及代码的实现,接下来我们通过一个简单的递归图来简单粗暴地理解一下快速排序的算法思想:
在这里插入图片描述
所以,用递归的方式实现快速排序,其结束条件就是只剩下一个数据时。

📅代码实现

void QuickSort(int* a, int begin, int end)
{
//这里要注意我们要考虑的是两种情况,第一种情况是区间不存在。第二种情况是区间中只有一个数字
	if (begin >= end)
	{
		return;
	}
	int keyi = PartSort3(a,begin, end);
	//[begin,keyi-1]  keyi  [keyi+1,end]
	QuickSort(a, begin, keyi - 1);
	QuickSort(a, keyi + 1, end);
}

📜快速排序的优化方法

📅快速排序的时间复杂度分析

首先,我们先要声明一下,由于每一次单趟排序都是遍历一遍数组,所以其时间复杂度均为N。
最好的情况:O(logN*N)
分析,我们来考虑一下什么时候快速排序的效率最高呢?肯定是当key的值取中位数或者近似中位数的时候,这个时候,快速排序相当于一个二叉树,其高度为 logN,总的时间复杂度则为O(logN*N)
在这里插入图片描述
最差的情况:O(N^2)
那么如果是在有序数组中,每次选择数组的首个位置作为key,由于数组是有序的,所以就会出现下图的情况,那么此时的时间复杂度就为O(N^2)。
在这里插入图片描述

📅三数取中法

经过刚才的分析,我们得出了一个结论:有序数组会导致快速排序的时间复杂度较大。那么为了避免这一情况,我们应该怎么办呢?
直接来讲,就是需要一个合适的key,最好是中位数,这样key值就不会过小或者过大了。但是又由于如果把key值设计在数组的中间,代码的实现又有些困难,我们便考虑到了三数取中法。
这个方法通俗来讲就是将数组的首值和末位的值以及中间的值进行比较,取出次大值(次小值)作为key值,并将该值交换到数组的首位上。
下面将代码附上~

int GetMidi(int* a, int left, int right)
{
	int mid = (left + right) / 2;
	// left mid right
	if (a[left] < a[mid])
	{
		if (a[mid] < a[right])
		{
			return mid;
		}
		else if (a[left] > a[right])  // mid是最大值
		{
			return left;
		}
		else
		{
			return right;
		}
	}
	else // a[left] > a[mid]
	{
		if (a[mid] > a[right])
		{
			return mid;
		}
		else if (a[left] < a[right]) // mid是最小
		{
			return left;
		}
		else
		{
			return right;
		}
	}
}

📅小区间非递归法

我们在学习二叉树的时候,一定对二叉树最后几层节点的巨大数据量有了一定的认识,所以,对于比较小的区间,我们没有必要使用递归形式进行排序,小区间非递归法就是在递归到小的子区间时(一般是区间长度低于10时),可以考虑使用插入排序的优化方式。

📜非递归

📅思路分析

我们上面讲解了快速排序的递归方法,但是递归会存在深度太深的风险,所以非递归的形式我们也需要掌握。
要实现快速排序的非递归,我们需要利用栈:“先进后出”的特性进行程序的设计。下面我们用图来直观理解一下。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
💡这里我们简要提一下,将递归形式转化成非递归形式,一般来说有两种解法,一种是利用for循环,比如:斐波那契数列的实现;而另一种方法,就是用栈的“先进后出”的思想。

📅代码实现

//用栈实现快速排序的非递归方法
void QuickSortNoR(int* a, int begin, int end)
{
	struct stack s1;
	STInit(&s1);
	//接着再将右区间先放进去,再将左区间放进去
	STPush(&s1, end);
	STPush(&s1, begin);
	while (!STEmpty(&s1))
	{
		int left = STTop(&s1);
		STPop(&s1);
		
		int right = STTop(&s1);
		STPop(&s1);
		int keyi = PartSort3(a, left, right);
		//[left,keyi-1]  keyi  [keyi+1,right]
		//当区间只剩下一个数据或者区间不存在的时候,不再放进栈中
		if (keyi + 1 < right)
		{
			STPush(&s1, right);
			STPush(&s1, keyi + 1);
		}
		if (left < keyi - 1)
		{
			STPush(&s1, keyi - 1);
			STPush(&s1, left);
		}
	}
	STDestroy(&s1);
}

今天的内容就分享到这里,欢迎各位老铁们订阅专栏🎓《数据结构初阶》 ,后续会持续更新!

🎁今天的内容就分享到这里,博主还是新手小白,希望大佬们可以多多批评指正,创作不易,希望得到您的支持和喜爱!感谢您的陪伴,草莓base将会努力将更多优质内容分享给大家!欢迎各位老铁在评论区讨论!

  • 18
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 15
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 15
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值