快速排序的几个实现及其在效率上的考虑

快速排序,Quicksort,通常被认为是在基于比较的排序中,对于大型的,随机的输入具有最快的排序速度的程序。C标准库中的qsort,c++ STL中的sort都是基于快速排序的(STL中的sort实现还包含了插入排序和堆排序)。

但是,Quicksort也很可能陷入最坏情况的时间复杂度O(n^2),这种情况往往是发生在把第一个或者最后一个元素作为枢纽然而数组却是接近有序或者完全有序的时候,此时分割将非常不平衡。在完全有序的状况下,快排的每一次分割都等于无效分割,即枢纽元一边为待分割数组的所有元素-1,另一边没有元素的情况。对于接近有序的状况,虽然初始分割不一定出现一边是全部一边没有元素的状况,但是递归进入子数组时,就会出现子数组是完全有序的情况,导致快排的退化至O(n^2)。但是,如果保证每次分割至少是1/10和9/10,那么这种情况下时间复杂度仍然是O(nlogn)。

为了避免这种退化情况,快排做了一些的改进。这篇博文讲述其中的一些变种。

零、本文的所用的比较函数、交换函数

快排是基于比较的排序,故为了适应各个不同类型的数值比较,以及确定升序还是降序或者类似于字符串的字典序,需要一个比较函数。

对于整型数组的升序排序可以使用以下的比较函数:

int cmp(int *i, int *j) 
{ return *i - *j; }
对于字符串可以用C标准库的strcmp。

另外,需要一个类型无关的交换函数,在C++中已经有swap实现了,在C语言中,还是得自己来。如下:

void swap(char *i, char *j, int n)
{
    do {
        char c = *i;
        *i++ = *j;
        *j++ = c;
    } while (--n > 0);
}
其中,i,j分别是指向待交换元素的指针,n是待交换元素的大小(类似于sizeof(int)得到的数值)。


一、从最基本的快排说起

首先,考虑对整型数组进行升序排序。以下是一个非常短但能实现出快排的程序。算法导论上的快排基础版也是用着类似的思路。但是算法导论上是用数组的最后一个元素作为快排的枢纽元的,而这里是采用第一个元素作为枢纽元。

void iqsort0(int *a, int n)
{
    int i, j;
    if (n <= 1) return;
    for (i = 1, j = 0; i < n; i++)
        if (a[i] < a[0])
            swap(++j, i, a);
        swap(0, j, a);
    iqsort0(a, j);
    iqsort0(a+j+1, n-j-1);
}

这段程序的循环不变式是

|P|      <P        |       >=P        |           ?         |

 0                   j                          i                 n-1

终止情况是

|P|           <P                  |           >=P            |

 0                                  j                              i

                                                                  n-1

结合程序与循环不变式来看,首先选数组第一个元素作为枢纽元。i为遍历整个数组的指针,同时也是未访问元素集合与大于枢纽元的元素集合的分割点,在每轮遍历开始,i指向是在未分割元素一边。j为小于枢纽元的元素集合与大于枢纽元的元素集合的分割点,指向在小于枢纽元的元素集合一边。即1 -小于枢纽元- j -大于等于枢纽元- i -未访问元素 - n-1这样排列。

在每轮循环之前,i指向待处理的元素。(1)如果待处理的元素小于枢纽元,则先把j移向j+1处,此元素与(新的)j处的元素交换,i移向i+1处。(2)如果待处理的元素大小或者等于枢纽元,则直接把i移向i+1处。

(1)(2)两者在每轮循环结束后,仍保持着循环不变式。在终止时,所有元素都都被访问,i指向n-1,j指向小于枢纽元的元素,j+1指向大于枢纽元的元素。把枢纽元(第一个元素)和j指向的元素交换。则0到j-1的元素为小于枢纽元的,j+1到n-1的元素为大于等于枢纽元的。然后分别对这两部分元素进行快排。

这个版本,对于数组按从左到右的顺序扫描,是比较容易理解和记忆的。


二、Hoare的原始版本和Sedgewick博士论文的版本

C.A.R. Hoare提出的最原始的快排,以及R. Sedgewick在他的博士论文里的快排版本,其循环不变式使用了两个索引是由两头向中间靠拢,直至两者交错为循环终止,这个版本是之后的基础。但是这个版本也更容易在编程上出错。代码如下:

void iqsort1(int *a, int n)
{
    int i, j;
    if (n <= 1) return;
    i = 0;
    j = n;
    for (;;) {
        do i++; while (i < n && a[i] < a[0]);
        do j--; while (a[j] > a[0]);
        if (j < i) break;
        swap(i, j, a);
    }
    swap(0, j, a);
    iqsort1(a, j);
    iqsort1(a+j+1, n-j-1);
}
循环不变式如下:

|P|     <=P       |     ?     |   >=P           |

  0                     i         j                     n-1

终止情况:

|     <=P                  | P|   >=P             |

                                  j    i

初始情况:

i在0处,j在n处(超出数组末尾一位)。

循环不变式:

