【算法】基于hoare快速排序的三种思想和非递归,基准值选取优化【快速排序的深度剖析-超级详细的注释和解释】你真的完全学会快速排序了吗?

在这里插入图片描述

前言

先赞后看好习惯 打字不容易,这都是很用心做的,希望得到支持你 大家的点赞和支持对于我来说是一种非常重要的动力 看完之后别忘记关注我哦!️️️

我们都知道,排序算法是编程高级语言里面极其重要的算法。 在此其中,比较重要的就是快速排序了。但是在很多人学习快速排序的过程中,其实并没有很好地理解快速排序。其实快速排序有三种思想

  • hoare版本
  • 挖坑法
  • 前后指针法

对于C语言中的qsort()函数,博主在早期的博客中做过详细的介绍,暂时还不会用的伙伴可以通过传送门食用哦

对于九大排序还不太清楚的朋友们,这里 博主提供汇总的传送门给大家。

另外还有一篇博客,是排序算法的复杂度测试源代码,博主也在这里提供给大家,等下再下面学习过程中,我们也要用到。


那么这里博主先安利一下一些干货满满的专栏啦!

数据结构专栏:数据结构 这里包含了博主很多的数据结构学习上的总结,每一篇都是超级用心编写的,有兴趣的伙伴们都支持一下吧!
算法专栏:算法 这里可以说是博主的刷题历程,里面总结了一些经典的力扣上的题目,和算法实现的总结,对考试和竞赛都是很有帮助的!
力扣刷题专栏:Leetcode 想要冲击ACM、蓝桥杯或者大学生程序设计竞赛的伙伴,这里面都是博主的刷题记录,希望对你们有帮助!
C的深度解剖专栏:C语言的深度解剖 想要深度学习C语言里面所蕴含的各种智慧,各种功能的底层实现的初学者们,相信这个专栏对你们会有帮助的!
为了方便伙伴们测试,这里博主提供一份测试用的main()函数里的源代码,各位测试的时候可以直接复制来进行测试。


本篇使用了一些C++包装的函数,例如swap,max等。
所以读者自己记得加上所需IO,算法等头文件

本期博客使用的编程语言是C++,但是,为了让目前只会C语言的伙伴也可以很好地掌握里面的算法核心,本篇博客没有使用C++包装的vector,全部使用了数组来进行演示。

本篇博客的代码实现的都是int类型数据的升序,想要实现降序或者其它数据类型排序的伙伴可以在此基础上做修改,我们本篇的目标是理解算法思想即可。

注:本篇博客的动画全部来自互联网

什么是快速排序

快速排序实质上的实现:

  • 每次确定一个key值(在优化之前一般选区间最左边的值)
  • key这个值排好,举个例子,原来的数组第一个数是6,数组是[1,2,3,4,5,6,7,8,9,10]打乱的顺序,那么如果6当key,排完单趟之后,6应该回到下标为5的位置。
  • 然后递归对由keyi(keyi是key的下标)划分的子区间数组再重复上面的步骤。也就是说,对[begin,keyi-1][keyi+1,end]这两个区间,排好它们的key。其实这个就是一个分治的过程,类似于二叉树的遍历。
  • 非递归的方式其实就是用数据结构栈模拟递归过程,单趟的排序是一样的。

单趟排序有三种实现思想
1.hoare思想
2.挖坑法
3.前后指针法


快速排序的递归实现

void _QuickSort(int* a, int begin, int end) {
    
    
	//区间不存在或者只会有一个值不需要再处理
	//快排:每次把key弄好,递归解决key两边的数
	if (begin >= end)return;
	//利用单趟排序找到key并排好,然后得到key的下标keyi
	int keyi = PartSort(a, begin, end);
	//PartSort即为单趟排序
	_QuickSort(a, begin, keyi - 1);
	_QuickSort(a, keyi + 1, end);
}
void QuickSort(int* a, int n) {
    
    
	int begin = 0;
	int end = n - 1;
	_QuickSort(a, begin, end);
}

tips:关于PartSort单趟排序的实现,我放在后面详细讲那三种实现思想。

