【啊哈算法】三、快速排序(一)

今天我们来学习一个不浪费空间,速度又快的排序算法,那就是快速排序

一、概念

我们上次学的冒泡排序是两两交换排序,这样很费时间,所以人们想出了跳跃式交换比较,让交换的距离增加,那么总的比较次数和交换次数就少了,这就是快速排序的思想,严格来阐述:

  • 我们每次选择一个基准(以它为标准来比较),定义哨兵i,j,从两边开始探测,每次j(右边哨兵)先走,哨兵 j 找小于基准的值,哨兵 i 找大于基准的值,找到后交换,直到 i 和 j 碰头,一轮结束,基准归位。
  • 递归将基准左右分为两分,继续上述的处理,直到只剩一个数字。

我们演示一轮排序,数据6,1,2,7,9,3,4,5,10,8;基准为6,哨兵 i 指向6,哨兵 j 指向8,现在开始:
在这里插入图片描述
那为什么必须是j先走呢? 我们进行解答:我们知道 i 负责找到比基准大的,j 负责找到比基准小的,最后我们的序列必须是左边比基准小,右边比基准大。如果我们让 i 先走,还是上面的序列:

  • i 先找到7,j 找到5,交换后序列为【6,1,2,5,9,3,4,7,10,8】
  • i 继续先走找到9,j 找到4,交换后序列为【6,1,2,5,4,3,9,7,10,8】
  • 此时 i 指向4,j 指向9,那么 i 继续向前走,找到比6大的9,此时 i,j 相遇,和基准交换,序列为:【9,1,2,5,4,3,6,7,10,8】 我们从结果明显看到出错了,如果是 j 先走的话,找小于基准的不会指向9,会先找到3,然后 i 走和 j 相遇,交换为【3,1,2,5,4,6,9,7,10,8】。

原因就是,j 是负责找比基准小的数,那么它先走的话,一定保证和 i 相遇指向值一定小于基准,那么交换后顺序一定正确;如果 i 先走,那么 i会先指向大于基准的数,然后 j 和它相遇,交换后结果自然不正确,所以必须 j 即右边哨兵先走。

我们处理了一轮数据后,第一个被我们选择的基准归位,这个过程我们称为划分过程,我们以这个基准为中心将数据划分两份,对左右两份数据继续做处理,这个过程很像二分法,我们可以使用递归或借助数据结构来实现这个过程,知道只剩一个数据为止,我们排序结束,我们来描述这个过程,排序数据不变:
在这里插入图片描述
那这个就是我们快速排序的过程,快速排序的每一轮的处理就是将这一轮的基准归位,归位的数字刚好是序列k位置的第k小,直到所有数由归位了,那么排序结束

二、特点

  • 时间复杂度:划分函数遍历数据O(N),每次递归二分O(logN),所以最好和平均复杂度为O(NlogN),当数据有序时,会出现两两交换,时间复杂度最坏为O(N^2)。
  • 空间复杂度:递归调用会用到栈,所以为O(logN)。
  • 是一种内排序。
  • 可以看到是跳跃式交换,所以不稳定排序。
  • 优点:速度快,空间少。
  • 缺点:基准的选择不当会让时间复杂度变高;递归表示简单,非递归则需要引入其他数据结构。

三、实现

(一)递归实现

那我们现在实现快速排序,先用递归写一个,我们这次用C++模板写,之后我会解释一下这个C++模板,不明白你可以当模板声明不存在,将T变为int或其他类型来理解代码。我们先整理一下思路:

  • 我们需要一个划分函数,这个函数用来做每一轮的基准数归位操作,参数是数组,起始位置,结束位置,返回值为基准数归位下标。
  • 一个总体函数,它用来调用划分函数,参数为数组,起始位置,结束位置,用mid保存一次划分的下标,然后进行左边递归,右边递归。
  • 代码思路就是概念描述的那样。

然后我们上面的思路来实现:

template<class T>
int Partition(vector<T> &nums,int left,int right)
{
	int i=left;//负责找比基准大的
	int j=right;//负责找比基准小的
	T stard=nums[left];//每次用第一个做基准
	while(i<j)
	{
		while(nums[j]>stard && i<j) j--;//寻找比基准小的,定位
		while(nums[i]<=stard && i<j) i++;//寻找比基准大的,定位
		//交换
		T temp=nums[j];
		nums[j]=nums[i];
		nums[i]=temp;	
	}
	//i,j相遇跳出,基准归位
	nums[left]=nums[i];
	nums[i]=stard;

	return i;
}
template<class T>
void QuickPass(vector<T> &nums,int left,int right)
{
	if(nums.size()==0) return;
	if(left<=right)//递归条件,表示还有元素
	{
		int mid=Partition(nums,left,right);//一次划分,分为两份
		//左
		QuickPass(nums,left,mid-1);
		//右
		QuickPass(nums,mid+1,right);
	}	

}
int main()
{
	vector<int> nums;
	nums.push_back(6);
	nums.push_back(1);
	nums.push_back(2);
	nums.push_back(7);
	nums.push_back(9);
	nums.push_back(3);
	nums.push_back(4);
	nums.push_back(5);
	nums.push_back(10);
	nums.push_back(8);
	QuickPass(nums,0,nums.size()-1);
	for(int i=0;i<nums.size();i++)
	{
		cout<<nums[i]<<" ";
	}
}

