【排序算法】快速排序(详解+各版本实现)

目录

一.交换排序

1.基本思想

2.冒泡排序

二.快速排序

1.hoare版本

2.挖坑法

3.前后指针版本

4.优化

优化①:三数取中

优化②:小区间优化

5.非递归版本

6.特性总结

①效率

②时间复杂度:O(N*logN)

③空间复杂度:O(logN)

④稳定性:不稳定


一.交换排序

1.基本思想

交换排序的核心思想就是根据序列中两个记录键值的比较结果来交换这两个记录在序列中的位置,将键值较大的向序列尾端移动,键值较小的记录向序列前端移动。

冒泡排序和快速排序都属于交换排序的类别。

2.冒泡排序

冒泡排序的核心思想是:从左到右相邻两个元素进行比较后判断是否交换。进行一轮比较都会找到序列中最大的一个,这个最大的数就会从序列右边冒出来。当然,进行一轮比较能使最大的数到最右端,进行第二轮比较就能使第二大的数到正确的位置,如此,进行多趟排序就能将序列变为有序。代码实现如下:

cpp 复制代码
//冒泡排序
void Bubble(int* a, int n)
{
	//n个数进行n-1趟排序
	for (int i = 0; i < n - 1; i++)
	{
		//冒泡排序的优化:若一轮没有交换则序列已有序
		int flag = 1;

		//单趟排序,每次排序终止的地方都要-1
		for (int j = 0; j < n - 1 - i; j++)
		{
			if (a[j] > a[j + 1])
			{
				int tmp = a[j];
				a[j] = a[j + 1];
				a[j + 1] = tmp;
				flag = 0;
			}
		}
		if (flag == 1)
		{
			break;
		}
	}
}

冒泡排序的特性总结:

1.时间复杂度:O(N^2)

2.空间复杂度:O(1)

3.稳定性:稳定

二.快速排序

1.hoare版本

hoare版本的快速排序的单趟过程如上动图所示,其核心思想是:将一个数(key)的位置找对,并把序列进行分割。 在上图单趟过程完成后,很明显能发现,6(key)最终位置上,左边都是比6小的数,同时右边都是比6大的数,这样就算6这个数的位置是排好了,同时将序列分为了在6左边的序列和在6右边的序列,那么要想把整个序列排为有序,只需要将左右序列都排为有序即可,这不就又遇到相似的问题了吗?只需要将左右序列各自再进行快速排序,继续将序列进行分割,直到拆分出的序列只剩1个值,此时就可以看作有序,整个序列也就有序了,下面的拆分图可以演示这个递归的过程(注意实际上并没有将序列拆分,只是逻辑上可以这样看)

代码实现: 根据上述原理可以想到用递归来实现快速排序,使用begin和end作为下标来进行左右查找,目的是不改变left和right的值,因为这两个还需要在下次的函数递归调用中使用(即拆分为left到keyi-1和keyi+1到right的两个新区间)

关于递归的终止条件可以如下图所示:

cpp 复制代码
//快速排序
void Quick(int* a, int left, int right)
{
	//终止条件
	if (left >= right)
		return;

	//keyi是Key的下标
	int keyi = left;
	int begin = left;
	int end = right;
	while (begin < end)
	{
		//右找小,前一个判断保证不会出现相遇后错过的情况
		while (begin < end && a[end] >= a[keyi])
		{
			end--;
		}

		//左找大
		while (begin < end && a[begin] < a[keyi])
		{
			begin++;
		}

		Swap(&a[begin], &a[end]);
	}
	Swap(&a[keyi], &a[begin]);

	//[left,keyi-1]keyi[keyi+1,right]
	Quick(a, left, keyi - 1);
	Quick(a, keyi + 1, right);
}

Tips:关于为什么相遇位置一定比key的值小的证明:

2.挖坑法

挖坑法跟hoare版本其实性质是相同的,不过挖坑法更好理解一点,只需要考虑坑位的变化即可。思想就是最左边的key的值拿出,形成一个坑位,然后左边找小,找到的值填入坑,然后该位置形成新的坑,如此进行直到相遇,效率相比hoare是一样的,没有提升。

cpp 复制代码
//快速排序-挖坑法
void Quick_pit(int* a, int left, int right)
{
	if (left >= right)
		return;

	int pit = left;
	int key = a[left];
	int begin = left;
	int end = right;
	while (begin < end)
	{
        //相遇的情况下end就是到达了pit的位置,之后key会覆盖掉
		while (begin<end && a[end] > key)
		{
			end--;
		}
		a[pit] = a[end];
		pit = end;

		while (begin < end && a[begin] < key)
		{
			begin++;
		}
		a[pit] = a[begin];
		pit = begin;
	}
	a[pit] = key;
	Quick_pit(a, left, pit - 1);
	Quick_pit(a, pit + 1, right);
}

3.前后指针版本

前后指针法,分别定义前后指针prev和cur,cur向前找 比key值小的数,找到后prev++,交换cur和prev位置,若没找到则cur++继续找,直到cur找出数组,最后将prev和key位置的值交换,此时key就在prev位置上,分割成两个序列继续递归。

cpp 复制代码
//快速排序-前后指针法
void Quick_pointer(int* a, int left, int right)
{
	if (left >= right)
		return;

	int keyi = left;
	int prev = left;
	int cur = prev + 1;

	while (cur <= right)
	{
		//若prev++后与cur位置相同,则没交换的必要
		if (a[cur] < a[keyi] && ++prev != cur)
		{
			Swap(&a[prev], &a[cur]);
		}
		cur++;
	}
	Swap(&a[keyi], &a[prev]);
	Quick_pointer(a, left, prev - 1);
	Quick_pointer(a, prev + 1, right);
}

