算法(读书笔记):2.排序

//排序算法类的模板
public class Example {
    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;iout.print(a[i]+"");
            System.out.println();
        }
    }
    public static boolean isSorted(Comparable[] a){
        //测试数组元素是否有序
        for (int i = 1; i < a.length; i++) {
            if(less(a[i],a[i-1])){
                return false;
            }
        }
        return true;
    }


    public static void main(String[] args) throws Exception {
        //从标准输入读取字符串,将它们排序并输出
        BufferedReader reader;
        StringTokenizer strToken=null;
        Integer[] str;
        reader = new BufferedReader(new InputStreamReader(System.in));
        strToken = new StringTokenizer(reader.readLine());
        str = new Integer[strToken.countTokens()];
        for (int i = 0; i < str.length; i++) {
             str[i] = Integer.parseInt(strToken.nextToken());
            }
        show(str);
        sort(str);
        //断言
        assert isSorted(str);
        show(str);
        }
}

我们的排序方法适用于任意实现了Comparable接口的数据类型。
同时,我们还在代码中增添了assert断言进行是否正确排序的验证。

运行时间相关:
排序成本模型:在研究排序算法时,我们需要计算比较交换的数量。对于不交换元素的算法,我们会计算访问数组的次数。

额外的内存使用:
排序算法的额外内存开销和运行时间是同等重要的。排序算法以此分类:1.除了函数调用所需的栈和固定数目的实例变量之外无需额外内存的原地排序算法,2.需要额外内存来存储另一份数组副本的其他排序算法。

排序算法

1.选择排序

思想:首先,找到数组中最小的那个元素,其次,将它和数组的第一个元素交换位置(如果比它小的话)。再次,在剩下元素中找到最小元素,将它和第二个元素交换。如此往复,直到将整个数组排好序。

命题A:对于长度为N的数组,选择排序需要大约N平方的1/2次比较和N次交换。

特点:
1.运行时间和输入无关。为了找出最小元素而扫描一遍数组并不能为下一遍扫描提供什么信息。而其他一些算法会更加利用输入的初始状态。(比如已经有序的数组)
2.数据移动是最少的。排序用了N次交换,交换次数和数组大小是线性关系。

选择排序:

public static void sort(Comparable[] a){
        //选择排序
        int N = a.length;
        for (int i = 0; i < N; i++) {
            int min = i;
            for (int j = i+1; j < N; j++) {
                if(less(a[j],a[min])){
                min = j;    
                }
            }
            exch(a,i,min);
        }
    }

2.插入排序

与选择排序不同的是,插入排序所需的时间取决于输入中元素的初始顺序
插入排序对于实际应用中的某些类型的非随机数组很有效。对于一个有序数组进行排序时,对比与比较排序,插入排序能够立即发现每个元素都已经在合适的位置之上,它的运行时间也是线性的。

插入排序

//插入排序
        int N = a.length;
        for (int i = 1; i < N; i++) {
            for (int j = i; j>0&&less(a[j],a[j-1]); j--) {
                exch(a,j,j-1);
            }
        }

我们考虑一般的情况是部分有序的数组。如果数组中倒置的数量小于数组大小的某个倍数,那么我们说这个数组是部分有序的。

几种典型的部分有序的数组
1.数组中每个元素距离它的最终位置都不远
2.一个有序的大数组接一个小数组
3.数组中只有几个元素的位置不正确。
插入排序对于这样的数组很有效,而选择排序则不然。事实上,当倒置的数量很少时,插入排序很可能比本章中(基于比较的排序中)所有算法都要快

命题C:插入排序需要的交换操作和数组中倒置的数量相同,需要的比较次数大于等于倒置的数量,小于等于倒置的数量加上数组的大小再减一。

自我感悟:我们研究算法时往往研究其数据输入为最差情况下的表现,之所以这样是因为能够对算法的最差运行时间得出一个评估和保证,不会高于某个值。但是,对于具体的输入数据,如果能够掌握他们输入的部分信息(比如部分有序),那么就应该选择合适的算法来使用。不要神话时间复杂度这个东东!

插入排序不会访问索引右侧的的元素,选择排序不会访问索引左侧的元素。