快速排序的非递归实现

其实说白了就是用数据结构栈来模拟递归的过程,这里和二叉树dfs遍历的非递归方法其实是一样的。

#include<stack>
void QuickSortNonR(int* a, int begin, int end) {
    
    
	//1.直接改循环
	//2.用数据结构栈模拟递归过程
	stack<int>st;
	st.push(end);
	st.push(begin);
	//栈里面没有区间了,就结束了
	while (!st.empty()) {
    
    
		int left = st.top();
		st.pop();

		int right = st.top();
		st.pop();
		//利用单趟排序找到key并排好,然后得到key的下标keyi
		int keyi = PartSort(a, left, right);
		//[left,keyi-1]keyi[keyi+1,right]
		
		//怎么迭代
		//先入右再入左

		//栈里面的区间都会拿出来,单趟排序分割,子区间再入栈
		if (left < keyi - 1) {
    
    
			st.push(keyi - 1);
			st.push(left);
		}
		if (keyi + 1 < right) {
    
    
			st.push(right);//同样,先入右再入左
			st.push(keyi + 1);
		}
	}
}
//
void QuickSort(int* a, int n) {
    
    
	int begin = 0;
	int end = n - 1;
	QuickSortNonR(a, begin, end);
}

单趟排序详解

hoare思想

  • begin从前往后找比基准值大的元素,找到之后停止;让end从后往前找比基准值小的元素,找到之后停止;如果beginend没有相遇:将begin位置上的元素和end位置上的元素进行交换;循环结束后将基准值和begin位置上的元素进行交换。

在这里插入图片描述

//hoare版本        O(nlogn)
//1.选出一个key,一般是最左边或者最右边的值
//2.单趟排完之后:要求左边比key小,右边比key大
//左边key,右边先走
//右边key,左边先走
//每次排完一趟,key的位置的值就是准确的了,不用动了
int PartSort1(int* a, int begin, int end) {
    
    //hoare
	int left = begin;
	int right = end;
	int keyi = left;//保存下标,也就是指针是最优的
//下面是单趟
	while (left < right) {
    
    //相遇就停下来
		//右边先走,找小
		while (left < right && a[right] >= a[keyi])--right;//while一定要带等号,否则会死循环
		                                               //而且要带多一个调节,否则有可能会越界
		//左边再走,找大
		while (left < right && a[left] <= a[keyi])++left;
		//交换
		swap(a[left], a[right]);
	}
	//交换key和相遇位置换
	swap(a[keyi], a[right]);
//为什么左边做key,要让右边先走?
//因为要保证相遇的位置的值比key小

	keyi = left;
	//[begin,keyi-1]和[keyi+1,end]有序即可
	return keyi;
}

挖坑法

挖坑法其实只是对hoare版本的一个改写而已,其实并没有得到真正意义上的改变。
以下是挖坑法的思路:
在这里插入图片描述

//挖坑法
//和hoare相同的地方就是-排完单趟-做到key左边比key小,右边比key大
int PartSort2(int* a, int begin, int end) {
    
    
	int key = a[begin];
	int piti = begin;//begin是第一个坑
	int left = begin;
	int right = end;
	while (left < right) {
    
    
		//右边找小,填到左边的坑
		while (left < right && a[right] >= key) {
    
    
			right--;
		}
		a[piti] = a[right];
		piti = right;//自己变成坑
		while (left < right && a[left] <= key){
    
    
			left++;
		}
		a[piti] = a[left];
		piti = left;
	}
	//一定相遇在坑的位置
	a[piti] = key;
	return piti;
}

前后指针法

定义两个指针,prevcur,再定义key的值,cur指针从left开始,遇到比key大的就过滤掉,比key小的,就停下来,prev++,判断prevcur是否相等,如果不相等,就将两个值进行交换最后一趟排序下来,比key小的,都留在了key的左边,比key大的都在key的右边。
动图中i代表curs代表prev
在这里插入图片描述

