一篇文章讲透排序算法之快速排序

前言

本篇博客难度较高,建议在学习过程中先阅读一遍思路、浏览一遍动图,之后研究代码,之后仔细体会思路、体会动图。之后再自己进行实现。

一.快排介绍与思想

快速排序相当于一个对冒泡排序的优化,其大体思路是先在文中选取一个数作为基准值,将数组分为两个区间,一个区间比这个数大,另一个区间比这个数小。不断进行这个操作,直到我们的区间内只有一个数为止。

因此,快速排序的步骤如下:

  • 1.先从数列中取出一个数作为基准数。
  • 2.将数组分区间,将比这个数大的数全放到它的右边,小于或等于它的数全放到它的左边。
  • 3.再对左右区间重复这个动作,直到各区间只有一个数。

快速排序有多个版本,分别有hoare大佬创建的版本:hoare版本;便以理解的挖坑法;以及效率很高的前后指针法。我们依次讲解这些版本

二.hoare版本

2.0算法思想

首先,我们定义两个指针,分别指向两个数组的最左侧和最右侧。

之后,让R(小人头盔)去找比key小的数,L去找比key大的数,都找到了之后交换L和R的数。

然后再让他们继续走,继续找,继续交换,直到他们相遇为止。

此时将相遇处的值与key交换,此时相遇处右侧都是比key大的数,左侧都是比key小的数。

整个过程如下:

之后我们选定新的基准值,并不断进行上述动作,即可让大的数在右侧,小的数在左侧。 

2.1单趟过程

  • 我们首先选定数组下标为0处的值为基准值
  • 之后便是让right先走,找到较小的数
  • left再走,找到较大的数字
  • 交换left和right的数
  • right再走,找小
  • left再走,找大
  • 相遇时,将相遇处的值和keyi的值交换,并将相遇处的坐标设置为新的新keyi

现在我们将这一个过程实现为代码:

	//keyi-->begin处的数据下标
	int keyi = begin;
	int left = begin;
	int right = end;
	while (left < right)
	{
		//right先走,找小    找小,所以大于的时候要right--
	while(left < right && a[right] >= a[keyi])
	{
		--right;
	}
		//left再走,找大
	while(left < right&& a[left] <= a[keyi])
	{
		--left;
	}
	swap(&a[left], &a[right]);
	}
	keyi = left;

下面我们来剖析一下这段代码

  •  首先,我们的右指针是要找到小了才会停下来的,因此我们需要用到循环才能保证它找到小的时候停下来
  • 我们要找到小的时候停下来,就代表找到小是退出循环的条件,因此我们的条件是a[right] >= a[keyi]
  • 另外,我们一定要在while循环内部判断left<right,否则很容易发生数组越界。

如下图,如果我们不判断等于的话,则会出现死循环;

若我们不判断left<right的话,则很容易走出循环,譬如下图的情况,right一直找不到小,就走出循环了。

然后我们再来分析另外一个问题

如果相遇处的值大于keyi怎么办?它大于keyi的话,交换后不就无法产生一个大一个小的区间了嘛?

我们把这个问题转换一下,即为什么left和right相遇处的值一定小于keyi吗?

 我们是让right先走的,这里我们分两种情况讨论:(如果是left先走的话就寄掉了)

1.right遇left,我们可以确保的一点是,right遇到left之前遇到的值都是比keyi的值大的,而left处的值又是小于keyi的,因此相遇时的值小于keyi。

2.left遇right, right先找到小的然后停下来了,之后left遇到它了,这个位置是比keyi处的值小的值。

2.2多趟过程的思想

当上面的单趟走完后,我们会发现,keyi左边的全是小于a[keyi]的,右边全是大于a[keyi]的。

现在我们不断的分区间,不断的重复刚刚的行为,就可以实现对整个数组的排序了,这是一个递归分治的典型。

现在我给大家画图来分析一下下面几趟的过程:

以左半边为例:

 第二趟排序:

  • 右边找小,找到3;左边找大,没找到,相遇了
  • 交换2和3的位置
  • 新的key为3
  • 以k为基准,左右分区间

第三趟排序:

右边的找小直接遇到左,然后交换,新的key为2,下一个区间只有一个值。

第四趟排序:只有一个值了,我们可以返回了!

现在我们先不探究返回的条件,先将刚刚的思路完成

