改进快速排序算法

11
如果可能的话,我该如何改进以下快速排序(从性能角度考虑)。有什么建议吗?
void main()
    {
      quick(a,0,n-1);
    }

    void quick(int a[],int lower,int upper)
    {
       int loc;
       if(lower<upper)
       {
        loc=partition(a,lower,upper);
        quick(a,lower,loc-1);
        quick(a,loc+1,upper);

       }
    }

    /* Return type: int
      Parameters passed: Unsorted array and its lower and upper bounds */

    int partition(int a[],int lower,int upper)
    {
      int pivot,i,j,temp;
      pivot=a[lower];
      i=lower+1;
      j=upper;
      while(i<j)
        {
            while((i<upper)&&(a[i]<=pivot))
            i++;
            while((a[j]>pivot))
            j--;
            if(i<j)
                {
                    temp=a[i];
                    a[i]=a[j];
                    a[j]=temp;
                }

        }//end while

        if(pivot>a[j])
        {
             temp=a[j];
             a[j]=a[lower];
             a[lower]=temp;
        }

         return(j);

}//end partition

2
两件事可能有助于获得更好的答案:告诉我们你可能会遇到什么样的数据(大多数已排序、大多数未排序、几乎所有类型...)。同时(虽然是一个通用的答案),运行分析器并找出最浪费时间的地方(尽管老实说我没有在 C 代码中这样做过,所以不知道有哪些好的分析器存在以及它们是否真的对这里有帮助)。 - Edan Maor
考虑到这种情况下的无序数字。 - Ravindra S
1
你确定需要提高性能吗?你使用性能分析器测量过性能,并确定该函数是性能热点吗? - Daniel Pryden
尊重每个人的意见,你如何定义性能?在算法中,CLRS表示计算基本操作,例如交换,Sipser根据图灵机找到复杂度,现在你有一个程序,想要让它更快。那么你需要知道应该改进什么。选择中位数作为中位数的5个中位数将更好地进行分区。已经证明,3或7的中位数不如5的中位数好。http://www.sci.brooklyn.cuny.edu/~amotz/700-FALL09/search.pdf - DarthVader
请看以下内容:http://www.sorting-algorithms.com/static/QuicksortIsOptimal.pdf 快速排序是最优的。除了使用中位数的枢轴,其余部分应该已经在较低级别上被编译器优化过了。 - DarthVader
14个回答

24

快速排序算法很容易并行化。如果你有多个核心可以使用,你可能会看到相当大的速度提升。取决于你的数据集有多大,它可能比任何其他优化都要快。但是,如果你只有一个处理器或者一个相对较小的数据集,速度提升将不会太明显。

如果有需要,我可以进一步阐述。


6
谢谢您的赞赏!为提出可能显著提高绩效的建议点赞。太好了! - Isak Savo
很好的答案,但我不认为那是他想要的。而且大多数分治算法和动态规划算法都可以并行运行。 - DarthVader
说实话,我有点失望他从未回来澄清他在寻找什么。 - Swiss
现在对于所有问题的答案都是“并行化”或“云计算”。 - Alexandru
很抱歉,我的账户被暂停了七天。因此,在这段时间里我无法在我的账户上进行任何活动,导致回复延迟。正是因为这个原因,你的答案被选为“最佳”答案。 - Ravindra S

19
  1. 选择更好的枢轴(pivot): 例如,在三数中位数法中,随机选择3个元素,并将中位数元素作为枢轴。
  2. 当数组长度(a[]) < M时(实践中选择M = 9),停止排序。在qsort()完成后应用插入排序,这大约需要M * N = O(N)的时间。这避免了在分治递归树的叶子节点附近进行许多函数调用。

