基础排序算法

1. 基于比较的排序(comparison-based sorting)

使用比较运算符"<"和">",将相容的序放到输入中,且除了赋值运算符外,这两种运算是仅有的容许对输入数据进行的操做,在这些条件下的排序叫作“基于比较的排序”。本文介绍的除了桶式排序都是基于比较的排序。算法

2. 插入排序(insertion sort)

最简单的排序算法之一是插入排序。插入排序由 N-1 趟(pass)排序组成。对于 P=1 趟到 P=N-1 趟,插入排序保证从位置 0 到位置 P 上的元素为已排序状态。shell

void InsertionSort(ElementType A[], int N) {
    int j, P;

    ElementType Tmp;
    for (P = 1; P < N; P++)
    {
        Tmp = A[P];
        for (j = P; j > 0 && A[j - 1] > Tmp; j--)
          A[j] = A[j - 1];
        A[j] = Tmp;
    }
}
复制代码

运行时间上界为
\sum_{i=1}^{N-1}i = 1, 2, \ldots, N-1 = \frac{1+N-1}{2} \times (N-1) = \frac{N^2}{2} - \frac{N}{2} ,去掉常系数项和低阶项,时间复杂度为 O(N^2)编程

3. 一些简单排序算法的下界

逆序(inversion)数组

成员存数的数组的一个逆序是指数组中具备性质i < jA[i] > A[j]的序偶(A[i], A[j])。例如数组A=[34, 8, 64, 51, 32, 21]有9个逆序,即(34, 8)(34, 32)(34, 21)(64, 51)(64, 32)(64, 21)(51, 32)(51, 21)以及(32, 21)。这正好是由插入排序执行的交换次数。状况老是这样,由于交换两个不按原序排列的相邻元素刚好消除一个逆序,而一个排过序的数组没有逆序。markdown

假设不存在重复元素,输入数据是前N个整数的某个排列,并设全部的排列都是等可能的。有以下定理:数据结构

定理1ide

N个互异数的数组的平均序数是N(N-1)/4oop

定理2性能

经过交换相邻元素进行排序的任何算法平均须要\Omega(N^2)时间。优化

4. 希尔排序(shellsort)

希尔排序的名称来源于它的发明者Donald Shell。该算法是冲破二次时间屏障的第一批算法之一,不过,自从它最初被发现,又过了若干年后才证实了它的亚二次时间界。它经过比较相距必定间隔的元素来工做;各趟比较所用的距离随着算法的进行而减少,直到只比较相邻元素的最后一趟排序为止。因为这个缘由,希尔排序有时也叫缩小增量排序(diminishing increment sort)。

希尔排序使用一个序列h_1, h_2, \ldots, h_t,叫作增量序列(increment sequence)。只要h_1=1,任何增量序列都是可行的,不过有些增量序列比另一些增量序列更好。

对于h_k-排序的通常作法是,对于h_k, h_{k+1}, ..., N-1中的每个位置i,把其上的元素放到i, i-h_k, i-2h_k \ldots中间的正确位置上。

增量序列的一种流行(可是很差)的选择是使用Shell建议的序列:h_t = \lfloor N / 2 \rfloorh_k = \lfloor h_{k_1}/2\rfloor。以下为使用这种增量序列的实现代码实现。

void Shellsort(ElementType A[], int N) {
    int i, j, Increment;
    ElementType Tmp;

    for (Increment = N/2; Increment > 0; Increment /=2)
        for (i = Increment; i < N; i++)
        {
            Tmp = A[i];
            for(j = i; j > Increment; j -= Increment)
                if(Tmp < A[j - Increment])
                    A[j] = A[j - Increment];
                else
                    break;
            A[j] = Tmp;
        }
}
复制代码

5. 堆排序(heapsort)

咱们知道,优先队列(堆)能够用于花费O(NlogN)时间的排序。基于该想法的算法叫作堆排序(heapsort)并给出咱们至今所见到的最佳的大O运行时间。然而,在实践中它却慢于使用Sedgewick增量序列的希尔排序。

