《算法》——第二章:排序

终于来到了第二章,排序也是算法中非常重要的一环。

2.1 初级排序算法

我们主要关注的对象是重新排列数组元素的算法,其中每个元素都有一个 主键 。排序算法的目标就是将所有元素的主键按某种方式排列(通常是按照大小或者字母的顺序)。排序后索引较大的主键大于等于索引较小的主键。

我们会将排序算法放在类的sort()方法中,该类还包含辅助函数less()和exch(),分别用于比较元素大小和交换元素位置。

下面看一下排序算法类的模版:

排序算法类的模版

    public class Example{
        public static void sort(Comparable[] a)
        { /*算法2.1、2.2、2.3、2.4、2.5、2.7会有不同类型的sort算法*/ }

        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; }

        public static boolean isShorted(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) {
            String[] a = In.readStrings();
            sort(a);
            assert isShorted(a);
            show(a);  //show()方法我没有写出来,就是打印数组
        }
    }

上面的模版非常容易看明白,只有在main方法中的assert可能比较少见,它表示断言,判断后面的值,如果为true程序继续向下执行,如果为false则抛出异常并终止程序。

排序算法可以分为两类:除了函数调用所需要的栈和固定数目的实力变量之外无需额外内存的 原地排序算法,以及需要额外名内存空间来存储另一份数组副本的其他排序算法。

上面的排序算法模版适用于任何实现了Comparable接口的数据类型,可以看看书中这个例子,关于compareTo是如何实现的:

这里写图片描述

选择排序

一种最简单的排序算法是这样的:首先找到数组中最小的那个元素,其次将它和数组的第一个元素交换位置(如果第一个元素最小就自交)。然后,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。如此往复,直到将整个数组排序。这种方法叫做 选择排序,因为它不断地选择剩余元素中的最小者。

通俗的说,选择排序就好像在玩斗地主的时候,一下子每人发17张牌,然后自己把扑克牌从3到K到A到2到大小王这样排序,大概就是这个意思。

选择排序有两个鲜明的特点,可以看一下书中的详细介绍:

  • 运行时间和输入无关 : 为了找出最小的元素而扫描一遍数组并不能为下一遍扫描提供什么信息。这种性质在某些情况下是缺点,因为使用选择排序的人可能会惊讶的发现,一个已经有序的数组或是主键全部相等的数组和一个元素随机排列的数组所用的排序时间竟然一样长!

  • 移动数据是最少的 : 每次交换都会改变两个数组元素的值,因此选择排序用了N次交换——交换次数和数组的大小是线性关系。我们将研究的其他任何算法都不具备这个特征(大部分增长数量级都是线形对数或是平方级别)

下面是选择排序的具体实现:

算法 2.1 选择排序

    public class Selection{
        public static void sort(Comparable[] a) {
            //将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);
            }
        }
    }

上面的排序是按照升序来排的,就是从小到大从左至右,代码也并不复杂。接下来看一下另外一个排序方法

插入排序

在计算机的实现中,为了给要插入的元素腾出空间,我们需要将其余所有元素在插入之前都右移一位。这种算法就叫插入排序。和选择排序一样,当前索引左边的所有元素都是有序的,但是他们最终的位置还不确定,为了给更小的元素腾出空间,它们可能会被移动。当索引到达数组的右端时,数组排序就完成了。

通俗一点儿来说,还是玩斗地主,不过这次发牌从一次17张到自己一张一张地抽,想象一下这时候你是怎么给扑克牌排序的吧。

插入排序对于某些常见的非随机数组类型特别有效,比如你想用插入排序对一个有序数组进行排序的时候,插入排序会立即发现每个元素已经在合适的位置上了,它的运行时间也是线性的(对于这种数组,选择排序是平方级别的)。

下面就是插入排序的具体实现

算法2.2 插入排序

    public class Insertion{
        public static void sort(Comparable[] a) {
            //将a[]按升序排列
            int N = a.length;
            for(int i = 1; i < N; i++) {    //将a[i]插入到a[i-1],a[i-2],a[i-3]...之中
                for(int j = i; j > 0 && less(a[j], a[j-1]); j--)
                    exch(a, j, j-1);
            }
        }
    }

插入排序的实现代码也比较简单,可以看一下书中关于该算法的运行轨迹,以及它特别适合的下面这些情况:

这里写图片描述

还有两种算法的可视轨迹图,左边是插入排序,右边是选择排序:

这里写图片描述

