前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >极客算法训练笔记(七),十大经典排序之归并排序,全网最详

极客算法训练笔记(七),十大经典排序之归并排序,全网最详

作者头像
阿甘的码路
发布2020-12-15 10:32:06
4320
发布2020-12-15 10:32:06
举报
文章被收录于专栏:阿甘的码路2阿甘的码路2

目录

  • 十大经典排序算法江山图
  • 归并排序
    • 算法描述
    • 算法思想
    • 动图演示
    • 代码实现
    • 稳定性分析
    • 时间复杂度分析
    • 空间复杂度分析
  • 归并排序和快速排序对比

十大经典排序算法江山图

十大经典排序算法江山图

冒泡,选择和插入排序,它们的时间复杂度都是O(n2),比较高,适合小规模数据的排序;希尔排序和快速排序都不稳定,这篇我们来说说稳定的归并排序。归并排序在数据量大且数据递增或递减连续性好的情况下,效率比较高,且是O(nlogn)复杂度下唯一一个稳定的排序,致命缺点就是空间复杂度O(n)比较高。

实际上,工程里面的算法都是结合这些算法的优缺点来使用的,例如Java1.8源码中Arrays.sort()排序函数,就同时使用了插入排序,快速排序和归并排序:

  1. 在元素小于 47 的时候用插入排序;
  2. 大于 47 小于 286 用双轴快排;
  3. 大于 286 用 timsort 归并排序,并在 timsort 中记录数据的连续的有序段的的位 置,若有序段太多,也就是说数据近乎乱序,则用双轴快排;
  4. 上面提到的快排的递归调用的过程中,若排序的子数组数据数量小,用插入排序。

归并排序使用的就是分治思想,这个思想我在上一篇讲希尔排序的时候也提到过,归并排序是高效算法设计中最典型的一个了。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决,小的子问题解决了,大问题也就解决了。分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。

道理咱都懂,怎么分,怎么治才是硬伤!!!

归并排序

归并排序有两种,自顶向下和自底向。

算法描述

先拆分再归并,将一个大的无序数组,拆分成两个,先处理左边再处理右边(可以对比二叉树前序遍历),一直递归拆分直至只有一个元素然后两两进行归并,一直重复这个过程直至合并完所有的子数组得到有序的完整数组,过程如下图。

自顶向下归并:

自顶向下归并

自底向上归并:

上面过程的逆过程,如图

自底向上归并

算法思想

分治,分而治之,将原数组一直拆分成左右两个小数组,直至小数组元素个数为1,然后每两个小数组进行有序归并,直至归并成一个完整的数组,从而达到整个数组有序的目的。由于每两个小的数组都是有序的,所以在每次合并的时候是很快的。

拆分都是无脑二分,直接用个递归拆分就可以,灵魂是归并过程,下面来图解下归并过程:

❝由图中黄色部分可知,实现归并时将两个不同的有序数组归并到第三个数组中了,这里借助了第三个数组。但是,我们需要进行很多次归并,这样每次归并时都创建一个新数组来存储排序结果就会浪费空间,因此我们可以只创建一个和原数组同样大小的数组作为辅助空间。但是这个数组不是用于存放归并后的结果,而是存放归并前的结果,然后将归并后的结果一个个从小到大放入原来的数组中,可知归并排序还需要一个n大小空间内存进行辅助排序,空间复杂度O(n)。如果原数组很大那么需要双倍的内存空间来排序,这里就是我上面提到的归并排序的最大的弊端,因为上几篇提到的排序那都是在原数组上面进行操作就可以的呢。 ❞

准备

归并1

归并2

归并3,边界处理

动图演示

归并排序

代码实现

如果上面的拆分和归并都理解了的话,写代码应该不难,静下心来写即可,看代码的理解的时候,如果对递归过程不好理解,可以在idea里面对代码进行打断点看全部的过程。

自顶向下,使用递归:

