美文网首页web攻城狮程序员
数据结构与算法--归并排序

数据结构与算法--归并排序

作者: sunhaiyu | 来源:发表于2017-10-29 14:55 被阅读58次

数据结构与算法--归并排序

归并排序

归并排序基于一种称为“归并”的简单操作。比如考试可能会分年级排名和班级排名,那么这个年级排名可能是合并了各个班级的排名后对其再排名得到——每个班级会上报本班学生成绩排名情况,上面的人根据各班提交的排名情况汇总成一个总排名。这就是归并在生活中的例子。那么归并排序,说得抽象些,其实就是将两个已经有序的数组归并成一个更大的有序数组,这里注意要求在归并之前两个子数组已经有序。于是,对整个大数组排序,可以先(递归地)将其分成两半分别排序,然后将结果归并起来。

实现归并排序的一种简单方法是:将两个不同的有序数组归并到第三个数组中去,因此该方法实现的归并排序需要额外空间,且所需空间的大小和待排序数组的长度N成正比。下面来看,通过怎样的比较才能将两个数组的元素有序地归并到一个数组中。

private static void merge(Comparable[] a, Comparable[] aux, int low, int mid, int high) {
    int i = low; // 左半数组的指针 [0, mid]
    int j = mid + 1; // 右半数组的指针 [mid +1, high]
    // 将待归并的数组元素全归并到一个新数组中
    for (int k = low; k <= high; k++) {
        aux[k] = a[k];
    }

    for (int k = low; k <= high; k++) {
        // 左半数组指针超出,被取完。于是取右半数组中的元素
        if (i > mid) {
            a[k] = aux[j++];
        // 右半数组被取完,取左半数组中的元素
        } else if (j > high) {
            a[k] = aux[i++];
        // 已满足i <= mid && j <= high
        // 右半数组的元素小,就取右半数组中元素
        } else if (less(aux[j], aux[i])) {
            a[k] = aux[j++];
            // 已满足i <= mid && j <= high
            // 左半数组元素小或者相等,取左半数组中的元素,相等时取左边保证了排序稳定性
        } else {
            a[k] = aux[i++];
        }
    }
}

该方法将数组区间[low, high]之间的所有元素都复制到了一个新的数组aux中,然后在归并回原数组a中。为此进行了4个判断:

  1. 左半数组被取尽 --> 取右半数组中的元素;
  2. 右半数组被取尽 --> 取左半数组中的元素;
  3. 左半数组的当前元素小于右半数组的当前元素 --> 取左半数组的元素;
  4. 左半数组的当前元素不小于右半数组中的元素 --> 取右半数组中的元素,因此当左右数组中当前元素等值时会去右半数组中的元素。

注意条件1、2和条件3、4的顺序不可颠倒,因为如果条件1、2不通过,执行条件3、4时候就保证了i <= mid && j <= mid,使得左右半数组的访问都不会出现下标越界。如果条件3、4放在前面判断,随着j的自增,可能就下标越界了。

如下图演示了归并的过程,依据上面的条件,不断从右边的aux[]中取处元素顺序放回原数组a中,达到将两个数组归并(由原数组分割成的左右数组),从而实现了排序。

自顶向下的归并排序

为了将小数组归并成大数组,需要保证小数组是有序的。于是我们可以利用的递归的方法,将数组分成左右两半分别排序,左右数组有序后就能将结果归并到一起了。根据描述,可写出如下代码。

package Chap9;

public class MergeSort {

    /* private static void merge...
       merge方法见上面
    */

    private static void sort(Comparable[] a, Comparable[] aux, int low, int high) {
        // high = low说明数组被划分到只有一个元素,无需排序和归并直接返回
        if (high <= low) {
            return;
        }

        int mid = low + (high - low) / 2;

        sort(a, aux, low, mid);
        sort(a, aux, mid + 1, high);
        merge(a, aux, low, mid, high);
    }

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

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


    public static boolean isSorted(Comparable[] a) {
        for (int i = 0; i < a.length - 1; i++) {
            if (less(a[i + 1], a[i])) {
                return false;
            }
        }
        return true;
    }

    public static String toString(Comparable[] a) {
        if (a.length == 0) {
            return "[]";
        }

        StringBuilder sb = new StringBuilder();
        sb.append("[");
        for (int i = 0; i < a.length; i++) {
            sb.append(a[i]);
            if (i == a.length - 1) {
                return sb.append("]").toString();
            } else {
                sb.append(", ");
            }
        }
        return sb.toString();
    }