//前后指针版
int PartSort3(int* a, int begin, int end) {
    
    
	//排完之后prev之前的比prev小,prev后的比prev大
	int prev = begin;
	int cur = begin + 1;//一开始cur和prev要错开
	int keyi = begin;
	
	while (cur <= end) {
    
    
		//如果cur的值小于keyi的值
		if (a[cur] < a[keyi] && ++prev != cur) {
    
    //只有这种情况要处理一下
			swap(a[prev], a[cur]);
		}
		++cur;
	}
	//
	swap(a[prev], a[keyi]);
	//此时prev的位置就是keyi的位置了
	keyi = prev;
	return keyi;
}

快速排序的优化

以上三种找key的方法,如果在数组是有序的时候,效率就会很低:
为什么,因为效率变成
n+n-1+n-2+n-3.....
因此为:O(n^2)
什么时候快排的效率是最高的?–每次的key都是区间的中位数的时候就是最快的,这样就是一个严格的二分分治,效率为O(NlogN)

而且因为是使用递归,所以如果数字稍微大一些,就会出现栈溢出的现象。

我们可以用TestOP来测试(效率测试见文章开头传送门)
如果用快排来排有序的时候,在debug版本小就可能会爆
而且效率会变得非常低。
在这里插入图片描述
在这里插入图片描述
那么我们怎么去优化呢?


三种优化方案:

  • 随机选一个数作为key
  • 三数取中
  • 小区间优化

第一种优化方案很好理解,这里重点介绍下面两种方式进行优化。

三数取中

三数取中的意思就是–选取区间中第一个数,中间那个数,最后那个数中不大不小的那个作为该区间的key值。

//优化-三数取中
int GetMidIndex(int* a, int begin, int end) {
    
    
	int mid = (begin + end) / 2;
	if (a[begin] < a[mid]) {
    
    
		if (a[mid] < a[end]) {
    
    
			return mid;
		}
		else if (a[begin] < a[end]) {
    
    
			return end;
		}
		else {
    
    
			return begin;
		}
	}
	else {
    
    //a[begin]>a[mid]
		if (a[mid] > a[end]) {
    
    
			return mid;
		}
		else if (a[begin] < a[end]) {
    
    
			return begin;
		}
		else {
    
    
			return end;
		}
	}
}

小区间优化

当区间比较小的时候,就不再递归划分去排序这个小区间。可以考虑其他排序。
这里建议直接用插入排序。

这里只需要对刚才的_QuickSort()函数里做个调整即可。

void _QuickSort(int* a, int begin, int end) {
    
    
	//记录递归次数
	callCount++;
	//区间不存在或者只会有一个值不需要再处理
	//快排:每次把key弄好,递归解决key两边的数
	if (begin >= end)return;
	//小区间优化
	if (end - begin > 10) {
    
    //当区间大于10的时候,继续递归
		int keyi = PartSort3(a, begin, end);//每一个partsort负责找到key
		_QuickSort(a, begin, keyi - 1);
		_QuickSort(a, keyi + 1, end);
	}
	else {
    
    //当区间较小的时候,直接使用插入排序
		InsertSort(a + begin, end - begin + 1);//注意+1和a+begin
	}
}

快速排序整体代码

在完整代码中,只对PartSort3()使用了三数取中,其它单趟排一个道理,也可以直接用,把key换掉就行了,很简单。

