高效排序算法——希尔排序、堆排序、归并排序、快速排序

如标题,这里讨论的是基于比较的排序算法中最高效的三种算法和希尔排序。堆排序、归并排序、快速排序的平均时间复杂度均为O(NlogN)。前面有介绍过O(N2)的三种简单排序算法(见三大简单排序算法——插入、选择、冒泡),其中实际表现最好的要属希尔排序。可以证明通过交换相邻元素来进行排序的任何算法都需要O(N2)的平均时间,其中插入排序虽然不是通过交换来排序,但是可以等价为交换的操作,依然是O(N2)。这里讨论的堆排序、归并排序、快速排序均是平均时间复杂度O(NlogN)的算法,实际表现最好的要属快速排序。可以证明O(NlogN)是基于比较的排序算法的时间复杂度下界。所以这三种排序算法都是渐进最优的。最后介绍一下C++的algorithm库中的sort()函数,一般我们自己写的排序算法优化不够时都是达不到这个效率的。还有会对这些算法在大规模数据下的运行时间进行一个简单的测试。

排序算法相关知识

逆序:逆序是指数组中具有性质 i > j 但是 A [ i ] < A [ j ] 的序偶 ( A [ i ] , A [ j ] ) 。(升序排列的情况,反之亦然)

排序的过程就是消除逆序的过程。很明显逆序的最大数目为 i = 1 N 1 i = N ( N 1 ) 2 ,平均情况下逆序数目为最大的一半即 N ( N 1 ) / 4

很容易看出,通过交换相邻元素一次最多只能消除一个逆序。所以通过交换相邻元素来排序的算法的平均时间复杂度一定为 O ( N 2 )

而要突破 O ( N 2 ) 的时间屏障,就必须在一次交换/操作中消除不止一个逆序。下面的排序算法都有这个特点。

另外如果下列实现中有要交换元素的均调用如下的模板。

template<typename T>
void swap(T & x , T & y)
{
    T tmp = x;
    x = y;
    y = tmp;
}

希尔排序

希尔排序(Shell sort)是第一批冲破 O ( N 2 ) 时间屏障的算法之一。也称缩小增量排序。上一篇(三大简单排序算法——插入、选择、冒泡)介绍得很详细。这里贴出代码。

//希尔排序,效率惊人
void Shell_sort(ElemType A[],int n)
{
    for(int d=n/2; d>0; d/=2)
    {
        for(int i=d; i<n; i++)
        {
            ElemType tmp = A[i];
            int j = i-d;
            while(j >= 0 && tmp < A[j])
            {
                A[j+d] = A[j];
                j = j-d;
            }
            A[j+d] = tmp;
        }
    }
}

这里的增量序列是 n / 2 , n / 4 , . . . , 1 ,为最原始的希尔序列。但这并不是高效的增量序列,一个更加高效的增量序列为Hibbard增量,形如 1 , 3 , 7 , . . . , 2 k 1 (使用时需要从合适的位置逆序),可以证明Hibbard增量的希尔排序最坏情形时间复杂度为 O ( N 3 / 2 ) 。希尔排序的算法实现很简单,但是分析却是很困难的。其实还有更好的增量序列,可以参见 Shellsort - Wikipedia - Gap sequences
动图演示(以下动图均来自Wikipedia):
此处输入图片的描述

堆排序

heap sort,这个介绍堆时也是介绍的很详细的。见基本数据结构——堆优先队列 & 堆
实现:

//升序排列、最大堆 
void sift_down(ElemType A[],int i,int n)
{
    ElemType tmp;
    int child;
    for(tmp = A[i]; 2*i <= n; i = child)
    {
        child = 2*i;
        if(child != n && A[child+1] > A[child])
            child ++;
        if(tmp < A[child])
            A[i] = A[child];
        else 
            break; 
    }
    A[i] = tmp;
}
//将数组建堆然后排序——升序 
void heap_sort(ElemType A[],int n)
{
    for(int i = n/2; i>0; i--)
        sift_down(A,i,n);
    for(int i=n; i>1; i--)
    {
        swap<ElemType>(A[1],A[i]);
        sift_down(A,1,i-1);
    }
}

需要注意的是这里堆排序的实现要求给数组多分配一个内存。当然可以将其改为与其他排序一致,这很容易做到,但我个人更偏向于现在这种实现。
动图演示:
此处输入图片的描述

归并排序

归并排序(Merge sort)采用分治策略。思路也很简单:将数组分为两个等长子数组,对两个子数组递归采用归并排序来排序,然后将两个数组合并,其中递归结束条件为子数组长为1直接返回。则时间 T ( N ) = T ( N / 2 ) + N ,由主定理得到时间复杂度为 O ( N l o g N )

实现也很清晰很简单。