    public static void main(String[] args) {
        Integer[] a = {9, 1, 5, 8, 3, 7, 4, 6, 2};
        MergeSort.sort(a);
        System.out.println(MergeSort.toString(a));
        System.out.println(MergeSort.isSorted(a));
    }
}

辅助数组aux是sort方法的局部变量,这表明每对一个序列调用sort方法都会生成一个aux数组。不能将aux设置成 类静态成员static Comparable[] aux,因为如果有多个程序用这个类进行排序,它们就共享了该辅助数组aux,在多线程中很容易出问题。将aux定义在merge方法里也不合适,这意味着每次归并都会生成一个数组,无疑是浪费。

下图显示了归并过程

第一次归并是a[0]和a[1],第二次归并是a[2]和a[3],第三次归并是a[0..3],然后是a[4]和a[5]...以此类推。你可能诧异为什么是这样的归并顺序,看下图递归方法调用的顺序就清楚了。

我们还可以将这种“先对左半数组进行排序,再将右半数组进行排序”的归并方法用来表示,如下

该树的高度为lg N,设lg N = n,对于[0, n]中任意k,自顶向下的第k层有2^k个子数组,每个数组的长度为2^(n-k),归并最多需要2^(n-k)次比较,所以第k层需要2^k * 2^(n-k) = 2^n次比较,总共n层需要n * 2^nNlg N次比较。故时间复杂度为O(Nlg N)

归并排序可以处理大规模的数组,但是主要缺点是引入的辅助数组所需的额外空间与数组大小N成正比。

改进

对小规模数组换用插入排序

用不同的算法处理小规模问题能改进大多数递归算法的性能,因为递归会使得小规模问题的方法调用过于频繁。对排序来说,我们知道插入排序非常简单而且适合小规模数组,因此在归并排序中如果需要处理的数组比较小,比如数组长度小只有十几,就可以用插入排序代替归并过程。

测试数组是否有序

我们知道在归并之前,左半数组和右半数组已经分别有序,如果左半数组的最后一个元素(下标为mid)比右半数组的小于等于第一个数组(下标为mid + 1),说明该数组本身就有序,无需再调用merge方法。

基于以上两点,对自顶而下的归并排序优化如下

private static void sort(Comparable[] a, Comparable[] aux, int low, int high) {

    // high <= low + 15说明当数组很小时直接换用插入排序,当数组长度不超过16时都使用插入排序
    if (high <= low + 15) {
        InsertSort.sort(a);
        return;
    }

    int mid = low + (high - low) / 2;

    sort(a, aux, low, mid);
    sort(a, aux, mid + 1, high);
    // a[mid] <= a[mid + 1]已经有序,跳过归并操作
    if (a[mid].compareTo(a[mid + 1]) > 0) {
        merge(a, aux, low, mid, high);
    }
}

在原来的基础上只是加了简单几行,就可以稍微提升一点性能!归并排序的可视化过程见下图

自底向上的归并排序

递归实现的归并是算法设计中分治思想体现,我们将一个大问题分割成小问题分别解决,然后用所有的小问题的答案解决整个大问题。实现归并排序的另一种思路是直接从小数组开始归并(因此无需使用递归的方法将大数组分成左右子数组),然后再成对归并得到的子数组,如此这般,直到将整个数组归并到一起。

首先我们进行两两归并,然后四四归并、八八归并,一直下去。最后一次归并的第二个子数组可能比第一个子数组要 小,即便如此merge方法也能很好地处理,因此不用在意。

public class MergeBU {

    public static void sort(Comparable[] a) {
        Comparable[] aux = new Comparable[a.length];
        // sz = 1, 2, 4, 8...
        for (int sz = 1; sz < a.length; sz = sz + sz) {
            // sz = 1: low= 0, 2, 4, 6, 8, 10...
            // sz = 2: low= 0, 4, 8, 12, 16...
            // sz = 4: low= 0, 8, 16, 24...
            for (int low = 0; low < a.length-sz; low += (sz + sz)) {
                // sz = 1: 归并子数组 (0,0,1) (2,2,3) (4,4,5)...
                // sz = 2: 归并子数组 (0,1,3) (4,5,7) (8,9,11)...
                // sz = 4: 归并子数组 (0,3,7) (8,11,15) (16,19,23)...

                // 可由归纳法得到mid = low + sz -1; high = low + 2sz -1
                // 最后一个子数组可能比sz小,所以通过low + 2sz -1计算high可能比原数组还要大,因此和a.length - 1取最小
                merge(a, aux, low,  low + sz - 1, Math.min(low + sz + sz - 1, a.length - 1));
            }
        }
    }
}

