算法-排序

时间复杂度

这里写图片描述

解决问题的通常步骤

  • 完整而详细地定义问题,找出解决问题所必需的基本抽象操作并定义一份API
  • 简洁地实现一种初级算法,给出一个精心组织的开发用例并使用时机数作为输入
  • 当前算法所能解决问题的最大规模达不到期望时决定改进还是放弃
  • 逐步改进实现,通过经验性分析或数学分析验证改进后的效果
  • 用更高层次的抽象表示数据结构或算法来设计更高级的改进版本
  • 如果可能尽量为最坏情况下的性能提供保证,但在处理普通数据时也要有良好的性能
  • 开发更有效算法固然很好,但也要在所花的成本和带来的效益之间做好权衡

排序

API模版

public class SortTemplate{
    public static void sort(Comparable[] a){
        //具体算法
    }
    private static boolean less(Comparable v,Comparable w){
        return v.compareTo(w)<0;
    }
    private static void exch(Comparable[] a,int i,int j){
        Comparable t = a[i];
        a[i] = a[j];
        a[j] = t;
    }
    private static void show(Comparable[] a){
        for (int i = 0; i < a.length; i++) {
            System.out.print(a[i]+" ");
        }
        System.out.println();
    }
    public static boolean isSorted(Comparable[] a){
        boolean isAsc = true;
        for (int i = 0; i < a.length; i++) {
            if(i == 0)
                isAsc = less(a[i],a[i+1]);
            else if(isAsc != less(a[i],a[i+1]))
                return false;
        }
        return true;
    }
}

参照此模版写出的排序算法适用于任意实现了Comparable接口的数据类型。

排序成本模型

在研究算法时,我们需要计算比较和交换的数量。对于不交换元素的算法,我们会计算访问数组的次数。以及需要考虑内存的使用情况。

选择排序

找到数组中最小的那个元素,将它和第一个元素交换,其次在剩下的元素中找到最小的,和第二个元素交换。如此往复。

分析

交换的总次数为N(数组元素个数),所以算法的效率取决于比较的次数。
比较的次数为N(N-1)/2~N^2/2(可以把排序的轨迹列成图表,可以很容易证明)

评价

数据移动是最少的,运行时间和输入无关。

插入排序

从第一个元素开始到最后一个元素,将当前元素插入到之前已经有序的数组元素之间,为了腾出空间要将要插入位置之后的所有元素移动一位。

分析

与选择排序不同,这种方法的效率受数组的初始状态影响很大,初始状态有序度越高插入排序越快。平均情况下插入排序需要~N^2/4次比较和~N^2/4次交换(对于每次插入操作比较操作总比插入操作多一次),可由图表证明。
这里写图片描述
对角线以下的元素,最好情况下都不需要移动,最坏情况下都需要移动,平均情况即为对角线以下的一半。
代码

    public class Insertion {
        public static void sort(Comparable[] a) {
            //升序
            for(int i = 1; i < a.length ; i++)
                for (int j = i; j > 0 && less(a[j] , a[j-1]); j--)
                    exch(a,j,j-1);
        }
        //略
    }

改进:在内循环中将较大的元素都向右移动(一次赋值),而不总是交换两个元素(三次赋值)。如下:

    public class Insertion {
        public static void sort(Comparable[] a) {
            //升序
            for(int i = 1; i < a.length ; i++){
                int j;
                for (j = i-1; j >= 0 && less(a[i] , a[j]); j--)
                    a[j+1] = a[j];
                a[j+1]=a[i];
            }

        }
        //略
    }
评价

对于部分有序的数组十分高效,适合小规模数组。

希尔排序

希尔排序是对插入排序的改进,它克服了插入排序中元素一次只能移动一位的问题。

分析

在最坏情况下希尔排序的比较次数和N^(3/2)成正比。
排序轨迹图
这里写图片描述
代码

    public class Shell {
        public static void sort(Comparable[] a) {
            //升序
            int N = a.length;
            int h = 1;
            while (h < N/3) h = 3*h+1;//1,4,13,40,121,364,1093
            while (h >= 1){
                //将数组变为h有序
                for (int i = h; i < N; i++) {
                    //将a[i]插入到a[i-h],a[i-2*h],a[i-3*h]..
                    for (int j = i; j >= h && less(a[j] , a[j-h]); j -= h){
                        exch(a,j,j-h);
                    }
                }
                h /= 3;
            }
        }
        //略
    }
