首页 > 代码库 > 快速排序怎么写?

快速排序怎么写?

介绍

快速排序有两种经典的写法,共同点是只有划分过程,都是:

// 随机化取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)

今天太晚了,明天再来做数学推导!

<script type="text/javascript"> $(function () { $(‘pre.prettyprint code‘).each(function () { var lines = $(this).text().split(‘\n‘).length; var $numbering = $(‘
    ‘).addClass(‘pre-numbering‘).hide(); $(this).addClass(‘has-numbering‘).parent().append($numbering); for (i = 1; i <= lines; i++) { $numbering.append($(‘
  • ‘).text(i)); }; $numbering.fadeIn(1700); }); }); </script>

    快速排序怎么写?