比较两种排序
比较两种算法的常用的科学的步骤:
1.实现并调试他们
2.分析他们的基本性质
3.对他们的相对性能做出猜想
4.用实验验证我们的猜想。

性质D:对于随机排序的无重复主键的数组,插入排序和选择排序的运行时间是平方级别的,两者之间之比应该是一个较小的常数。

3.希尔排序

希尔排序是改进了的插入排序的算法。希尔排序为了加快速度简单地改进了插入排序,交换不相邻的元素以及对数组的局部进行排序,并最终用插入排序将局部有序的数组排序。

希尔排序的思想是使数据中任意间隔为h的元素都是有序的。这样的数组被称为h有序数组
换句话说,一个h有序数组就是h个互相独立的有序数组编织在一起的一个数组。
在进行排序时,如果h很大,我们就能将元素移动到很远的地方,为实现更小的h有序创造方便。用这种方式,对于任意以1结尾的h序列,我们都能够将数组排序。

希尔排序更高效的原因是它权衡了子数组的规模有序性。排序之初,各个子数组都很短,排序之后子数组都是部分有序的,这两种情况都适合插入排序。

其实,希尔排序内部就是一个插入排序,只是他动态的更新h的值而已。

希尔排序:

//希尔排序
        int N = a.length;
        int h = 1;
        while(h3){
            h=3*h+1;
        }
        while(h>=1){
            for (int i = h; i < N; i++) {
                for (int j = i; j>=h&&less(a[j],a[j-h]);j=j-h) {
                    exch(a,j,j-h);
                }
            }
            //动态更新h
            h=h/3;
        }

可以看出,希尔排序比插入排序和选择排序要快得多,并且数组越大,优势越大。

重要理念:通过提升速度来解决其他方式无法解决的问题是研究算法的设计和性能的主要原因之一。

性质E:使用递增序列1,4,13,40,……的希尔序列所需的比较次数不会超过N的若干倍乘以递增序列的长度。

课后问答:
问:为什么有这么多排序算法?
答:原因之一是,许多排序算法的性能都和输入有很大的关系,因此不同的算法适用于不同的应用场景中的不同输入。例如,对于部分有序和小规模的数组应该选择插入排序。其他限制条件,例如空间重复的主键,也都是需要考虑的因素。

4.归并排序(自顶向下)

归并排序最吸引人的性质是,能够保证将任意长度为N的数组排序所需时间和NlogN成正比。
实现归并的一种直截了当的方法是将两个不同的有序数组归并到第三个数组中,两个数组中的元素应该都实现了Comparable接口。

归并排序:

//归并排序
        aux = new Comparable[a.length];
        int N = a.length;
        sort(a,0,N-1);

        private static void sort(Comparable[] a,int lo,int hi){
        //将数组a[lo...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){
        //将a[lo...mid]和a[mid+1...hi]归并
        int i = lo,j =mid+1;
        for (int k = lo; k <=hi; k++) {
            aux[k] = a[k];//将a的信息复制到aux中
        }
        for (int k = lo; k <=hi; k++) {
            if(i>mid){
                //i>mid说明左侧元素全部取完了
                a[k] = aux[j++];
            }else if(j>hi){
                //j>hi说明右侧元素全部取完了
                a[k] = aux[i++];
            }else if(less(aux[j],aux[i])){
                a[k] = aux[j++];
            }else{
                a[k] = aux[i++];
            }
        }

    }

其中,利用了aux这个辅助数组

实际上,真正实现排序的功能在merge方法中。sort只是形式上的调用而已。这也是为什么自下而上的归并排序中只有merge的原因。因为真正起作用的就是merge这个函数。

sort方法的作用其实在于安排多次merge方法调用的正确顺序。

命题F:对于长度为N的任意数组,自顶向下的归并排序需要1/2NlogN至NlogN次比较。
命题G:对于长度为N的任意数组,自顶向下的归并排序最多需要访问数组6NlogN次。