但是当我们的数据有序,如果还是选择最左边的作为基准,那么就会出现每次都要交换,那么快排就退化为冒泡了,两两交换,那么为了解决这个问题,我们对基准的选择进行优化。

(二)对于基准选择的优化

  1. 随机化基准:我们可以随机在数组中选择一个数作为基准,将这个数和第一个数交换,那么第一个数就是我们选出来的基准,其他的代码都不变。只需要把划分函数中T stard=nums[left];替换为这几句代码即可:
    记得加上头文件:
//随机数头文件
#include<stdlib.h>
#include<time.h>
//添加
int pos=rand()%(right-left+1)+left;//在left~right中随机取一个
//和最左边交换
int tmp=nums[pos];
nums[pos]=nums[left];
nums[left]=tmp;
T stard=nums[left];
  1. 三位取中法:第一个值,中间值,最后一位,取最大的值。
    //三位取中
	int start=nums[left];
	int pos=(right-left)/2+left;
	int mid=nums[pos];
	int end=nums[right];
	int mmax=start>mid?left:pos;//保存下标
	int max=nums[mmax]>end?mmax:right;//最大值
	//交换
	int tmp=nums[max];
	nums[max]=nums[left];
	nums[left]=tmp;
	T stard=nums[left];

这两种办法都可以防止快排退化为冒泡。

总体比较,三位取中的方法比随机化基准更好,因为结果可控。

(三)非递归实现

我们实现非递归的快排,我们先分析递归的作用是啥:每一次的递归,就是为了不断地将数据分为2份,进行排序,递归每次传递需要排序地数组范围下标。非递归实现就离不开数据结构,我们可以引入栈,队列等。
我使用栈写一个,其他的原理一样。我们可以用栈来保存数组范围的下标,然后不断地出栈进行划分函数调用,再入栈左右范围,直到栈空结束,我们还是来举个例子:数据不变
在这里插入图片描述
那么我们的思路就很明确了,就是用栈不断保存需要排序区间的初始和末尾下标,栈不为空,就弹出下标,进行划分,如果两边还有数据,就压栈,不断循环这个过程即可。那么代码思路:

  • 先定义栈,C++有,所以不用自己实现,先压入最开始的数组范围,即left,right。
  • 循环条件是栈不为空,我们出栈得到 i,j 哨兵的值,然后删除栈顶元素,进行划分函数调用得到基准数归位的下标mid,划分函数不变,和递归的实现一样。
  • 判断左右是否还有数据,有数据就压入需要排序的数组范围值。
  • 继续循环。

那我们写出代码:

# include<iostream>
using namespace std;
# include <vector>
#include<stdlib.h>
#include<time.h>
#include<stack>

template<class T>
int Partition(vector<T> &nums,int left,int right)
{
	int i=left;//负责找比基准大的
	int j=right;//负责找比基准小的
	T stard=nums[left];//每次用第一个做基准
	while(i<j)
	{
		while(nums[j]>stard && i<j) j--;//寻找比基准小的,定位
		while(nums[i]<=stard && i<j) i++;//寻找比基准大的,定位
		//交换
		T temp=nums[j];
		nums[j]=nums[i];
		nums[i]=temp;	
	}
	//i,j相遇跳出,基准归位
	nums[left]=nums[i];
	nums[i]=stard;

	return i;
}
//非递归
template<class T>
void QuickNicePass(vector<T> &nums,int left,int right)
{
	if(nums.size()==0) return;

	stack<T> stack;
	stack.push(left);//先压入左,在栈底
	stack.push(right);//压入右,在栈顶,所以出栈第一个元素为右边边界

	while(!stack.empty())//不等于空为循环条件
	{
		int j=stack.top();//出栈,得到哨兵j
		stack.pop();//删除栈顶元素
		int i=stack.top();//得到哨兵i
		stack.pop();
		//调用划分函数
		int mid=Partition(nums,i,j);
		//判断左右是否还有数据,有就压栈
		if(i<mid-1)//表示存在至少2个数据
		{
			stack.push(i);//左边初始范围
			stack.push(mid-1);//结束范围
		}
		if(j>mid+1)
		{
			stack.push(mid+1);
			stack.push(j);
		}
	}

}
int main()
{
	vector<int> nums;
	nums.push_back(6);
	nums.push_back(1);
	nums.push_back(2);
	nums.push_back(7);
	nums.push_back(9);
	nums.push_back(3);
	nums.push_back(4);
	nums.push_back(5);
	nums.push_back(10);
	nums.push_back(8);
	QuickNicePass(nums,0,nums.size()-1);
	for(int i=0;i<nums.size();i++)
	{
		cout<<nums[i]<<" ";
	}
}

队列的原理也是一样的,就是这样。

加油哦!💪。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值