YAZONG 我的开源

(数据结构)第十讲-排序(10.1 快速排序)

  , ,
0 评论0 浏览

10.1.1 算法概述

快速排序是现实应用中最快的一种排序算法。

但是,没有任何一种排序算法是在任何情况下都是最好的。

我们总是可以构造出最快情况下排序算法的表现。

对于大规模的随机数据,快速排序的表现,还是相当出色的,但前提是快速排序中所有的小细节都实现的十分到位。

image.png

快速排序第一步:在这堆整数中随便挑一个整数作为主元。

image.png

这里选择65作为中枢枢纽。

数字集合被分为两大块,左边的数字都小于65,右边的都大于65。

递归的去治理左边,递归的去治理右边。

把这三块数据最终都放到一个数组中,排序合并,就完成了最后的快速排序。

分而治之(分别递归左右两边-O(NLogN))

image.png

(1)选主元。

(2)根据主元划分子集,递归治理。

(3)快速排序、合并。

时间复杂度:中分的选择决定了快速排序算法好坏,当每一次选的主元位于数组的中间,把数组正好一分为二时,也就是把问题可以等分,那么,此时的快速排序算法的效率是最好的,

T(N) = O(NLogN)。

10.1.2 选主元 (Median3算法:三数中值法)

image.png

如果主元选择A的第一个元素A[0],非常不合适的做法。

一开始是有序的。

第一步:第一行数字,选择A[0]作为主元之后,必须要把剩余的元素全部扫描一遍,发现这个主元是最小的。在子集划分时,发现主元的左边是空集,没数据,右边包含了N-1个元素,然后需要对这N-1个元素递归的处理,先递归处理第一层,后续重复此步骤,选择主元,又是选择的最左边的A[0]元素,重复此步骤。

image.png

解决整个问题的时间复杂度,等于选择了主元,扫描一遍主体,这是O(N)的时间复杂度。

完成子集划分以后,剩下的东西包含着N-1个元素,然后递归的去处理这N-1个元素,花费了N-1的时间去继续做子集划分,递归的去解决N-2个元素,重复此步骤,一直到最后只剩下一个元素返回。

整个时间复杂度的和=O(N^2)。

此时间复杂度在所有的排序算法中是相当差的,编写递归程序,什么都没做,因为一开始序列就是有序的,并且还花费了O(N^2),所以主元肯定不能按上述情况选择A[0]。

image.png

/*
选择主元
题目是选3个元素的中位数,还有选5个数,更复杂的7个数。
最小的一定放在最左边
*/
/*
ElementType A[]当前数组, 
int Left头, 
int Right尾
*/
ElementType Median3(ElementType A[], int Left, int Right){

	/* A[ Left ] <= A[ Center ] <= A[ Right ] */
	int Center = (Left + Right) / 2;
	/*
	最小的一定放在最左边,最大的一定在最右边.
	*/
	if(A[Left] > A[Center]){
		Swap(&A[Left], &A[Center]);
	}
	if(A[Left] > A[Right]){
		Swap(&A[Left], &A[Right]);
	}
	if(A[Center] > A[Right]){
		Swap(&A[Center], &A[Right]);
	}
	/* 
	将pivot藏到右边,Right - 1位置而不是Right位置,为了后续操作方便这么放置.
	因为right肯定比center大在右边,left在左边.
	当考虑子集划分的时候,根本不用考虑左右两个元素.
	*/
	Swap(&A[Center], &A[Right - 1]);
	/* 后续只需要考虑 A[ Left+1 ] … A[ Right–2 ] */
	/* 返回 pivot主元 */
	return A[Right - 1];

}

10.1.3 子集划分

image.png

调用上述Median3算法后,8和6两边的元素就不考虑了,6是选择的主元放在最右边的位置,要找元素6最终放置的位置。

image.png

定制左右两边的指针i和j,记录它们的下标,比较和主元的大小。

image.png

主元6左边的元素(i)都要小于6。

主元6右边的元素(j)都要大于6。

8>6红色警告,i不动。

7<6标记黑色,j移动一位指针。

image.png

当j移动后,发现2<6,红色警告。

当发现两边都有不对的元素之后,把8和2交换一下,继续下一轮的比较。

image.png

移动i指针,同时继续比较,1<6,黑色标记,重复上述比较步骤。

image.png

image.png

到这应该停住了,但是机器并不知道,会继续往前走,继续移动i的指针。

image.png

移动i的指针发现,9<6,红色标记,停止。

image.png

继续移动j的指针,3<6,并不符合j<主元6的逻辑 ,那么要停止后结束。

发现i<j,那么子集划分就要结束,那么主元6的位置就是i这个位置。

image.png

image.png

把A[i]与主元做交换,把主元放在应该放的位置。

快速排序为什么快

主元被选中之后,在子集划分完成之后,它被一次性的放在了正确的位置,以后再也不会移动。

不像插入排序,插入排序每一次做了元素交换以后,元素所持的位置只是临时的,当下一张扑克牌进来的时候,这些牌的位置全部都要往后错,所以一张牌可能在插入排序的过程中,被移动了很多次。

元素正好等于主元pivot怎么办

image.png

一种选择:停下来交换。

非常极端情况:这个序列中的所有元素都相等,比如1,首先调用median3,比头尾中间发现都不用动,于是把中间的median放到了右边,指针ij都跟主元相等,ij做交换,于是i++,j--,反复做了很多次没有用的交换,最后ij会停留在比较中间的位置。

这样做的好处,每一次递归,原始序列都被基本上等分成两个等长的序列,也就是N/2的序列,这样往下递归的时间复杂度为NLogN。

另一种选择:不理它,继续移动指针。