创建N个元素的二叉堆花费O(N)时间,而后执行NDeleteMin操做。按照顺序,最小的元素先离开堆。经过将这些元素记录到第二个数组而后再将数组拷贝回来,咱们获得N个元素的排序。因为每一个DeleteMin花费时间O(log N),所以总的运行时间是O(NlogN)。使用这样的策略,在最后一次DeleteMin后,该数组将以递减的顺序包含这些元素。若是想要这些元素排成更典型的递增顺序,那么咱们能够改变序的特性使得父亲的关键字的值大于儿子的关键字的值。这样就获得最大堆(max heap)

#define LeftChild(i) (2* (i) + 1)

void PercDown(ElementType A[], int i, int N) {
    int Child;
    ElementType Tmp;
    
    for(Tmp = A[i]; LeftChild(i) < N; i = Child)
    {
        Child = LeftChild(i);
        if(Child != N-1 && A[Child + 1] > A[Child])
            Child++;
        if(Tmp < A[Child])
            A[i] = A[Child];
        else
            break;
    }
    A[i] = Tmp;
}

void Heapsort(ElementType A[], int N) {
    int i;

    for(i = N/2; i >= 0; i--)   /* BuildHeap */
        PercDown(A, i, N);
    for(i = N-1; i > 0; i--)
    {
        Swap(&A[0], &A[i]);     /* DeleteMax */
        PercDown(A, 0, i);
    }
}
复制代码

能够证实,堆排序老是使用至少NlogN - O(N)次比较,并且存在输入数据可以达到这个界。

6. 归并排序(mergesort)

归并排序以O(NlogN)最坏情形运行时间运行,而所使用的比较次数几乎是最优的。它是递归算法一个很好的实例。

这个算法中基本的操做是合并两个已排序的表。由于这两个表是已经排序的。因此若将输出放到第三个表中时则该算法能够经过对输入数据一趟排序来完成。基本的合并算法是取两个输入数组AB,一个输出数组C,以及三个计数器Aptr, Bptr, Cptr,它们初始置于对应数组的开始端。A[Aptr]B[Bptr] 中的较小者被拷贝到C中的下一个位置,相关的计数器向前推动一步。当输入表有一个用完的时候,则将另外一个表中剩余部分拷贝到C中。

合并两个已排序的表的时间显然是线性的,由于最多进行了N-1次比较,其中N是元素的总数。为了看清这一点,注意每次比较都是把一个元素加到C中,但最后的比较除外,它至少添加两个元素。

/* Lpos = start of left half, Rpos = start of right half */
void Merge(ElementType A[], ElementType TmpArray[], int Lpos, int Rpos, int RightEnd) {
    int i, LeftEnd, NumElements, TmpPos;

    LeftEnd = Rpos - 1;
    TmpPos = Lpos;
    NumElements = RightEnd - Lpos + 1;

    /* main loop */
    while(Lpos <= LefEnd && Rpos <= RightEnd)
        if (A[Lpos] <= A[Rpos])
            TmpArray[TmpPos++] = A[Lpos++];
        else
            TmpArray[TmpPos++] = A[Rpos++];
    while(Lpos <= LeftEnd)
        TmpArray[TmpPos++] = A[Lpos++];
    while(Rpos <= RightEnd)
        TmpArray[TmpPos++] = A[Rpos++];

    /* copy TmpArray back */
    for (i = 0; i < NumElements; i++, RightEnd--)
        A[RightEnd] = TmpArray[RightEnd];
}

void MSort(ElementType A[], ElementType TmpArray[], int Left, int Right) {
    int Center;

    if (Left < Right)
    {
        Center = (Left + Right) / 2;
        MSort(A, TmpArray, Left, Center);
        Msort(A, TmpArray, Center + 1, Right);
        Merge(A, TmpArray, Left, Center + 1, Right);
    }
}

void Mergesort(ElementType A[], int N) {
    ElementType *TmpArray;

    TmpArray = malloc(N * sizeof(ElementType));
    if (TmpArray != NULL)
    {
        MSort(A, TmpArray, 0, N-1);
        free(TmpArray);
    }
    else
        FatalError("No space for tmp array!!!");
}
复制代码

7. 快速排序(quicksort)