代码语言:javascript
复制
public class MergeSort {
    public static int[] mergeSort(int[] arr) {
        int[] arr_temp = Arrays.copyOf(arr, arr.length);
        sort(arr, arr_temp, 0, arr.length-1);
        return arr;
    }

    public static void sort(int[] arr, int[] arr_temp, int left, int right) {
        // 递归终止条件,子数组长度为1
        if (left >= right) {
            return;
        }
        int mid = (left + right) / 2;
        sort(arr, arr_temp, left, mid);
        sort(arr, arr_temp, mid + 1, right);
        merge(arr, arr_temp, left, mid, right);
    }

    public static void merge(int[] arr, int[] arr_temp, int left, int mid, int right) {
        System.arraycopy(arr, left, arr_temp, left, right - left + 1);
        int i = left;
        int j = mid + 1;
        // 将需要合并的两个数组合起来全部遍历一遍,将其放入临时数组
        for (int k = left; k <= right; k++) {
            // 先考虑两个边界问题,左边指针和右边指针都到头了,即一边处理结束的情况

            // 左半边元素全部处理完毕,右半边元素都大于左半边,右边元素直接落下来到临时数组,右边指针动
            if (i > mid) {
                arr[k] = arr_temp[j];
                j ++;

            // 右半边指针到头了,左半边元素都大于右半边,左边元素直接落下来到临时数组,左边指针动
            } else if (j > right) {
                arr[k] = arr_temp[i];
                i ++;

            // 比较,遍历到的左边元素小于右边元素,左边元素进入临时数组,左边指针右动一位
            } else if (arr_temp[i] < arr_temp[j]) {
                arr[k] = arr_temp[i];
                i ++;

            // 比较,遍历到的左边元素大于右边元素,右边元素进入临时数组,右边指针向右动一位
            } else {
                arr[k] = arr_temp[j];
                j ++;
            }
        }
    }

    public static void main(String[] args) {
        //int[] arr = {5, 7, 8, 3, 1, 2, 4, 6, 8};
        int[] arr = {3, 1, 2, 4};
        //int[] arr = {1, 2, 3};
        arr = mergeSort(arr);
        for (int i=0; i<arr.length; i++) {
            System.out.println(arr[i]);
        }
    }
}

自底向上,非递归的循环方式:

代码语言:javascript
复制
public class MergeSort {
    // 非递归式的归并排序
    public static int[] mergeSort(int[] arr) {
        int n = arr.length;
        // 子数组的大小分别为1,2,4,8...
        // 刚开始合并的数组大小是1,接着是2,接着4....
        for (int i = 1; i < n; i += i) {
            //进行数组进行划分
            int left = 0;
            int mid = left + i - 1;
            int right = mid + i;
            //进行合并,对数组大小为 i 的数组进行两两合并
            while (right < n) {
                // 合并函数和递归式的合并函数一样
                merge(arr, left, mid, right);
                left = right + 1;
                mid = left + i - 1;
                right = mid + i;
            }
            // 还有一些被遗漏的数组没合并,千万别忘了
            // 因为不可能每个字数组的大小都刚好为 i
            if (left < n && mid < n) {
                merge(arr, left, mid, n - 1);
            }
        }
        return arr;
    }
    
    // 合并函数,把两个有序的数组合并起来
    // arr[left..mif]表示一个数组,arr[mid+1 .. right]表示一个数组
    private static void merge(int[] arr, int left, int mid, int right) {
        //先用一个临时数组把他们合并汇总起来
        int[] a = new int[right - left + 1];
        int i = left;
        int j = mid + 1;
        int k = 0;
        while (i <= mid && j <= right) {
            if (arr[i] < arr[j]) {
                a[k++] = arr[i++];
            } else {
                a[k++] = arr[j++];
            }
        }
        while(i <= mid) a[k++] = arr[i++];
        while(j <= right) a[k++] = arr[j++];
        // 把临时数组复制到原数组
        for (i = 0; i < k; i++) {
            arr[left++] = a[i];
        }
    }
}

稳定性分析

稳定。