从左边指针i开始移动,碰到指针i指向的元素等于主元,那么不理主元,一直移动到最右边指针j的位置,那么指针j根本就没机会移动。

好处:避免很多没必要的交换。

坏处:每一次子集的划分,基本上主元都被放在某一个端点,最坏的时间复杂度O(N^2)。

小规模的数据处理(简单排序而非递归)

image.png

递归会占用额外的系统堆栈空间,有很多的进栈、进栈、进栈、、和POP、POP、POP。。

大规模数据再用递归,减少更多的进栈、POP操作。

image.png

阈值Cutoff(英ˈkʌtɒf,美ˈkʌtˌɔːf)当递归数据规模小于此阈值时,就不递归了,直接使用插入排序即可,不同的阈值对程序的效率有不同的影响。

10.1.4源码:算法实现 (统一接口模板)

#include<stdio.h>
/*
排序-快速排序-自己实现
*/
int main(void){
	return 0;
}
/*
选择主元
题目是选3个元素的中位数,还有选5个数,更复杂的7个数。
最小的一定放在最左边
*/
/*
ElementType A[]当前数组, 
int Left头, 
int Right尾
*/
ElementType Median3(ElementType A[], int Left, int Right){

	/* A[ Left ] <= A[ Center ] <= A[ Right ] */
	int Center = (Left + Right) / 2;
	/*
	最小的一定放在最左边,最大的一定在最右边.
	*/
	if(A[Left] > A[Center]){
		Swap(&A[Left], &A[Center]);
	}
	if(A[Left] > A[Right]){
		Swap(&A[Left], &A[Right]);
	}
	if(A[Center] > A[Right]){
		Swap(&A[Center], &A[Right]);
	}
	/* 
	将pivot藏到右边,Right - 1位置而不是Right位置,为了后续操作方便这么放置.
	因为right肯定比center大在右边,left在左边.
	当考虑子集划分的时候,根本不用考虑左右两个元素.
	*/
	Swap(&A[Center], &A[Right - 1]);
	/* 后续只需要考虑 A[ Left+1 ] … A[ Right–2 ] */
	/* 返回 pivot主元 */
	return A[Right - 1];

}

/*快速排序统一函数接口*/
void Quicksort(ElementType A[], int N){
	/*
	A待排原始数组,0起始位置,N-1终止位置.
	这里从最大数组开始递归解决快速排序问题.
	*/
	Quicksort(A, 0, N - 1);
}
/*快速排序递归函数接口*/
void Quicksort(ElementType A[], int Left, int Right){
	if(Cuttof <= Right - Left){
		/*
		调用Median3获取主元,也对原来数组的Left和Right做了修改.
		left保存三个元素中的最小值.
		right保存三个元素中的最大值.
		真正的主元Pivot在right-1的位置.
		*/
		Pivot = Median3(A, Left, Right);
		/*左端下标,也可以写成left-1*/
		i = Left;
		/*右端下标,也可以写成right-2*/
		j = Right - 1;
		/*将序列中比基准小的移到基准左边,大的移到右边*/
		for( ; ; ){
			/*从左向右移动,大于主元那么红色警告停下来,移动指针j*/
			while(A[++i] < Pivot){
			}
			/*从右向左移动,小于主元那么红色警告停下来,移动指针i*/
			while(A[--j] > Pivot){
			}
			/*上述循环挺停掉,如果i<j这个顺序,那么互换ij二者元素位置,否则自己划分是结束的.*/
			if(i < j){
				Swap(&A[i], &A[j]);
			}else{
				break;
			}
		}
		/*子集划分后,要把放在right-1的主元pivot放在A[i]这个位置.*/
		Swap(&A[i], &A[Right - 1]);
		/*递归排序主元左边的元素*/
		Quicksort(A, Left, i - 1);
		/*递归排序主元右边的元素*/
		Quicksort(A, i + 1, Right);
	}else{
		/*
		小于阈值直接跳出,元素太少,直接用简单排序-插入排序.
		A + Left:开始位置.
		Right - Left + 1:当前待排序列元素的总个数.
		*/
		InsertionSort(A + Left, Right - Left + 1);
	}
}

源码-算法实现(直接调用库函数)

#include<stdio.h>
/*
排序-快速排序-直接调用库函数
*/
int main(void){
	return 0;
}

/*---------------简单整数排序--------------------*/
int compare(const void *a, const void *b){
	/* 比较两整数。非降序排列 */
	return (*(int*)a - *(int*)b);
}
/* 调用接口 */ 
qsort(A, N, sizeof(int), compare);
/*--------------- 一般情况下,对结构体Node中的某键值key排序 ---------------*/
struct Node{
	int key1, key2;
}A[MAXN];

/* 比较两种键值:如果key1不相等,按key1非升序排列;如果key1相等,则按key2非降序排列. */
int compare2keys(const void *a, const void *b){
	int k;
	/* 如果key1不相等,按key1非升序排列 */
	if((((const struct Node*)a)->key1) < (((const struct Node *)b)->key1)){
		key = 1;
	}else if((((const struct Node*)a)->key1) > (((const struct Node *)b)->key1)){
		key = -1;
	}
	/* 如果key1相等,则按key2非降序排列. */
	else{
		if((((const struct Node*)a)->key2) < (((const struct Node*)b)->key2)){
			key = -1;
		}else{
			key = 1;
		}
	}
	return k;
}
/* 调用接口 */
qsort(A, N, sizeof(struct Node), compare2keys);

标题:(数据结构)第十讲-排序(10.1 快速排序)
作者:yazong
地址:https://blog.llyweb.com/articles/2023/10/22/1697980425745.html