【图解C语言】快速排序,真的好快捏

快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法。值得一提的是,大佬现在依旧活跃在学术界。

在这里插入图片描述

快速排序简介

我们先来粗略了解一下快速排序的思路:

其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
在这里插入图片描述

// 假设按照升序对array数组中[left, right)区间中的元素进行排序
void QuickSort(int array[], int left, int right)
{
if(right <= 1+left)
return;
// 按照基准值对array数组的 [left, right)区间中的元素进行划分
int div = partion(array, left, right);
// 划分成功后以div为边界形成了左右两部分 [left, div) 和 [div+1, right)
// 递归排[left, div)
QuickSort(array, left, div);
// 递归排[div+1, right)
QuickSort(array, div+1, right);
}

上述为快速排序递归实现的主框架,发现与二叉树前序遍历规则非常像,同学们在写递归框架时可想想二叉树前序遍历规则即可快速写出来,后序只需分析如何按照基准值来对区间中数据进行划分的方式即可

基准值划分法

将区间按照基准值分为左右两半有几种常见的方式:

1. hoare版本

选定一个keyi值 为我们的基准值的下标,我们这里先暂时将啊数组第一个元素作为基准值,也就是keyi=0
在这里插入图片描述
我们的目的是把小于a[keyi]的值放在一边,大于a[keyi]的值放在另外一边。
我们利用left和right两个 移动下标 ,left往右移动 ,right往左移动。

  1. 先走right,当a[right]比a[keyi]小,停止移动,再走left,当a[left]比a[keyi]大,停止移动。
  2. 我们将a[right]与a[left]交换.
  3. 继续步骤1,2,直到left和right之间没有元素存在
  4. 将a[left]与a[keyi]交换

在这里插入图片描述


再者,我们还有一个问题,关于keyi的取值,我们该以什么标准选择。

代码如下:

int  PartSort_1(int* a, int left, int right)
{
	int mid = GetMidIndex(a, left, right);
	swop(&a[left], &a[mid]);
	int keyi = left;
	while (left < right)
	{
		//左边做key,右边先走找小
		while (a[right] >= a[keyi]&&left<right)
		{
			right--;
		}
		//swop(&a[left], &a[right]);
		//左边再走,找大
		while (a[left] <= a[keyi]&&left<right)
		{
			left++;
		}
		//交换,把大的换到右边,把小的换到左边
		swop(&a[left], &a[right]);
	}
	swop(&a[left], &a[keyi]);

	return left;
   
}

这时候有同学会产生疑问,为什么我反复强调right要先走?

其实这不是偶然的,如果我们让left先走,会给逻辑判断造成难度。

2. 挖坑法

挖坑法 是 hoare版本 的2.0 ,它的思路更加易于理解。

  1. 选定一个key作为基准值
  2. 选定一个移动下标hole(坑),初始位置在数组最左边
  3. 与hoare法类似,设置left和right两个移动下标
  4. right先走,当找到a[right]<key的时候停止,将a[right]填入hole这个位置中,这个时候right这个位置就“空”了,就变成了hole.
  5. left后走,当找到a[left]<key的时候停止,将将a[left]填入hole这个位置中,这个时候left这个位置就“空”了,就变成了hole.
  6. 重复 步骤4与步骤5 ,当不满足left<right的时候,停止。
  7. 此时hole依然存在,我们将key填入其中
  8. 返回hole

在这里插入图片描述
完整的代码为:

int PartSort_2(int* a, int left, int right)
{
	int midi = GetMidIndex(a, left, right);
	swop(&a[left], &a[midi]);

	int key = a[left];
	int hole = left;

	while (left < right)
	{
		while (a[right] > key&&left<right)
		{
			right--;
		}
		// 把右边找的小的,填到左边的坑,自己形成新的坑
		a[hole] = a[right];
		hole = right;
		while (a[left] < key&&left<right)
		{
			left++;
		}
		// 把左边找的大的,填到右边的坑,自己形成新的坑
		a[hole] = a[left];
		hole = left;

	}
	a[hole] = key;
	return hole;
}