评价

处理中等大小的数组时运行的时间还是可以接受的,它不需要额外的内存空间,代码量也很想,相对复杂程度不高,在处理中小型问题时可以使用。

归并排序

归并:将两个有序数组归并成一个更大的有序数组
归并算法:将原数组分成两段分别排序,然后将结果归并起来(递归的)。
它可以将任意长度N的数组排序所需时间和NlogN成正比
但它所需的额外空间和N成正比。
基础的归并代码:

        public static void merge(Comparable[] a,int lo,int mid,int hi) {
            int i = lo,j=mid+1;
            for(int k = lo; k <= hi; k++)
                aux[k] = a[k];
            for (int k = lo; k <= hi; k++) {//归并回到a[lo..hi]
                if(i > mid)                     a[k] = aux[j++];
                else if(j > hi)                 a[k] = aux[i++];
                else if(less(aux[j] , aux[i]))  a[k] = aux[j++];
                else                            a[k] = aux[i++];

            }

        }
自顶向下的归并排序

当真正的要将一个乱序的数组进行排序的时候需要递归的调用上面的方法,即自顶向下的归并排序

    public static class Merge {
        public static void sort(Comparable[] a){
            aux = new Comparable[a.length];
            sort(a,0,a.length-1);
        }

        private static void sort(Comparable[] a, int lo, int hi) {
                if(hi <= lo) return;
                int mid = lo + (hi - lo)/2;
                sort(a,lo,mid);
                sort(a,mid+1,hi);
                merge(a,lo,mid,hi);
        }

        private static Comparable[] aux ;
        public static void merge(Comparable[] a,int lo,int mid,int hi) {
            int i = lo,j=mid+1;
            for (int k = lo; k <= hi; k++) {
                aux[k] = a[k];
            }//复制数组
            for (int k = lo; k <= hi; k++) {//归并回到a[lo..hi]
                if(i > mid)                     a[k] = aux[j++];
                else if(j > hi)                 a[k] = aux[i++];
                else if(less(aux[j] , aux[i]))  a[k] = aux[j++];
                else                            a[k] = aux[i++];

            }

        }
        //略
    }

自顶向下的归并排序数组的依赖树如下
依赖树
在最坏情况下一次归并的时间复杂度是线性的,上图中树的每一层最坏情况下都是线性的。最终的时间复杂度就是MNLgN(M是常数)。数学证明可知对于以上算法而言M=6。
递归中小数组的改进:在处理小数组时递归的方法调用过于频繁,可以使用插入排序处理小规模数组的排序。可以提升以上算法的性能。

自底向上的归并排序

自顶向下的归并排序实际上是从上到下的将数组拆解再从下到上的将数组归并的过长,实际上我们可以直接从数组的底部开始往上进行归并,即我们可以把所要处理的一个大数组看成是长度为1的很多“小数组“组成的。然后两两归并,四四归并,八八归并。。。知道最后只剩下一个数组。在归并过程中最后一个数组可能比其他的数组长度小。
代码如下:

    public class MergeBU{
        private static Comparable[] aux;
        public static void sort(Comparable[] a){
            int N = a.length;
            aux = new Comparable[N];
            for(int sz = 1; sz < N ;sz = sz + sz){//sz子数组大小
                for(int lo = 0; lo < N-sz ;lo += sz + sz)//lo:子数组索引
                    merge(a,lo,lo+sz-1,Math.min(lo+sz+sz-1,N-1));//merge方法同上
            }
        }
    }

自底向上的归并排序比较适合用链表组织数据结构,在排序时只要改变链表的连接就能将链表原地排序。

基于比较的排序算法总结

在设计一个排序算法之前,我们应该首先考虑这一类的排序算法是否有它的性能的上限,如果有的话他的性能上限是什么。这很重要,因为如果我们已经知道某种方法根本无法达到我们的预期,就不用再去做尝试,而去寻找其他的途径解决问题。