可以比较一下两种排序算法:

    public static double time(String alg, Double[] a) { 
        Stopwatch sw = new Stopwatch(); 
        if      (alg.equals("Insertion"))       Insertion.sort(a); 
        else if (alg.equals("InsertionX"))      InsertionX.sort(a); 
        else if (alg.equals("BinaryInsertion")) BinaryInsertion.sort(a); 
        else if (alg.equals("Selection"))       Selection.sort(a); 
        else if (alg.equals("Bubble"))          Bubble.sort(a); 
        else if (alg.equals("Shell"))           Shell.sort(a); 
        else if (alg.equals("Merge"))           Merge.sort(a); 
        else if (alg.equals("MergeX"))          MergeX.sort(a); 
        else if (alg.equals("MergeBU"))         MergeBU.sort(a); 
        else if (alg.equals("Quick"))           Quick.sort(a); 
        else if (alg.equals("Quick3way"))       Quick3way.sort(a); 
        else if (alg.equals("QuickX"))          QuickX.sort(a); 
        else if (alg.equals("Heap"))            Heap.sort(a); 
        else if (alg.equals("System"))          Arrays.sort(a); 
        else throw new IllegalArgumentException("Invalid algorithm: " + alg);
        return sw.elapsedTime(); 
    } 

这里写图片描述

插入排序和选择排序差不多就到这里,接下来是一个非常厉害的算法——希尔排序

希尔排序

对于大规模乱序数组来说,插入排序很慢,因为它只会交换相邻的元素,一次元素只能一点儿一点儿地从数组的一端移动到另外一端。希尔排序为了加快速度,对插入排序进行了简单的改进,它交换不相邻的元素以对数组的局部进行排序,并最终用插入排序将局部有序的数组排序。

希尔排序的思想是使数组中任意间隔为h的元素都是有序的,这样的数组被成为h有序数组。换句话说,一个h有序数组就是h个互相独立的有序数组编织在一起组成的一个数组。

书中对此说明有如下的插图:

这里写图片描述

下面是希尔排序的实现方法

算法2.3 希尔排序

    public class Shell{
        public static void sort(Comparable[] a) {
            //将a[]按升序排列
            int N = a.length;
            int h = 1;
            while(h < N/3) h = 3*h + 1;
            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 = h/3;
            }
        }
    }

可以看到,希尔排序的代码不多,但是特别不好理解,可以看一下下面的轨迹:

这里写图片描述

然后是希尔排序的可视视图:

这里写图片描述

—————————————————

2017-11-22日更新

2.2 归并排序

归并就是将两个有序的数组归并成一个更大的有序数组,要将一个数组排序,可以先将它分成两半分别排序,然后将结果归并起来。

归并最吸引人的性质是,它能够保证任意长度为N的数组排序所需时间和NlogN成正比;它的主要缺点则是它所需的额外空间和N成正比。

实现归并可以把两个不同的有序数组归并到第三个数组中去,不过如果去归并一个大数组的时候,这样会带来问题。所以我们希望一种能够在原地归并的方法,下面就是这个方法的实现:

原地归并的抽象方法

    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++)       //将a[lo..mid]复制到aux[lo..mid]
            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++];
        }
    }

上面的代码只有短短几行,一开始看的时候我是懵逼的,因为我找不到判断条件中的i和j。之后我才发现它们被默默地放在aux[]中,在我看来这简直是神来之笔!看懂这段代码对我而言是非常不容易的,接下来说一下怎么去理解这整段代码吧。

首先前面的初始化i,j好懂,然后的复制数组也简单,主要是最后的一段for循环。

这段循环不应该先看前两个判断,而是应该从后两个判断看起,当aux[j]比aux[i]小的时候aux[j]赋值给a[k],然后j++,当aux[j]比aux[i]大的时候aux[i]赋值给a[k],然后i++;就这样当i大于mid或者j大于hi的时候,就无需判断了,直接取a[j++]或者a[i++]赋值给a[k],建议在草稿纸上实现一下过程,然后就都明白了!

看一下书中此方法的轨迹吧:

这里写图片描述

下面是自顶向下的归并排序,这也是应用高效算法设计中 分治思想 最典型的一个例子:

算法 2.4 自顶向下的归并排序

    public class Merge{
        private static Comparable[] aux;        //归并所需的辅助数组

        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);     //归并结果(原地归并)
        }
    }

相对而言,理解了之前的原地归并排序再看这个的话不会太难,书中还有以下轨迹方便理解:

这里写图片描述

然后是自底向上的归并排序:

    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));
        }
    }

