快速排序与寻找第k小值问题

快速排序与寻找第k小值问题

一、分治法(Divide and Conquer):

我们知道分治法就是将原问题划分为若干个规模较小、性质相同的子问题,

然后递归着解决他们,最后进行合并。

而快速排序,也就是快排,是分治法的一个典型的排序算法。

二、快速排序(QuickSort):

基本原理就是对于每个数都保证比自身小的数在左边,比自身大的数载右边,那么就整体有序。
在这里插入图片描述
默认情况是选择需要排序的区间内的第一个元素作为标准,将该区间分成两个部分,左边小,右边大。

像上面的例子:

​ 第一趟:区间为(4~6),故选择 4 作为标准, 将 2 移动到 4 的左边,其余元素均大于 4 。

​ 第二趟:区间为(2~2),只能选择 2 作为标准,只有一个元素不需要操作。

​ 另一个区间为(5~7),选择 5 作为标准,5 均小于 7 和 6,故不移动。

​ 第三趟:区间为(7~6),故选择 7 作为标准,将 6 移动到 7 的左边。

没有剩余的区间需要排序,因此整体排序结束,这就是快排的一个大概过程。

如何设计这样一个算法?

我们可以发现,在每一个区间内我们都做的是同样一件事情,那就是使用第一个元素为标准进行分区

只不过区间的范围不一样罢了,因此非常适合使用分治法来递归进行。

可是分治法不是划分、解决和合并三个步骤吗?这里似乎没有合并的过程。

是的,在快排算法中重要时间放在了划分问题这个部分,而在合并方面比较少,甚至说是没有。

因为只要划分到最后已经整体有序了,不需要合并的操作。

而对于每个区间,只要对分区后的序列左边进行QuickSort,右边也进行QuickSort就可以。

下面给出我的代码,仅供参考。

void quickSort(int *arr, int begin, int end){
	if(end > begin){  // 当end>begin时,说明区间内一个元素都不存在
		int pos = partition(arr, begin, end);
		quickSort(arr, begin, pos-1);
		quickSort(arr, pos+1, end);
	}
}
新的问题:在每个区间内进行划分的时候,如何让计算机正确执行?

事实上,快排中的分区方法 Partition 是一个重要组成部分,下面列出 3 种常见的方法:

​ ① 单向扫描分区:主元x + 左指针begin + 右指针end

​ 从左往右,若 begin 所指元素 < x,则 begin 右移;

​ 若 begin 所指元素 > x,则 begin 和 end 所指的元素进行交换 swap,

​ 然后 end 左移,begin 不动。

​ 边界:当 begin > end 时,end 左边的元素均 <= x,把 x 与 end 所指元素交换即可。
在这里插入图片描述
参考代码:

int partition(int *arr, int begin, int end){
	int mid = arr[begin];  // 通过mid将区间分为两个部分
	int scan = begin+1;  // scan为左指针
	int bigger = end;  // bigger为右指针
	while(scan <= bigger){
		if(arr[scan] <= mid){  // 小的元素置于左边
			scan++;
		}
		else{  // 大的元素置于右边
			swap(arr[scan], arr[bigger]);
			bigger--;
		}
	}
	swap(arr[begin], arr[bigger]);
	return bigger;
}

​ ② 双向扫描法:主元x + 左指针begin + 右指针end

​ 先从左往右,begin 移到第一个 > x 的元素;

​ 再从右往左,end 移到第一个 <= x 的元素;

​ 交换 begin 和 end 所指的元素,重复以上过程。

​ (这种方法用的比较多
在这里插入图片描述
参考代码:

int partition(int *arr, int begin, int end){
	int mid = arr[begin];  // mid将分区划分为两个部分
	int left = begin+1;  // left为左指针
	int right = end;  // right为右指针
	while(left <= right){
		while(left <= right && arr[left] <= mid) left++;  // 找到第一个 > x 的元素
		while(left <= right && arr[right] > mid) right--;  // 找到第一个 <= x 的元素
		if(left < right){  // 符合条件则交换
			swap(arr[left], arr[right]);
		}
	}
	swap(arr[begin], arr[right]);
	return right;
}

​ ③ 三分法:主元x + 左指针begin + 等于指针equal + 右指针end

​ 从左往右,若 begin 所指元素 < x,则 begin 与 equal 的元素交换,均右移;

​ 若 begin 所指元素 = x,则 begin 右移;

​ 若 begin 所指元素 > x,则 begin 与 end 的元素交换,end 右移,begin 不动。

​ 边界:当 begin > end 时,equal 右移,x 与 equal 的元素交换。
在这里插入图片描述
参考代码:

void partition(int *arr, int begin, int end, int &pos1, int &pos2){
	int mid = arr[begin];  // mid left right含义如上
	int left = begin+1;
	int equal = left;
	int right = end;
	while(left <= right){
		if(arr[left] == mid){  // 相同则继续扫描
			left++;
		}
		else if(arr[left] < mid){  // 较小的元素与equal交换
			swap(arr[equal], arr[left]);
			equal++;
			left++;
		}
		else{  // 较大的元素左右交换
			swap(arr[left], arr[right]);
			right--;
		}
	}
	swap(arr[begin], arr[--equal]);
	pos1 = equal;  // pos1保存区间[e, end]的左边界
	pos2 = right;  // pos2保存区间[e, end]的右边界
}
三、快速排序的时间复杂度

设快排所需的运行时间为T(n),那么 T(n) = T(i) + T(n-i) + n + 1

其中 i 为每趟排序标准值的最终位置,这个位置将区间划分为两个长度分别为 i 和 n-i 的子区间继续递归。

而要确定这个最终位置而进行的 Partition 需要遍历一遍区间直至指针交错,故为 n + 1。

我们可以看到,快速排序的时间复杂度是会因为 i 取值的不同而发生变化。

最优情况:当 i 恰好位于区间的中间,即 i = n/2,那么这个区间就被平均地一分为二。

​ 故 T(n) = 2T(n/2) + n + 1,解开这个递归表达式,时间复杂度就为 O(nlgn)

最坏情况:当 i 恰好位于区间的边界,即 i = 0 || i = n-1,这个时候区间的长度只减少 1。

​ 故 T(n) = T(n-1) + n +1,时间复杂度为 O(n2)

平均情况:我们知道了 T(n) = T(i) + T(n-i) + n + 1,假设 i 出现在0~n 任意位置的概率相同,

​ 那么 T(n) = ((T(0) + T(n-1) + n + 1) + (T(1) + T(n-2) + n + 1) + … + (T(n-1) + T(0) + n) + 1))/n。

