怎样让快速排序(quick sort)更快?

来源:互联网 发布:痤疮用云南白药知乎 编辑:程序博客网 时间:2024/04/27 23:01

 

快速排序(quick sort)是目前应用最广泛的排序算法,它的平均复杂度为O(NlogN),但因其内循环较小,所以速度很快,而且不需要太多额外的空间(主要是递归调用所需的栈空间,对于随机文件不大于logN)。关于算法的基础介绍,网上已有很多讲解得很好的资料,如July同学的快速排序算法》,我就不拾人牙慧了,切入正题。

 

快速排序有点像二分搜索(binary search):思想很简单,算法很高效,大家都知道,实现很困难。快速排序的一大特点就是效率不稳定,运行时间与输入数据密切相关,原因在于,快排是基于划分(partition)的排序算法,划分操作决定了整个算法的效率。许多简单实现中,划分的基准(pivot)都取输入序列中的第一个或最后一个元素。对于随机数据而言,这是足够有效的;但对于已经有序或者逆序的数据,这种划分将导致快速排序的时间效率退化成O(N2),空间使用也会达到O(N)。可见一个好的划分过程对快速排序多么重要,因此第一个加速就从划分开始。

 

划分(partition)

划分操作中有两个值得关注的方面:

  • 划分的过程
  • 划分的基准

快排中移动元素的实际操作由划分完成,因此划分过程必须快速。目前已知的一个非常高效的算法,通过从数组两端向中间扫描的方法来完全划分。

                    

 

令划分的基准v放在数组最右边,指针ileft开始向右扫描,遇到大于v的值则停住;同时令指针jright-1开始向左扫描,遇到小于v的值时停下来,交换ij指向的值,然后重复上述过程,直至ij相遇。

该算法维护了一个不变式:i左边的元素都小于vj右边的元素都大于v。这个策略是线性时间地原地划分,只扫描数组一次就能完成,C++实现如下(请仔细斟酌):

 

template <typename Item>int partition(Item a[], int left, int right) {  int i = left-1, j = right;  Item v = a[right];  for (;;) {    while (a[++i] < v) {}    while (a[--j] > v)      if (j == left)        break;    if (i >= j)      break;    exchange(a[i], a[j]);  }  exchange(a[i], a[right]);  return i;}


 

 

其中exchange是简单的交换操作:

template <typename Item>void exchange(Item &a, Item &b) {  Item temp = a;  a = b;  b = temp;}


 

 

说完了划分过程,再来说说更重要的基准选取。从根本上说,快速排序的效率依赖于分割文件的位置的选取。最理想的划分值能把数组分成相当的两部分,对于随机的输入数据,固定的选取数组最右端元素和其他选取方法是一样的,在平均情况下二者都会接近中间的位置。但是为了防止输入数据本身有序而引起快速排序退化的情况,必须采取一些手段。一旦成功地消除了这种异常,就是对快速排序一个很大的加速。

一种较为稳妥的方法是随机选取数组中的某个位置,而不是总是顽固地选择最右端的元素,这样确实可以避免排序的退化。

再回想我们为什么要取随机值?就是为了避免输入数据有序造成的异常,如果一种方法能够在这种情况下利用这种原有的有序性岂不是更好吗?三值取中法(median-of-three method)就是这样的方法,它的选取方法是先从数组的开头、结尾和中间选取3个元素,再取这3个元素的中间值作为划分的基准。

首先,三值取中法本身带有一定的随机性,所以能够很好的处理随机数据;其次,它使得最坏情况几乎不可能发生,如果数组原本就具有有序性,那么按照原始的划分方法,取到的3个元素中必然有2个将被划分到大于(或小于)v的值所在的数组中,而三值取中法则扭转了这种不利;最后,与随机化方法相比,三值取中法省去了生成随机数的开销。

在实际实现中,可通过三交换法来实现三值取中法:

 

template <typename Item>void quick_sort(Item a[], int left, int right) {  if (right-left < M)    return;  exchange(a[(left+right)/2], a[right-1]);  compare_exchange(a[left], a[right-1]);  compare_exchange(a[left], a[right]);  compare_exchange(a[right-1], a[right]);  int i = partition(a, left+1, right-1);  quick_sort2(a, left, i-1);  quick_sort2(a, i+1, right);}


 

 