自底向上的归并排序比较适合用链表阻止的数据。

它的可视轨迹如下:

这里写图片描述

—————————————————

2017-11-22日更新

2.2 归并排序

归并就是将两个有序的数组归并成一个更大的有序数组,要将一个数组排序,可以先将它分成两半分别排序,然后将结果归并起来。

归并最吸引人的性质是,它能够保证任意长度为N的数组排序所需时间和NlogN成正比;它的主要缺点则是它所需的额外空间和N成正比。

实现归并可以把两个不同的有序数组归并到第三个数组中去,不过如果去归并一个大数组的时候,这样会带来问题。所以我们希望一种能够在原地归并的方法,下面就是这个方法的实现:

原地归并的抽象方法

    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++)       //将a[lo..mid]复制到aux[lo..mid]
            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++];
        }
    }

上面的代码只有短短几行,一开始看的时候我是懵逼的,因为我找不到判断条件中的i和j。之后我才发现它们被默默地放在aux[]中,在我看来这简直是神来之笔!看懂这段代码对我而言是非常不容易的,接下来说一下怎么去理解这整段代码吧。

首先前面的初始化i,j好懂,然后的复制数组也简单,主要是最后的一段for循环。

这段循环不应该先看前两个判断,而是应该从后两个判断看起,当aux[j]比aux[i]小的时候aux[j]赋值给a[k],然后j++,当aux[j]比aux[i]大的时候aux[i]赋值给a[k],然后i++;就这样当i大于mid或者j大于hi的时候,就无需判断了,直接取a[j++]或者a[i++]赋值给a[k],建议在草稿纸上实现一下过程,然后就都明白了!

看一下书中此方法的轨迹吧:

这里写图片描述

下面是自顶向下的归并排序,这也是应用高效算法设计中 分治思想 最典型的一个例子:

算法 2.4 自顶向下的归并排序

    public class Merge{
        private static Comparable[] aux;        //归并所需的辅助数组

        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);     //归并结果(原地归并)
        }
    }

相对而言,理解了之前的原地归并排序再看这个的话不会太难,书中还有以下轨迹方便理解:

这里写图片描述

然后是自底向上的归并排序:

    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));
        }
    }

自底向上的归并排序比较适合用链表阻止的数据。

它的可视轨迹如下:

这里写图片描述

—————————————————

2017-11-28日更新

2.3 快速排序

快速排序可能是应用最广泛的排序算法了,它适用于各种不同的输入数据且在一般应用中比其他排序算法都要快得多。

快速排序引人注目的特点包括它是原地排序(只需要一个很小的辅助栈),且将长度为N的数组排序所需的时间和NlgN成正比。

快速排序是一种分治的排序算法。它将一个数组分成两个子数组,将两部分独立地排序。快速排序和归并排序是互补的:归并排序将数组分成两个子数组分别排序,并将有序的子数组归并以将整个数组排序;而快速排序将数组排序的方式则是当两个子数组都有序时整个数组也就自然有序了。

在归并排序中,一个数组被等分为两半;在快速排序中,切分(partition)的位置取决于数组的内容。

image

下面就是快速排序的实现:

算法 2.5 快速排序

    public class Quick{
        public static void sort(Comparable[] a) {
            StdRandom.shuffle(a);       //消除对输入的依赖
            sort(a, 0, a.length - 1);
        }

        private 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]排序
        }
    }

image

实现切分方法:一般的策略是先随意地取a[lo]作为切分元素,即那个将会被排定的元素,然后从数组的左端开始向右扫描知道找到一个大于等于它的元素,再从数组的右端开始向左扫描知道找到一个小于等于它的元素。这两个元素显然是没有排定的,因此交换他们的位置。如此继续,就可以保证左指针i的左侧元素都不大于切分元素,右指针j的右侧元素都不小于切分元素。当两个指针相遇时,我们只需要将切分元素a[lo]和左子数组最右侧的元素(a[j])交换然后返回j即可。

image

快速排序的切分

    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(i == hi) break;
            while(less(v, a[--j])) if(j == lo) break;
            if(i >= j) break;
            each(a, i, j);
        }
        exch(a, lo, j);         //将v = a[j]放入正确位置
        return j;               //a[lo .. j-1] <= a[j] <= a[j+1 .. hi]达成
    }

上面的代码可读性还是比较差的,不过写的是真的好,仔细看还是好懂的。

image

书中还有好几种对快速切分算法的改进版本。

image

猜你喜欢

转载自blog.csdn.net/asjqkkkk/article/details/78595930