动画:一篇文章快速学会归并排序

763 阅读17分钟

内容介绍

归并排序简介

我们以前读书时,学校会举行运动会,运动会上有很多比赛项目,比如跳远比赛。当参加跳远比赛人数比较多时,通常会将所有参赛选手分成多组,每组的同学比赛,并按成绩进行排名,最后将所有组的学生成绩汇总得到所有学生的排名。

上面案例中的所有学生跳远排名就是归并排序的思想。归并排序是一个典型的基于分治的算法。归并一词的中文含义就是合并、并入的意思,归并排序分成两个步骤:1.拆分,2.合并。

归并排序的思想

归并排序思想,将原数据序列分成大小相等的两个子序列,继续划分子序列,直到子序列有序时,将划分的有序子序列合并成大的有序序列,最终合并成一个有序序列。

归并排序动画演示

归并排序分析

一般没有特殊要求排序算法都是升序排序,小的在前,大的在后。
数组由{7, 3, 1, 9, 5, 2, 8, 6} 这8个无序元素组成。

归并排序分成两个步骤:1.拆分,2.合并。

  1. 拆分
    当我们要排序由{7, 3, 1, 9, 5, 2, 8, 6}这样一个数组的时候,归并排序法首先将这个数组分成两半。

    然后想办法对左边的数组进行排序,右边的数组进行排序,然后再将它们归并起来。很显然,现在左边和右边两个数组依然是无序的,接着再拆分,将左边的数组分成两半,将右边的数组分成两半,效果如下:

现在被拆分的每个子数组长度是2,每个子数组依然是无序的,接着拆分,效果如下:


拆分后的效果如上图,经过这次拆分后,每个子数组的长度被为1,我们认为长度为1的子数组是有序的。不需要再进行拆分了。到此拆分就结束了。

  1. 合并。
    到现在每个子数组长度为1,是有序的子数组,需要将两个长度为1有序的子数组合并成一个长度为2的有序数组。

动画效果如下:

到现在每个子数组长度为2,是有序的子数组,需要将两个长度为2有序的子数组合并成一个长度为4的有序数组。


动画效果如下:

到现在每个子数组长度为4,是有序的子数组,需要将两个长度为4有序的子数组合并成一个长度为8的有序数组。


动画效果如下:

归并排序合并时的细节

归并排序再进行将有序子数组合并成大一点有序数组时,需要对比左右两个子数组哪个元素小取哪个元素。

  1. 左边数组的元素大于右边数组的元素,取右边的小元素。
  2. 右边数组的元素大于等于左边数组的元素,取左边的小元素。

以上两种情况是常见情况,还有两种特殊情况需要注意:

  1. 左边的全部是小值提前取完了,剩下右边的直接赋值
  2. 右边的全部是小值提前取完了,剩下左边的直接赋值

归并排序代码编写

代码如下:

public class MergeSortTest2 {
    public static void main(String[] args) {
        int[] arr = new int[] {73195286};
        mergeSort(arr);
    }

    // 归并排序的方法
    public static void mergeSort(int[] arr) {
        // 使用一个额外的数组,容量和要排序的数组容量一样大
        int[] aux = new int[arr.length];
        mergePass(arr, 0, arr.length - 1, aux);
        // mergePass2(arr, 0, arr.length - 1, aux);
    }

    // 对arr数组的[low, hight]索引元素进行归并排序
    private static void mergePass(int[] arr, int low, int high, int[] aux) {
        // 递归的终止条件,当拆分后,小索引和大索引是相同的,也就是一个元素的时候就终止拆分
        if (low >= high)
            return;

        // 将一个数组拆分为两个数组
        int mid = (low + high) / 2;
        mergePass(arr, low, mid, aux);
        mergePass(arr, mid + 1, high, aux);
        // 对拆分后的这两个数组进行排序
        merge(arr, low, mid, high, aux);
    }

