排序总结

【前言】此篇是《数据结构和算法》的第七章读书笔记 :排序

本篇总结数组中元素的排序问题。根据元素的规模,通常的排序可以在内存中完成,规模太大而必须在磁盘等存储上完成的排序称为外排序。任何通用的排序算法都需要 Ω(NlogN) 次比较。

一、几种简单排序算法

数组的一个逆序指数组中位置 i 和 j 满足 i<j 但 A[i]>A[j] 的序偶。 N 个互异数的数组的平均逆序数为 N(N1)/4 。

每次交换相邻元素可以消除一个逆序,通过交换相邻元素进行排序的任何算法平均需要 Ω(N2) 时间。

插入排序

插入排序是最简单的排序算法。插入排序由 N1 趟排序组成,对于第 i 趟,保证位置0到 i 的元素为已排序的状态,即将位置 i 上的元素前移到它在前 N+1 个元素中的正确位置。

 1 void insertion_sort(int a[], int n)
 2 {
 3     int i, j;
 4     int t;
 5 
 6     for (i = 1; i < n; i++) {
 7         t = a[i];
 8         for (j = i; j > 0 && a[j-1] > t; j--)
 9             a[j] = a[j-1];
10         a[j] = t;   //上面那句代码仅是替换掉前面一个
11         //swap(a[j] , a[j-1]);此条语句可以替换上面两条语句,但是调用了库函数
12     }
13 }

插入排序的平均运行时间为 Θ(N2) ,如果输入元素已经预先排序,那么运行时间为 O(N) 。

冒泡排序

冒泡排序每趟从后到前比较相邻元素的大小,将较小值交换到前面,就像冒泡一样。冒泡排序和插入排序的交换次数相同。最坏时间复杂度:O(N2)