//优化-三数取中
int GetMidIndex(int* a, int begin, int end) {
    
    
	int mid = (begin + end) / 2;
	if (a[begin] < a[mid]) {
    
    
		if (a[mid] < a[end]) {
    
    
			return mid;
		}
		else if (a[begin] < a[end]) {
    
    
			return end;
		}
		else {
    
    
			return begin;
		}
	}
	else {
    
    //a[begin]>a[mid]
		if (a[mid] > a[end]) {
    
    
			return mid;
		}
		else if (a[begin] < a[end]) {
    
    
			return begin;
		}
		else {
    
    
			return end;
		}
	}
}
//hoare版本        O(nlogn)
int PartSort1(int* a, int begin, int end) {
    
    //hoare
	int left = begin;
	int right = end;
	int keyi = left;//保存下标,也就是指针是最优的
//下面是单趟
	while (left < right) {
    
    //相遇就停下来
		//右边先走,找小
		while (left < right && a[right] >= a[keyi])--right;//while一定要带等号,否则会死循环
		                                               //而且要带多一个调节,否则有可能会越界
		//左边再走,找大
		while (left < right && a[left] <= a[keyi])++left;
		//交换
		swap(a[left], a[right]);
	}
	//交换key和相遇位置换
	swap(a[keyi], a[right]);
	keyi = left;
	//[begin,keyi-1]和[keyi+1,end]有序即可
	return keyi;
}
//挖坑法
int PartSort2(int* a, int begin, int end) {
    
    
	int key = a[begin];
	int piti = begin;//begin是第一个坑
	int left = begin;
	int right = end;
	while (left < right) {
    
    
		//右边找小,填到左边的坑
		while (left < right && a[right] >= key) {
    
    
			right--;
		}
		a[piti] = a[right];
		piti = right;//自己变成坑
		while (left < right && a[left] <= key){
    
    
			left++;
		}
		a[piti] = a[left];
		piti = left;
	}
	//一定相遇在坑的位置
	a[piti] = key;
	return piti;
}
//前后指针版
int PartSort3(int* a, int begin, int end) {
    
    
	//排完之后prev之前的比prev小,prev后的比prev大
	int prev = begin;
	int cur = begin + 1;//一开始cur和prev要错开
	int keyi = begin;

	//加入三数取中的优化
	int midi = GetMidIndex(a, begin, end);
	swap(a[keyi], a[midi]);//这样换一下,key就变成GetMidIndex()函数的取值了-后面的代码都不变了

	while (cur <= end) {
    
    
		//如果cur的值小于keyi的值
		if (a[cur] < a[keyi] && ++prev != cur) {
    
    //只有这种情况要处理一下
			swap(a[prev], a[cur]);
		}
		++cur;
	}
	swap(a[prev], a[keyi]);
	keyi = prev;
	return keyi;
}
void _QuickSort(int* a, int begin, int end) {
    
    
	//区间不存在或者只会有一个值不需要再处理
	//快排:每次把key弄好,递归解决key两边的数
	if (begin >= end)return;
	//小区间优化
	if (end - begin > 10) {
    
    
		int keyi = PartSort3(a, begin, end);//每一个partsort负责找到key
		_QuickSort(a, begin, keyi - 1);
		_QuickSort(a, keyi + 1, end);
	}
	else {
    
    
		InsertSort(a + begin, end - begin + 1);//注意+1和a+begin
	}
}
void QuickSortNonR(int* a, int begin, int end) {
    
    
	//1.直接改循环
	//2.用数据结构栈模拟递归过程
	stack<int>st;
	st.push(end);
	st.push(begin);
	//栈里面没有区间了,就结束了
	while (!st.empty()) {
    
    
		int left = st.top();
		st.pop();

		int right = st.top();
		st.pop();

		int keyi = PartSort3(a, left, right);
		//[left,keyi-1]keyi[keyi+1,right]
		
		//怎么迭代
		//先入右再入左

		//栈里面的区间都会拿出来,单趟排序分割,子区间再入栈
		if (left < keyi - 1) {
    
    
			st.push(keyi - 1);
			st.push(left);
		}
		if (keyi + 1 < right) {
    
    
			st.push(right);//同样,先入右再入左
			st.push(keyi + 1);
		}
	}
}
void QuickSort(int* a, int n) {
    
    
	int begin = 0;
	int end = n - 1;
	_QuickSort(a, begin, end);//这里也可以改成用非递归那个函数
	//QuickSortNonR(a, begin, end);
}

尾声

看到这里,我相信伙伴们对快速排序已经有了比较深入的了解了,在这快速里面,其实渗透了很多人的智慧结晶,我们掌握这些排序知识一部分,更重要的还是掌握里面精妙的算法思路。
如果这篇博客对你有帮助,一定不要忘了点赞关注和收藏哦!

猜你喜欢

转载自blog.csdn.net/Yu_Cblog/article/details/125766452