归并排序 - Merge Sort

September 22, 2022

『归并排序』基于分支算法实现,时间复杂度为:O(nlogn)。一般有两种实现方式,一种是自顶向下的递归实现,但递归调用需要使用额外的栈空间,空间复杂度为 O(logn);另一种是自底向上的时间方式,空间复杂度为常数级的 O(1)。

nlogn 比 n^2 快多少?

下面的表格中简单比较了在 10 ~ 10^5 数据量下,nlogn 和 n^2 分别耗费的时间:

n^2 nlogn faster
n = 10 100 33 3
n = 100 1000 664 15
n = 10^3 10^6 9966 100
n = 10^4 10^8 132877 753
n = 10^5 10^10 1660964 6020

最后一列 faster 表示了 nlogn 比 n^2 快了多少倍。在 n = 10 时,nlogn 只比 n^2 快了 3 倍,在现代的计算机上,可能只是 1ms 和 3ms 的差别,我们可能根本感知不到。

但随着 n 的数据量逐渐增大,nlogn 比 n^2 的优势会越来越大,当 n = 10^5 时,这个数据量还没那么大的时候,nlogn 已经比 n^2 快了近 6000 倍。这是什么概念呢?如果我们处理一个 10^5 左右数据量的数据集,用 nlogn 的算法处理需要花费一天,那么用 n^2 的算法就需要花费六千天。

实现思路 - 自顶向下

现有一待排序数组 [8,6,2,3,1,5,7,4]。归并排序要做的事就是将该数组分成两半,然后分别将左边数组排序,再将右边数组排序,之后再将两部分归并起来:

MergeSort-a

对左边和右边数组排序的过程,就是再将左边的数组和右边的数组分成两半,然后对每个部分排序:

MergeSort-2

对于上面数组的每个部分进行的排序操作,依然是先将其分半,之后再将其归并。等到数组不能再分的时候,也就是下图中的这种情况,每个元素就是单独的一半时,每个元素默认就是有序的了:

MergeSort-3

上述待排序的数组共 8 个元素,每次将每个整块元素分为两块,共分了 3 次,也就是计算 8 除以 2 除几次等于 1,换算成数学公式就是以 2 为底的 8 的对数 等于 3:

MergeSort-4

如果共有 N 个元素的话,就会分成 logN 个层级,如果 N 为奇数的话,向上取整就好了。

===》 归并的过程:

虽然每层都将元素分成了不同的部分,但每层要处理的元素个数是相同的。如果每层都能以 O(N) 的时间复杂度解决的话,那么就设计出了 Nlog(N) 级别的算法。

实际上,这也是 Nlog(N) 时间复杂度算法的主要来源。通常都是上述这种二分法达到了 log(N) 的层级,之后每个层级用 O(N) 级别的算法做事情。

大概流程如下:

Merge-a

对第三层中的每一半进行两两归并,结果如下↯↯

Merge-b

对第二层中的每一半进行两两归并,结果如下↯↯

Merge-c

🤔 现在需要解决的问题是,我们能不能将数组划分成两部分,之后将这两部分都分别排好序后,使用 O(N) 级别的算法,将它们归并到一起,形成一个新的数组呢?

答案是可以的。但是在原数组上操作比较困难,需要开辟一块新的空间。这也是归并排序与其他排序不同的地方。归并排序虽然能将算法的时间复杂度提升到 O(nlogn) 级别,但是它比其他的算法要多使用了额外的空间。

归并的过程简单描述如下:

使用索引 k 指向原数组,索引 i 指向辅助数组的左半部分的第一个元素,索引 j 指向辅助数组的右半部分的第一个元素:

merge-1

比较 索引i 和 索引j 指向的元素的值,如果 nums[i] > nums[j],就将 nums[j] 的值赋给 索引k 指向的值,并将 索引j 和 索引k 移动到下一个元素:

merge-2

重复上述操作:

merge-3

为了避免 i 和 j 越界,需要设置 l、r 和 mid 指针分别指向如下位置:

merge-4

码一下

Java 版