正如它的名字所标示的,快速排序是在实践中最快的已知排序算法,它的平均运行时间是O(NlogN)。该算法之因此特别快,主要是因为很是精炼和高度优化的内部循环。它的最坏情形的性能为 O(N^2) ,但稍加努力就能够避免这种情形。虽然多年来快速排序算法被认为是理论上高度优化而在实践中却不可能正确编程的一种算法,可是现在该算法简单易懂并且不难证实。像归并排序同样,快速排序也是一种分治的递归算法。

将数组S排序的基本算法由下列简单的四步组成:

  1. 若是S中 元素个数是0或者1,则返回。
  2. S中任一元素v, 称之为枢纽元(pivot)。
  3. S - \{v\}S中其他元素)分红两个不相交的集合:S_1 = \{x \in S - \{v\} | x \le v\}S_2 = \{ x \in S - \{v\} | x \ge v \}
  4. 返回\{ quicksort(S_1)后,继随 v,继而 quicksort(S_2) \}

因为对那些等于枢纽元的处理,第(3)步分割的描述不是惟一的,所以这就成了一个设计上的决策。一部分好的实现是将这种情形尽量有效地处理。直观地看,咱们但愿把等于枢纽元的大约一半的关键字分到S_1中,而另外的一半分到S_2中,很像咱们但愿二叉查找树保持平衡同样。

7.1 选取枢纽元

三数中值分割法(Median-of-Three Partitioning)。一组N个数的中值是第\lceil N / 2 \rceil个最大的数。枢纽元最好的选择是数组的中值。不幸的是,这很难算出,且明显减慢快速排序的速度。这样的中值的估计量能够经过随机选取三个元素并用它们的中值做为枢纽元获得。事实上,随机性并无多大的帮助,所以通常的作法是使用左端,右端和中心位置上的三个元素的中值做为枢纽元。例如,输入为 8, 1, 4, 9, 6, 3, 5, 2, 7, 0,它的左边元素是8,右边元素是0,中心位置\lfloor (Left + Right)/2 \rfloor = \lfloor (0 + 9)/2 \rfloor = 4 上的元素是6。因而枢纽元则是v=6

7.2 分割策略

有几种分割策略用于实践,这里介绍一种比较好的(但它依然很容易作错或产生低效)。该方法:

  1. 将枢纽元与最后的元素交换使得枢纽元离开要被分割的数据段。
  2. i从第一个元素开始,j从倒数第二个元素开始。
  3. 分割阶段要作的就是把全部小元素移到数组的左边而把全部大元素移到数组的右边,小和大是相对于枢纽元而言的。
  4. ij的左边时,将i右移,移过那些小于枢纽元的元素,并将j左移,移过那些大于枢纽元的元素。
  5. ij中止时,i指向一个大元素而j指向一个小元素。若是ij的左边,那么将这两个元素互换(结果是小的元素左移,大的元素右移)。
  6. 重复(4)和(5),直到ij彼此交错为止。此时,ij已经交错,故再也不互换。
  7. 分割的最后一步是将枢纽元i所指向的元素交换。

等于枢纽元的关键字处理:若是ij遇到等于枢纽元的关键字,那么咱们就让ij都中止。对于这种输入,这其实是不花费二次时间的四种可能性中唯一的一种可能。

7.3 小数组场景

对于很小的数组(N\le20),快速排序不如插入排序好。不只如此,由于快速排序是递归的,因此这样的情形还常常发生。一般的解决方法是对于小的数组不递归地使用快速排序,而代之以诸如插入排序这样的对小数组有效的排序算法。使用这样的策略实际上能够节省大约\%15(相对于自始至终使用快速排序)的运行时间。

7.4 快速排序示例

void Quicksort(ElementType A[], int N) {
    Qsort(A, O, N - 1);
}

ElementType Median3(ElementType A[], int Left, int Right) {
    int Center = (Left + Right) / 2;

    if(A[Left] > A[Center])
        Swap(&A[Left], &A[Center]);
    if(A[Left] > A[Right])
        Swap(&A[Left], &A[Right]);
    if(A[Center] > A[Right])
        Swap(&A[Center], &A[Right]);

    /* Invariant: A[Left] <= A[Center] <= A[Right] */
    Swap(&A[Center], &A[Right - 1]);    /* Hide pivot */
    return A[Right - 1];                /* Return pivot */
}

#define Cutoff (3)

