首页 > 代码库 > 快速排序怎么写?
快速排序怎么写?
介绍
快速排序有两种经典的写法,共同点是只有划分过程,都是:
// 随机化取pivot下标,注意先在main函数里初始化“随机种子”
inline int getPivotIndex(int left, int right) {
return rand() % (right - left + 1) + left;
}
int partition(vector<int>& nums, int left, int right) {
// 两种写法,只有这个函数体不一样
}
void quickSortHelper(vector<int>& nums, int left, int right) {
if (left >= right)
return;
int mid = partition(nums, left, right);
quickSortHelper(nums, left, mid - 1);
quickSortHelper(nums, mid + 1, right);
}
void quickSort(vector<int>& nums) {
quickSortHelper(nums, 0, nums.size() - 1);
}
而划分过程都有两个“指针”,不同点是,一种这两个“指针”在同侧,另一种这两个“指针”在异侧。
是不是更摸不着头脑了?看看下面逐步分解例子就清楚了!
同侧
这个方法是《算法导论》里的伪代码展示出来的!我也一直是用这种写法,十分简洁易懂!
比如原始数组是[7, 2, 8, 9, 1, 6],选择最后一个数字 6作为pivot,设置两个指针now=0(意思是当前遍历到哪个数字),little=-1(意思是小于等于pivot的部分的右边界,初始为待划分左边界的左边一位)。
pivot放在最右边。
now指针顺序从左到右遍历过去,每次找到一个小于等于pivot的元素,就交换到little的右边一位。为什么呢?因为我们在循环时维护这样一个性质:little及它以左的部分都是小于等于pivot的,而它右边一位刚好是大于pivot的,所以交换它是合理的!
初始:now=0,little=-1,pivot=6,数组为[7, 2, 8, 9, 1, 6]。
第一步:now=0,nums[now]=7 > pivot,那么little不变仍然为-1;
第二步:now=1,nums[now]=2 <= pivot,那么little加1变为0,交换nums[little]和nums[now],数组变成[2, 7, 8, 9, 1, 6];
第三步:now=2,8 > pivot,那么little不变仍然为1;
第四步:now=3,9 > pivot,那么little不变仍然为1;
第五步:now=4,nums[now]=1 <= pivot,那么little加1变为1,交换nums[little]和nums[now],数组变成[2, 1, 8, 9, 7, 6];
第五步:now=5,nums[now]=6 <= pivot,那么little加1变为2,交换nums[little]和nums[now],数组变成[2, 1, 6, 9, 7, 8];
结束了,是不是发现pivot(6,下标为2)的左边全部是小于等于pivot的,而pivot右边所有元素都是大于pivot的?
还有一点值得注意的是,你可以验证一下,在循环的过程中,性质“little及它以左的部分都是小于等于pivot的,而它右边一位刚好是大于pivot的”是否真的保持不变?
这个过程很容易理解,写成代码是:
int partition(vector<int>& nums, int left, int right) {
swap(nums[getPivotIndex(left, right)], nums[right]);
int pivot = nums[right], little = left - 1;
for (int now = left; now <= right; ++now) {
if (nums[now] <= pivot) {
// 做一点小优化,如果是相同的,就不用交换了
if (++little != now)
swap(nums[little], nums[now]);
}
}
return little;
}
总的测试代码如下:
#include <iostream>
#include <vector>
#include <algorithm>
#include <ctime>
#include <cstdlib>
using namespace std;
// 随机化取pivot下标,注意先在main函数里初始化“随机种子”
inline int getPivotIndex(int left, int right) {
return rand() % (right - left + 1) + left;
}
int partition(vector<int>& nums, int left, int right) {
swap(nums[getPivotIndex(left, right)], nums[right]);
int pivot = nums[right], little = left - 1;
for (int now = left; now <= right; ++now) {
if (nums[now] <= pivot) {
// 做一点小优化,如果是相同的,就不用交换了
if (++little != now)
swap(nums[little], nums[now]);
}
}
return little;
}
void quickSortHelper(vector<int>& nums, int left, int right) {
if (left >= right)
return;
int mid = partition(nums, left, right);
quickSortHelper(nums, left, mid - 1);
quickSortHelper(nums, mid + 1, right);
}
void quickSort(vector<int>& nums) {
quickSortHelper(nums, 0, nums.size() - 1);
}
// 判断数组是否有序排列
bool isSorted(const vector<int>& v) {
vector<int> sorted(v.begin(), v.end());
sort(sorted.begin(), sorted.end());
for (int i = 0; i < v.size(); ++i)
if (v[i] != sorted[i])
return false;
return true;
}
// 随机生成大小为size的数组
void gen(vector<int>& v, size_t size) {
static const int MAX = 99997;
v = vector<int>(size);
for (int i = 0; i < size; ++i)
v[i] = rand() % MAX;
}
int main() {
srand(time(0));
for (size_t size = 0; size < 10000; ++size) {
vector<int> v;
gen(v, size);
quickSort(v);
if (!isSorted(v)) {
cout << "FAIL with size = " << size << endl;
break;
} else {
cout << "GOOD with size = " << size << endl;
}
}
return 0;
}
异侧
首先声明,这种解法最后有个细节需要理清楚,虽然前面的过程很直观,总的来说,个人觉得不如上面的解法来得简洁漂亮~
维护两个指针:left(表示小于等于pivot的部分的右边界),right(表示大于pivot的部分的左边界,初始值为倒数第二个元素的下标)。
pivot放在最右边。
每次循环时,left从左到右找到第一个大于pivot的值,而right则从右到左找到第一个小于等于pivot的值,如果条件合适则交换两者,使得小于等于pivot的元素总在left以左的范围内,而大于pivot的元素总在right以右的范围内!
还是拿数组[7, 2, 8, 9, 1, 6]来举例子。
初始时,left=0,right=6-1-1=4(6表示数组长度,因为下标从0开始所以要先减一,然后right初始为倒数第二个元素,所以需要再减一,因为最后一个是pivot),pivot=nums[6-1]=6,数组为[7, 2, 8, 9, 1, 6]。
第一步:
left=0,nums[left] = 7 > 6,找到了第一个大于6的元素!
right=4,nums[right]=1 < 6,找到了第一个小于等于6的元素!
发现left < right,可以交换两者,数组变成[1, 2, 8, 9, 7, 6]。
第二步:
left从0一直到2,才会使得nums[left]=8 > 6;
right从4到1,才会使得nums[right]=2 <= 6;
发现left > right,不用交换了!
为什么?
回顾一下,left以左的范围都是小于等于pivot的,而right以右的范围都是大于pivot的,现在有left > right,表示这两个范围有交集了,也就是这两个区间的并集覆盖了整个原始区间,说明已经划分好了!所以不需要再交换了~
退出循环后:
但是,有没有发现一点?pivot,也就是最后面的6,一直没动过,而目前的结果是[1, 2, 8, 9, 7, 6],但我们想要的结果应该是[1, 2, 6, 9, 7, 8]!
所以,退出循环后,还需要检查一步,看看nums[left]和pivot的大小关系,如果大于pivot,就把pivot和nums[left]交换一下。
那如果最后nums[left] <= pivot呢?意味着什么?
意味着原来整个区间都是小于等于pivot的,所以此时left应该自然等于原来的右边界。
为什么一定是这样?
答案是:(用end表示区间的右端点的下标,)退出循环时,必定有left >= right。
假设right有移动过,那么[right, end-1]这段区间,必定都是大于pivot的,而left >= right,那么就是落在[right, end-1]上的,按理说应该又nums[left] > pivot才对,但是又有nums[left] <= pivot???
所以结论是,right从未向左移动过,而left一次性从0移动到了end-1,并且有nums[end-1] <= num[end],所以left应该被赋值为end而返回。
int partition(vector<int>& nums, int left, int right) {
swap(nums[getPivotIndex(left, right)], nums[right]);
int pivot = nums[right], end = right;
--right;
while (left < right) {
// 从左到右找到第一个大于pivot的值
while (left < right && nums[left] <= pivot)
++left;
// 从右到左找到第一个小于等于pivot的值
while (right > left && nums[right] > pivot)
--right;
// 如果条件合适则交换两者
if (left < right)
swap(nums[left], nums[right]);
}
if (nums[left] > nums[end])
swap(nums[left], nums[end]);
else
left = end;
return left;
}
总的测试代码如下:
#include <iostream>
#include <vector>
#include <algorithm>
#include <ctime>
#include <cstdlib>
using namespace std;
// 随机化取pivot下标,注意先在main函数里初始化“随机种子”
inline int getPivotIndex(int left, int right) {
return rand() % (right - left + 1) + left;
}
int partition(vector<int>& nums, int left, int right) {
swap(nums[getPivotIndex(left, right)], nums[right]);
int pivot = nums[right], end = right;
--right;
while (left < right) {
// 从左到右找到第一个大于pivot的值
while (left < right && nums[left] <= pivot)
++left;
// 从右到左找到第一个小于等于pivot的值
while (right > left && nums[right] > pivot)
--right;
// 如果条件合适则交换两者
if (left < right)
swap(nums[left], nums[right]);
}
if (nums[left] > nums[end])
swap(nums[left], nums[end]);
else
left = end;
return left;
}
void quickSortHelper(vector<int>& nums, int left, int right) {
if (left >= right)
return;
int mid = partition(nums, left, right);
quickSortHelper(nums, left, mid - 1);
quickSortHelper(nums, mid + 1, right);
}
void quickSort(vector<int>& nums) {
quickSortHelper(nums, 0, nums.size() - 1);
}
// 判断数组是否有序排列
bool isSorted(const vector<int>& v) {
vector<int> sorted(v.begin(), v.end());
sort(sorted.begin(), sorted.end());
for (int i = 0; i < v.size(); ++i)
if (v[i] != sorted[i])
return false;
return true;
}
// 随机生成大小为size的数组
void gen(vector<int>& v, size_t size) {
static const int MAX = 99997;
v = vector<int>(size);
for (int i = 0; i < size; ++i)
v[i] = rand() % MAX;
}
int main() {
srand(time(0));
for (size_t size = 0; size < 10000; ++size) {
vector<int> v;
gen(v, size);
quickSort(v);
if (!isSorted(v)) {
cout << "FAIL with size = " << size << endl;
break;
} else {
cout << "GOOD with size = " << size << endl;
}
}
return 0;
}
两种解法的比较
直观和简洁,在上面已经谈过了,这里主要想说,两者的复杂度如何!
上面举例,用的是同个数组,发现第一种需要交换三次,而第二种只需要交换两次,是不是意味着第二种更好呢?
有人确实有过这样的错觉,hhh,不记得在哪看到的了,应该是知乎或伯乐在线中的一个。
为什么我要说是错觉呢?是不是觉得,第一种解法,一个数字,可以被交换多次?而第二种解法,省了很多交换?(确实有点道理,不过不是真理就对了,且听下面分析)
第一种解法的交换次数
比如[1, 6, 2, 3, 5, 4],其中在now遇到元素2时,就会变成[1, 2, 6, 3, 5, 4],元素6被交换第一次!
紧接着,now遇到元素3,元素6又被交换一次,数组变成[1, 2, 3, 6, 5, 4]。
最后,now遇到元素4,元素6又被交换一次,数组变成1, 2, 3, 4, 5, 6]。
在这个过程中,元素6 一共被交换了3次!
但是,你得注意到,不是所有大于pivot的元素都被交换了3次!!!比如5就从来没被交换过!究其本质,使用第一种解法,元素被交换的总次数,实际上等于数组中小于等于pivot的元素的个数!
说到这里是不是觉得我在扯淡?
因为上面的数组里,明明小于等于pivot的元素个数是4,而不是3!再想想那一个优化的小步骤……对吧?你不优化的话,就是4了,至于为什么,我暂时不给严格的证明(有空再来,写博客的现在已经深夜一点多了),其实如果真的理解了这个算法,是能够很直观想出来为什么的。
第二种解法的交换次数
那第二种解法的呢?
假设数组中小于等于pivot的元素个数为P,交换次数实际上等于区间[left + P - 1, right]上所有小于等于pivot的元素的个数!
比如上面的[1, 6, 2, 3, 5, 4],此时P=4,而区间[3, 5]上面有两个元素小于等于pivot(=4),所以交换次数为2(第一次交换6和3,第二次交换6和4)。
是不是比第一种的少了?确实是的,因为[left, left + P - 1)上一般会有小于等于pivot的元素,所以这种解法的交换次数就自然小于P,而P等于第一种解法未优化时的交换次数。
综上所述
其实第一种解法加了优化之后,几乎是跟第二种解法的交换次数一模一样的(直觉)。
算法的性能只由交换元素的次数决定吗?
很明显答案是否定的!
要注意有个概念叫做“高速缓存”,假设数组很长的话,那么顺序去访问会比较好,因为有cache;但是如果每次都是同时从两端来访问的话,cache不断地失效,对吧?
上面都是纸上谈兵
其实如果真的想找出这两种算法的效率是否存在高低之分的话,应该生成长度齐全、各种数据分布等等的测试样例,来测试,但是测试还涉及各种编译器环境、运行环境……略晕。
这项伟大的工作就交给后人或科学家来完成吧 T_T 跑完把实验结果发我一份,thx
优化
1. 如何取主元(pivot)
尽量随机化取,不要固定位置(比如第一个或最后一个),这样可以比较容易避开最坏的情况。
目前提出的还有中位数法(median-of-three)!即选取nums[left], nums[(left+right)>>1], nums[right]三个数字中的中位数,还可以扩展……具体请参考:三种快速排序以及快速排序的优化
2. 内省排序
关于内省排序的介绍请参考内省排序-维基百科。
其实它的想法很简单,就是:当递归到待排序数组的长度达到阈值下限时,改用其它类型的排序如插入排序、堆排序等。
这样做是利用了,插入排序等排序方式在数据规模很小的时候,性能比快速排序好(毕竟快排的常数项较大)!
这个长度阈值一般是[26, 29],是试验出来的,有兴趣也可以自己设计试验来找!
为什么是O(nlog n)
今天太晚了,明天再来做数学推导!
快速排序怎么写?