比较树

如图为一个N=3时的比较树。比较树的内部节点格式为“i:j“,叶子节点为排好序的数组元素的顺序,“i:j“表示a[i]和a[j]的一次比较操作,左子树表示a[i]小于a[j]时进行的其他比较,右子树表示a[i]大于a[j]时进行的其他比较。
比较树
比较树的叶子节点的数量至少是N个元素不同排列的可能行,即N!
比较树的高度即为算法比较次数的最坏情况。一个高度为h的二叉树最多只可能有2^h个叶子节点,拥有2^h个叶子节点的二叉树为完美平衡的,或称完全树。
故,任意基于比较的排序算法都应该对应这一颗高度为h的比较树,叶子节点的数量满足:N!<=叶子节点数量<=2^h。对该不等式取对数可得lgN!<=h根据斯特灵公式对结成函数的近似可得lgN!~NlgN
到这里我们得出了结论:任意基于比较的排序算法所需的比较次数都是~NlgN

快速排序

基本概念

快速排序和归并排序都属于分治的排序算法。它们都是将数组一分为二并且进行递归。不同的是归并排序的递归调用发生在处理整个数组之前,而快速排序的递归调用发生在处理整个数组之后(递归的使切分点两边的子数组都有序时整个数组就有序了)。
快速排序递归:

    public class Quick{
        public static void sort(Comparable[] a){
            randomArray(a);
            sort(a,0,a.length - 1);
        }
        public static void sort(Comparable[] a,int lo,int hi){
            if(hi <= lo) return;
            int j = partition(a,lo,hi);//切分
            sort(a,lo,j-1);//将左半部分a[lo .. j-1]排序
            sort(a,j+1,hi);//将右半部分a[j+1 .. hi]排序
        }
    }

该算法的关键在于切分,这个过程使得数组满足下面三个条件:

  • 对于某个j,a[j]已经排定;
  • a[lo]到a[j-1]中的所有元素都不大于a[j];
  • a[j+1]到a[hi]中的所有元素都不小于a[j].

快速排序就是通过递归的调用切分来排序的。
每次递归只要保证左子数组,切分元素,右子数组是有序的。
通常的做法是先随意的取a[lo]作为切分元素,即那个将会被排定的元素,然后我们从数组的左端开始向右扫描直到找到一个大于等于它的元素,再从数组的右端开始向左扫描直到找到一个小于等于它的元素。这两个元素显然是没有排定的,因此我们交换他们的位置。如此继续,我们就可以保证左指针i的左侧元素都不大于切分元素,右指针j的右侧元素都不小于切分元素。当这两个指针”相遇”时,我们只需要将切分元素a[lo]和左子数组最右侧的元素(a[j])交换然后返回j即可。
代码如下

   private static int partition(Comparable[] a,int lo,int hi){
        //将数组切分为a[lo .. i-1], a[i], a[i+1, .. hi]
        int i = lo, j = hi + 1; //左右扫描指针
        Comparable v = a[lo];   //切分元素
        while (true){
            //扫描左右,检查扫描是否结束并交换元素
            //可以先将最大元素选出放在最右侧当作哨兵,这样可以去掉右侧的边界条件
            while (less(a[++i],v)) if(j == hi) break;
            while (less(v,a[--j]));
            if (i >= j) break;
            exch(a,i,j);
        }
        exch(a,lo,j);
        return j;
    }
注意事项

快速排序主要缺点是非常脆弱,在实现时一不小心就会使性能大大下降,下面的几条原则可以作为测试参考。

  • 原地切分,使用辅助数组会使性能下降
  • 别越界,小心别让指针跑出数组边界
  • 保持随机,有利于预测算法的运行时间
  • 终止循环,注意循环终止条件
  • 处理切分元素值有重复的情况,尽量避免交换等值元素
  • 终止递归,保证递归能够在任何情况下正确终止
性能特点
  • 内循环短小,归并排序和希尔排序一般都比快速排序慢,因为他们在内循环中会移动数据。
  • 比较次数少,但这最终还是依赖需切分的效果。最好就是每次切分都正好是数组的中间值,这样递归的树就是一个完美平衡的二叉树。