在排序之前,先将位于中间的元素交换到right-1的位置,然后分别对leftright-1 right三个位置的元素进行三交换排序,这样a[right-1]就是中值,也是即将要用来划分的基准。经过三交换后,a[left]a[right-1]a[right]a[right-1],因此这两个元素不需要参与划分。

 

compare_exchange()比较-交换的一个封装:

template <typename Item>void compare_exchange(Item &a, Item &b) {  if (a > b)    exchange(a, b);}


 

 

经过划分的优化后,除非碰上了Douglas McIlroy的无敌的对手(killer adversary),否则基本上不必担心这个快速排序会退化成O(N2),对于有序数据,这是一个很大的加速。目前,它还有一个明显的bug(导致它还不能运行)和其它可以优化的地方,不过接下来的优化会消除这个bug

 

这个bug就是快速排序使用了三值取中法,这导致算法需要数组有3个元素才可以进行排序。现在先不考虑三值取中法,那么它应该实现成这样:

 

template <typename Item>void quick_sort(Item a[], int left, int right) {  if (right <= left)    return;  int i = partition(a, left, right);  quick_sort(a, left, i-1);  quick_sort(a, i+1, right);}


 

 

我们来考察一下它还有什么瓶颈。

在快速排序算法的递归实现中,存在一种不太好的现象:随着递归层层深入,大量数据被分割成了小数组;快排对于大数组的划分可以迅速地将元素移动到它正确位置的附近,比如说对1024进行一次均等划分,那么某个元素可能会移动数百个单位位置,若进行4次均等划分,元素在正确位置上的概率就从1/1024骤升到1/64,考虑到641024的绝对数值,这是相当高的效率;然而对于小数组,快速排序的效率就不那么理想了,对于16个元素的数组,快速排序也要划分4次才能把它移动到正确的位置上,相对于之前几百个位置的移动,小数组排序一次只能移动几个单位的位置。

换句话说,快速排序对少量数据的划分远不如它对大量数据的划分这么划算,当排序进入到小数组阶段后,它将多次因为这些小数组而频繁调用自身,但获得的收益并不大。我姑且把这种现象叫做

小数组的边际效益

对大量数据排序时,我们应该在前期利用快速排序的特点,让这些数据迅速移动到正确位置附近,然后在后期消除小数组的边际效应。

消除边际效应的一个方法就是设定一个M值,当数组元素个数小于M时,视为小数组,此时快速排序就直接返回,最后把数组处理差不多时,再用其它排序方法对数组进行最终排序。那么M值应该取多少?又应该选择何种排序算法进行最终排序?

首先回答第二个问题,因为它的答案是显而易见的。对接近有序的数据排序,没有什么算法比插入排序(insertion sort)更合适了,插入排序的执行开销与所有元素偏离自己正确位置的距离成正比,实现如下:

 

template <typename Item>void insertion_sort(Item a[], int left, int right) {  int min, i;  for (i = left, min = 0; i <= right; ++i)    if (a[min] > a[i])      min = i;  exchange(a[min], a[left]);  if (right-left < 2)  return;  for (i = left+2; i <= right; ++i) {    Item t = a[i];    int j = i;    while (a[j-1] > t)      a[j] = a[--j];    a[j] = t;  }}


 

 

排序一开始把最小值作交换到最左边的位置为哨兵(sentinel),这样可以减少内循环中的代码。

现在回答第一个问题。可以想到,M值不能取太小,否则不能消除边际效应;但又不能取太大,否则会加重插入排序的负担。经过研究,M5~25之间的值是最理想的,具体数值视实际情况而定。C++ STLsort函数使用了同样的优化,在SGI版本的STL中,M=16

现在可以消除由三值取中法遗留下来的bug了:

 

const int M = 16;template <typename Item>void quick_sort(Item a[], int left, int right) {  if (right-left < M)    return;  exchange(a[(left+right)/2], a[right-1]);  compare_exchange(a[left], a[right-1]);  compare_exchange(a[left], a[right]);  compare_exchange(a[right-1], a[right]);  int i = partition(a, left+1, right-1);  quick_sort(a, left, i-1);  quick_sort(a, i+1, right);}template <typename Item>void sort(Item a[], int left, int right) {  quick_sort(a, left, right);  insertion_sort(a, left, right);}


 

 

