浅析基数排序的基数选取引起的效率问题

基数排序,是一种非基于比较的排序,这意味着这种排序方法可以做到比基于比较的排序更优的时间复杂度。基于比较的排序,比如快速排序,其渐进时间复杂度为 O( nlog2n ),而基数排序理论上的渐进时间复杂度为 O( n )。

但是,以上分析的只是渐进时间复杂度。事实上,基数排序的运行时间函数远不止这么简单,它是一个和三个因变量有关的函数。设待排序元素个数为 n,待排序最大元素 m,选取基数 p,则基数排序的运行时间函数为 T(n,m,p)=logpm(n+p) 。由于基数排序是 logpm 次计数排序的结果,而一次计数排序的运行时间为 n+p ,所以以上函数是正确的。

由于基数排序无法对负数进行操作,当遇到负数的情况时可以将所有元素都加上一个常数使得所有元素为自然数后再排序,输出时再减去这个常数即可。在这种情况下,m 表示待排序序列中元素的极差。为了简化问题,在这里我们假设所有待排序元素都为自然数。

在大多数情况下,我们直觉性的将基数取 10 而不去探究其中的原因,其原因也是显而易见的,因为我们早已习惯使用十进制,而基数取 10 的结果便是每一轮计数排序就是对待排序元素的各个数位从低到高进行计数排序的过程。这样更便于我们理解什么是基数排序。但是,基数就一定要选 10 吗?基数选 10 的方法又是最优的吗?

基数排序,就是将待排序元素的各个数位拆开来,从低到高进行线性稳定排序的过程。但是,基数排序并不是十进制特有的排序方法,我们也可以把其他进制的数按上述过程进行基数排序。读者或许会担心,将其转为其他进制后拆开数位的操作会比十进制浪费时间。但事实上,这个问题是不存在的,在计算机里所有信息的储存方式都是二进制,将其转化为任何进制再拆数位的操作都是一样的。例如以下两段代码:

for(long long i = 1; i < MAX_M; i *= 10)
    countsort(i);

for(long long i = 1; i < MAX_M; i *= p)
    countsort(i);

这两行代码又有什么本质上的区别呢?只是第一段代码 p 的取值为 10 而已,其他没有什么不同。当把 p 转化为 p 进制表示后,第二段代码也就成了第一段代码。换句话说,第一段代码只是第二段代码的 p 进制下的表达而已。我们只是习惯了十进制表示法,而忽视了它们其实是等同的这一事实。

了解了以上事实之后,我们明白,十进制不再特殊,而十进制也再也没有任何理由被认为是最优的基数。即使是在高精度排序中,输入给出了它们的十进制形式,我们也没有必要一定要按十进制来存储,我们还有千进制、万进制,甚至亿进制。那么,究竟选取什么基数才能尽可能的使基数排序的效率提高呢?

考虑上文的时间函数 T(n,m,p)=logpm(n+p) ,我们发现,当 p 增大时, logpm 减小,而 (n+p) 增大。用数学代数分析当然可以解决这个问题,但考虑到 p 只能选取整数(不选整数是自找麻烦),我们可以确定 n 和 m,让计算机穷举可能的 p 值来对时间函数的大致趋势进行判定。关于其具体实现代码笔者将于下文给出。

事实上,我们也并非不可以进行简单的数学分析来解决效率的问题。我们观察到,由于时间函数中有 logpm 一项,当 m 已知确定时,p 在一定范围内取值的结果是相同的!这意味着,为了让时间尽可能的优,我们只有很少的 p 值可以选择,因为当 logpm 相同时,我们想让 (n+p) 尽可能小,就是想让 p 尽可能的小。于是,我们将 p 可能的取值划分为若干个区间,在每个区间内 logpm 的取值相同,而 p 可能取的最优值只可能在每个区间的最小值处。举个例子,当 m = 2 ^ 30 时,p 取 1024 一定比 1025 更优,因为两者 logpm 的值都是 3,而 (n+p) 的值显然前者小于后者。

p 最小可以取 2,但是为了 logpm 一项不至于过大,p 不能取过小的值。当基数 p 取到 n 的时候,基数排序就退化成了计数排序。同样,由于基数排序的运行空间函数为 S(n,p)=(n+p) ,我们也不能让 p 取的值过大而超过空间限制。在这样的情况下,考虑到 p 是离散的这一特点,我们大可不必绞尽脑汁地思考 p 的最优值,我们只需编写一个程序,穷举可能的最优值然后让电子计算机帮助我们找出 p 的最优解即可。

代码如下:

#include"algorithm"
#include"iostream"
#include"stdio.h"
#include"time.h"
#include"windows.h"
using namespace std;
#define MAX_M (0x3FFFFFFF)
#define MAX_N (0xFFFFFF)
#define MAX_P (0xFFFFF)
#define RANDNUM (rand()%1024*1048576+rand()%1024*1024+rand()%1024)
#define REPEATIME (5)
#define QUICKSORT sort(array,array+n);
#define RADIXSORT radixsort();
#define SORTEST(type) { \
    FILETIME beg,end; \
    dur=0; \
    for(int i=0;i<REPEATIME;i++) \
    { \
        for(int j=1;j<=n;j++) \
            array[j]=base[j]; \
        GetSystemTimeAsFileTime(&beg); \
        type \
        GetSystemTimeAsFileTime(&end); \
        dur+=(end.dwLowDateTime-beg.dwLowDateTime)/10; \
    }}
int array[MAX_N],base[MAX_N],n,p,sum[MAX_P],temp[MAX_N];
void countsort(int power)
{
    for(int i=0;i<p;i++)
        sum[i]=0;
    for(int i=1;i<=n;i++)
        sum[(temp[i]=array[i])/power%p]++;
    for(int i=1;i<p;i++)
        sum[i]+=sum[i-1];
    for(int i=n;i>0;i--)
        array[sum[temp[i]/power%p]--]=temp[i];
}
void radixsort()
{
    for(long long i=1;i<MAX_M;i*=p)
        countsort(i);
}
int main()
{
    long dur;
    n=10000000;
    srand(time(NULL));
    for(int i=1;i<=n;i++)
        base[i]=RANDNUM;
    SORTEST(QUICKSORT)
    printf("QuickSort time=%ldus\n",dur/REPEATIME);
    for(p=1020;p<=1050;p++)
    {
        SORTEST(RADIXSORT)
        printf("RadixSort p=%d time=%ldus\n",p,dur/REPEATIME);
    }
    system("pause");
    return 0;
}

根据程序运行的结果显示,当基数 p 选取得当时,基数排序的运行时间只有快速排序的三分之一左右,而且 n 越大优势越明显。对结果进行分析的时候笔者发现一个很有意思的细节,当 m 取 2 ^ 30 时,在 p = 1023 和 p = 1024 之间运行时间有一个很明显的突变。结合上文的分析,我们就可以很好地解释这个现象了。

由此可见,基数选取会对基数排序的效率造成很大的影响。经过试验,当基数取 10 时,基数排序比快速排序快不了多少。对此,很多初学者对基数排序产生了一个错觉,那就是基数排序由于常数过大,其在实际应用中的效率不比快速排序快多少的想法。事实上,我们可以在一定程度上通过控制基数的选取来控制常数的大小,从而做到使基数排序的效率变得更高的目的。

点赞