GCC 的 qsort 源代码中的注释指出,像“三数取中”这样的技巧在实践中并没有真正的改进 :) 我倾向于同意这一观点。除非您的输入数据是特定的,以至于使用“三数取中”选择方法可以更好地工作,否则没有必要使用它。 - AnT stands with Russia
我认为median-of-three只是对经常出现的有序数组的一种防御,而这种情况比击败median-of-three所需的模式要常见得多。在平均情况下(随机排序的字符串?),它并没有提供任何好处。 - Alexandru
1
事实上已经证明,5个数的中位数比3个数的中位数更好。 - DarthVader

18
第一个建议是:将其中一个递归调用替换为迭代。我指的是真正的迭代,而不是手动实现递归堆栈。也就是说,不要从quick中进行两个“新”调用,而是“回收”您当前对quick的调用来处理递归的一个分支,并递归调用quick以处理另一个分支。
现在,如果您始终确保为更短的分区进行真正的递归调用(并对更长的分区使用迭代),即使在最坏的情况下,即使您选择的中位数有多好,递归深度也永远不会超过log N
所有这些都在GCC标准库附带的qsort算法中实现。看一下源代码,应该会很有用。
大致上,遵循上述建议的一个更实际的实现可能如下所示。
void quick(int a[], int lower, int upper)
{
  while (lower < upper)
  {
    int loc = partition(a, lower, upper);
    if (loc - lower < upper - loc)
    { /* Lower part is shorter... */
      quick(a, lower, loc - 1); /* ...process it recursively... */
      lower = loc + 1; /* ...and process the upper part on the next iteration */
    }
    else
    { /* Upper part is shorter... */
      quick(a, loc + 1, upper); /* ...process it recursively... */
      upper = loc - 1; /* ...and process the lower part on the next iteration */
    }
  }
}

当然,这只是一个想法的草图。没有经过测试。同样,看看GCC实现相同想法的方式,他们也用“手动”递归替换了剩余的递归调用,但这并不是真正必要的。


抱歉,我不同意你的观点。将递归转换为迭代根本没有任何好处。GCC和大多数编译器使用符号表和缓存在较低级别上进行递归优化,动态规划。使用迭代而非递归根本没有任何好处。请原谅我这样说;计算机科学基于递归理论和递归函数。 - DarthVader
3
@unknown:绝对不正确。显然,你完全没有抓住重点。这种修改的主要目的不是性能(可能不会改进),而是对堆栈深度的硬性限制。原始实现在最坏情况下具有O(N)递归深度。这意味着在糟糕的输入情况下,由于堆栈溢出而导致崩溃。我建议的实现方式再次保证了递归深度不超过log2(N)。也就是说,在32位的平台上永远不会有超过32层的递归,并且堆栈永远不会溢出。 - AnT stands with Russia
@unknown: 请注意,“log2(N)”保证适用于输入的质量以及中位数选择方法的质量如何之前。显然,这是非常有价值和实际的好处。此外,这是每个人都应该首先考虑的最重要的事情。性能其次。你对这个“没有任何好处”的评判最多只能让人发笑。至于计算机科学参考-我完全不知道那个无关紧要的评论的点。 - AnT stands with Russia
+1,但如果你不是针对DS9K质量的编译器,使用尾递归来处理较长的子数组可能更易读,让编译器优化尾递归即可。 - dsimcha

10
使用无循环算法对小块(<5个元素)进行排序可以提高性能。我只找到了一个有关如何编写5个元素的无循环排序算法的示例:http://wiki.tcl.tk/4118
这个想法可以用来在C语言中生成2、3、4、5个元素的无循环排序算法。
编辑:我在一组数据上尝试了它,并且与问题中的代码相比,我测量出了87%的运行时间。在<20个块上使用插入排序在同一数据集上得到了92%。这个测量结果并不代表全部情况,但可能表明这是一种可以改进快速排序代码的方法。
编辑:这个示例代码为2-6个元素编写了无循环排序函数。
#include <stdio.h>

#define OUT(i,fmt,...)  printf("%*.s"fmt"\n",i,"",##__VA_ARGS__);

#define IF( a,b, i, block1, block2 ) \
  OUT(i,"if(t[%i]>t[%i]){",a,b) block1 OUT(i,"}else{") block2 OUT(i,"}")