merge方法直接使用自顶向下的归并排序中的改进的merge方法。注释中写出了sz和low的递增过程,外循环中sz=1表示两两归并,sz=2表示四四归并,以此类推,每次都成倍增长...内循环中low每次增长sz + sz,实际上是直接跳到了下一个子数组,边界条件low < a.length - sz比较难懂,如果是low < a.length就比较好理解,表示的是最后一个子数组的开始下标,后者当然能得出正确结果,但是相比前者在处理最后一个子数组时可能有多余的归并操作

low < a.length - sz终止条件,说明顶多最后一个sz长度的子数组没有进行归并操作就跳出循环了,这种情况发生在倒数第二个子数组的low刚好等于a.length - 3sz时,该子数组的范围是[a.length - 3sz, a.length - sz -1],下一次low自增low += (sz + sz)得到low = a.length - sz刚好不满足条件,于是后面长度为sz的子数组没有归并。如下图所示。

其他情况下,没有被归并的子数组长度都比上述情况要小。如下两幅图所示,

再来看,最后一个长度为小于等于sz的数组没有被归并影响结果吗?由外层for循环的sz = sz + sz可知,sz正好是上一轮中被归并的子数组长度(现在的sz是上一轮中sz的两倍,而被归并的子数组长度2sz),因此本轮中最后这长度为sz的子数组,在上一轮中就已经归并过了,条件low < a.length - sz正好规避了多余的归并操作。

至于merge方法中,mid = low + sz -1,以及high = low + sz + sz -1都可以通过数学归纳法得到,有时候最后一个子数组可能比sz小,所以通过low + sz + sz -1计算high可能超出原数组长度,因此和a.length - 1取最小以防止下标越界。

下面是自底向上的归并排序轨迹图以及可视化过程。

和自顶而下的归并排序相比,可以发现他们的归并顺序并不一样。

归并排序在最坏情况下需要比较~Nlg N次,这已经是所有基于比较的排序算法中最好的效率了,但是归并排序需要额外空间,这不是最优的。

归并排序中先对子数组两两归并,由于merge方法代码中else分支包含了左半数组元素和右半数组元素相等的情况,此时取的是左半数组的元素,这保证了等值元素的相对位置不会发生改变,随着归并数组的增大,这种关系依然成立。所以归并排序是稳定的排序算法。


by @sunhaiyu

2017.10.29

相关文章

  • 排序算法-堆排序

    参考: Java排序算法(五):堆排序 【算法与数据结构】图说堆排序 【数据结构】排序算法:希尔、归并、快速、堆排...

  • 数据结构与算法--归并排序

    数据结构与算法--归并排序 归并排序 归并排序基于一种称为“归并”的简单操作。比如考试可能会分年级排名和班级排名,...

  • 算法与数据结构路线图

    学习算法与数据结构,深刻理解计算机科学 排序算法:插入、冒泡、选择、希尔、快速、归并、堆排序、计数排序、桶排序、基...

  • 一步一步学习数据结构和算法(二) O(nlogn) 的排序算法

    排序算法 文中使用的图片来自慕课网课程算法与数据结构 本章介绍的算法都是时间复杂度为 级别的算法. 归并排序 (...

  • 排序算法6:快速排序

    数据结构与算法 快速排序为应用最多的排序算法,因为快速二字而闻名。快速排序和归并排序一样,采用的都是分治思想。 分...

  • all

    算法与数据结构 常见算法类型 排序算法(冒泡、插入、选择、快排、希尔、堆排、归并、桶排、基数、计数)、字符串操作、...

  • 排序算法

    常考排序 快速排序 归并排序 归并排序求逆序数对 堆排序 堆排序是指利用堆这种数据结构所设计的一种排序算法。 堆积...

  • 2018-06-30

    排序算法之归并排序 归并排序算法是排序算法中的经典算法之一,其核心思想是利用归并的思想实现的排序方法,该算法采用经...

  • 算法笔记:快排算法与归并排序

    快排算法与归并算法时间复杂度都是O(nlogn)的排序算法。适合大规模的数据排序。思想利用的是分治思想。 归并排序...

  • 常见排序算法

    1 前言 2 排序基础2.1 选择排序2.2 插入排序 3 高级排序算法3.1 归并排序3.1.1 插入排序与归并...

网友评论

    本文标题:数据结构与算法--归并排序

    本文链接:https://www.haomeiwen.com/subject/wosypxtx.html