​ 整理得:T(n) = n + 1 + 2/n * (T(0) + T(1) + … + T(n-1))。

​ 解开此递归式,得 T(n) = O(nlgn)

此处快速排序的时间复杂度分析部分引用自《图解算法》 俞征武 著

在工业实践中的优化:由于 i 的值会直接影响到运行时间的快慢,因此需要对选择的标准值进行甄别。

​ ① 三点中值法:取区间中的 begin、end 所指的元素以及序列中间元素 mid 三者之中的最小值。

​ 这样可以一定程度较小随机性,提高了快排的性能。

​ ② 绝对中值法:以 5 个元素为一组,使用插入排序找到中值,再将所有组的中值进行插入排序

​ 找到多组中值间的中值,即绝对中值,这将会耗费 O(n) 的时间。

​ 这样做会使得快排非常稳定,但是速度较慢。

​ ③ 若待排序的序列长度较短时,转向去使用插入排序。

插入排序 O(n^2) 快速排序 O(lgn)
n(n-1)/2 n(lgn+1)

当 n <= 8 时,使用插入排序的效率高于快速排序。

四、快速排序的经典应用:寻找第k小值问题

问题的要求很简单,就是要在 n 个数中寻找第 k 小元素。

如果使用一般暴力的方法,先排序再找到对应位置的元素,时间复杂度为最快也要 O(nlgn)。

可以更快吗?跟快排有什么关系?

我们来回想一下快排的执行过程:
在这里插入图片描述
每一趟进行快排的时候,我们总是选择一个数字作为标准值,就像上图中的 4 。

在经过一个时间复杂度为 O(n) 的 Partition 分区之后标准值 4 落在了位置 1。

而我们发现在最终排序好的结果中 4 的位置就是 1,发现规律了吗?

每一趟分区处理之后标准值所处的位置就是最终排序后的位置。

我们可以利用这一点在遇到落在第 k 位的标准值直接退出,因为这就是要求的元素。

比如我们需要求的是 k = 1 的元素,那么我们只要进行一趟分区就可以直接得到结果了。

但如果是 k = 2 呢?

这里是有讲究的,因为我们已经得知了元素 4 的最终位置为 1,而 k = 2 > 1,因此这个元素

一定位于 4 的右侧,我们完全可以抛弃 4 左侧的所有元素,只对右侧的元素进行下一次分区。

可以类比于二分查找,也是一次丢掉一半,我们这里也是一次丢掉一部分,因此效率提高了。

参考代码:

// 利用了快排的特性,时间复杂度大概为O(n) 
#include<iostream>
using namespace std;

int arr[100];

void swap(int &a, int &b){
	if(a == b) return;
	a = a^b;
	b = a^b;
	a = a^b;
}

int partition(int *arr, int begin, int end){  // 单向扫描 
	int mid = arr[begin];
	cout << "mid: " << mid << endl;
	int left = begin+1;
	int right = end;
	while(left <= right){
		if(arr[left] > mid){
			swap(arr[left], arr[right]);
			right--;
		}
		else{
			left++;
		}
	}
	swap(arr[begin], arr[right]);
	return right;
}

void quickSort(int *arr, int begin, int end, int th){
	if(begin < end){
		int mid = partition(arr, begin, end);
		if(mid == th-1){  // 若已经把th位置的元素排好则返回 
			return;
		}
		else if(mid > th-1){  // 若大于th位置则向左边排序 
			quickSort(arr, begin, mid-1, th);
		}
		else{  // 若小于th位置则向右边排序 
			quickSort(arr, mid+1, end, th);
		}
	}
}

int main(){
	int n;
	while(cin >> n){
		for(int i = 0; i < n; i++){
			cin >> arr[i];
		}
		int th;
		cin >> th;
		quickSort(arr, 0, n-1, th);
		cout << arr[th-1] << endl;   
	}
	return 0;
} 

【END】感谢观看

发布了44 篇原创文章 · 获赞 17 · 访问量 9123

猜你喜欢

转载自blog.csdn.net/qq_41765114/article/details/88360977
今日推荐