i左边的元素都小于等于枢纽元,j右边的元素都大于等于枢纽元。

i向右移,直至遇到大于枢纽元的元素;j向左移,直至遇到小于枢纽元的元素。交换上述两个元素。

这轮循环过后,i左边的元素仍然是小于等于枢纽元,j右边的元素仍然是大于等于枢纽元。

还有一种情况,就是枢纽元大于等于整个数组的元素,那么i移至

终止:

i,j交错的时候,整个数组由i,j分割为小于等于和大于等于枢纽元的两部分。最后把枢纽元和j指向的元素交换。

然后送入下一次快排的递归。


和第一个程序(或者是CLRS)上的快速排序有一些不同的是,Hoare和Sedgewick的循环不变式分割完成后的左右两个子数组都包含了等于枢纽元的元素,而CLRS的版本的快排中,所有等于枢纽元的元素在分割完成后都放在了右边(大于等于)的子数组中。


三、枢纽元的选取

上面两个程序,在选取枢纽元的时候,都是选取数组的第一个或者最后一个元素作为枢纽元的,但是如果数组本身是有序的时候,这样的话,对于这个数组的排序就会退化为O(N^2)。这在一个大数组的子数组中可能会很常见。又如之前分析,只要枢纽元能把数组分为两个部分,那么快排的复杂仍然是O(NlogN)。因此,现在的问题是避免选取到最坏的情形。


一个简单的考虑就是用随机数。即在上面程序的第4行和第5行中加入以下代码:

i = rand() % n;
swap(0, i, a);

用随机数来选取枢纽元的位置使得多次出现枢纽元出现在均大于或者小于整个数组的情况的可能性微乎其微。但是,获取随机数本身是一个时间代价很高的操作。于是这种方法只有理论上的可能但并不现实。


从另一个角度来看,枢纽元的选取最好的情况是数组的中位数。但在整个数组排序出来之前,中位数是不可知的。退而求次,我们取三个数的中位数,数组的第一个、中间、最后一个数的中位数。这样来避免最坏情况。

首先我们需要一个三数取中的程序。

static char *med3(char *a, char *b, char *c, int (*cmp)())
{ 
    return cmp(a, b) < 0 ?
            (cmp(b, c) < 0 ? b : cmp(a, c) < 0 ? c : a)
            : (cmp(b, c) > 0 ? b : cmp(a, c) > 0 ? c : a);
}

其中cmp可以根据不同数据类型而定义不同的的具体实现。

对于更大的数组,可以通过递归地使用三数取中(两次三数取中)来取枢纽元。

如下:

pm = a + (n/2); /* Small arrays, middle element */
if (n > 7) {
    pl = a;
    pn = a + (n-1);
    if (n > 40) { /* Big arrays, pseudomedian of 9 */
        s = n/8;
        pl = med3(pl, pl+s, pl+2*s, cmp);    
        pm = med3(pm-s, pm, pm+s, cmp);
        pn = med3(pn-2*s, pn-s, pn, cmp);
    }
    pm = med3(pl, pm, pn, cmp); /* Mid-size, med of 3 */
}
即在数组元素个数小于等于7时,直接选取数组中间的元素作为枢纽元。

在数组8到40个元素时,用三数取中法。

在数组多于40个元素时,用两次三数取中法。


四、类型无关的排序

上面的例子基本以整型升序作为排序,现在把第二段中的程序扩展为类型无关的情况。也就是类似于c标准库里的qsort的调用方法。

void qsort1(char *a, int n, int es, int (*cmp)())
{
    int j;
    char *pi, *pj, *pn;
    if (n <= 1) return;
    pi = a;
    pj = pn = a + n * es;
    for (;;) {
        do pi += es; while (pi < pn && cmp(pi, a) < 0);
        do pj -= es; while (cmp(pj, a) > 0);
        if (pj < pi) break;
        swap(pi, pj, es);
    }
    swap(a, pj, es);
    j = (pj - a) / es;
    qsort1(a, j, es, cmp);
    qsort1(a + (j+1)*es, n-j-1, es, cmp);
}

其中es是步长,接收类似于sizeof(int)这样形式的参数。

这个程序用于替换三中的程序的分割和递归部分,这样就可以达到类型无关,升降序自定,且对不同大小的数组可以用不同的方法选取枢纽元了。



五、总结

这里只是对快速排序本身进行一些改进。

阅读C++STL的源码,发现STL里的sort是结合了快速排序、堆排序和插入排序的,优化已经用至极致。所以STL里对于避免sort退化至O(N^2)已经是做了非常多的改进的,而不是仅仅对快排本身。有兴趣还是去看看STL源码来得好。


————————————————————————————————————————————————————————

参考文献

J. L. Bentley, Engineering a Sort function, SOFTWARE-PRACTICE AND EXPERIENCE, VOL.23(11),1249-1265(NOVEMBER 1993)

Mark allen Weiss, 数据结构与算法分析(C语言描述)

Thomas H.Cormen, Charles E.Leiserson, Ronald L.Rivest, Clifford Stein, 算法导论


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值