public class MergeSort {

    public static void sort(int[] nums) {
        mergeSort(nums, 0, nums.length - 1);
    }

    /**
     * 递归使用归并排序,对 arr[l...r] 的范围进行排序
     *
     * @param nums
     * @param l
     * @param r
     */
    private static void mergeSort(int[] nums, int l, int r) {
        // 处理递归到底的情况
        if (l >= r) {
            return;
        }
        int mid = l + (r - l) / 2;
        mergeSort(nums, l, mid);
        mergeSort(nums, mid + 1, r);
        merge(nums, l, mid, r);
    }

    /**
     * 对 arr[l...mid] 和 arr[mid+1...r] 两部分进行合并
     *
     * @param nums
     * @param l
     * @param mid
     * @param r
     */
    private static void merge(int[] nums, int l, int mid, int r) {
        // 辅助的空间,aux -> auxiliary
        int[] aux = new int[r - l + 1];

        // 初始化辅助空间的值,aux 数组从索引 0 开始赋值,但 nums 数组从 l 开始遍历,所以赋值时需要设置 i-l 的偏移量
        for (int i = l; i <= r; i++) {
            aux[i - l] = nums[i];
        }

        // 索引 i 和 j 分别指向 mid 指针左右两侧的首个元素
        int i = l, j = mid + 1;
        for (int k = l; k <= r; k++) {
            if (i > mid) {
                nums[k] = aux[j - l];
                j++;
            } else if (j > r) {
                nums[k] = aux[i - l];
                i++;
            } else if (aux[i - l] < aux[j - l]) {
                nums[k] = aux[i - l];
                i++;
            } else {
                nums[k] = aux[j - l];
                j++;
            }
        }
    }
}

GoLang 版

优化

优化一

在合并两部分数组之前,就已经保证了这两部分数组,从 l ~ mid,mid+1 ~ r 都是有序的,那么如果 nums[mid] <= nums[mid+1],就无需在执行合并两部分数组的操作了,因为这两部分已经是有序的了。所以只有当 nums[mid] > nums[mid+1] 时,才需要执行 merge 操作。修改如下:

public class MergeSortOptimizeOne {

    public static void sort(int[] nums) {
        mergeSort(nums, 0, nums.length - 1);
    }

    private static void mergeSort(int[] nums, int l, int r) {
        if (l >= r) {
            return;
        }
        int mid = l + (r - l) / 2;
        mergeSort(nums, l, mid);
        mergeSort(nums, mid + 1, r);
        if (nums[mid] > nums[mid + 1]) {
            merge(nums, l, mid, r);
        }
    }

    private static void merge(int[] nums, int l, int mid, int r) {
        int[] aux = new int[r - l + 1];
        for (int i = l; i <= r; i++) {
            aux[i - l] = nums[i];
        }
        int i = l, j = mid + 1;
        for (int k = l; k <= r; k++) {
            if (i > mid) {
                nums[k] = aux[j - l];
                j++;
            } else if (j > r) {
                nums[k] = aux[i - l];
                i++;
            } else if (aux[i - l] < aux[j - l]) {
                nums[k] = aux[i - l];
                i++;
            } else {
                nums[k] = aux[j - l];
                j++;
            }
        }
    }
}

优化二

当数组内待排序的元素个数比较小时,整个数组近乎有序的概率就比较大,此时可以将归并排序替换为插入排序,因为在近乎有序的情况下,插入排序更有优势。

实现思路 - 自底向上

现在有一待排序数组:[8,6,2,3,1,5,7,4],从左到右将该数组中的元素依次划分成小段,比如两个元素一个小段:

ms-m600

进行归并排序:

ms-01-m600

当归并元素排序完成一轮之后,再 4 个元素一个小段的进行归并排序:

ms-02-m600

ms-03-m600

最后再 8 个元素一个小段,在当前示例中也就是整个数组全体,进行归并排序:

ms-04-m600

最终完成了整个数组的排序过程。

ms-05-m600


Profile picture

栗子 今日事 · 今日毕
Never put off till tomorrow what you can do today