这个排序算法混合了快速排序和插入排序,也结合了它们各自的优点。

 

到目前为止,我们的快速排序对有序数据和随机数据都工作的很好,但是,如果数组中含有大量重复元素,比如将某个学校的学生按出生年份排序,那么上面的快速排序仍然不够快。再考虑一种极端情况:所有元素都相等,这时候应该不用执行排序,但是我们的快速排序仍然会一直划分,直到分成长度为M的小数组,但这些划分做的都是无用功。我们应该有效利用连续、相等的元素不需要再参与排序的事实,进一步加速快速排序,所以我们要处理的下一个问题就是

重复值

既然要利用连续、相等的元素不需要再参与排序这个事实,一个直接的想法就是通过划分让相等的元素连续地摆放:

 

 

 

然后只对a[left]...a[i]以及a[j]...a[right]排序。这种三路划分与计算机科学中无处不在的Dijkstra提出的“荷兰国旗问题”(The Dutch National Flag Problem)非常相似。但是在快速排序中,划分的过程也必须快速,最好是能只扫描一趟就完成划分。BentleyMcIlroy提出了一种三路划分方法,只在标准划分过程的基础上做出改进:在划分过程中,i遇到的与pivot相等的元素交换到最左边,j遇到的与pivot相等的元素交换到最右边,ij相遇后再把数组两端与pivot相等的元素交换到中间:

 

 

 

这个方法不能完全满足只扫描一次的要求,但它有两个好处:首先,如果数据中没有重复的值,那么该方法几乎没有额外的开销;其次,如果有重复值,那么这些重复的值不会参与下一趟排序,减少了无用的划分。

由于三路划分需要更多的下标信息,partition被集成到quick_sort内部,下面是集成了各种优化的快速排序终极版本:

 


template <typename Item>void quick_sort(Item a[], int left, int right) {  if (right-left < M) // prevent little array recursion    return;  // median-of-three  compare_exchange(a[left], a[(left+right)/2]);  compare_exchange(a[left], a[right]);  compare_exchange(a[right], a[(left+right)/2]);  // three-way-partition  Item v = a[right];  int i = left-1, p = left-1, j = right, q = right;  for (;;) {    while (a[++i] < v) {}    while (a[--j] > v)      if (j == left)        break;    if (i >= j)      break;    exchange(a[i], a[j]);    if (a[i] == v)      exchange(a[++p], a[i]);    if (a[j] == v)      exchange(a[--q], a[j]);  }  exchange(a[i], a[right]);  // move equal elements to middle of array  j = i-1;  i = i+1;  int k;  for (k = left; k <= p && j > p; ++k, --j)    exchange(a[k], a[j]);  j -= p-k+1;  for (k = right-1; k >= q && i < q; --k, ++i)    exchange(a[k], a[i]);  i += k-q+1;  quick_sort(a, left, j);  quick_sort(a, i, right);}


 

 

我们针对快速排序的性能瓶颈和几种原始的排序方法不能有效处理的情况进行了一系列优化,这些优化效果如何?让实验来说话。

 

让原始的快速排序、改进边际效应的排序和终极快速排序对一百万个int数排序,分为三种情况:

1) 由库函数rand()生成的1000000个随机数

2) 1000000...0的逆序数

3) 100000049的重复值

除了溢出的情况,每个实验都做了100次,计时单位为毫秒(ms),最终结果取平均值,结果如下:

 

原始快速排序

改进边际效应

终极快速排序

随机数

287.05

251.67

247.71

逆序数

overflow...

163.96

90.52

重复值

172.40

150.70

35.73

 

 

    对小数组改用插入排序的改进带来了约16%的性能提升,如果数据量更大,这个优化的效果应该更明显。三值取中的划分也大大加速了对特殊数据(已有序或部分有序)的排序,同时防止了划分不当导致过度递归而栈溢出的情况。

    另外,终极排序做了更多的比较-交换操作却比只改进边际效应的排序还要快,原因应该是终极排序把partition放进了排序函数的内部,减少了函数调用的开销所致。最后,三路划分使得终极快排在包含大量重复值的情况下仍然很有效率,由于这个实验太特殊(所有值都相等),所以对速度的提升极其明显。

 

现在,你还能让快速排序快一点吗?

 

——TT

 

 

 

原创粉丝点击