快速排序之种种:参考必须简化,评判必须单纯
Posted Debroon
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了快速排序之种种:参考必须简化,评判必须单纯相关的知识,希望对你有一定的参考价值。
原理
排序
在评判“最好”之前,我们还是要加一些限制条件,比如是一般情况下最好,还是恶劣的情况下最好。
快速排序算法相当于设置标准,把客户分为超级客户、VIP客户、一般客户,进行不同的服务,创造更高的价值。对员工的能力进行分级,重赏头部员工更利于公司发展。
首先,对于一大堆无序的数字,从中随机挑选一个,比如是 53,这个被随机选上的数字被称为枢值(枢纽的枢),接下来,将所有要排序的数字分成两部分,第一部分是大于等于枢值 53 的,第二部分是小于枢值 53 的。
在第一步完成后,一大堆无序的数字就变得稍微有序一点了。
第二步,从上面得到的两堆数字,分别采用第一步的方法各自再找一个枢值。
对于第一堆,由于所有的数字都比 53 大,至少也等于 53,因此,第二次随机挑选的枢值肯定是一个大于 53 的数字,比如 79;类似地,对于第二堆,由于所有的数字都小于 53,因此第二次随机挑选的枢值肯定小于它,比如4。
接下来,再把两堆数字各自分成大于等于相应枢值的数字序列,以及小于枢值的数字序列。这样做下来,原来的一大堆数就变成了四小堆,它们分别是小于 4 的数字,介于 4 到 53 之间的,介于 53 到 79 之间的,以及大于或等于 79 的。
再接下来,用同样的方法,四堆变八堆,八堆变十六堆,很快所有的数字就排好序了。
······
计算机中快速排序的算法也在强调少做事情,将一大堆数字分成几小堆,再从中不断挑选枢值做对比。
社会上为什么要将人分为三六九等而不仅仅是一律平等?
一个社会的管理,要想效率高,最简单的办法就是对每一个人作一些区分,而效率最低的办法就是刻意追求所有人一律平等,不作区分。
如重点学校入学都有门槛,是为了控制学生大体相近的成绩水平,方便老师教学,也保持学校整体不差的成绩。
-
当一个学校的学生水平都比较接近,老师教起来就容易,因此按照成绩对学生作一个初步的划分是有道理的,特别是在资源不足的情况下。
-
如果一个学校的学生从100分的到0分的都有,那么老师教起来就困难了,如果想达到前面同样的效果,就必须多投入资源。
从效率上讲,层级就如同快速排序事先划定的枢值,有了三六九等,这样才有效率可言。
人与人之间为什么要各种比较,就是为了要让社会以最快的速度识别,发现你。
从这个角度讲,贴标签也并不是什么坏事。有能力的,让人贴上优秀的标签,而不是糟糕的标签。
partition
选定一个参考点,使得左边元素都小于它,右边元素都大于它,这个过程就叫 partition
。
整个快速排序的核心区别,就是 partition
这个函数的区别。
p = partition(arr, left, right);
// p 是 参考点的下标
大致框架:
void QuickSort(int arr[], int l, int r) {
if( l >= r ) return;
int p = partition(arr, l, r); // p 是 参考点的下标
QuickSort(arr, l, p-1); // 小于 p
QuickSort(arr, p+1, r); // 大于 p
}
关键是 partition
这个函数怎么实现?
简单起见,我们把第一个元素当作参考点,而后参考点左侧都小于它,右侧都大于它。
不使用额外空间,我们要让数组分段:
- 橙色区间:arr[l+1, j] < v
- 紫色区间:arr[j+1, i-1] > v
如果新元素 e > 参考点 v,放入 紫色区间段 > v,i ++,看下一个元素。
如果新元素 e < 参考点 v,放入 橙色区间段 < v,swap(e,arr[j+1]),i++,j++,看下一个。
遍历完成后:
此时,再 swap(arr[l], arr[j]):
初始状态(注意索引 i、j 的位置):
- j 的作用是,维护橙色区间:arr[l+1, j] < v
- i 的作用是,维护紫色区间:arr[j+1, i-1] > v
代码实现:
void QuickSort(int arr[], int l, int r) {
if( l >= r ) return;
p = partition(arr, l, r); // p 是 参考点的下标
QuickSort(arr, l, p-1); // 小于 p
QuickSort(arr, p+1, r); // 大于 p
}
int partition(int arr[], int l, int r) {
int j = l;
for( int i=l+1; i<=r; i++)
if( arr[i] == arr[j] )
j++, swap(arr, i, j);
swap(arr, l, j); // 把最左侧参考点放到中间
return j;
}
优化
插入排序优化
使用插入排序优化的原理:对于小规模的数组,使用插入排序更快。
在快速排序的过程中,在最底部就是很多个小数组 — 把零个、一个元素的区间直接返回变成对小到一定程度的区间,转而使用插入排序。
void QuickSort(int arr[], int l, int r) {
if( r - l <= 15 ) { // 只有 15 个元素以内时
InsertionSort(arr, l, r); // 使用插入排序
return; // 一定要 return
}
p = partition(arr, l, r); // p 是 参考点的下标
QuickSort(arr, l, p-1); // 小于 p
QuickSort(arr, p+1, r); // 大于 p
}
int partition(int arr[], int l, int r) {
int j = l;
for( int i=l+1; i<=r; i++)
if( arr[i] == arr[j] )
j++, swap(arr, i, j);
swap(arr, l, j); // 把最左侧参考点放到中间
return j;
}
随机快速排序
第一版快速排序的问题,在完全有序的数组情况下,快速排序复杂度变成 O ( n 2 ) O(n^{2}) O(n2)。
- 1、2、3、4、5、6、7、8、9、···
因为第一版快速排序,我们选择的参考点是第一个元素,那后面的元素都比参考点大,那 partition
过程:
每次 partition
过程都是:
- 左边为空
- 右边为少一个元素的数组
在完全有序的数组情况下,快速排序复杂度变成 O ( n 2 ) O(n^{2}) O(n2)。
如何解决呢?我们不能固定以最左边的元素为参考点,我们取最中间的值。
也不可行,只要你选取标定点的方式是固定的,我们就能找到一组极端的测试用例,使得快速排序算法 100% 退化成 O ( n 2 ) O(n^2) O(n2) 的算法。
所以,我们应该随机选择一个参考点,打破有序数组的局面。如:
随机选择了一个参考点 6,和第一个元素交换位置。
-
最左边元素索引: l l l
-
最右边元素索引: r r r
-
目标:生成 [ l , r ] [l,~r] [l, r] 之间的随机值
// 生成 low ~ high 之间的随机数公式 : rand()%(high-low+1)+low
int p = rand() % (r - l + 1) + l;
完整代码:
void QuickSort(int arr[], int l, int r) {
if( r - l <= 15 ) { // 只有 15 个元素以内时
InsertionSort(arr, l, r); // 使用插入排序
return; // 一定要 return
}
p = partition(arr, l, r); // p 是 参考点的下标
QuickSort(arr, l, p-1); // 小于 p
QuickSort(arr, p+1, r); // 大于 p
}
int partition(int arr[], int l, int r) {
int p = rand() % (r - l + 1) + l; // 生成 low ~ high 之间的随机数
swap(arr[l], arr[p]); // 选用随机参考点
int j = l;
for( int i=l+1; i<=r; i++)
if( arr[i] == arr[j] )
j++, swap(arr[i], arr[j]);
swap(arr[l], arr[j]); // 把最左侧参考点放到中间
return j;
}
随机选择,避免了固定选择的退化情况。
双路快速排序
其实随机快速排序也有问题,当所有元素都相同时,那 partition
过程(如果等于放左边):
- 左边为少一个元素的数组
- 右边为空
为了解决这个问题,我们需要调整 partition
过程:
之前俩个区间我们是放在一起的,现在我们分开放。
partition
过程,俩个哨兵 i、j,i++
:
-
如果新元素 e <= v,i ++,i 继续往后走。
-
如果新元素 e >= v,i 不动,
j--
。
- 如果新元素 e >= v,j ++,j 继续往前走。
- 如果新元素 e <= v,j 不动。
现在的情况:
swap(arr[i], arr[j]) 即可,而后 i++,j–,继续下一轮循环。
为了解决之前数组元素全部相同的问题,我们的交换条件都是附加了 =
,无论是 >=
,还是 <=
。
哪怕遇到全部相同的元素,partition
过程也会划分到不同的俩侧,而不是单侧。
把划分数组的区间放在俩端,而不是一端,这种方法叫双路快速排序。
void QuickSort(int arr[], int l, int r) {
if( r - l <= 15 ) { // 只有 15 个元素以内时
InsertionSort(arr, l, r); // 使用插入排序
return; // 一定要 return
}
int p = partition(arr, l, r); // p 是 参考点的下标
QuickSort(arr, l, p-1); // 小于 p
QuickSort(arr, p+1, r); // 大于 p
}
int partition(int arr[], int l, int r) {
int p = rand() % (r - l + 1) + l; // 生成 low ~ high 之间的随机数
swap(arr[l], arr[p]); // 选用随机参考点
int i = l+1, j = r;
while( true ){ // 不能改成 i<j,arr=[0, 2, 3, 1]通不过
while( i <= j && arr[i] < arr[l] ) // i值找到大于 p 的位置
i ++; // 右移 i
while( j >= i && arr[j] > arr[l] ) // j值找到小于 p 的位置
j --; // 左移 j
if( i >= j ) // 如果 i>=j 退出
break; // 退出
swap(arr[i], arr[j]); // 不然,交换
i ++, j --; // 继续循环
}
swap(arr[l], arr[j]); // 把最左侧参考点放到中间
return j;
}
通常一个算法的复杂度,我们是分析最坏复杂度,但引入随机后,我们更多的是看数学期望
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn)。
三路快速排序
双路快速排序,面对全部相同元素时,解决了一边为空,一边所有元素的情况,避免时间复杂度退化。
但双路快速排序,面对全部相同元素时,虽然把参考点放到中间,但左右俩边都是相同元素。
根据算法逻辑,会对所有元素继续各种操作,其实这种情况是可以跳过的,避免重复操作,三路快速排序就解决这个问题。
按图所示,对于等于 v 的部分,不用管了,只需要递归的对小于 v、大于 v 的部分即可。
我们用索引分别指代划分好各部分,再看算法逻辑。
- 如果新元素 e == v,i++
- 如果新元素 e < v,swap(arr[lt+1], arr[i]),it++,i++
- 如果新元素 e > v,swap(arr[gt-1], arr[i]),gt–,i++
循环结束时( i == gt ):
我们再 swap(arr[v], arr[lt]) 即可:
接着:
- QuickSort(arr, l, lt-1)
- QuickSort(arr, gt, r)
中间部份,就不重复操作了。
现在面对数组全部相同元素,那么时间复杂度是
O
(
n
)
O(n)
O(n),只是运行了一次 partition
过程。
void partition(int arr[], int l, int r) {
if( l >= r ) return;
int p = rand() % (r - l + 1) + l; // 生成 low ~ high 之间的随机数
swap(arr[l], arr[p]); // 选用随机参考点
/* 定义清楚循环不变量, 才能写清楚边界:
arr[l+1, lt] < v
arr[lt+1, i-1] == v
arr[gt, r] > v
*/
int lt = l, i = l+1, gt = r+1;
while( i < gt ) {
if( arr[i] < arr[l] )
lt ++, swap(arr[i], arr[lt]), i++;
else if( arr[i] > arr[l] )
gt --, swap(arr[i], arr[gt]);
else
i ++;
}
swap(arr[l], arr[lt]); // 把最左侧参考点放到中间
partition(arr, l, lt-1);
partition(arr, gt, r);
}
最快的快速排序,三路快速排序。
游戏
75. Sort Colors
题目链接:https://leetcode-cn.com/problems/sort-colors/
我们可以使用 三路快速排序 的 partition 思想解决这个问题。
因为,整个数组中只有 0、1、2 三种元素。
如果我们把 1 当做参考点,0 就是小于参考点的部分,2 就是大于参考点的部分。
只需要运行一遍三路快速排序的 partition
就得解了。
循环不变量的定义:
- nums[0…zero] == 0
- nums[zero + 1, i - 1] == 1
- nums[two, n - 1] == 2
变量 zero 相当于 lt,two 相当于 gt。
class Solution {
public:
void sortColors(vector<int>& nums) {
// 循环不变量:nums[0...zero] == 0, nums[zero + 1, i] == 1, nums[two, n - 1] == 2
int zero = -1, i = 0, two = nums.size();
while(i < two) {
if(nums[i] == 0) {
zero ++;
swap(nums, zero, i);
i ++;
} else if (nums[i] == 2) {
two --;
swap(nums, i, two);
} else // nums[i] == 0
i ++;
}
}
void swap(vector<int>& nums, int i, int j) {
int t = nums[i];
nums[i]= nums[j];
nums[j] = t;
}
};
Select K
题目:给出一个无序数组,找出数组的第 K 小的元素。
思路一:直接快排,时间复杂度 O ( n l o n g n ) O(nlongn) O(nlongn)
思路二:partition
,时间复杂度
O
(
n
)
O(n)
O(n)
因为 partition
一次后,就已经排好序了。
参考点 p 已经就位(虽然左右俩边还未排好序),是数组的第 p 小元素。
- 如果 p == k,找到了
- 如果 p < k,到左边找,右边部分不需要管了
- 如果 p > k,到右边找,左边部分不需要管了
// 封装 partition
int partition(vector<int>& arr, int l, int r) {
// 生成 [l, r] 之间的随机索引
int p = rand() % (r - l + 1) + l;
swap(arr[l], arr[p]);
// arr[l+1...i-1] <= v; arr[j+1...r] >= v
int i = l + 1, j = r;
while(true) {
while(i <= j && arr[i] < arr[l])
i ++;
while(j >= i && arr[j] > arr[l])
j --;
if(i >= j) break;
swap(arr[i以上是关于快速排序之种种:参考必须简化,评判必须单纯的主要内容,如果未能解决你的问题,请参考以下文章