void qsort(int* a, int begin, int end)
{
	//keyi-->begin处的数据下标
	int keyi = begin;
	int left = begin;
	int right = end;
	while (left < right)
	{
		//right先走,找小
	while(left < right && a[right] >= a[keyi])
	{
		--right;
	}
		//left再走,找大
	while(left < right&& a[left] <= a[keyi])
	{
		--left;
	}
	swap(&a[left], &a[right]);
	}
	keyi = left;
	// [begin, keyi-1]keyi[keyi+1, end]
	QuickSort(a, begin, keyi - 1);
	QuickSort(a, keyi + 1, end);
}

那么,我们返回的条件如何判断呢?

我们在实现代码时可以发现,函数体里面是需要两个递归的,而我们需要探究的是递归的终止条件。

递归的终止条件是什么?就需要从传入的参数入手,这里我们将第三趟排序完成之后的两个递归图画出

 

在这里我们发现,递归的终止条件为begin>=end.

现在我们将代码补全:

void qsort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}
	//keyi-->begin处的数据下标
	int keyi = begin;
	int left = begin;
	int right = end;
	while (left < right)
	{
		//right先走,找小
	while(left < right && a[right] >= a[keyi])
	{
		--right;
	}
		//left再走,找大
	while(left < right&& a[left] <= a[keyi])
	{
		--left;
	}
	swap(&a[left], &a[right]);
	}
	keyi = left;
	// [begin, keyi-1]keyi[keyi+1, end]
	QuickSort(a, begin, keyi - 1);
	QuickSort(a, keyi + 1, end);
}

至此,我们便完成了一次快速排序。

三.快排的优化

如果用上述的代码进行排序,假设我们要将一个数组排升序,而原数字是降序的话,那么我们的快速排序的时间复杂度就来到了O(n),这样的消耗是非常大的。

我们发现,这是因为key的值造成的,那么,如果我们可以控制让数组中的哪个数当key,是不是就可以解决问题了呢?

3.1随机数法选key

我们可以在数组下标中随便选一个数来当作我们的keyi,这就需要我们的rand函数。

	int left = begin;
	int right = end;
	int randi = rand() % (right - left + 1);
	randi += left;
	Swap(&a[left], &a[randi]);
	int keyi = left;

这里我们对第三行和第四行代码做出解释

第三行:如果一个数%99,那么它的结果是0-98;如果我们想要其的范围是0-99,则需要+1.

第四行:我们的left并不一定从最左边开始。

3.2三数取中选key

有的人觉得随机选数未免有些风险,就用了三数取中选key法,即找出数组最左边,最右边,以及中间的三个数,然后比较这三个数。谁的值是中位数,谁当key。

int GetMidi(int* a, int left, int right)
{
	int mid = (left + right) / 2;
	if (a[left] > a[right])//如果左大于右
	{
		if (a[right] > a[mid])//左大于右  右大于中  右为中位数
			return right;
		else if (a[mid] > a[left])//中大于左  左大于右  左为中位数
			return left;
		else
			return mid;
	}
	else  //a[right]>a[left]   右大于左
	{
		if (a[left] > a[mid])  左大于中    左为中位数
			return left;
		else if (a[mid] > a[right])  中大于右   右为中位数
			return right;
		else
			return mid;
	}
}

我们将选取k的代码改为下面这几行即可。 

	int midi = GetMidi(a, left, right);
	swap(&a[left], &a[midi]);
	int keyi = left;

3.3小区间改造

由于快速排序是使用递归进行排序的,而每次递归都极大的占用空间,但其实我们的递归快到头的时候数组已经快有序了,这时我们再利用递归进行排序则会及大的占用栈的空间。

因此,我们在数组比较小的时候,直接换种排序即可,就譬如用插入排序。

void qsort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}
	if (end - begin + 1 < 10)//数组中不足10个元素
	{
		InsertSort(a + begin, end - begin + 1);
	}

	else
	{
		int midi = GetMidi(a, begin, end);
		swap(&a[begin], &a[midi]);
		int keyi = begin;
		int left = begin;
		int right = end;
		while (left < right)
		{
			//right先走,找小
			while (left < right && a[right] >= a[keyi])
			{
				--right;
			}
			//left再走,找大
			while (left < right && a[left] <= a[keyi])
			{
				--left;
			}
			swap(&a[left], &a[right]);
		}
		keyi = left;
		// [begin, keyi-1]keyi[keyi+1, end]
		qsort(a, begin, keyi - 1);
		qsort(a, keyi + 1, end);
	}
}