1 void bubble_sort(tp a[], int n)
2 {
3     int i, j;
4 
5     for (i = 0; i < n - 1; i++)
6         for (j = n - 1; j > i; j--)
7             if (a[j] < a[j-1])
8                 swap(&a[j], &a[j-1]);
9

选择排序

选择排序和冒泡排序有些类似,但交换次数较少,每趟记住位置 i 应放置的最小值并进行交换,最多交换 N1次,但比较次数仍为 Θ(N2) 。

 1 void selection_sort(tp a[], int n)
 2 {
 3     int i, j, min;
 4 
 5     for (i = 0; i < n - 1; i++) {
 6         min = i;
 7         for (j = n - 1; j > i; j--)
 8             if (a[j] < a[min])
 9                 min = j;
10         swap(&a[i], &a[min]);
11     }
12 }

希尔排序

  希尔排序通过比较相距一定间隔的元素来工作,每趟比较所用的间隔随算法的进行而减小,直到最后比较相邻元素,因此也称为缩小增量排序。它使用一个增量序列 h1,h2,...,ht ,只要 h1=1 ,任何增量序列都可行。使用增量 hk排序后,对每个 i 都有 A[i]A[i+hk] ,即所有相隔 hk 的元素都被排序,称为是 hk -排序的。希尔排序的一个性质是 hk -排序的数组保持它的 hk -排序性。

  一趟 hk -排序的作用就是对 hk 个独立的子数组执行一次插入排序。增量序列的一个常见选择是希尔增量, ht=N/2 和 hk=hk+1/2 ,但这并不算是一个好的选择。

 1 void shell_sort(tp a[], int n)
 2 {
 3     int i, j, incr;
 4     tp t;
 5 
 6     for (incr = n / 2; incr > 0; incr /= 2)
 7         for (i = incr; i < n; i++) {
 8             t = a[i];
 9             for (j = i; j >= incr && a[j-incr] > t; j -= incr)
10                 a[j] = a[j-incr];
11             a[j] = t;
12         }
13 }

使用希尔增量时希尔排序的最坏运行时间为 Θ(N2) 。

  希尔增量因为未必互素,所以较小的增量可能影响很小。Hibbard提出了另一个增量序列, 1,3,...,2k1 ,可以得到更好的结果。使用Hibbard增量的希尔排序的最坏运行时间为 Θ(N3/2) ,模拟显示平均运行时间为 O(N5/4) 。Sedgewick提出了几种更好的序列,其中最好的是 1,5,19,41,109,... ,序列中的项为 94i92i+1 或 4i32i+1 。 Θ(N3/2) 的界适用于广泛的增量序列。

  希尔排序编程简单,性能也可以接受,因此是一个常用的排序算法。

堆排序

堆排序利用堆数据结构进行排序,它可以达到 O(NlogN) 的运行时间。堆排序使用了一个技巧,构建一个最大堆,然后删除最大元素并把它放到堆最后新空出来的位置上,这样就避免了使用额外的数组。最后得到元素由小到大的排序。有一个创建堆的过程,其时间复杂度o(n)。具体可参考前面的一篇博客“堆”,里面的应用部分就是写的堆排序。

static void perc_down(tp a[], int i, int n)
{
    int j;
    tp t;

    for (t = a[i]; i*2 + 1 < n; i = j) {
        j = i * 2 + 1;
        if (j != n - 1 && a[j+1] > a[j])
            j++;
        if (t < a[j])
            a[i] = a[j];
        else
            break;
    }
    a[i] = t;
}

void heap_sort(tp a[], int n)
{
    int i;

    for (i = n / 2; i >= 0; i--)    /* build max-heap */
        perc_down(a, i, n);
    for (i = n - 1; i > 0; i--) {
        swap(&a[0], &a[i]);
        perc_down(a, 0, i);
    }
}

注意这里的堆不使用标记,数据从位置0开始,因此位置 i 的元素的左右儿子分别在位置 2i+1 和 2i+2 。

堆排序是一种非常稳定的算法,它最多使用 2NlogNO(N) 次比较,最少使用 NlogNO(N) 次比较。在实践中,堆排序要慢于使用Sedgewick增量序列的希尔排序。

归并排序

归并排序以 O(NlogN) 的最坏运行时间运行,所使用的比较次数几乎是最优的。归并排序中的基本操作是合并两个已排序的数组,这是线性时间的。排序时,递归地将前半部分和后半部分的数据进行归并排序,得到的排序后的两部分再用上述的基本操作合并。

static void merge(tp a[], tp ta[], int l, int r, int rend)
{
    int lend, t, i;

    lend = r - 1;
    t = i = l;
    while (l <= lend && r <= rend) {
        if (a[l] <= a[r])
            ta[t++] = a[l++];
        else
            ta[t++] = a[r++];
    }
    while (l <= lend)
        ta[t++] = a[l++];
    while (r <= rend)
        ta[t++] = a[r++];
    for (; i <= rend; i++)
        a[i] = ta[i];
}

static void merge_sort_rec(tp a[], tp ta[], int l, int r)
{
    int m;

    if (l < r) {
        m = (l + r) / 2;
        merge_sort_rec(a, ta, l, m);
        merge_sort_rec(a, ta, m+1, r);
        merge(a, ta, l, m+1, r);
    }
}

void merge_sort(tp a[], int n)
{
    tp *ta;

    ta = malloc(sizeof(tp)*n);
    if (ta == NULL)
        err_quit("malloc error.");
    merge_sort_rec(a, ta, 0, n-1);
    free(ta);
}

归并排序的运行时间满足:

T(1)=1
T(N)=2T(N/2)+N

可以解得

T(N)=NlogN+N=O(NlogN)

归并排序的缺点在于使用了附加存储,并且数据在数组间复制也减慢了排序的速度。可以通过在递归的交替层次交换数组和临时数组的角色来减少复制。但通常内部排序更多地选择快速排序,合并则常常用于外部排序算法。

快速排序

  1、快速排序是已知的实际中最快的算法,平均运行时间为 O(NlogN) 。比较快的原因是它具有非常精炼和高度优化的内部循环。最坏时为 O(N2) ,不过一般可以避免。快速排序也是一种分治的递归算法,对数组 S 的排序可以分为以下四步:

  • 如果 S 中元素个数为0或1,则返回。
  • 取 S 中任一元素 v ,称为枢轴。
  • 将 S{v} 分成两个不相交的集合: S1={xS{v}|xv} 和 S2={xS{v}|xv} 。
  • 返回 {quicksort(S1),v,quicksort(S2)} 。

  2、枢纽元的选取:枢纽元素的选择会很大地影响快速排序的性能。一种安全的选择是随机地选取。枢轴的最好选择是数组的中值,但这很困难,一般选择左、中、右三个位置的中值作为枢轴。

  3、分割策略:分割时,将枢纽元素和最右元素交换位置,然后对枢轴元素前面的元素从两边遍历,跳过已经正确划分的元素,交换划分相反的元素,直到遍历位置交错,再将最右的枢轴元素交换回中间。对枢轴分割的左右两部分递归地执行快速排序。需要注意的是对于相等的元素,也应该停止遍历并交换,这样可以保证每趟遍历后左右两部分的大小接近相等,达到分治的作用。

  对于小数组( N20 ),快速排序不如插入排序好,一般对小数组用插入排序代替快速排序,比较好的截止范围是 N=10 。

 1 static tp median3(tp a[], int l, int r)
 2 {
 3     int m = (l + r) / 2;
 4 
 5     if (a[l] > a[m])
 6         swap(&a[l], &a[m]);
 7     if (a[l] > a[r])
 8         swap(&a[l], &a[r]);
 9     if (a[m] > a[r])
10         swap(&a[m], &a[r]);
11     swap(&a[m], &a[r-1]);
12     return a[r-1];
13 }
14 
15 static void quick_sort_rec(tp a[], int l, int r)
16 {
17     tp pivot;
18     int i, j;
19 
20     if (l + 3 <= r) {
21         pivot = median3(a, l, r);
22         i = l;
23         j = r - 1;
24         while (1) {
25             while (a[++i] < pivot);
26             while (a[--j] > pivot);
27             if (i < j)
28                 swap(&a[i], &a[j]);
29             else
30                 break;
31         }
32         swap(&a[i], &a[r-1]);
33         quick_sort_rec(a, l, i-1);
34         quick_sort_rec(a, i+1, r);
35     }
36     else
37         insertion_sort(a+l, r-l+1);
38 }
39 
40 void quick_sort(tp a[], int n)
41 {
42     quick_sort_rec(a, 0, n-1);
43 }

1、快速排序满足:

T(N)=T(i)+T(Ni1)+cN

其中, i=|S1| 为 S1 中的元素个数。

2、最坏情况下,枢轴始终是最小元素, i=0 ,有

T(N)=T(N1)+cN,N>1

可得 T(N)=O(N2) 。

3、最好情况时,枢轴正好位于中间,近似有

T(N)=2T(N/2)+cN

可得 T(N)=O(NlogN) 。

4、对于平均情况,假设对于 S1 ,每个大小都是可能的,则均有概率 1/N 。因此, T(i) 和 T(Ni1) 的平均值均为 (1/N)N1j=0T(j) ,有

T(N)=2N⎡⎣j=0N1T(j)⎤⎦+cN

可得 T(N)=O(NlogN) 。

快速选择

  对于查找第 k 个最大/最小元素的问题,可以使用优先队列以 O(N+klogN) 的时间完成,对中值,有 O(NlogN) 。

采用快速选择,可以得到一个更好的时间界。快速选择和快速排序原理相同,区别是它只使用一个递归。快速选择的最坏运行时间和快速排序的相同,为 O(N2) ,平均运行时间为 O(N) 。

static void quick_select_rec(tp a[], int k, int l, int r)
{
    tp pivot;
    int i, j;

    if (l + 3 <= r) {
        pivot = median3(a, l, r);
        i = l;
        j = r - 1;
        while (1) {
            while (a[++i] < pivot);
            while (a[--j] > pivot);
            if (i < j)
                swap(&a[i], &a[j]);
            else
                break;
        }
        swap(&a[i], &a[r-1]);
        if (k <= i)
            quick_select_rec(a, k, l, i-1);
        else if (k > i + 1)
            quick_select_rec(a, k, i+1, r);
    }
    else
        insertion_sort(a+l, r-l+1);
}

void quick_select(tp a[], int k, int n)
{
    quick_select_rec(a, k, 0, n-1);
}
小结:可以多个排序算法混合使用,对于大数据,比如先快排,然后进行插入排序!

二、排序的一般下界

  可以证明,任何只用到比较的排序算法在最坏情况下需要 Ω(NlogN) 次比较,还可以进一步证明在平均情况下也要进行 Ω(NlogN) 次比较。

  可以用决策树来证明。决策树是用于证明下界的抽象概念,这里它是一棵二叉树,每个节点表示元素之间的一组可能的排序,树的边表示比较的结果。只使用比较的排序算法都可以用决策树表示,算法所使用的比较次数等于最深的树叶的深度。

  用数学归纳法可以证明,深度为 d 的二叉树最多有 2d 个树叶。因此具有 L 个树叶的二叉树的深度至少为 logN。对 N 个元素排序的决策树有 N! 个树叶,因此只使用比较的排序算法在最坏情况下至少需要 log(N!) 次比较。计算得 log(N!)=Ω(NlogN) ,这样即得证。排序算法的平均运行时间的证明类似。

  可以推广得到一个一般定理:如果存在 N 种不同的情况要区分,问题是Y/N的形式,则通过任何算法求解该问题总需要 logN 个问题。

  某些特殊情况下以线性时间进行排序是可能的,一个例子是桶式排序。桶式排序需要一些额外的信息,输入数据必须由小于 M 的正整数组成。使用一个大小为 M 的数组,初始化为全0,这样就构成了 M 个桶,读入数据,按数组索引增加对应位置的值,输入结束后,遍历数组就得到排序后的序列。它的运行时间为 O(M+N) ,如果 M 为 O(N) ,则运行时间即为 O(N) 。注意桶式排序利用了输入数据的额外信息,并不属于前面的下界模型,因此并不矛盾。实际中也应该充分利用数据的额外信息。

外部排序

  输入数据太大,内存装不下只能外部排序。大部分内部排序都利用了内存直接寻址,但如果输入数据在磁盘上,I/O读取会造成实际上效率很低。外部排序对设备的依赖要严重得多。以磁带为例,可以以正反两个方向进行有效访问。假设至少有三个磁带来进行排序工作。外部排序的基础是归并排序。

2路合并

这是最简单的情况。设有四个磁带 a1,a2,b1,b2 , a 和 b 要么用作输入,要么用作输出。设内存一次可以读入并排序 M 个记录。假设数据最初在 a1 上,一次读出 M 个记录,进行排序得到顺串,将顺串交替地写入到 b1 和 b2上,这样完成了初始顺串的构造。然后倒回磁带,从 b1 和 b2 上读出各自的第一个顺串并合并,写入到 a1 ,再读出各自第二个顺串并合并,写入到 a2 ,交替进行直到合并完成。再倒回磁带,读入顺串并合并,如此重复,最后就得到了排序后的记录。

该算法需要 log(N/M) 趟处理,再加上一趟初始顺串的构造。

多路合并

如果有更多的磁带,可以扩充2路合并为多路合并。 k 路合并和2路合并的区别是需要选择 k 个记录中的最小记录,可以通过堆来完成,每次执行删除最小值操作,然后将相应的磁带向前推进,如果该磁带的当前顺串未读完,就将新记录加入堆中。

完成初始顺串的构造后,使用 k 路合并需要的趟数为 logk(N/M) 。

多项合并

k 合并需要 2k 个磁带,这有时很不方便。 k+1 个磁带也能完成排序工作。以3个磁带为例。在初始构造时,在另外两个磁带上写入数量不等的顺串,采用Fibonacci数进行分配,如果生成的顺串数不能满足Fibonacci数,则添加一些哑的顺串作为填充。这样每次合并两个磁带上的顺串到第三个磁带上,必然有一个磁带剩余了顺串,而它的数量又和第三个磁带上的顺串数构成Fibonacci数,这样就能完成合并了。

k 路合并需要使用 k 阶Fibonacci数来分配顺串。

替换选择

关于顺串的生成,有一个替换选择的方法。读入到内存的 M 个记录被放入堆中,执行删除最小值操作把最小的记录写到输出磁带上,这时再读入下一个记录,如果比刚刚写的记录大,则加入堆中,否则不放入当前的顺串,而放在堆后的死区中,当前顺串构造完之后,死区中的记录放入下一个顺串。

替换选择平均会产生长度为 2M 的顺串,这样初始时总的顺串数减少了一半。对于输入数据部分排序的情况,替换选择会产生少数非常长的顺串,对外部排序非常有利。

posted @ 2018-06-17 17:37  秋雨声  阅读(244)  评论(0编辑  收藏  举报