通过改进归并排序缩短运行时间:
1.对小规模子数组使用插入排序
用不同的方法处理小规模问题能改进大多数递归算法的性能,因为递归会使小规模问题中方法的调用过于频繁。所以改进对他们的处理方法就能改进整个算法。
2.测试数组是否已经有序
我们可以添加一个判断条件了,如果a[mid]小于等于a[mid+1],我们就认为数组已经是有序的并跳过merge方法。这个改动不影响排序的递归调用,但是任意有序的子数组算法的运行时间就变成线性的了。
方法变为:

private static void sort(Comparable[] a,int lo,int hi){
        //将数组a[lo...hi]排序
        if(hi<=lo){
            return;
        }
        int mid = lo+(hi-lo)/2;
        sort(a,lo,mid);//将左侧排序
        sort(a,mid+1,hi);//将右侧排序
        if(less(a[mid],a[mid+1])){
            return;
        }
        merge(a,lo,mid,hi);
    }

3.不将元素复制到辅助数组

5.归并排序(自底向上)

这种实现方法比标准递归方法所需要的代码更少。

//归并排序(自底向上)
        aux = new Comparable[a.length];
        int N = a.length;
        for (int sz = 1; sz < N; sz=sz+sz) {//sz表示子数组的大小,每次翻倍
            //lo表示每次两个对比子数组的开始索引
            for (int lo = 0; lo < N-sz; lo+=sz+sz) {
                //两个子数组分别是a[lo...lo+sz-1]和a[lo+sz...lo+sz+sz-1],但要注意右侧数组右边界与总长度的关系
                merge(a, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1));
            }
        }

        public static void merge(Comparable[] a,int lo,int mid,int hi){
        //将a[lo...mid]和a[mid+1...hi]归并
        int i = lo,j =mid+1;
        for (int k = lo; k <=hi; k++) {
            aux[k] = a[k];//将a的信息复制到aux中
        }
        for (int k = lo; k <=hi; k++) {
            if(i>mid){
                //i>mid说明左侧元素全部取完了
                a[k] = aux[j++];
            }else if(j>hi){
                //j>hi说明右侧元素全部取完了
                a[k] = aux[i++];
            }else if(less(aux[j],aux[i])){
                a[k] = aux[j++];
            }else{
                a[k] = aux[i++];
            }
        }

    }

当数组长度为2的幂时,自顶向下和自底向上的归并排序所用的比较次数和数组访问次数正好相同,只是顺序不同。

自底向上的归并排序比较适合用链表组织的数据。将链表先按大小为1的子链表进行排序,然后是2,再次4…这种方法只需要重新组织链表链接就能将链表原地排序(不需要创建任何新的链表节点)。

排序算法的复杂度:
命题I:没有任何基于比较的算法能够保证使用小于NlogN次比较将长度为N的数组排序。(用比较树证明)

命题J:归并排序是一种渐进最优的基于比较排序的算法。
准确的说,应该是,归并排序在最坏情况下的比较次数和任意基于比较的排序算法所需的最少比较次数都是NlogN。

但归并排序的最优性并不是结束,也不代表实际应用中不考虑其他方法了。理论上它有这些局限性
1.归并排序的空间复杂度不是最优。
2.在实践中不一定会遇到最坏情况。
3.除了比较,算法的其他操作(例如访问数组)也可能很重要。
4.不进行比较也能够排序。

6.快速排序

优点:
1.原地排序(只需要一个很小的辅助栈)
2.将长度为N的数组排序所需时间和NlogN成正比。
3.快排的内循环比大多数排序都小。
缺点:
1.非常脆弱,在实现时要非常小心避免低劣的性能。

快排思想:
快排是一种分治的排序算法。快排和归并排序是互补的。在归并排序中,一个数组被等分成两半;在快排中,切分(partition)的位置取决于数组的内容。

快排代码:

//快速排序
        //随机打乱
        Collections.shuffle(Arrays.asList(a));
        sort(a,0,a.length-1);

private static void sort(Comparable[] a,int lo,int hi){
        if(lo>=hi){
            return;
        }
        int j = merge(a,lo,hi);
        sort(a,lo,j-1);//排序左边
        sort(a,j+1,hi);//排序右边
    }

