数据结构与算法(4):归并排序

归并排序

要将一个数组排序,可以先(递归地)将它分成两半分别排序,然后将结果归并起来。

原地归并的抽象方法

我们需要创建辅助数组aux,首先对应下标范围(low到high,包括low和high)的元素复制到aux数组的对应下标,再遍历aux数组进行归并。归并流程类似于对两堆已经排好序的纸牌进行合并。假设有两堆分别排序完成的纸牌(假设从小到大排序,小的在上面),我们需要将它们合并时一堆。基本的流程是,首先找出两堆纸牌最上面一张最小的那张,将其取出加入到新牌堆中(原来新牌堆为空,加入后新牌堆有一张牌),如此反复,如果其中一个牌堆变为空,只需要把另一个牌堆一次加入新牌堆中,即可完成合并。归并的流程大致如下,首先进行数组复制构建aux数组。然后将low到mid划分为左半部分,mid+1到high划分为右半部分。i和j分别为左半部分和右半部分当前遍历的下标,初始值分别为low和mid+1。遍历流程如下:

  • 如果i大于mid,则说明左半部分已经遍历完成(每次从左半部分取出一个元素,则i加1,当取出左半部分的最后一个元素,也就是下标为mid的元素之后,i的值更新为mid+1,这时候i大于mid),则将右半部分的下一个元素归并到a数组中。
  • 如果j大于high,则说明右半部分已经遍历完成(因为右半部分最后一个元素下标为high),理由同上。
  • 进行比较,如果左半部分当前元素(即下标i对应的元素)比较小,则将其加入到a数组中,i加1(代表考虑左半部分下一个元素)。反之,将有半部分当前元素加入到a数组中,j加1。
private static void merge(Comparable[] a, Comparable[] aux, int low, int mid, int high){
        int i = low, j = mid + 1;
        System.arraycopy(a, low, aux, low, high - low + 1);// 将a数组中下标low到high的元素复制到aux的相应位置中
        for(int k = low; k <= high; k++){
            if(i > mid){
                a[k] = aux[j++];
            }else if(j > high){
                a[k] = aux[i++];
            }else if(less(aux[i], aux[j])){
                a[k] = aux[i++];
            }else{
                a[k] = aux[j++];
            }
        }
    }

这里的less方法是一个辅助函数,作用是对比两个变量的大小:

    private static boolean less(Comparable a, Comparable b){
        return a.compareTo(b) < 0;
    }
    private static boolean less(Comparable a, Comparable b, Comparator comparator){
        return comparator.compare(a, b) < 0;
    }

我们可以看到less有两个重载函数,这是为了让算法支持指定comparator。为了这个目的,sort方法也将会多出一倍的重载函数,具体在文章底部的github项目里可以看到完整的代码。为了方便,我们下文只展示不执行comparator的方法。

算法复杂度:

  • 对于长度为N的任意数组,归并排序需要1/2NlgNNlgN次比较。
  • 对于长度为N的任意数组,归并排序最多需要访问数组6NlgN次。

自底向上的归并方法

实现归并排序的一种方法是先归并那些微型数组,然后再成对归并得到的子数组,如此这般,直到我们将整个数组归并在一起。

    public static void sort(Comparable[] a){
        int N = a.length;
        Comparable[] aux = new Comparable[N];
        for(int i = 0; i < N; i++){
            aux[i] = a[i];
        }
        for(int sz = 1; sz < N; sz += sz){// sz为子数组尺寸,每次循环,翻倍。
            for(int low = 0; low < N - sz; low += 2 * sz){// 因为当low = N - sz时mid = low + sz - 1 = N - 1这时候另一半为空(mid + 1 = (N - 1) + 1 = N下标的元素不存在),不需要归并, 所以low < N - sz
                merge(a, aux, low, low + sz - 1, Math.min(low + 2 * sz - 1, N - 1));
            }
        }
        assert isSorted(a);
    }

自顶向下的归并方法

这是应用高效算法设计中分治思想的最经典的例子。这段代码是归纳证明算法能够正确地将数组排序的基础:如果它能将两个子数组排序,它就能通过归并两个子数组来将整个数组排序。

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

改进:

  1. 加快小数组的排序速度,对小数组使用插入排序:用不同的方法处理小规模问题能改进大多数递归算法的性能,因为递归会使小规模问题中的调用过于频繁,所以改进它们的处理方法就能改进整个算法。
  2. 检测数组是否已经有序:如果a[mid]小于等于a[mid+1],我们就认为数组已经是有序的并跳过merge()方法。
  3. 不将元素复制到辅助函数:我们可以节省将数组复制到辅助数组的时间,这需要一些技巧。先克隆原数组到辅助数组,然后在之后的递归交换输入数组和辅助数组的角色(通过看代码更容易理解)。
    private static void sort(Comparable[] a, Comparable[] aux, int low, int high){
        if((high -low) <= CUTOFF){
            insertionSort(a, low, high);
            return;
        }
        int mid = (low + high) / 2;

        // 注意这两个子调用交换了aux和a的位置
        sort(aux, a, low, mid);
        sort(aux, a, mid + 1, high);


        if(less(a[mid], a[mid + 1])){// 已经有序时跳过merge(a中lo到mid mid到hi分别都是有序的)
            System.arraycopy(aux, low, a, low, high - low + 1);
            return;
        }
        merge(a, aux, low, mid, high);
    }

排序算法复杂度的思考

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

思考和掌握

归并算法是分治思想的一个经典应用,在这个算法里我们学到。在递归算法的局部可以采用其他方法来改进整个算法。其次是对于算法需要的复制操作,我们可以用适当的方法进行改进(比如归并算法的改进版本中交替排序数组和辅助数组)。然后我们可以对一些容易判别的特殊情况进行检测,并跳过一些步骤(比如我们在归并排序中仅仅通过一次比较就可以判断数组是否有序,如果有序,就可以跳过归并过程,这使得在有序的数组里归并排序达到线性级别)。

详细代码:https://github.com/617976080/algorithms-4th-code

扫描二维码关注公众号,回复: 5627735 查看本文章

参考书籍:《算法第4版)》Robert Sedgewick / 美Kevin Wayne编写,人民邮电出版社在2012年出版。

猜你喜欢

转载自blog.csdn.net/a617976080/article/details/86549435