void Qsort(ElementType A[], int Left, int Right) {
    int i, j;

    ElementType Pivot;

    if (Left + Cutoff <= Right)
    {
        Pivot = Median3(A, Left, Right);
        i = Left; j = Right - 1;
        for (;;)
        {
            while(A[++i] < Pivot) {}
            while(A[--j] > Pivot) {}
            if (i < j)
                Swap(&A[i]), &A[j];
            else
                break;
        }
        Swap(&A[i], &A[Right - 1]); /* Restore pivot */

        Qsort(A, Left, i - 1);
        Qsort(A, i + 1, Right);
    }
    else /* Do an insertion sort on the subarray */
        InsertionSort(A + Left, Right - Left + 1);
}
复制代码

8. 大型结构的排序

目前为止,关于排序的所有讨论,都假设要被排序的元素是一些简单的整数。实际应用中,经常须要某个关键字对大型结构进行排序。例如,咱们可能有些工资名单的记录,每一个记录由姓名,地址,电话号码,诸如工资这样的财务信息,以及税务信息组成。咱们可能要经过一个特定的域,好比姓名,来对这些信息进行排序。对于全部算法来讲,基本的操做就是交换,不过这里交换两个结构多是很是昂贵的操做,由于结构实际上很大。这种状况下,实际的解法是让输入数组包含指向结构的指针。咱们经过比较指针指向的关键字,并在必要时交换指针来进行排序。这意味着,全部的数据移动基本上就像咱们对整数排序那样进行。咱们称之为间接排序(indirect sorting));可使用这种方法处理咱们已经描述过的大部分数据结构。

9. 排序的通常下界

虽然咱们获得一些O(NlogN)的排序算法,可是,尚不清楚咱们是否还能作得更好。能够证实,任何只用到比较的算法在最坏的状况下须要\Omega(NlogN)次比较(从而\Omega(NlogN)的时间),所以归并排序和堆排序在一个常数因子范围内是最优的。

决策树

决策树(decision tree)是用于证实下界的抽象概念。它是一棵二叉树,每一个节点表示在元素之间一组可能的排序,它与已经进行的比较一致。比较的结果是树的边。经过只使用比较进行排序的每一种算法均可以用决策树表示。固然,只有输入数据很是少的状况下画决策树才是可行的。由排序算法所使用的比较次数等于最深的树叶的深度

10. 桶式排序

虽然任何只使用比较的通常排序算法在最坏的状况下须要运行时间\Omega(NlogN),可是咱们要记住,在某些特殊状况下以线性时间进行排序仍然是可能的。

一个简单的例子就是桶式排序(bucket sort)。为使桶式排序可以正常工做,必需要知足一些额外的条件。输入数据A_1, A_2, \ldots, A_N必须只由小于M的正整数组成。这种状况下,排序算法就很简单:使用一个大小为M称为Count的数组,它被初始化为全0。因而,CountM个单元(或称桶),这些桶初始化为空。当读A_i时,Count[A_i]增1。在全部的输入数据读入后,扫描数组Count,打印出排序后的表。该算法用时O(M + N)。若是MO(N),那么总量就是O(N)

尽管桶式排序看似太平凡用处不大,可是实际上却存在许多输入只是一些小的整数的状况,使用像快速排序这样的排序方法真的是小题大做了。

11. 外部排序

目前为止,咱们讲到过的全部算法都须要将输入数据装入内存。而后,存在一些应用程序,它们的输入数据量太大装不进内存。外部排序(external sorting)就是设计用来处理这样很大的输入数据的。

11.1 为何要设计一种新的算法

大部份内存排序算法都用到内存可直接寻址的事实。希尔排序用一个时间单位比较元素A[i]A[i - h_k]堆排序用一个时间单位比较元素A[i]A[i*2 + 1]。使用三数中值分割法快速排序以常数个时间单位比较A[Left]A[Center]A[Right]。若是输入数据在磁盘上,那么全部这些操做就失去了它们的效率,由于磁盘上的元素只能被顺序访问。即便数据在一张磁盘上,因为转动磁盘和移动磁盘所需的延迟,仍然存在实际上的效率损失。

11.2 外部排序模型