四.挖坑法

上面的方法有些难理解,于是有人给出了一个理解起来比较容易的快排方法。 

现在我们来理解一下这种挖坑法的算法思想。

  • 先将第一个数拿走,形成坑位,将此数定义为key
  • 之后right先走,找小,找到了之后把数放到坑位中,right处形成新的坑
  •  left再走,找大,找到后将数放到坑位中,left处形成新的坑位
  • 重复上述动作,直到两者相遇,将key放置在坑位。
void qsort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}
	//记录坑位
	int piti = begin;
	//记录坑位的值
	int key = a[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;
	qsort(a, begin, piti - 1);
	qsort(a, piti + 1, end);
}

四.前后指针法

上面的方法效率比较低下,有一位大佬又发明了这么一个方法

这个算法的思路是:

  • 记录第一个位置为key
  • 首先将prev置于第一个位置,cur置于第二个位置
  • 判断cur处的值是否小于key,若小于,则prev先走一步,之后cur再走一步,若++prev!=cur,则将cur指向的内容和prev指向的内容交换,之后cur指针++
  • 一直重复上一个动作,直到cur遇到的值大于key。
  • 遇到的值大于key时,让cur往前走一步,prev不走。
  • 等cur越界时,将prev和key的内容互换
  • 此时key左边的值比key小,右边的值比key大。

这个思路的原理是:通过前后指针法,遇到大的则不让prev走,只让cur走。此时prev和cur中间就差了一个数,而这个数是大于key的,然后让cur再走,直到遇到小于key的值,此时prev和cur都走一步,prev处的值是第一个大于key的值,而此时cur的值是小于key的,让他们交换即可让大于key的值后移,小于key的值前移。而这两个数之间的值都是大于key的,重复上述动作直到cur越界.

void qsort(int* a, int left, int right)
{
	if (left >= right)
		return;
	int keyi = left;
	int prev = left;
	int cur = left + 1;
	while (cur <= right)
	{
		if (a[cur] < a[keyi] && ++prev != cur)
		{
			swap(&a[prev], &a[cur]);
		}
		++cur;
	}
	swap(&a[keyi], &a[prev]);
	qsort(a, left, keyi - 1);
	qsort(a, keyi + 1, right);
}

为什么左边的小,右边的大?//结合代码理解

  • 对于prev指向的元素,它们都比基准元素大或等于基准元素。因为在遍历过程中,如果a[cur]比基准元素小,并且prevcur不相等,则将a[cur]a[prev]进行交换,将prev加1。如果a[cur]比基准元素大或等于基准元素,则不会进行交换操作,prev保持不变。
  • 对于cur指向的元素,它们都比基准元素小或等于基准元素。因为在遍历过程中,如果a[cur]比基准元素大,则不会进行交换操作,cur继续向后遍历。如果a[cur]比基准元素小,则与prev所指向的元素进行交换,并将prev加1

六.利用栈将递归改非递归

因为函数栈帧是在栈(非数据结构上的栈)上开辟的,所以容易出现栈溢出的情况,为了解决这个问题,还可以将快速排序改为非递归版本,这样空间的开辟就在堆上了,堆上的空间比栈要多上许多。

为了实现快速排序的非递归版本,我们要借助我们以前实现的栈,来模拟非递归

原理是:

  • 我们通过栈的先进后出的性质,先入一个左边的下标,再入一个右边的下标。
  • 之后我们将它们弹出,并完成单趟排序。然后我们就可以得到了左区间和右区间。
  • 我们先入右区间,再入左区间,以保证我们会先排序左区间再排序右区间。
  • 之后再弹出两个,再排序一次,再入一次两个区间
  • 一直循环这样的操作,一直到区间内没有元素为止。