#define AB2(i,a,b)         IF(a,b,i,P2(i+2,b,a),P2(i+2,a,b))
#define  P2(i,a,b)         print_perm(i,2,(int[2]){a,b});

#define AB3(i,a,b,c)       IF(a,b,i,BC3(i+2,b,a,c),BC3(i+2,a,b,c))
#define AC3(i,a,b,c)       IF(a,c,i, P3(i+2,c,a,b), P3(i+2,a,c,b))
#define BC3(i,a,b,c)       IF(b,c,i,AC3(i+2,a,b,c), P3(i+2,a,b,c))
#define  P3(i,a,b,c)       print_perm(i,3,(int[3]){a,b,c});

#define AB4(i,a,b,c,d)     IF(a,b,i,CD4(i+2,b,a,c,d),CD4(i+2,a,b,c,d))
#define AC4(i,a,b,c,d)     IF(a,c,i, P4(i+2,c,a,b,d), P4(i+2,a,c,b,d))
#define BC4(i,a,b,c,d)     IF(b,c,i,AC4(i+2,a,b,c,d), P4(i+2,a,b,c,d))
#define BD4(i,a,b,c,d)     IF(b,d,i,BC4(i+2,c,d,a,b),BC4(i+2,a,b,c,d))
#define CD4(i,a,b,c,d)     IF(c,d,i,BD4(i+2,a,b,d,c),BD4(i+2,a,b,c,d))
#define  P4(i,a,b,c,d)     print_perm(i,4,(int[4]){a,b,c,d});

#define AB5(i,a,b,c,d,e)   IF(a,b,i,CD5(i+2,b,a,c,d,e),CD5(i+2,a,b,c,d,e))
#define AC5(i,a,b,c,d,e)   IF(a,c,i, P5(i+2,c,a,b,d,e), P5(i+2,a,c,b,d,e))
#define AE5(i,a,b,c,d,e)   IF(a,e,i,CB5(i+2,e,a,c,b,d),CB5(i+2,a,e,c,b,d))
#define BE5(i,a,b,c,d,e)   IF(b,e,i,AE5(i+2,a,b,c,d,e),DE5(i+2,a,b,c,d,e))
#define BD5(i,a,b,c,d,e)   IF(b,d,i,BE5(i+2,c,d,a,b,e),BE5(i+2,a,b,c,d,e))
#define CB5(i,a,b,c,d,e)   IF(c,b,i,DC5(i+2,a,b,c,d,e),AC5(i+2,a,b,c,d,e))
#define CD5(i,a,b,c,d,e)   IF(c,d,i,BD5(i+2,a,b,d,c,e),BD5(i+2,a,b,c,d,e))
#define DC5(i,a,b,c,d,e)   IF(d,c,i, P5(i+2,a,b,c,d,e), P5(i+2,a,b,d,c,e))
#define DE5(i,a,b,c,d,e)   IF(d,e,i,CB5(i+2,a,b,c,e,d),CB5(i+2,a,b,c,d,e))
#define  P5(i,a,b,c,d,e)   print_perm(i,5,(int[5]){a,b,c,d,e});