public static int merge(Comparable[] a,int lo,int hi){
        Comparable v = a[lo];
        int i=lo,j=hi+1;//两个移动指针,j=hi+1是因为要用a[--j],而不能用a[j--]
        while(true){
            //扫描左右,检查扫描是否结束并交换元素
            while(less(a[++i],v)){
                if(i==hi) break;
            }
            while(less(v,a[--j])){
//              //可以去掉该检测,因为不可能比自身小
//              if(j==lo)break;
            }
            if(i>=j) break;
            exch(a,i,j);
        }
        exch(a,lo,j);
        return j;
    }

该方法关键在于切分(partition):
1.对于某个j,a[j]已经排定
2.a[lo]到a[j-1]中所有元素都不大于a[j]
3.a[j+1]到a[hi]中所有元素都不小于a[j]

注意点:
1.原地划分
如果使用一个辅助数组,我们可以很容易实现切分,当将切分后的数组复制回去的开销会得不偿失。
2.别越界
3.保持随机性。这对于预测算法的运行时间很重要。另一种方法是在partition方法中随机选择一个切分元素。
4.终止循环
5.处理切分元素有重复的情况
左侧扫描最好是遇到大于等于切分元素值的元素时停下,右侧同理(小于等于)。尽管会有一些不必要的等值交换,但在某些情况下,能够避免算法的运行时间变为平方级别
6.终止递归。
常见错误是不能保证将切分元素放入正确的位置,从而导致程序在切分元素正好是子数组的最大或最小元素时陷入无限的递归循环中。

性能特点:
归并排序和希尔排序一般比快排慢,其原因是他们还在内循环中移动数据,而快排只是比较数据

while(less(a[++i],v)){
                if(i==hi) break;
            }
            while(less(v,a[--j])){
//              //可以去掉该检测,因为不可能比自身小
//              if(j==lo)break;
            }

可以看出内循环中没有移动数据。

潜在缺点:切分不平衡程序低效。
解决办法:随机化的思想。将能够产生糟糕的切分情况可能性降低。

命题L:快排最多需要1/2N平方次比较,当随机打乱数组可以预防这种情况。

算法改进
1.切换到插入排序。基于两点:
1.对于小数组,快排比插入排序慢
2.因为递归,快排的sort方法在小数组中也会调用自己。

2.三取样切分。使用子数组的一小部分元素的中位数来切分。这样切分更好,代价是需要计算中位数

3.熵最优排序(三向切分快速排序)
将数组切分为三部分:小于,等于,大于。

思想:它从左到右遍历数组一次,维护一个指针lt使得a[lo…lt-1]中元素都小于v,一个指针gt使得a[gt+1…hi]中元素都大于v,一个指针i使得a[lt…i-1]中元素都等于v,a[i…gt]中元素还未确定。
我们直接处理以下情况:
1.a[i]小于v,将a[lt]和a[i]交换,将lt和i加1
2.a[i]大于v,将a[gt]和a[i]交换,将gt减1
3.a[i]等于v,将i加1

这些操作都会保证数组元素不变且缩小gt-i的值(这样循环才会结束)。另外,除非和切分元素相等,其他元素都会被交换。

//快速排序
        //随机打乱
        Collections.shuffle(Arrays.asList(a));
        sort(a,0,a.length-1);

private static void sort(Comparable[] a,int lo,int hi){
        if(lo>=hi){
            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++;
            }
            sort(a,lo,lt-1);
            sort(a,gt+1,hi);
        }
    }

这里可能会对你有点矛盾

我们已经证明过归并排序是最优的。怎么还有这种方法呢?
问题在于,命题I中,我们说归并排序最优,是对于任意输入的最差性能的比较。而我们现在讨论时已经知道输入数组的一些信息了。对于含有以任意概率分布的重复元素的输入,归并排序无法保证最佳性能。

对于存在大量重复元素的数组,这种方法比标准的快排的效率高很多

对于只有若干不同主键的随机数组,归并排序的时间复杂度是线性对数的,而三向切分快速排序则是线性的。对于三向切分,最欢情况正是所有主键均不相同(这种情况下,比标准方法多了很多交换)。

课后问答:
问:为什么要将注意力放在重复元素上?
答:这个问题直接影响实际应用中的性能。因为实际中,包含大量重复很常见。

你可能感兴趣的:(算法(第四版))