    // 将arr[low, mid]和arr[mid+1, high]两部分进行归并
    private static void merge(int[] arr, int low, int mid, int high, int[] aux) {
        // 临时数组的内容就是两个待排序数组的内容,排好序的内容会放到arr中
        for (int k = low; k <= high; k++) {
            aux[k] = arr[k];
        }

        // i是左边数组的索引
        // j是右边数组的索引
        int i = low;
        int j = mid + 1;

        // 遍历左边和右边的小数组合并到一个大数组中
        for (int k = low; k <= high; k++) {
            if (i > mid) {
                // 左边的全部是小值提前取完了,剩下右边的直接赋值
                arr[k] = aux[j];
                j++;
            } else if (j > high) {
                // 右边的全部是小值提前取完了,剩下左边的直接赋值
                arr[k] = aux[i];
                i++;
            } else if (aux[i] > aux[j]) {
                // 左边数组的元素大于右边数组的元素,取右边的小元素
                arr[k] = aux[j];
                j++;
            } else { // aux[left] <= aux[right]
                // 右边数组的元素大于等于左边数组的元素,取左边的小元素
                arr[k] = aux[i];
                i++;
            }
        }
    }
}

归并排序代码优化1

归并排序代码优化1,小数据规模时改用插入排序。

如果待排序的数据量很大,当子数组拆分的比较小时,可以改成插入排序,因为插入排序的小数据规模时效率很高,这种优化方式可以改进大多数递归排序算法的性能。

优化后代码如下:

public class MergeSortTest2 {
    public static void main(String[] args) {
        int[] arr = new int[] {73195286};
        mergeSort(arr);
        System.out.println(Arrays.toString(arr));
    }

    public static void mergeSort(int[] arr) {
        // 使用一个额外的数组,容量和要排序的数组容量一样大
        int[] aux = new int[arr.length];
        mergePass2(arr, 0, arr.length - 1, aux);
    }

    // 优化: 对小规模子数组使用插入排序
    // 对arr数组的[low, hight]索引元素进行归并排序
    private static void mergePass2(int[] arr, int low, int high, int[] aux) {
        if (low >= high) return;
        // 对小规模子数组使用插入排序
        if (high - low <= 15) {
            insertionSort(arr, low, high);
        }

        // 将一个数组拆分为两个数组进行排序
        int mid = (low + high) / 2;
        mergePass2(arr, low, mid, aux);
        mergePass2(arr, mid + 1, high, aux);
        // 拆分完成后需要对这两个数组进行排序
        merge(arr, low, mid, high, aux);
    }

    // 对数组指定索引范围的元素使用插入排序
    public static void insertionSort(int[] arr, int low, int high) {
        for (int i = low + 1; i < high; i++) {
            int e = arr[i]; // 得到当前这个要插入的元素
            int j;
            for (j = i; j > 0 && arr[j-1] > e; j--) {
                arr[j] = arr[j-1];
            }
            arr[j] = e;
        }
    }

    // 将arr[low, mid]和arr[mid+1, high]两部分进行归并
    private static void merge(int[] arr, int low, int mid, int high, int[] aux) {
        // 临时数组的内容就是两个待排序数组的内容,排好序的内容会放到arr中
        for (int k = low; k <= high; k++) {
            aux[k] = arr[k];
        }

        // i是左边数组的索引
        // j是右边数组的索引
        int i = low;
        int j = mid + 1;

        // 遍历左边和右边的小数组合并到一个大数组中
        for (int k = low; k <= high; k++) {
            if (i > mid) {
                // 左边的全部是小值提前取完了,剩下右边的直接赋值
                arr[k] = aux[j];
                j++;
            } else if (j > high) {
                // 右边的全部是小值提前取完了,剩下左边的直接赋值
                arr[k] = aux[i];
                i++;
            } else if (aux[i] > aux[j]) {
                // 左边数组的元素大于右边数组的元素,取右边的小元素
                arr[k] = aux[j];
                j++;
            } else { // aux[left] <= aux[right]
                // 右边数组的元素大于等于左边数组的元素,取左边的小元素
                arr[k] = aux[i];
                i++;
            }
        }
    }
}