#define AB6(i,a,b,c,d,e,f) IF(a,b,i,CD6(i+2,b,a,c,d,e,f),CD6(i+2,a,b,c,d,e,f))
#define AC6(i,a,b,c,d,e,f) IF(a,c,i, P6(i+2,c,a,b,d,e,f), P6(i+2,a,c,b,d,e,f))
#define AE6(i,a,b,c,d,e,f) IF(a,e,i,CB6(i+2,e,a,c,b,d,f),CB6(i+2,a,e,c,b,d,f))
#define BD6(i,a,b,c,d,e,f) IF(b,d,i,DF6(i+2,c,d,a,b,e,f),DF6(i+2,a,b,c,d,e,f))
#define BE6(i,a,b,c,d,e,f) IF(b,e,i,AE6(i+2,a,b,c,d,e,f),DE6(i+2,a,b,c,d,e,f))
#define CB6(i,a,b,c,d,e,f) IF(c,b,i,DC6(i+2,a,b,c,d,e,f),AC6(i+2,a,b,c,d,e,f))
#define CD6(i,a,b,c,d,e,f) IF(c,d,i,EF6(i+2,a,b,d,c,e,f),EF6(i+2,a,b,c,d,e,f))
#define DB6(i,a,b,c,d,e,f) IF(d,b,i,BE6(i+2,a,b,c,d,e,f),BE6(i+2,c,d,a,b,e,f))
#define DC6(i,a,b,c,d,e,f) IF(d,c,i, P6(i+2,a,b,c,d,e,f), P6(i+2,a,b,d,c,e,f))
#define DE6(i,a,b,c,d,e,f) IF(d,e,i,CB6(i+2,a,b,c,e,d,f),CB6(i+2,a,b,c,d,e,f))
#define DF6(i,a,b,c,d,e,f) IF(d,f,i,DB6(i+2,a,b,e,f,c,d),BE6(i+2,a,b,c,d,e,f))
#define EF6(i,a,b,c,d,e,f) IF(e,f,i,BD6(i+2,a,b,c,d,f,e),BD6(i+2,a,b,c,d,e,f))
#define  P6(i,a,b,c,d,e,f) print_perm(i,6,(int[6]){a,b,c,d,e,f});