3. 前后辈指针版本

第三种,与前两中的思路就不太一样了。大家可以了解一下:

  1. 选取左边作为key (基准值)
  2. 设置两个 移动下标 prev和cur,他们的初始位置分别在数组第一位和第二位。
  3. cur往前走,此时分两种情况:
    • 找到比key小的数据,停下来,++prev,交换prev和cur指向位置的值,但是如果两个下标相邻就不交换。
    • 找到比key大的数据,继续往前走,直到找到比key小的数
  4. 当cur走到数组的结尾,结束。
  5. 讲prev位置的值与key做交换

这样讲有点抽象,我们依旧是看图说话:
在这里插入图片描述
依照这种方法,我们可以得出代码:

int PartSort3(int* a, int left, int right)
{
	int midi = GetMidIndex(a, left, right);
	Swap(&a[left], &a[midi]);

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

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

		++cur;
	}

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

	return prev;
}

如果说,我们将key值设置为初始数组最右边的值(在本例子中为8),这种方法也是可以的,不过我们需要注意几个细节:
1. prev和cur的初始位置发生变化
在这里插入图片描述

2. 接近判断结束时,有些许变化。
我们还是以之前的数组为例子:

在这里插入图片描述

*基准值的选取

细心的同学会发现,在我给出的三种方法中,在开头都有一段意义不明的代码:

int midi = GetMidIndex(a, left, right);
	Swap(&a[left], &a[midi]);

这段代码是什么意思呢?

这就要引出一个问题:基准数字的选择。
在之前我们选择的是最左边的数字作为基准数,这是没有问题的,但是在某些情况下,基准数的不合理选择会影响快速排序的效率。

当面对数据量较大的数组接近有序的情况之下,我们来测试一下快速排序以及其他排序的相对运算时间:
在这里插入图片描述
我们发现快排在数组有序的情况下劣势十分明显,这就是由于我们在数组有序的状态下依然选择最左边为基准数:
在这里插入图片描述
可以看出,这种情况下,其算法的优势完全没有显现出来。

所以,有人提出了 “三数取中法”:

我们在数组中比较a[left],a[midi],a[right]三个数字,mid=(left+right),我们取三数中的位于中间值的数作为基准数。

我们再测试一下,其相对的时间:
在这里插入图片描述
可以看到,快速排序 又变的 很快了。

基于递归的快速排序

经过一次基础值划分法之后,我们的数组相对变得有序了一些,但这对我们完全不够。

但是,如果我们不断的划分,是否会使数组最终有序呢?这就是最初的快速排序的基本思路。

其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

我们来看一下,递归的代码:

void quick_sort(int* a, int left,int right)

{
	
	
	if (left >= right)
		return;

	
	int keyi = PartSort_1(a, left, right);
	//Print(a+left,right-left+1);

	quick_sort(a, left, keyi-1);
	

	quick_sort(a, keyi + 1, right);
	

	
}

我们依旧画图来 加深一下理解:

我们先把递归的过程打印出来:
在这里插入图片描述
在这里插入图片描述


同时这个图也可以解释为什么当left >= right我们不再递归下去:

我们截取这张图递归的底部:
在这里插入图片描述
我们可以发现此时keyi=0(a[keyi]=1),左边的区间是空的,这造成了左边绿色区域left>right的情况,而此时右边的区域是只有一个元素,这造成了橙色区域left==right的情况。

综上我们可以总结:

  1. 区间为空,表现为left>right
  2. 区间里只有一个元素,表现为left==right

我们完全可以将这种过程理解为二叉树的递归:
在这里插入图片描述

非递归的快速排序

看完递归实现的快速排序,我们现在面临一个问题:

递归程序的通病,会有相对循环迭代程序的问题

  1. 递归的深度太深,导致栈溢出
  2. 性能问题,不过现在编译优化的很好,问题不大。

所以我们可以考虑使用非递归的方式去实现,用非递归的方式去实现递归的程序,一般有两种方法:
1. 循环
3. 栈+循环·