void QuickSortNonR(int* a, int left, int right)
{
	ST st;
	STInit(&st);
	//先入右,再入左
	STPush(&st, right);
	STPush(&st, left);

	while (!STEmpty(&st))
	{
		//先弹左
		int begin = STTop(&st);
		STPop(&st);
		//再弹右
		int end = STTop(&st);
		STPop(&st);

		// 单趟
		int keyi = begin;
		int prev = begin;
		int cur = begin + 1;

		while (cur <= end)
		{
			if (a[cur] < a[keyi] && ++prev != cur)
				Swap(&a[prev], &a[cur]);

			++cur;
		}

		Swap(&a[keyi], &a[prev]);
		keyi = prev;

		// [begin, keyi-1] keyi [keyi+1, end] 
		//先入右区间
		if (keyi + 1 < end)
		{
			STPush(&st, end);
			STPush(&st, keyi + 1);
		}
		//再入左区间
		if (begin < keyi - 1)
		{
			STPush(&st, keyi - 1);
			STPush(&st, begin);
		}
	}

	STDestroy(&st);
}

我现在来帮助大家画个图进行理解

后语

到此就结束了,下篇博客会更新归并排序的相关内容,希望大家持续关注,可以的话点个免费的赞或者评论关注一下啊!

贴一下专栏: 排序算法专栏       数据结构专栏

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/661360.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

如何找出真正的交易信号?Anzo Capital昂首资本总结7个

匕首是一种新兴的价格走势形态&#xff0c;虽然不常见&#xff0c;但具有较高的统计可靠性。它通常预示着趋势的持续发展。该模式涉及到同时参考两个不同的时间周期进行交易&#xff0c;一个是短期&#xff0c;另一个是长期&#xff0c;比如一周时间框架与一天时间框架、一天时…

【芯片验证方法】

术语——中文术语 大陆与台湾的一些术语存在差别&#xff1a; 验证常用的英语术语&#xff1a; 验证&#xff1a;尽量模拟实际应用场景&#xff0c;比对芯片的所需要的目标功能和实现的功能 影响验证的要素&#xff1a;应用场景、目标功能、比对应用场景、目标功能&#xff…

最长递增子序列,交错字符串

第一题&#xff1a; 代码如下&#xff1a; int lengthOfLIS(vector<int>& nums) {//dp[i]表示以第i个元素为结尾的最长子序列的长度int n nums.size();int res 1;vector<int> dp(n, 1);for (int i 1; i < n; i){for (int j 0; j < i; j){if (nums[i]…

深入解析Web前端三大主流框架:Angular、React和Vue

