算法(5) 归并排序

在本节中我们讨论的算法都基于归并这个简单的操作,即将两个有序的数组归并成一个更大的有序数组。很快人们就根据这个操作发明了一种简单的递归排序算法:归并排序。

要将一个数组排序,可以先(递归地)将它分成两半分别排序,然后将结果归并起来。你将会看到,归并排序最吸引人的性质是它能够保证将任意长度为N的数组排序所需时间和NlogN成正比;它的主要缺点则是它所需的额外空间和N成正比。简单的归并如图所示:

原地归并的抽象方法:

  实现归并的一种直截了当的办法是将两个不同的有序数组归并到第三个数组中,两个数组中的元素应该都实现了Compare接口。实现的方法很简单,创建一个适当大小的数组然后将两个输入数组中的元素一个个从小到大放入这个数组中。

  但是,当用归并将一个大数组排序时,我们需要进行很多次归并,因此在每次归并时都创建一个新数组来存储排序结果会带来问题。我们更希望有一种能够在原地归并的办法,这样就可以先将前半部分排序,再将后半部分排序,然后在数组中移动元素而不需要使用额外的空间。你可以停下来先想一想应该如何实现这一点,乍一看很容易做到,但实际上已有的实现都非常复杂,尤其是和使用额外空间的方法相比。

/**
* 原地归并的抽象方法
*/
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;

        //将a[lo..hi]复制到aux[lo..hi]
        for(int k=lo; k<=hi; k++){
            aux[k] = a[k];
        }

        //归并回到a[lo..hi]
        for(int k=lo; k<=hi; k++){
            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++];
            }
        }
    }
View Code

 

自顶向下的归并排序

算法2.4基于原地归并的抽象实现了另一种递归归并,这也是应用高效算法设计中分治思想的最典型的一个例子。这段递归代码是归纳证明算法能够正确地将数组排序的基础:如果它能将两个子数组排序,它就能够通过归并两个子数组来将整个数组排序。

package algorithm;

/**
 * 〈算法2.4 自顶向下的归并排序〉<br>
 */
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){
        //将数组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);          //归并结果
    }

    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;

        //将a[lo..hi]复制到aux[lo..hi]
        for(int k=lo; k<=hi; k++){
            aux[k] = a[k];
        }

        //归并回到a[lo..hi]
        for(int k=lo; k<=hi; k++){
            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++];
            }
        }
    }

    private static boolean less(Comparable v, Comparable w){
        return v.compareTo(w) < 0;
    }
}
View Code
命题F:对于长度为N的任意数组,自顶向下的归并排序需要½NlgN至NlgN次比较。
命题G:对于长度为N的任意数组,自顶向下的归并排序最多需要访问数组6NlgN次。

(这些我没看懂,书上p186,怎么证明的看不懂...)

命题F和命题G告诉我们归并排序所需的时间和NlgN成正比。这和2.1节所述的初级排序方法不可同日而语,它表明我们只需要比遍历整个数组多个对数因子的时间就能将一个庞大的数组排序。可以用归并排序处理数百万甚至更大规模的数组,这是插入排序或者选择排序做不到的。归并排序的主要缺点是辅助数组所使用的额外空间和N的大小成正比。另一方面,通过一些细致的思考我们还能够大幅度缩短归并排序的运行时间。

对小规模子数组使用插入排序
用不同的方法处理小规模问题能改进大多数递归算法的性能,因为递归会使小规模问题中方法的调用过于频繁,所以改进对它们的处理方法就能改进整个算法。对排序来说,我们已经知道插入排序(或者选择排序)非常简单,因此很可能在小数组上比归并排序更快。

 

测试数组是否已经有序
我们可以添加一个判断条件,如果a[mid]小于等于a[mid+1],我们就认为数组已经是有序的并跳过merge()方法。这个改动不影响排序的递归调用,但是任意有序的子数组算法的运行时间就变为线性的了。

