zoukankan      html  css  js  c++  java
  • 排序总结

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

    本篇总结数组中元素的排序问题。根据元素的规模,通常的排序可以在内存中完成,规模太大而必须在磁盘等存储上完成的排序称为外排序。任何通用的排序算法都需要 Ω(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 的顺串,这样初始时总的顺串数减少了一半。对于输入数据部分排序的情况,替换选择会产生少数非常长的顺串,对外部排序非常有利。

  • 相关阅读:
    树分治
    实现自己的shell--MIT xv6 shell
    逆元打表
    Linux fork()函数
    三分:求解凸函数极值
    anti-nim 游戏
    nginx配置文件详解
    nginx之别名、location使用
    shell脚本编程基础知识点
    linux任务计划
  • 原文地址:https://www.cnblogs.com/huangfuyuan/p/9193322.html
Copyright © 2011-2022 走看看