这里我们使用栈来实现:
我们依次把需要单趟排的区间入栈,再依次取出栈里面的区间出来单趟排,再把需要处理的子区间入栈

我们依旧是用图说话:

在这里插入图片描述

在这里插入图片描述
ps:图比较大,点开来放大看就行了。

这里可能还会有些同学思考我们将区间 入栈的判断标准是什么?

1.对于右区间,如果keyi + 1 < end,那么我们就入栈
2. 对于左区间,如果keyi-1>begin,那么我们就入栈

为什么?因为我们入栈的区间,当我们去除的时候,必须保证至少区间中有两个数据,空区间或单元素区间对于我们来说没有排序的必要。

由此我们可以得出

void quickSortNonR(int* a, int left, int right)
{
	//依次把需要单趟排列的区间入栈
	//依次取栈里面的区间出来单趟排
	//再把需要处理的子区间入栈
	ST st;
	StackInit(&st);
	StackPush(&st, right);
	StackPush(&st, left);
	while (!StackEmpty(&st))
	{
		int begin = StackTop(&st);
		StackPop(&st);
		int end = StackTop(&st);
		StackPop(&st);

		int keyi = PartSort_1(a, begin, end);
		//保证要有区间存在(类递归的返回条件)
		if (keyi + 1 < end)
		{
			StackPush(&st, end);
			StackPush(&st, keyi+1);
		}
		if (begin < keyi - 1)
		{
			StackPush(&st, keyi-1);
			StackPush(&st, begin);
		}

	}
	StackDestroy(&st);
}

到这里,我们就讲完了有关快速排序的所有常用知识点。

#include void swap(int *a, int *b) //swap()函数实现交换两个数组元素的值的功能。 { int t=*a; *a=*b; *b=t; } void qsort(int arr[],int left,int right) //qsort()函数实现快速排序,并且是递归调用,而且,递归调用qsort()函数本身两次,因为要对中值两边的 { //部分分别进行排序。arr是待排序的数组名,left是排序的左边界,第一次调用时,是整个数组最左边元素的序号,通常 //为0,right是排序的右边界,第一次调用时,是整个数组最右边元素的序号,如果数组长度为n,right通常为 n-1. int i = left; //用i从左边开始扫描数组。 int j = right; //用j从右边开始扫描数组。 int key = arr[(i+j)/2]; //先设置一个基准值key,此程序是以数组的中间位置的元素为基准值。 while(i =j时,i所指向的数组元素,都是j已经访问,判断过的元素,不用再用i去访问,判断了。 { for(;(i < right)&&(arr[i] < key);i++); /*当i<right,且,arr[i]<key时,表示,还没有找到要放到key右边的元素,或者,i还没有指到数组 的右边第二个元素,则,i++,继续找。此处,为什么是i<right,而不是i<(i+j)/2(基准值key的下标),因为, key现在的位置不一定是它此轮排序的最终位置,所以是整个数组拉通交换排序,所以,i除了最右边一个元素外,数组其它 元素都要访问,所以必须是i<right。 i<=right, 也能实现功能,但是,i left)&&(arr[j] > key);j--); /*当j>left,且,arr[j]>key时,表示,还没有找到要放到key左边的元素,或者,j还没有指到数组的左边第 二个元素,则,j--,继续找。此处,为什么是j>left,而不是i>(i+j)/2(基准值key的下标),因为,key现在的 位置不一定是它此轮排序的最终位置,所以整个数组拉通交换排序,所以,j除了最左边一个元素外,数组其它元素都要访问, 所以必须是j>left。 j>=left, 也能实现功能,但是,j>left,少循环一次,程序执行更,而且,因为i初值为left, 所以已经用i访问,判断过left元素是否需要移动了,不需要再用j来访问,判断了。*/ //注意,此处,是两个for循环执行完了以后,再执行下面的交换操作。即,左,右两边都找到了需要移动的元素后,再互相交换位置。 if (i <= j) //此处改成i<j的话,程序会出错。至于为什么,可以看程序末尾的解释。 {
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Ornamrr

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值