算法系列之一 快速排序

快速排序可能应用得最广泛的一个排序算法了。流行的主要原因是实现起来简单,也比其他算法快得多,更重要的是,它原地排序的两点,使得算法只需要一个很小的辅助栈。理想情况下,长度为N的数组,所需的时间和 NlgN 成正比。

快速排序是C.R.A.Hoare于1962年提出的,采用了分治的策略,也叫分治法

实际应用中,快排算法非常的脆弱,对于数据的要求很高,稍不注意就会引起算法性能的快速下降。后面,我会详细说明什么样的情况会让快速排序变得性能极差,以及如何优化。

基本算法

快排将数组分为两个子数组,分别进行排序,递归使用,直到每一个子数组只有一个值,整个数组就有序了。

快速排序的大概思想分为三步:

  1. 设定一个基准值
  2. 将比基准值小的放入左边,反之放入右边。
  3. 对左右两边递归使用算法,直到每一个子数组只有一个数

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void quick_sort(int *a, int lo, int hi){

if (hi <= lo) return; // 递归退出条件
int j = partition(a, lo, hi); // 用基准数将数组分割,并获取基准数位置
quick_sort(a, lo, j-1); // 排序右半部分
quick_sort(a, j+1, hi); // 排序右半部分
}

int partition(int *a, int lo, int hi) {

int s = a[lo]; // 使用第一个数作为基准数
int i = lo;
int j = hi + 1;

while(1){
while(a[++i] <= s) if (i == hi) break; // 从左到右寻找大于基准数的 数字
while(a[--j] >= s) if (j == lo) break; // 从右到左寻找小于基准数的 数字

if ( i >= j ) break; // 寻找结束
exchange(a, i, j); // 交换两个数的位置
}

exchange(a, lo, j); // 寻找结束,交换 基准数 和最后一个小于基准数的 数字
return j; // 返回 基准数 的位置
}

我们用下面的图来解释上面的算法步骤:

  1. 原始数据为第一行;
  2. 基准值 为第一个数 12
  3. i 从 1 开始向右扫描,寻找大于基准值的数;
  4. j 从 11 开始向右扫描,寻找小于基准值的数;
  5. 如果找到,则交换 i 和 j 所指两个数的位置;
  6. 重复 2-4 步;
  7. 如果 i 的位置 大于 j的位置,则停止重复;
  8. 交换 基准值 和 j 所指的 数字;
  9. 以基准值 将 数组 分为两个子数组,重复2-7步

15245381951558

在上述的算法中有几个需要注意的地方:

1、不要越界

在使用指针操作数组的时候,循环的过程中要注意不要让指针越界访问,判断边界尤为重要。

2、 终止递归

递归算法要极其小心,来制定终止递归的条件,使算法在任何输入的情况下都可以有限循环终止。

问题&改进

数学上已经对快速排序进行了详尽的分析,因此我们可以精确的说出它的性能。

快速排序的切分方法的内循环用到了一个递增的索引将数组元素和一个定值比较。这种简洁是其他算法难以见到的,比如归并排序和希尔排序都比快排慢一些,原因就是它们在循环中还要移动数据。

影响算法的几个地方:

1、切分元素的选择

在排序算法中,最好的情况是,每一次的切分都能讲数组对半分,这样递归树是一个平衡二叉树。对于栈的使用最少,效率最高。

最坏的情况是,每一次基准值的选择都只能让分割后的数组减少一个数据,这样的话,递归的调用栈会变得非常深,且浪费资源。

在实际应用中,如何解决这个问题呢?

一般使用中位数来决定切分的 基准值,一般随机采样三次,取中位数的效果更好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
int median(int *a, int lo, int hi) {

int c = 0;
int x[3];
int median;

while(1){

int tmp = rand()% hi;
if (tmp <= lo) continue;

x[c] = tmp;
if (++c == 3) break;
}

int x0 = a[x[0]];
int x1 = a[x[1]];
int x2 = a[x[2]];

if ( x0 >= x1 ) {

if (x0 >= x2) median = x1 >= x2 ? x[1]:x[2];
else median = x[0];
} else {

if ( x1 >= x2 ) median = x0 >= x2 ? x[0]:x[2];
else median = x[1];
}

return median;
}
1
2
// 在 parition 函数最开始添加一行    
if( hi < lo + 3) swap(a, lo, median(a, lo, hi));

2、小数组的排序

在处理较小的数组的时候,快排慢于插入排序。所以在对小数组处理的时候,切换到插入排序。

1
2
3
4
5
// 替换 quick_sort 函数中: if (hi <= lo) return;

if (hi <= lo + M) { insertion_sort(a, lo, hi); }

// 转换临界值 M 和系统是相关的,一般来说 取值 5-15 能满足大部分情况

3、重复元素处理

快排使用了二分法来切割数组的元素,当数组中有大量的相同的元素的时候,性能并不会下降,但是却有巨大的提升空间。

E.W.Dijkstra 提供了一种三向切分的快速排序法。

算法维护了三个指针:lt、gt、i

  1. 选择一个基准值 v;
  2. a[lo...lt-1] 元素都小于 v;
  3. a[lt...i] 元素都等于 v;
  4. a[i+1...gt] 元素的状态不确定;
  5. a[gt...hi] 元素都大于 v;
  6. 在分组时,不再将 相等元素的区间分组,减少了比对的次数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void quick_sort_3way(int *a, int lo, int hi){

if (hi <= lo) return;

int lt = lo, i = lo +1, gt = hi;
int s = a[lo];

while(i <= gt){
if (a[i] < s) swap(a, lt++, i++);
else if (a[i] > s) swap(a, i, gt--);
else i++;
}

quick_sort(a, lo, lt-1);
quick_sort(a, gt+1, hi);
};

结尾

快排在实际应用,面试、笔试中,都是比较常见的,也是应用效果比较好的一种排序算法。分治的策略也在很多的其他算法中有体现,是非常值得学习的一种算法。

文章中用到了插入排序,会在下一篇中介绍。

reposkeeper wechat
关注微信公众号