假设至少有三个磁盘驱动器进行排序工做。咱们须要两个驱动器执行有效的排序,而第三个驱动器进行简化的工做。若是只有一个磁盘驱动器可用,那么咱们则不得不说:任何算法都将须要\Omega(N^2)次磁盘访问。

11.3 简单实现方法

基本的外部排序算法使用归并排序中的Merge子程序。假设咱们有四个磁盘,T_{a1}, T_{a2}, T_{b1}, T_{b_2},它们是两个输入磁盘和两个输出磁盘。设数据最初在T_{a1}上,并设内存能够一次容纳(和排序)M个记录。一种天然的作法是第一步从输入磁盘一次读入M个记录,在内部(内存)将这些记录排序,而后再把这些排过序的记录交替地写到T_{b1}T_{b2}上。咱们将把每组排过序的记录叫作一个顺串(run)。作完这些以后,咱们倒回全部的磁盘。假设咱们的输入数据以下:

磁盘 数据
T_{a1} 81 , 94 , 11 , 96 , 12 , 35 , 17 , 99 , 28 , 58 , 41 , 75 , 15
T_{a2}
T_{b1}
T_{b2}

若是M = 3,那么在顺串构造之后,磁盘将包含以下所示的数据。

磁盘 顺串 顺串 顺串
T_{a1}
T_{a2}
T_{b1} 11, 81, 94 17, 28, 99 15
T_{b2} 12, 35, 96 41, 58, 75

如今T_{b1}T_{b2}都包含一组顺串。咱们将每一个磁盘的第一个顺串取出并将两者合并,把结果写到T_{a1}上,该结果是一个二倍长的顺串。而后,咱们再从每一个磁盘取出下一个顺串合并,并将结果写到T_{a2}上。继续这个过程,交替使用T_{a1}T_{a2},直到T_{b1}T_{b2}为空。此时,或者T_{b1}T_{b2}均为空,或者剩下一个顺串。对于后者,咱们把剩下的顺串拷贝到适当的顺串上。将四个磁盘倒回,并重复相同步骤,这一次用两个a磁盘做输入,两个b磁盘做输出,结果获得一些4M的顺串。咱们继续这个过程直到获得长为N的一个顺串

该算法将须要\lceil log(N/M) \rceil趟工做,外加一趟构造初始化的顺串。例如,若咱们有1000万个记录,每一个记录128个字节,并有4兆字节的内存,则第一趟将创建320个顺串。此时咱们再须要\lceil log320 \rceil = 9趟以完成排序。上面的例子则是\lceil log13/3 \rceil = 3趟。

第一趟:

磁盘 顺串 顺串
T_{a1} 11, 12, 35, 81, 94, 96 15
T_{a2} 17, 28, 41, 58, 75, 99
T_{b1}
T_{b2}

第二趟:

磁盘 顺串
T_{a1}
T_{a2}
T_{b1} 11, 12, 17, 28, 35, 51, 58, 75, 81, 94, 96, 99
T_{b2} 15

第三趟:

磁盘 顺串
T_{a1} 11, 12, 15, 17, 28, 35, 51, 58, 75, 81, 94, 96, 99
T_{a2}
T_{b1}
T_{b2}

11.4 其余实现方法

  • 多路合并
  • 多相合并
  • 替换选择

12. 总结

对于最通常的内部(内存)排序应用程序,选用的方法不是插入排序希尔排序,就是快速排序,它们的选用主要是根据输入的大小来决定。下表显示在一台相对较慢的计算机上处理各类不一样大小的文件时的运行时间(单位:秒)。

N 插入排序
O(N^2)
希尔排序
O(N^{7/6})(?)
堆排序
O(NlogN)
快速排序
O(NlogN)
优化快速排序
O(NlogN)
10 0.00044 0.00041 0.00057 0.00052 0.00046
100 0.00675 0.00171 0.00420 0.00284 0.00244
1000 0.59564 0.02927 0.05565 0.03153 0.02587
10000 58.864 0.42998 0.71650 0.36765 0.31532
100000 NA 5.7298 8.8591 4.2298 3.5882
1000000 NA 71.164 104.68 47.065 41.282

这里没有包括归并排序,由于它的性能对于在主存(内存)排序不如快速排序那么好,并且它的编程一点也不省事。然而归并(合并)倒是外部排序的中心思想。

相关文章
相关标签/搜索