归并排序代码优化2

归并排序使用递归进行排序,在代码编写和阅读上比较清晰,容易理解,但是当待排数据量很大时,递归会造成时间和空间上的性能损耗,并且可能会造成栈溢出。我们排序追求的就是效率,可以将递归转化成迭代,也就是自底向上归并排序。从而改善归并排序的性能。

自底向上归并排序可以分为两个过程:

  1. 合并排序:从子序列长度为1开始,进行两两合并排序,得到2倍长度的有序大序列。
  2. 循环:子序列长度从1开始,循环让子序列长度是原先的两倍,不断进行合并排序。

自底向上归并排序动画效果如下:

自底向上归并排序代码如下:

public class BottomUpMergeSortTest2 {
    public static void main(String[] args) {
        int[] arr = new int[] {73195286};
        mergeSortBottomUp(arr);
        System.out.println(Arrays.toString(arr));
    }

    // 不使用递归,自底向上的归并排序
    public static void mergeSortBottomUp(int[] arr) {
        // 使用一个额外的数组,容量和要排序的数组容量一样大
        int[] aux = new int[arr.length];

        // 在for循环中对数组进行拆分
        // size为子数组的长度
        for (int size = 1; size < arr.length; size += size) { // size = 1, 2, 4
            for (int low = 0; low < arr.length -size; low += size+size) {
                // 优化: 如果左边子数组的最后一个元素大于右边子数组的最小值说明需要排序
                if (arr[low+size-1] > arr[low+size]) {
                    merge(arr, low, low + size -1, Math.min(low + size + size - 1, arr.length - 1), aux);
                }
            }
        }
    }

    // 将arr[low, mid]和arr[mid+1, high]两部分进行归并
    private static void merge(int[] arr, int low, int mid, int high, int[] aux) {
        // 临时数组的内容就是两个待排序数组的内容,排好序的内容会放到arr中
        for (int k = low; k <= high; k++) {
            aux[k] = arr[k];
        }

        // i是左边数组的索引
        // j是右边数组的索引
        int i = low;
        int j = mid + 1;

        // 遍历左边和右边的小数组合并到一个大数组中
        for (int k = low; k <= high; k++) {
            if (i > mid) {
                // 左边的全部是小值提前取完了,剩下右边的直接赋值
                arr[k] = aux[j];
                j++;
            } else if (j > high) {
                // 右边的全部是小值提前取完了,剩下左边的直接赋值
                arr[k] = aux[i];
                i++;
            } else if (aux[i] > aux[j]) {
                // 左边数组的元素大于右边数组的元素,取右边的小元素
                arr[k] = aux[j];
                j++;
            } else { // aux[left] <= aux[right]
                // 右边数组的元素大于等于左边数组的元素,取左边的小元素
                arr[k] = aux[i];
                i++;
            }
        }
    }
}

自底向上归并排序,避免了递归时深度为log2n的栈空间,额外使用了aux数组和原数组一样大小的空间,因此空间复杂度为0(n),并且避免了递归在时间性能上有一定的提升,所以使用归并排序时,优先考虑非递归方法。

归并排序复杂度

一张图看懂归并排序时间复杂度,如下图:

归并排序会将数据规模为n的数据拆分成

每次比较n次,因此总的时间复杂度为

归并排序空间复杂度复杂度:因为归并排序过程中需要使用一个和原始数组相同大小的辅助数组,所以归并排序的空间复杂度为O(n)。

总结

  1. 归并排序思想,将原数据序列分成大小相等的两个子序列,继续划分子序列,直到子序列有序时,将划分的有序子序列合并成大的有序序列,最终合并成一个有序序列。归并排序分成两个步骤:1.拆分,2.合并。
  2. 归并排序代码优化1,小数据规模时改用插入排序。
  3. 自底向上归并排序,避免递归时间和空间上的额外消耗。

---------- End ----------
原创文章和动画制作真心不易,您的点赞就是最大的支持!

想了解更多文章请关注微信公众号:表哥动画学编程