性能参数
  • 快速排序平均需要~2NlnN次比较(1/6次交换)
  • 最多需要约N^2/2次比较(第一次从最小元素切分,第二次从次小元素切分…每次无法排定两边数组),但是随机打乱会预防这种情况
性能改进
  • 切换到插入排序,小数组时切换到插入排序(5~15个元素)
  • 三取样切分,取子数组中位数切分,通常取3个元素的中间值效果最好。
  • 熵最优的排序,当数组中存在大量重复元素时,可把数组切分中大于,等于,小于切分元素的三个数组(三向切分),这样甚至可以将线性对数级别的性能提高到线性级别。

三向切分的快速排序

如上所述,熵最优排序的实现:

public class Quick3way{
    private static void sort(Comparable[] a,int lo,int hi){
        if(hi < lo) return;
        int lt = lo,i = lo + 1,gt = hi;
        Comparable v = a[lo];
        while (i <= gt){
            int cmp = a[i].compareTo(v);
            if     (cmp < 0) exch(a,lt++,i++);
            else if(cmp > 0) exch(a,i,gt--);
            else             i++;
        }//现在a[lo .. lt-1] < v = a[lt .. gt] < a[gt+1 .. hi]成立
        sort(a,lo,lt -1);
        sort(a,gt+1,hi);
    }
}

这段代码的切分能将与切分元素相等的元素归位,这样就不会进入下层递归,对于存在大量重复元素的数组这种方法比标准快速排序的效率高得多。

性能参数

对于大小为N的数组,三向切分的快速排序需要~(2ln2)NH次比较(H为主键值出现频率定义的香农信息量)。(香农信息量:信息的不稳定度)当所有的主键均不重复时H=lgN。

经验结论

要将大量重复元素的数组排序的用例很常见,经过精心调优的快速排序在绝大多数计算机上的绝大多数应用中都会比其他基于比较的排序算法更快。因为快速排序的内循环中指令很少,它的运行时间的增长数量级为~cNlgN,这个c比其他线性对数级别的排序算法的相应常数都要小。在使用三相切分之后,对于某些输入会变成线性级别。但是也有例外,如果追求稳定性而空间又不是问题,那归并排序可能是最好的。排序的选择应该结合应用场景,对各方面的因素进行综合考虑。

在java中的应用

见Arrays.sort()

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
排序是计算机科学中常见的操作,它将一组元素按照特定的顺序重新排列。排序算法的目标通常是将元素按照升序或降序排列。 常见的排序算法有很多种,每种算法都有不同的时间复杂度和空间复杂度。以下是几种常见的排序算法: 1. 冒泡排序(Bubble Sort):比较相邻的两个元素,如果顺序不正确就交换位置,每次遍历将一个最大(最小)的元素移到最后(最前)。时间复杂度为O(n^2)。 2. 插入排序(Insertion Sort):将数组分为已排序和未排序两部分,每次从未排序部分取出一个元素,插入已排序部分的适当位置。时间复杂度为O(n^2)。 3. 选择排序(Selection Sort):每次从未排序部分选择一个最小(最大)的元素放到已排序部分的末尾。时间复杂度为O(n^2)。 4. 快速排序(Quick Sort):选取一个基准元素,将数组划分为两个子数组,小于基准元素的放在左边,大于基准元素的放在右边,然后对子数组进行递归排序。时间复杂度平均情况下为O(nlogn),最坏情况下为O(n^2)。 5. 归并排序(Merge Sort):将数组递归分成两个子数组,然后对子数组进行排序,最后将两个已排序的子数组合并成一个有序数组。时间复杂度为O(nlogn)。 6. 堆排序(Heap Sort):将数组构建成一个最大(最小)堆,每次从堆顶取出最大(最小)元素放到已排序部分的末尾,然后调整堆使其满足堆的性质。时间复杂度为O(nlogn)。 这里只介绍了几种常见的排序算法,每种算法都有其适用的场景和优缺点。在实际应用中,根据数据规模和性能要求选择合适的排序算法非常重要。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值