归并排序稳不稳定关键要看merge()函数,也就是两个有序子数组合并成一个有序数组的那部分代码。在合并的过程中,如果有相同的元素,可以按照顺序依次放入原数组中,这样就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法。

时间复杂度分析

归并排序涉及递归,时间复杂度的分析稍微有点复杂。我们正好借此机会来学习一下,如何分析递归代码的时间复杂度。

递归的适用场景是,一个问题a可以分解为多个子问题b、c,那求解问题a就可以分解为求解问题b、c。问题b、c解决之后,我们再把b、c的结果合并成a的结果。

如果我们定义求解问题a的时间是T(a),求解问题b、c的时间分别是T(b)和 T( c),那我们就可以得到这样的递推关系式: T(a) = T(b) + T(c) + K,其中K等于将两个子问题b、c的结果合并成问题 a 的结果所消耗的时间。

套用这个公式,我们来分析一下归并排序的时间复杂度。我们假设对n个元素进行归并排序需要的时间是T(n),那分解成两个子数组排序的时间都是T(n/2)。我们知道,merge()函数合并两个有序子数组的时间复杂度是O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:

❝T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。 ❞

❝T(n) = 2*T(n/2) + n; n>1 ❞

通过这个公式,如何来求解T(n)呢?还不够直观?那我们再进一步分解一下计算过程。

代码语言:javascript
复制
T(n) = 2*T(n/2) + n

= 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n

= 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n

= 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n ......

= 2^k * T(n/2^k) + k * n

通过这样一步一步分解推导,我们可以得到T(n) = 2^kT(n/2^k)+kn。

当T(n/2^k)=T(1)时,也就是n/2^k=1,我们得到 k=log2n 。

我们将k值代入上面的公式,得到T(n)=Cn+nlog2n 。如果我们用大O标记法来表示的话,T(n)就等于O(nlogn)。所以归并排序的时间复杂度是O(nlogn)。

从我们的原理分析可知,归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情 况,还是平均情况,时间复杂度都是O(nlogn)。

空间复杂度分析

归并排序的时间复杂度任何情况下都是O(nlogn),看起来非常优秀,因为上一篇分析到的即便是快速排序,最坏情况下,时间复杂度也是O(n2)。

注:快速排序算法虽然最坏情况下的时间复杂度是O(n2),但是平均情况下时间复杂度都是O(nlogn)。不仅如此,快速排序算法时间复杂度退化到O(n2)的概率非常小, 我们可以通过合理地选择pivot来避免这种情况。。

但是,归并排序并没有像快排那样,应用广泛,因为它有一个致命的“弱点”,那就是归并排序不是原地排序算法。

通过代码和上面的讲解也知道我借助了和原数组大小相同的数组来进行辅助排序,所以空间复杂度是O(n)。

归并排序和快速排序对比

快排不理解的,看我上一篇 极客算法训练笔记(六),十大经典排序之希尔排序,快速排序

相同点:

  1. 都采用分治算法
  2. 都可以递归实现
  3. 平均时间复杂度都是O(nlogn)

不同点:

  1. 归并排序是先切分、后排序,快速排序是切分、排序交替进行;
  2. 归并排序是稳定的排序,而快速排序是不稳定的排序;
  3. 归并排序在最坏和最好情况下的时间复杂度均为O(nlogn),而快速排序最坏O(n^2),最好O(n);
  4. 快速排序是原地排序,归并不是。

下一篇是堆排序,有收获的朋友帮忙点个赞吧,感谢,另外欢迎关注公众号《阿甘的码路》,各大平台号也有内容同步只不过排版可能会乱~

参考资料:极客时间算法训练营笔记,数据结构与算法之美,算法书籍

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-12-06,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 阿甘的码路 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 十大经典排序算法江山图
  • 归并排序
    • 算法描述
      • 算法思想
        • 动图演示
          • 代码实现
            • 稳定性分析
              • 时间复杂度分析
                • 空间复杂度分析
                • 归并排序和快速排序对比
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档