• 基数排序


    本文转载

    最近需要对大小在0到100万内的很多数组进行排序,每一个数组的长度都不固定,短则几十,长则几千。为了最快完成排序,需要将数组大小和数据范围考虑进去。由于快速排序是常规排序中速度最快的,首选肯定是它。但是数组中数据的范围固定,可以考虑基数排序。为了使排序耗时尽可能短,需要测试这两种排序算法。

    快排是面试过程中常考的手写代码,需要背得滚瓜烂熟,代码如下:

     

    1. void swap(int* a,int *b)  
    2. {  
    3.     int temp=*a;  
    4.     *a=*b;  
    5.     *b=temp;  
    6. }  
    7.   
    8. void q_sort(int* a,int left,int right)  
    9. {  
    10.     if(left>=right) return;  
    11.   
    12.     int i=left,j=right+1;  
    13.     int pivot=a[left];  
    14.   
    15.     while(true)  
    16.     {  
    17.         do  
    18.         {  
    19.             i++;  
    20.         }while(i<=right&&a[i]<pivot);  
    21.   
    22.         do  
    23.         {  
    24.             j--;  
    25.         }while(j>=left&&a[j]>pivot);  
    26.   
    27.         if(i>=j) break;  
    28.   
    29.         swap(&a[i],&a[j]);  
    30.     }  
    31.   
    32.     a[left]=a[j];  
    33.     a[j]=pivot;  
    34.   
    35.     q_sort(a,left,j-1);  
    36.     q_sort(a,j+1,right);  
    37. }  
    38.   
    39. void quick_sort(int* a,int n)  
    40. {  
    41.     q_sort(a,0,n-1);  
    42. }  
    void swap(int* a,int *b)
    {
        int temp=*a;
        *a=*b;
        *b=temp;
    }
    
    void q_sort(int* a,int left,int right)
    {
        if(left>=right) return;
    
        int i=left,j=right+1;
        int pivot=a[left];
    
        while(true)
        {
            do
            {
                i++;
            }while(i<=right&&a[i]<pivot);
    
            do
            {
                j--;
            }while(j>=left&&a[j]>pivot);
    
            if(i>=j) break;
    
            swap(&a[i],&a[j]);
        }
    
        a[left]=a[j];
        a[j]=pivot;
    
        q_sort(a,left,j-1);
        q_sort(a,j+1,right);
    }
    
    void quick_sort(int* a,int n)
    {
        q_sort(a,0,n-1);
    }
    

    下面着重介绍基数排序。关于基数排序的原理和两种不同实现可以参考:基数排序,该博客详细介绍了LSDLeast significant digital)或MSDMost significant digital)两种基数排序的原理和代码实现。由于LSD更符合大家的思维方式,所以对比时也采用LSD。最初版本的LSD代码如下:

     

     

    1. const static int radix=100;  
    2. int get_part(int n,int i)  
    3. {  
    4.     int p=(int)pow(radix,i);  
    5.     return (int)(n/p)%radix;  
    6. }  
    7. void radix_sort(int* a,int n)  
    8. {  
    9.     int* bucket=(int*)malloc(sizeof(int)*n);  
    10.     int* count=(int*)malloc(sizeof(int)*radix);  
    11.   
    12.     for(int i=0;i<3;++i)  
    13.     {  
    14.         memset(count,0,sizeof(int)*radix);  
    15.   
    16.         for(int j=0;j<n;++j)  
    17.         {  
    18.             count[get_part(a[j],i)]++;  
    19.         }  
    20.   
    21.         for(int j=1;j<radix;++j)  
    22.         {  
    23.             count[j]+=count[j-1];  
    24.         }  
    25.   
    26.         for(int j=n-1;j>=0;--j)  
    27.         {  
    28.             int k=get_part(a[j],i);  
    29.             bucket[count[k]-1]=a[j];  
    30.             count[k]--;  
    31.         }  
    32.   
    33.         memcpy(a,bucket,sizeof(int)*n);  
    34.     }  
    35.   
    36.     free(bucket);  
    37.     free(count);  
    38. }  
    const static int radix=100;
    int get_part(int n,int i)
    {
        int p=(int)pow(radix,i);
        return (int)(n/p)%radix;
    }
    void radix_sort(int* a,int n)
    {
        int* bucket=(int*)malloc(sizeof(int)*n);
        int* count=(int*)malloc(sizeof(int)*radix);
    
        for(int i=0;i<3;++i)
        {
            memset(count,0,sizeof(int)*radix);
    
            for(int j=0;j<n;++j)
            {
                count[get_part(a[j],i)]++;
            }
    
            for(int j=1;j<radix;++j)
            {
                count[j]+=count[j-1];
            }
    
            for(int j=n-1;j>=0;--j)
            {
                int k=get_part(a[j],i);
                bucket[count[k]-1]=a[j];
                count[k]--;
            }
    
            memcpy(a,bucket,sizeof(int)*n);
        }
    
        free(bucket);
        free(count);
    }
    

     

    下面详细分析一下上面的基数排序代码。首先定义常量radix用来表示选择的基数,上面的代码一开始选择的基数为100。很多代码在使用基数排序的时候总是默认基数为10,但是这样往往会提高复杂度,后面会详细分析10为基的坏处。之后定义了一个get_part函数,用来获得数据的不同部分。最后是具体的基数排序函数。通过函数体可以很清楚的看出基数排序的复杂度,上面代码给出的复杂度为O(3*(3n+r))。其中外面的3表示范围在100万以内的数通过基数100分解最多只需要分解三次;里面的3n+r表示每次循环的复杂度。For循环内部有三个小的for循环,复杂度分别为nrn。此外还有一个memset调用,复杂度也是n,因而每次循环总的复杂度是3n+r

    由上面的分析,我们可以获得基数排序在数据任意大小时的复杂度为:

     

    其中,r为基数,n为数组长度,Max为数组最大值。由此我们可以看出,影响基数排序的因素有三个:数组长度,基数大小和数组最大值。优化基数排序的方法也就是从这三方面入手。

    在介绍优化之前,我们先对比原始的基数排序和快速排序的性能,测试结果如表一:

    测试结果很让人吃惊,基数排序完全比不上快速排序,性能差距而且不小,这很奇怪。如果看代码,基数排序的复杂度确实很低,但是性能却比较差劲。不过好的一点是,基数排序的性能确实满足线性增长规律。

    优化一:避免内存分配

    原始基数排序中桶变量count使用malloc分配空间,我们首先将该动态内存分配改为栈上固定内存分配,基数排序的前后性能对比如表二。可以看出,通过改为固定内存分配,基数排序的性能有小幅提升,但是这还不足以与快速排序相匹配。我们还需要考虑别的优化技巧。

     

    优化二:修改基数

     

           通过基数排序的复杂度可以看出,影响复杂度的很大一个参数是基数的选择。很多人在使用基数排序时都会默认基数为10,但是这样会显著增大算法复杂度的常量,因而在数组长度较大时,选用较大的基数可能会使性能更好。我们将算法的基数改为1000,性能对比图如表三。可以看出,通过修改基数,基数排序的性能有很大提升,当数组长度大于10000时,基数排序的性能已经超过快速排序。

           但是这还不足以说明基数排序的优势。按复杂度推算,快速排序的复杂度为O(n*logn),基数排序复杂度在基为1000时复杂度为O(6n+2r),因而当元素个数在500左右时,两者的性能就应该达到一样,这说明算法还有优化余地。

     

    优化三:避免复杂的数学运算

     

           基数排序中有一个频繁的操作是获取整数的不同部分,该操作通过一个get_part函数获得,函数代码如下:

    1. int get_part(int n,int i)  
    2. {  
    3.     int p=(int)pow(radix,i);  
    4.     return (int)(n*p)%radix;  
    5. }  
    int get_part(int n,int i)
    {
        int p=(int)pow(radix,i);
        return (int)(n*p)%radix;
    }
    

    上述函数有一个复杂的pow系统调用,这可能会影响速度。但其实该操作就是计算基数的次方。在基数固定的前提下,我们可以将次方计算提前计算出来,每次通过查表来获得基数的次方。为此,我们定义一个常量数组p用来保存基数的次方:

    1. static int p[]={1,radix,radix*radix,radix*radix*radix,radix*radix*radix*radix};  
    static int p[]={1,radix,radix*radix,radix*radix*radix,radix*radix*radix*radix};

    当基数为1000时,上述数组可以应对最大为10^12的整数。然后get_part函数就变为:

    1. int get_part(int n,int i)  
    2. {  
    3.     return (n/p[i])%radix;  
    4. }  
    int get_part(int n,int i)
    {
        return (n/p[i])%radix;
    }
    

    在此测试两种排序,性能对比如表四。这次可以看出,当元素为1000左右时,基数排序性能开始占优;当数组很长时,基数排序有很明显的性能提升,已经远远超过了快速排序。

    优化四:除法变乘法

     

    get_part函数中有一个除法取整的操作,一般情况下除法要比乘法更耗时,一个优化技巧是将除法变成乘法。在此我们可以定义一个常量数组,用来保存基数次方的倒数,这样就可以将除法转变成乘法:

    1. static double rp[]={1,1.0/p[1],1.0/p[2],1.0/p[3],1.0/p[4]};  
    static double rp[]={1,1.0/p[1],1.0/p[2],1.0/p[3],1.0/p[4]};

    然后get_part函数就变为:

    1. int get_part(int n,int i)  
    2. {  
    3.     return (int)(n*rp[i])%radix;  
    4. }  
    int get_part(int n,int i)
    {
        return (int)(n*rp[i])%radix;
    }
    

    再次测试基数排序前后的性能有表五。可以看出,避免除法操作又可以使性能获得较大提升。当数组长度在400左右时,基数排序性能开始占优。

    优化五:内联函数

     

    可以看出get_part函数在基数排序中多次调用,同时其构造又很简单,可以考虑将其作为内联函数。修改一个函数为内联函数的方式很简单,只需要在声明函数时加inline关键词即可。将get_part函数修改为内联函数后,性能又有些许提升,如表六。当数组长度在300左右时,基数排序性能开始占优。

     

    优化六:采用2的幂作为基数

     

    在选择基数时我们总会习惯性的以10的幂作为基数,这与我们平时多用10进制运算相符合。但是,计算机是以二进制存储数据的,所以当采用10的幂作为基数时就会出现很多问题,最明显的就是上面出现的乘法除法问题。虽然我们对get_part函数做了很多优化,但是还有一个取余操作尚未优化。昨天,经俊哥指点,我们完全可以采用2的幂作为基数,这样就可以完全避免复杂的乘除法运算。

    我们将基数改为1024,此时我们需要修改常量数组p为:

    1. static int p[]={0,10,20,30};  
    static int p[]={0,10,20,30};

    然后get_part函数变为:

    1. inline int get_part(int n,int i)  
    2. {  
    3.     return n>>p[i]&(radix-1);  
    4. }  
    inline int get_part(int n,int i)
    {
    	return n>>p[i]&(radix-1);
    }
    

    可以看出,修改基数为1024之后,除法操作就变为了右移操作,取模操作就变成了与操作。直观上,性能会有很大提升;测试结果也是如此,如表七。当数组长度在200左右时,基数排序性能开始占优。

    下面把调优之后的完整基数排序代码列出:

     

    1. const static int radix=1024;  
    2. static int p[]={0,10,20,30};  
    3.   
    4. inline int get_part(int n,int i)  
    5. {  
    6.     return n>>p[i]&(radix-1);  
    7. }  
    8.   
    9. void radix_sort(int* a,int n)  
    10. {  
    11.     int* bucket=(int*)malloc(sizeof(int)*n);  
    12.     int count[radix];  
    13.   
    14.     for(int i=0;i<2;++i)  
    15.     {  
    16.         memset(count,0,sizeof(int)*radix);  
    17.   
    18.         for(int j=0;j<n;++j)  
    19.         {  
    20.             count[get_part(a[j],i)]++;  
    21.         }  
    22.   
    23.         for(int j=1;j<radix;++j)  
    24.         {  
    25.             count[j]+=count[j-1];  
    26.         }  
    27.   
    28.         for(int j=n-1;j>=0;--j)  
    29.         {  
    30.             int k=get_part(a[j],i);  
    31.             bucket[count[k]-1]=a[j];  
    32.             count[k]--;  
    33.         }  
    34.   
    35.         memcpy(a,bucket,sizeof(int)*n);  
    36.     }  
    37.   
    38.     free(bucket);  
    39. }  
    const static int radix=1024;
    static int p[]={0,10,20,30};
    
    inline int get_part(int n,int i)
    {
        return n>>p[i]&(radix-1);
    }
    
    void radix_sort(int* a,int n)
    {
        int* bucket=(int*)malloc(sizeof(int)*n);
        int count[radix];
    
        for(int i=0;i<2;++i)
        {
            memset(count,0,sizeof(int)*radix);
    
            for(int j=0;j<n;++j)
            {
                count[get_part(a[j],i)]++;
            }
    
            for(int j=1;j<radix;++j)
            {
                count[j]+=count[j-1];
            }
    
            for(int j=n-1;j>=0;--j)
            {
                int k=get_part(a[j],i);
                bucket[count[k]-1]=a[j];
                count[k]--;
            }
    
            memcpy(a,bucket,sizeof(int)*n);
        }
    
        free(bucket);
    }

     

    再次运行快速排序和上述代码,获得一个性能对比图:

     

    总结

     

    通过上面的分析我们可以看出,基数排序确实符合它线性复杂度的优势。如果我们知晓整数数组元素的范围,基数排序确实是一个很好的选择。但是要获得好的性能并不是特别容易,需要很多优化技巧。最好的优化方法就是选择2的幂作为基数。在具体应用时,我们要根据实际的数据范围去合理的选择基数,在确定基数之后再去考虑需要循环的次数。在上面的对比中,数据范围在100w以内,因而循环只有两次,所以快速排序和基数排序的性能差异接近5倍;如果在10亿以内,则需要三次循环,性能差异可能就会降为3倍左右。其实,10亿以内的数几乎快覆盖了int型整数的范围;如果基数选择为2048,则三次循环就完全覆盖了整个int型整数范围。所以,如果要排序的数据范围很大,但是数据量又不足以使用计数排序时,可以考虑采用基数为2048的基数排序。

  • 相关阅读:
    iscroll中使用input框的话是导致无法选中input框
    JS中setInterval()和clearInterval()的使用以及注意事项
    连接oracle出现的问题以及解决办法
    Oracle数据库数据显示乱码问题解决方法。
    Android4.4以上Uri转换成绝对路径的工具类
    安卓,调用相机并适配6.0后
    问题-解决
    ORACLE相关
    PL/SQL表结构/数据的导出
    springMVC笔记
  • 原文地址:https://www.cnblogs.com/mascotxi/p/4490901.html
Copyright © 2020-2023  润新知