4.优化

优化①:三数取中

快速排序在处理逆序的情况下效率很低,而且有栈溢出的风险。在逆序情况下,key就是最大的数,每次左找大都找不到直到相遇,这样的分割造成的递归层次是最多最深的,效率自然很差。

那么什么时候能让快速排序效率优化提升呢?可以发现,上述逆序情况是因为选key的问题,每次选得的key都是最大的数,那么这里就可以想到一个优化方法:三数取中,顾名思义:就是在left,right,mid(中间)值中选择排在中间的值为key,并将其移动到最左边即可(不改变快排逻辑),这样得到的key一定就不是最大,或最小的值了。

cpp 复制代码
//三数取中
int Getmid(int* a, int left, int right)
{
	int mid = (left + right) / 2;
	if (a[left] < a[mid])
	{
		if (a[mid] < a[right])
		{
			return mid;
		}
		else if (a[right] < a[left])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
	else//a[left]>a[mid]
	{
		if (a[right] > a[left])
		{
			return left;
		}
		else if (a[mid] > a[right])
		{
			return mid;
		}
		else
		{
			return right;
		}
	}

}

优化②:小区间优化

快速排序是递归展开的,在二叉树的学习中知道第h层节点数是2^(h-1)个,这快占了整个树总节点的二分之一,递归调用也是类似的,越往后展开递归的次数也越多,那么当序列中值的个数小于某个值的时候就不再使用快速排序来递归展开,而使用其他排序方法,这样就能使递归调用次数大大降低,能有效提升性能。

不过这里有个问题,使用哪种排序方法呢?针对区间较小的序列,没必要动用希尔排序等,直接使用插入排序即可。

5.非递归版本

尽管如今编译器对递归的优化十分显著,但递归始终会有一些缺陷,例如递归太深的情况下会有栈溢出的风险,因此这里考虑有非递归版本。

此处非递归版本使用栈(Stack)来实现,将left和right作为一组数据,代表一次排序,如下图所示,第一次是0到9,取得的key是5,那么将0,9出栈后,让0,4和6,9入栈,这就代表分割后的两个新序列的排序,如此继续出栈后带动入栈的操作,left>=right就不入栈,直到栈为空,就能实现非递归版本的快速排序。

在具体的实现,一组数据可以自定义结构体(int left ,int right)再插入栈,当然也可以不用这么麻烦,直接两个数据先后入栈,再两个数据先后出栈,也能实现相应的操作,代码实现如下:

cpp 复制代码
int QuickSort_1(int* arr, int left, int right)
{
	int prev = left;
	int cur = left + 1;
	while (cur <= right)
	{
		if (arr[cur] < arr[left] && ++prev != cur)
		{
			Swap(&arr[prev], &arr[cur]);
		}

		cur++;
	}
	Swap(&arr[left], &arr[prev]);

	return prev;
}


//快速排序-非递归版本
void Quick_NorR(int* a, int left, int right)
{
	ST st;
	STInit(&st);
	//先入后出,先入的right等会先出栈的就是left
	STPush(&st, right);
	STPush(&st, left);

	while (!STEmpty(&st))
	{
		int begin = STTop(&st);
		STPop(&st);
		int end = STTop(&st);
		STPop(&st);

		//之前的无论哪种版本都能得到key
		int key = QuickSort_1(a, begin, end);
		
		//保证left<right才入栈
		if (key - 1 > begin)
		{
			STPush(&st, key - 1);
			STPush(&st, begin);
		}
		
		if (end > key + 1)
		{
			STPush(&st, end);
			STPush(&st, key + 1);
		}
	}
}

6.特性总结

①效率

快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序

②时间复杂度:O(N*logN)

关于时间复杂度,可以这样简单解释:若递归调用的展开是二分的,就很类似于二叉树的结构,那么就存在logN层,每层遍历的时间复杂度为O(N),因此总的时间复杂度可以看为O(N*logN),当然这只是一种简单的解释,方便记忆。

③空间复杂度:O(logN)

④稳定性:不稳定

相关推荐
福大大架构师每日一题19 分钟前
文心一言 VS 讯飞星火 VS chatgpt (396)-- 算法导论25.2 1题
算法·文心一言
EterNity_TiMe_33 分钟前
【论文复现】(CLIP)文本也能和图像配对
python·学习·算法·性能优化·数据分析·clip
陌小呆^O^39 分钟前
Cmakelist.txt之win-c-udp-client
c语言·开发语言·udp
机器学习之心44 分钟前
一区北方苍鹰算法优化+创新改进Transformer!NGO-Transformer-LSTM多变量回归预测
算法·lstm·transformer·北方苍鹰算法优化·多变量回归预测·ngo-transformer
yyt_cdeyyds1 小时前
FIFO和LRU算法实现操作系统中主存管理
算法
daiyang123...1 小时前
测试岗位应该学什么
数据结构
alphaTao1 小时前
LeetCode 每日一题 2024/11/18-2024/11/24
算法·leetcode
kitesxian2 小时前
Leetcode448. 找到所有数组中消失的数字(HOT100)+Leetcode139. 单词拆分(HOT100)
数据结构·算法·leetcode
VertexGeek2 小时前
Rust学习(八):异常处理和宏编程:
学习·算法·rust
石小石Orz2 小时前
Three.js + AI:AI 算法生成 3D 萤火虫飞舞效果~
javascript·人工智能·算法