#define SORT(ABn,n,...) \
  OUT( 0, ""); \
  OUT( 0, "inline void sort" #n "( int t[" #n "] ){" ) \
  OUT( 2, "int tmp;" ) \
  ABn( 2, __VA_ARGS__ ) \
  OUT( 0, "}" )

void print_perm( int ind, int n, int t[n] ){
  printf("%*.s", ind-1, "");
  for( int i=0; i<n; i++ )
    if( i != t[i] ){
      printf(" tmp=t[%i]; t[%i]=",i,i);
      for( int j=t[i],k; j!=i; k=j,j=t[j],t[k]=k)
        printf("t[%i]; t[%i]=",j,j);
      printf("tmp;");
    }
  printf("\n");
}

int main( void ){
  SORT( AB2, 2, 0,1 )
  SORT( AB3, 3, 0,1,2 )
  SORT( AB4, 4, 0,1,2,3 )
  SORT( AB5, 5, 0,1,2,3,4 )
  SORT( AB6, 6, 0,1,2,3,4,5 )
}

生成的代码共有3718行:

sort2():8行
sort3():24行
sort4():96行
sort5():512行
sort6():3072行

仅翻译文本内容:+1 是因为你的勇气非常大。喜欢那些嵌套的 #define。 - rpj
我很想看到编译器的汇编输出。使用大多数处理器上可用的条件移动指令,可以非常快速地完成无循环排序。 - Goz
@Goz:看看生成的代码,我认为它不能使用条件移动指令进行编译。 - sambowry
我已经把这个打印出来给我的(C#最棒了)朋友看了。他还在找他的下巴。谢谢你,sambowry:D - beermann
@beerman:我也是这么认为 - 这段代码可能非常聪明,但在可读性和理解方面却极其糟糕。 - user82238

9

尝试另一种排序算法。

根据您的数据,您可以通过交换内存来提高速度。

根据维基百科

  • 快速排序具有最佳情况下O(n log n)性能和O(1)空间
  • 归并排序具有固定的O(n log n)性能和O(n)空间
  • 基数排序具有固定的O(n . <数字位数>)性能和O(n . <数字位数>)空间

编辑

显然,您的数据是整数。对于范围在[0, 0x0fffffff]之间的250万个整数,我的基数排序实现比您的快速排序实现快4倍左右。

$ ./a.out
qsort时间:0.39秒
radix时间:0.09秒
好的:2000;恶魔:0
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define ARRAY_SIZE 2560000
#define RANDOM_NUMBER (((rand() << 16) + rand()) & 0x0fffffff)

int partition(int a[], int lower, int upper) {
  int pivot, i, j, temp;
  pivot = a[lower];
  i = lower + 1;
  j = upper;
  while (i < j) {
    while((i < upper) && (a[i] <= pivot)) i++;
    while (a[j] > pivot) j--;
    if (i < j) {
      temp = a[i];
      a[i] = a[j];
      a[j] = temp;
    }
  }
  if (pivot > a[j]) {
    temp = a[j];
    a[j] = a[lower];
    a[lower] = temp;
  }
  return j;
}

void quick(int a[], int lower, int upper) {
  int loc;
  if (lower < upper) {
    loc = partition(a, lower, upper);
    quick(a, lower, loc-1);
    quick(a, loc+1, upper);
  }
}

#define NBUCKETS 256
#define BUCKET_SIZE (48 * (1 + ARRAY_SIZE / NBUCKETS))

/* "waste" memory */
int bucket[NBUCKETS][BUCKET_SIZE];

void radix(int *a, size_t siz) {
  unsigned shift = 0;
  for (int dummy=0; dummy<4; dummy++) {
    int bcount[NBUCKETS] = {0};
    int *aa = a;
    size_t s = siz;
    while (s--) {
      unsigned v = ((unsigned)*aa >> shift) & 0xff;
      if (bcount[v] == BUCKET_SIZE) {
        fprintf(stderr, "not enough memory.\n");
        fprintf(stderr, "v == %u; bcount[v] = %d.\n", v, bcount[v]);
        exit(EXIT_FAILURE);
      }
      bucket[v][bcount[v]++] = *aa++;
    }
    aa = a;
    for (int k=0; k<NBUCKETS; k++) {
      for (int j=0; j<bcount[k]; j++) *aa++ = bucket[k][j];
    }
    shift += 8;
  }
}

int ar1[ARRAY_SIZE];
int ar2[ARRAY_SIZE];

int main(void) {
  clock_t t0;

  srand(time(0));
  for (int k=0; k<ARRAY_SIZE; k++) ar1[k] = ar2[k] = RANDOM_NUMBER;

  t0 = clock(); while (clock() == t0) /* void */; t0 = clock();
  do {
    quick(ar1, 0, ARRAY_SIZE - 1);
  } while (0);
  double qsort_time = (double)(clock() - t0) / CLOCKS_PER_SEC;

  t0 = clock(); while (clock() == t0) /* void */; t0 = clock();
  do {
    radix(ar2, ARRAY_SIZE);
  } while (0);
  double radix_time = (double)(clock() - t0) / CLOCKS_PER_SEC;

  printf("qsort time: %.2f secs\n", qsort_time);
  printf("radix time: %.2f secs\n", radix_time);

  /* compare sorted arrays by sampling */
  int evil = 0;
  int good = 0;
  for (int chk=0; chk<2000; chk++) {
    size_t index = RANDOM_NUMBER % ARRAY_SIZE;
    if (ar1[index] == ar2[index]) good++;
    else evil++;
  }
  printf("good: %d; evil: %d\n", good, evil);

  return 0;
}

6

4
这段内容的意思是:这篇文章提出了一堆通用的理论想法,但遗憾的是几乎没有实际的想法(这是可以理解的,因为这篇文章需要独立于实现和编程语言)。 - AnT stands with Russia
实际上,应用程序开发人员最好使用框架函数进行排序。在早期,您需要自己编写代码,但现在对于大约99%的所有用例来说,这些时代已经过去了。此外,随着框架变得越来越好,应用程序代码中的所有排序功能用户都会受益。(同样适用于使用STL的C++代码,而不是创建自己的B树类等) - Thorsten79

2
  1. You can a eliminate recuriosn overhead by using QuickSort with Explicit Stack

    void quickSort(int a[], int lower, int upper)
    {
        createStack(); 
        push(lower); 
        push(upper);
    
        while (!isEmptyStack()) {
        upper=poptop();
        lower=poptop();
           while (lower<upper) {
                     pivPos=partition(selectPivot(a, size), a, lower, upper);
                     push(lower);
                     push(pivPos-1);
                     lower = pivPos+1; // end = end;
           }
       }
    }
    
  2. You can use better pivot selection technique such as:

    1. median of 3
    2. median of medians
    3. random pivot

1
再次说,这种方法无条件地将较低的分区推入堆栈,同时迭代处理上部分区。一般情况下,堆栈深度将是O(n)(除非您保证几乎完美的中位数),这是不好的。如果您添加另一个“if”,以确保始终将较短的分区推入堆栈,则即使选择最差的中位数,堆栈深度也永远不会超过O(log n)。 - AnT stands with Russia
递归实际上没有开销。请参见http://en.wikipedia.org/wiki/Dynamic_programming。 - DarthVader
@AndreyT,感谢建议 :) @unknown(google),这里如何应用DP?递归会在程序堆栈上创建大量的堆栈帧(包含返回地址、帧指针等等...),显然是一种开销。使用显式堆栈将消除此开销。 - atv

2

目前最先进的快速排序算法是Java中实现的DualPivotQuicksort.java,因此您可以简单地遵循该方法,您会看到性能得到了显著提升:

  • 对于小数组(Java中使用47),请使用插入排序
  • 使用双轴快速排序算法选择5个元素中的第2个和第4个元素作为两个枢轴
  • 对于具有已排序数字的运行数组,请考虑使用归并排序

或者,如果您想增加一些复杂性,那么可以编写一个三轴快速排序


1
根据您的代码,当排序长度为10时,最深的堆栈看起来像这样。
#0  partition (a=0x7fff5ac42180, lower=3, upper=5) 
#1  0x000000000040062f in quick (a=0x7fff5ac42180, lower=3, upper=5) 
#2  0x0000000000400656 in quick (a=0x7fff5ac42180, lower=0, upper=5) 
#3  0x0000000000400644 in quick (a=0x7fff5ac42180, lower=0, upper=8) 
#4  0x0000000000400644 in quick (a=0x7fff5ac42180, lower=0, upper=9) 
#5  0x00000000004005c3 in main 

除了算法本身,如果我们考虑系统行为,如堆栈活动等,除了正常的调用堆栈成本(推/弹)之外,其他一些因素可能会大大降低性能,例如多任务系统中的上下文切换,你知道大多数操作系统都是多任务的,堆栈越深,切换成本就越高,再加上缓存未命中或跨缓存线边界。

我相信在这种情况下,如果长度变得更长,与其他排序算法相比,您将会失去优势。

例如,当长度达到40时,堆栈可能看起来像下面所示(可能比下面显示的条目更多):

#0  partition (a=0x7fff24810cd0, lower=8, upper=9) 
#1  0x000000000040065d in quick (a=0x7fff24810cd0, lower=8, upper=9)  
#2  0x0000000000400672 in quick (a=0x7fff24810cd0, lower=8, upper=10) 
#3  0x0000000000400672 in quick (a=0x7fff24810cd0, lower=8, upper=14) 
#4  0x0000000000400684 in quick (a=0x7fff24810cd0, lower=4, upper=14) 
#5  0x0000000000400672 in quick (a=0x7fff24810cd0, lower=4, upper=18) 
#6  0x0000000000400684 in quick (a=0x7fff24810cd0, lower=0, upper=18) 
#7  0x0000000000400672 in quick (a=0x7fff24810cd0, lower=0, upper=26) 
#8  0x0000000000400672 in quick (a=0x7fff24810cd0, lower=0, upper=38) 
#9  0x0000000000400672 in quick (a=0x7fff24810cd0, lower=0, upper=39) 
#10 0x00000000004005ee in main  

堆栈太深了。

函数内联也很有帮助,但会增加最终可执行文件的代码占用空间。 我同意@Swiss的观点,并行编程可能会对您有很大帮助。


1
如果这不仅仅是为了学习,可以使用stdlib.h中的qsort

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接