Web前端三大主流框架分别是Angular、React和Vue。下面我将为您详细介绍这三大框架的特点和使用指南。 Angular 核心概念: 组件(Components): 组件是Angular应用的构建块,每个组件由一个带有装饰器的类、一个HTML模板、一个CSS样式表组成。组件通过输入(@Input)和输出(…

海外社媒账号如何运营安全稳定?

由于设备与网络原因&#xff0c;通常一个海外社媒账号尤其是多账号的稳定性都有一定限制&#xff0c;错误的操作或者网络都可能使得账号被封&#xff0c;前功尽弃。本文将为大家讲解如何通过IP代理来维持账号稳定与安全&#xff0c;助力海外社媒矩阵的搭建。 一、社媒账号关联…

Docker安装nginx详细教程

详细教程如下&#xff1a; 1. 拉取Nginx镜像 docker pull nginx默认拉最新的&#xff08;也可以根据自己的需求指定版本&#xff09; 2. 运行Nginx容器 docker run --name my-nginx -d -p 80:80 nginx--name my-nginx&#xff1a;容器名称&#xff0c;便于管理。-d&#xf…

使用C语言实现学生信息管理系统

前言 在我们实现学生信息管理系统的过程中&#xff0c;我们几乎会使用到C语言最常用最重要的知识&#xff0c;对于刚学习完C语言的同学来说是一次很好的巩固机会&#xff0c;其中还牵扯到数据结果中链表的插入和删除内容。 实现学生信息管理系统 文件的创建与使用 对于要实现…

【国产中颖】SH79F9202U单片机驱动LCD段码液晶学习笔记

1. 引言 因新公司之前液晶数显表产品单片机一直用的是 C51单片机(SH79F9202U9)&#xff0c;本人之前没有接触过这款单片机&#xff0c;为了维护老产品不得不重新研究研究这款单片机。 10位ADC LCD的增强型8051微控制器 SH79F9202是一种高速高效率8051可兼容单片机。在同样振…

TH方程学习(1)

一、背景介绍 根据CW方程的学习&#xff0c;CW方程的限制条件为圆轨道&#xff0c;不考虑摄动&#xff0c;二者距离相对较小。TH方程则可以将物体间的相对运动推广到椭圆轨道的二体运动模型&#xff0c;本部分将结合STK的仿真功能&#xff0c;联合考察TH方程的有用性&#xff…

19 - grace数据处理 - 补充 - 地下水储量计算过程分解 - 冰后回弹(GIA)改正

19 - grace数据处理 - 补充 - 地下水储量计算过程分解 - 冰后回弹(GIA)改正 0 引言1 gia数据处理过程0 引言 由水量平衡方程可以将地下水储量的计算过程分解为3个部分,第一部分计算陆地水储量变化、第二部分计算地表水储量变化、第三部分计算冰后回弹改正、第四部分计算地下…

学习笔记——数据通信基础——数据通信网络(基本概念)

数据通信网络基本概念 网络通信&#xff1a;是指终端设备之间通过计算机网络进行的通信。 数据通信网络(Data Communication Network)&#xff1a;由 路由器、交换机、防火墙、无线控制器、无线接入点&#xff0c;以及个人电脑、网络打印机&#xff0c;服务器等设备构成的通信…

canfd与can2.0关系

canfd是can2.0的升级版&#xff0c; 支持canfd的设备就支持can2.0&#xff0c;但can2.0的设备不支持canfd 参考 是选CAN接口卡还是CANFD接口卡_哔哩哔哩_bilibilihttps://www.bilibili.com/video/BV1Hh411K7Zn/?spm_id_from333.999.0.0 哪些STM32有CANFD外设 STM32G0, STM…

Django 做migrations时出错,解决方案

在做migrations的时候&#xff0c;偶尔会出现出错。 在已有数据的表中新增字段时&#xff0c;会弹出下面的信息 运行这个命令时 python manage.py makemigrationsTracking file by folder pattern: migrations It is impossible to add a non-nullable field ‘example’ to …

软件架构设计属性之一:功能性属性浅析

引言 软件架构设计属性中的功能性属性是评估软件架构是否满足其预定功能需求的关键指标。功能性属性确保软件能够执行其设计中的任务&#xff0c;并提供所需的服务。以下是对软件架构设计中功能性属性的浅析&#xff1a; 一、定义 功能性属性是指软件系统所具备的功能特性&a…

flutter开发实战-类似微博帖子列表及下拉刷新上拉加载效果

flutter开发实战-类似微博帖子列表及下拉刷新上拉加载效果 在之前处理类似微博帖子列表及下拉刷新上拉加载效果&#xff0c;刷新使用的是EasyRefresh 一、引入EasyRefresh与likeButton 在工程的pubspec.yaml中引入插件 # 下拉刷新、上拉更多easy_refresh: ^3.3.21pull_to_re…

MySQL建库

删除数据库 新建数据库 右键-新建数据库 字符集选中utf8(支持中文) 修改字符集 右键--数据库的属性 将字符集支持的数量变少可以修改

算法的时间与空间复杂度

算法是指用来操作数据、解决程序问题的一种方法。对于同一问题&#xff0c;使用不同的算法&#xff0c;也许最终结果是一样的&#xff0c;但在过程中消耗的资源和时间却会有很大的区别。 那我们该如何去衡量不同算法之间的优劣呢&#xff1f;主要还是从算法所占用的【时间】和…

最新!2023年台湾10米DEM地形瓦片数据

上次更新谷歌倾斜摄影转换生成OSGB瓦片V1.1版本&#xff0c;使用该版本生产了台北、台中、桃园三个地方的倾斜摄影OSGB数据&#xff0c;在OSGB可视化软件中进行展示&#xff0c;可视化效果和加载效率俱佳。已经很久没更新地形瓦片数据&#xff0c;主要是热点地区的原始数据没有…

6.S081的Lab学习——Lab5: xv6 lazy page allocation

文章目录 前言一、Eliminate allocation from sbrk() (easy)解析&#xff1a; 二、Lazy allocation (moderate)解析&#xff1a; 三、Lazytests and Usertests (moderate)解析&#xff1a; 总结 前言 一个本硕双非的小菜鸡&#xff0c;备战24年秋招。打算尝试6.S081&#xff0…

HTTP Digest Access Authentication Schema

HTTP Digest Access Authentication Schema 背景介绍ChallengeResponse摘要计算流程总结参考 背景 本文内容大多基于网上其他参考文章及资料整理后所得&#xff0c;并非原创&#xff0c;目的是为了需要时方便查看。 介绍 HTTP Digest Access Authentication Schema&#xff…