void merge_sort(ElemType A[], int n)
{
    ElemType * TmpArr = new ElemType[n];
    m_sort(A,TmpArr,0,n-1);
    delete [] TmpArr;
}
void m_sort(ElemType A[], ElemType TmpArr[], int left, int right)
{
    if(left < right)
    {
        int center = (left + right) / 2;
        m_sort(A, TmpArr, left, center);
        m_sort(A, TmpArr, center+1, right);
        merge(A, TmpArr, left, center+1, right);
    }
}
//两个子数组的合并,lpos~rpos-1 and rpos~rightend ,临时存储在TmpArr[rpos~rightend] 
void merge(ElemType A[], ElemType TmpArr[], int lpos, int rpos, int rightend)
{
    int leftend = rpos - 1;
    int tmppos = lpos;
    int begin = lpos;
    while(lpos <= leftend && rpos <= rightend)
    {
        if(A[lpos] <= A[rpos])
            TmpArr[tmppos ++] = A[lpos ++];
        else
            TmpArr[tmppos ++] = A[rpos ++];
        //TmpArr[tmppos++] = A[lpos] <= A[rpos] ? A[lpos ++] : A[rpos ++];
    }
    while(lpos <= leftend)
        TmpArr[tmppos ++] = A[lpos ++];
    while(rpos <= rightend)
        TmpArr[tmppos ++] = A[rpos ++];
    for(int i = begin; i <= rightend; i ++)
        A[i] = TmpArr[i];
}

其中动态分配了一个与原数组等长的数组存放那些临时子数组合并之后的数组。这样实现比在每一次递归中都去申请一个数组来存放临时数据要更好。
动图演示:
此处输入图片的描述

快速排序

Quick sort)最近很烦,写到这真的无心去写了。有空再优化。现在没有优化甚至都比不过希尔排序、堆排序和归并排序。当然差std::sort肯定是差远了。

给一个算法导论上的实现:

//划分
int Partition(int A[], int p, int r)
{
    int x = A[r];
    int i = p - 1;
    for (int j = p; j<r; j++)
    {
        if (A[j] <= x)
        {
            i++;
            if (i != j)
                swap(A[i], A[j]);
        }
    }
    swap(A[i + 1], A[r]);
    return i + 1;
}

//随机化的划分函数,使主元随机化 
int Randomized_Partition(int A[], int p, int r)
{
    int i = rand() % (r - p + 1) + p;   //产生p到r的随机数
    swap(A[i], A[r]);
    return Partition(A, p, r);
}

void Quick_Sort(int A[], int p, int r)
{
    if (p < r)
    {
        int q = Randomized_Partition(A, p, r);
        Quick_Sort(A, p, q - 1);
        Quick_Sort(A, q + 1, r);
    }
}
void quick_sort2(int A[], int n)
{
    Quick_Sort(A, 0, n - 1);
}

动图演示:
此处输入图片的描述

排序算法的时间下界

可以使用决策树模型证明通过比较元素大小实现排序的排序算法的复杂度下界为O(NlogN)。所以快速排序、堆排序、归并排序都是渐进最优的。希尔排序由于增量序列的改变时间界会发生改变,但实际使用效果一般都很理想。并不差前面的三个排序算法多少,某些情况下甚至可能更优一点。

C++ STL sort()函数

一般我们自己实现的排序算法在优化不够的情况下都达不到C++的algorithm库中的sort()函数的效率。
std::sort的实现采用了快速排序、插入排序、堆排序。并且进行了编译优化。一般是比不过的。

调用方式,可以传入两个参数,或者三个参数。第一个参数为指向初始元素的指针,第二个参数为指向最后一个待排序元素的下一个位置的指针(或者迭代器),第三个元素可选,默认进行升序排序,可以定义一个返回值为bool类型的比较函数。当对结构或者对象进行排序时,重载运算符或者传入一个函数参数都可以实现排序。

std::sort(A,A+n);    //对数组A中的n个元素排序
std::sort(A.begin(),A.end(),cmp);   //对容器A中所有元素按照比较函数cmp()的规则进行排序

简单的时间测试

我对上述实现以及以前O(N2)的排序算法进行了一个测试。结果如下:

test 1: 1e5 elements,静态分配
simple aort algoritms :
bubble sort time : 15735ms
selection sort time : 10665ms
insertion sort time : 1095ms
binary insertion sort time : 872ms

efficient sort algorithms :
shell sort time : 13ms
merge sort time : 15ms
heap sort time : 8ms
quick sort time : 9ms
stl sort function time : 8ms

test 2: 1e7 elements, 动态分配
efficient sort algorithms :
shell sort time : 1985ms
merge sort time : 1858ms
heap sort time : 1816ms
quick sort time : 2010ms
stl sort function time : 697ms

ps: 测试时请将编译器设置为release模式。不然不开优化std::sort会非常慢。可以看到开启优化之后std::sort是吊打一切的存在。愉快地使用STL吧!

源码下载。

参考资料:数据结构与算法分析——C语言描述、算法导论

猜你喜欢

转载自blog.csdn.net/qq_35527032/article/details/79873624