不将元素复制到辅助数组
我们可以节省将数组元素复制到用于归并的辅助数组所用的时间(但空间不行)。要做到这一点我们要调用两种排序方法,一种将数据从输入数组排序到辅助数组,一种将数据从辅助数组排序到输入数组。这种方法需要一些技巧,我们要在递归调用的每个层次交换输入数组和辅助数组的角色。

 

 

 自底向上的归并排序

 递归实现的归并排序是算法设计中分治思想的典型应用。我们将一个大问题分割成小问题分别解决,然后用所有小问题的答案来解决整个大问题。尽管我们考虑的问题是归并两个大数组,实际上我们归并的数组大多数都非常小。实现归并排序的另一种方法是先归并那些微型数组,然后再成对归并得到的子数组,如此这般,直到我们将整个数组归并在一起。这种实现方法比标准递归方法所需要的代码量更少。首先我们进行的是两两归并(把每个元素想象成一个大小为1的数组),然后是四四归并(将两个大小为2的数组归并成一个有4个元素的数组),然后是八八的归并,一直下去。在每一轮归并中,最后一次归并的第二个子数组可能比第一个子数组要小(但这对merge方法不是问题),如果不是的话所有的归并中两个数组大小都应该一样,而在下一轮中子数组的大小会翻倍。

自底向上的归并排序算法实现如下:

package algorithm;

/**
 * 〈自底向上的归并排序〉<br>
 */
public class MergeBU {

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

    public static void sort(Comparable[] a){
        //进行lgN次两两归并
        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));
            }
        }
    }

    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;

        //将a[lo..hi]复制到aux[lo..hi]
        for(int k=lo; k<=hi; k++){
            aux[k] = a[k];
        }

        //归并回到a[lo..hi]
        for(int k=lo; k<=hi; k++){
            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++];
            }
        }
    }

    private static boolean less(Comparable v, Comparable w){
        return v.compareTo(w) < 0;
    }
}
View Code

自底向上的归并排序会多次遍历整个数组,根据子数组大小进行两两归并。子数组的大小sz的初始值为1,每次加倍。最后一个子数组的大小只有在数组大小是sz的偶数倍的时候才会等于sz(否则它会比sz小)

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

p278页,这边看的不是太懂...

 

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

 

 排序算法的复杂度

 学习归并排序的一个重要原因是它是证明计算复杂性领域的一个重要结论的基础,而计算复杂性能够帮助我们理解排序自身固有的难易程度。计算复杂性在算法设计中扮演着非常重要的角色,而这个结论正是和排序算法的设计直接相关的,因此接下来我们就要详细地讨论它。

研究复杂度的第一步是建立一个计算模型。一般来说,研究者会尽量寻找一个和问题相关的最简单的模型。对排序来说,我们的研究对象是基于比较的算法,它们对数组元素的操作方式是由主键的比较决定的。一个基于比较的算法在两次比较之间可能会进行任意规模的计算,但它只能通过主键之间的比较得到关于某个主键的信息。因为我们局限于实现了Comparable接口的对象,本章中的所有算法都属于这一类(注意,我们忽略了访问数组的开销)。在第5章中,我们会讨论不局限于Comparable元素的算法。

命题I: 没有任何基于比较的算法能够保证使用少于lg(N!) ~ NlgN次比较将长度为N的数组排序。

p281页,这边的很多证明看不懂。

命题J: 归并排序是一种渐进最优的基于比较排序的算法。

但归并排序的最优性并不是结束,也不代表在实际应用中我们不会考虑其他的方法了,因为本节中的理论还是有许多局限性的,例如:

.归并排序的空间复杂度不是最优的;

.在实践中不一定会遇到最坏情况;

.除了比较,算法的其他操作(例如访问数组)也可能很重要;

.不进行比较也能将某些数据排序。

因此在本书中我们还将继续学习其他一些排序算法。

 

 

 

 

 

---

 

posted on 2018-12-12 20:43  有点懒惰的大青年  阅读(580)  评论(0)    收藏  举报