那咱们借用 cs50 里的例子,好比要把一摞卷子排好序,那用并归排序的思想是怎么作的呢?java
感受啥都没说?
那是由于上面的过程里省略了不少细节,咱们一个个来看。面试
答案是用一样的方法排好序。算法
这里须要借助两个指针和额外的空间,而后左边画一个彩虹🌈右边画个龙🐲,不是,是左边拿一个数,右边拿一个数,两个比较大小以后排好序放回到数组里(至于放回原数组仍是新数组稍后再说)。编程
这其实就是分治法 divide-and-conquer 的思想。
归并排序是一个很是典型的例子。数组
顾名思义:分而治之。分布式
就是把一个大问题分解成类似的小问题,经过解决这些小问题,再用小问题的解构造大问题的解。ide
听起来是否是和以前讲递归的时候很像?函数
没错,分治法基本都是能够用递归来实现的。动画
在以前,咱们没有加以区分,固然如今我也认为不须要加以区分,但你若是非要问它们之间是什么区别,个人理解是:spa
分治法是一种解决问题的思想:
因此分治法的三大步骤是:
「分」:大问题分解成小问题;
「治」:用一样的方法解决小问题;
「合」:用小问题的解构造大问题的解。
那回到咱们的归并排序上来:
「分」:把一个数组拆成两个;
「治」:用归并排序去排这两个小数组;
「合」:把两个排好序的小数组合并成大数组。
这里还有个问题,就是何时可以解决小问题了?
答:当只剩一个元素的时候,直接返回就行了,分解不了了。
这就是递归的 base case,是要直接给出答案的。
暗示着齐姐对大家的爱啊~❤️
先拆成两半,
分红两个数组:{5, 2} 和 {1, 0}
没到 base case,因此继续把大问题分解成小问题:
固然了,虽然左右两边的拆分我都叫它 Step2,可是它们并非同时发生的,我在递归那篇文章里有说缘由,本质上是由冯诺伊曼体系形成的,一个 CPU 在某一时间只能处理一件事,但我之因此都写成 Step2,是由于它们发生在同一层 call stack,这里就不在 IDE 里演示了,不明白的同窗仍是去看递归那篇文章里的演示吧。
这一层都是一个元素了,是 base case,能够返回并合并了。
合并的过程就是按大小顺序来排好,这里借助两个指针来比较,以及一个额外的数组来辅助完成。
好比在最后一步时,数组已经变成了:
{2, 5, 0, 1},
那么经过两个指针 i 和 j,比较指针所指向元素的大小,把小的那个放到一个新的数组?里,而后指针相应的向右移动。
其实这里咱们有两个选择:
这个取决于题目要求的返回值类型是什么;以及在实际工做中,咱们每每是但愿改变当前的这个数组,把当前的这个数组排好序,而不是返回一个新的数组,因此咱们采起重新数组往原数组合并的方式,而不是把结果存在一个新的数组里。
那具体怎么合并的,你们能够看下15秒的小动画:
挡板左右两边是分别排好序的,那么合并的过程就是利用两个指针,谁指的数字小,就把这个数放到结果里,而后移动指针,直到一方到头(出界)。
public class MergeSort { public void mergeSort(int[] array) { if(array == null || array.length <= 1) { return; } int[] newArray = new int[array.length]; mergeSort(array, 0, array.length-1, newArray); } private void mergeSort(int[] array, int left, int right, int[] newArray) { // base case if(left >= right) { return; } // 「分」 int mid = left + (right - left)/2; // 「治」 mergeSort(array, left, mid, newArray); mergeSort(array, mid + 1, right, newArray); // 辅助的 array for(int i = left; i <= right; i++) { newArray[i] = array[i]; } // 「合」 int i = left; int j = mid + 1; int k = left; while(i <= mid && j <= right) { if(newArray[i] <= newArray[j]) { // 等号会影响算法的稳定性 array[k++] = newArray[i++]; } else { array[k++] = newArray[j++]; } } if(i <= mid) { array[k++] = newArray[i++]; } } }
写的不错,我再来说一下:
首先定义 base case,不然就会成无限递归死循环,那么这里是当未排序区间里只剩一个元素的时候返回,即左右挡板重合的时候,或者没有元素的时候返回。
而后定义小问题,先找到中点,
虽然数学上是同样的,
可是这样写,
有可能出现 integer overflow.
这样咱们拆好了左右两个小问题,而后用“一样的方法”解决这两个自问题,这样左右两边就都排好序了~
那在这里,能不能把它写成:
mergeSort(array, left, mid-1, newArray); mergeSort(array, mid, right, newArray);
也就是说,
这样对不对呢?
答案是否认的。
由于会形成无限递归。
最简单的,举个两个数的例子,好比数组为{1, 2}.
那么 left = 0, right = 1, mid = 0.
用这个方法拆分的数组就是:
因此这样来分并没有缩小问题,没有把大问题拆解成小问题,这样的“分”是错误的,会出现 stack overflow.
再深一层,究其根本缘由,是由于 Java 中的小数是「向零取整」
。
因此这里必需要写成:
接下来就是合并的过程了。
在这里咱们刚才说过了,要新开一个数组用来帮助合并,那么最好是在上面的函数里开,而后把引用往下传。开一个,反复用,这样节省空间。
咱们用两个指针:i 和 j 指向新数组,指针 k 指向原数组,开始刚才动画里的移动过程。
要注意,这里的等于号跟哪边,会影响这个排序算法的稳定性。不清楚稳定性的同窗快去翻一下上一篇文章啦~
那像我代码中这种写法,指针 i 指的是左边的元素,遇到相等的元素也会先拷贝下来,因此左边的元素一直在左边,维持了相对顺序,因此就是稳定的。
最后咱们来分析下时空复杂度:
归并排序的过程涉及到递归,因此时空复杂度的分析稍微有点复杂,在以前「递归」的那篇文章里我有提到,求解大问题的时间就是把全部求解子问题的时间加起来,再加上合并的时间。
咱们在递归树中具体来看:
这里我右边已经写出来了:
「分」的过程,每次的时间取决于有多少个小问题,能够看出来是
1,2,4,8...这样递增的,
那么加起来就是O(n).
「合」的过程,每次都要用两个指针走彻底程,每一层的 call stack 加起来用时是 O(n),总共有 logn 层,因此是 O(nlogn).
那么总的时间,就是 O(nlogn).
其实归并排序的空间复杂度和代码怎么写的有很大的关系,因此我这里分析的空间复杂度是针对我上面这种写法的。
要注意的是,递归的空间复杂度的分析并不能像时间复杂度那样直接累加,由于空间复杂度的定义是在程序运行过程当中的使用空间的峰值,自己就是一个峰值而非累加值的概念。
那也就是 call stack 中,所使用空间最高的时刻,其实就是递归树中最右边的这条路线:它既要存着左边排好序的那半边结果,还要把右边这半边继续排,总共是 O(n).
那有同窗说 call stack 有 logn 层,为何不是 O(logn),由于每层的使用的空间不是 O(1) 呀。
这两节介绍的排序算法都属于内部排序算法,也就是排序的过程都是在内存中完成。
但在实际工做中,当数据量特别大时,或者说比内存容量还要大时,数据就没法一次性放入内存中,只能放在硬盘等外存储器上,这就须要用到外部排序算法算法来完成。一个典型的外排序算法就是外归并排序(External Merge Sort)。
这才是一道有意思的面试题,在经典算法的基础上,加上实际工做中的限制条件,和面试官探讨的过程当中,就能看出 candidate 的功力。
要解决这个问题,实际上是要明确这里的限制条件是什么:
首先是内存不够。那除此以外,咱们还想尽可能少的进行硬盘的读写,由于很慢啊。
好比就拿wiki上的例子,要对 900MB 的数据进行排序,可是内存只有 100MB,那么怎么排呢?
那这是在一台机器上的,若是数据量再大,好比在一个分布式系统,那就须要用到 Map-Reduced 去作归并排序,感兴趣的同窗就继续关注我吧~