定义
归并排序(Merge Sort)是一种基于分治思想的排序算法,其主要思想是将待排序数组分成两部分,分别对这两部分进行排序,然后将已排序的子数组合并成一个大的有序数组。
约翰·冯·诺依曼发明的算法;采用分治思想,将问题分成一些小问题,然后再将小问题进行解决,最终将解决完的小问题合并成原有的问题,这样就解决了问题。这种思想在编程中屡屡体现,例如微服务,可以说是非常经典的解决方式了。
但是算法思想和整个代码的实现,说实话我觉得很厉害,冯·诺依曼不愧为大佬,此文会探究其实现的思想步骤。
图解
假如,我们有一个数组,数组中的元素需要排序,假设给出的数组是这个:
int[] arr = {1,4,7,8,3,6,9};
注意:排序的方法当然有很多,此处主要讨论题目说的归并排序,其他的不做讨论。 以上述假设为例,归并排序是采用以下所示进行排序的:
step1:分
step2:治
以上就是采用归并排序算法将数组排序的过程,其所有对比的然后合并的过程都在治这个图里面了;以防初学者不明白对比过程,我贴出纯数字对比的图,如下:
思想
上面的图片看明白之后,是不是觉得很简单。算法流程是不难的,但是要理解算法的思想却并不是那么容易的;
我看了网上的一些博文,他们也会贴出我上面的类似的图片,将整个算法流程都明白的展示出来,然而我看了之后却并不满足,因为看完之后对整个对比过程充满了疑惑,疑惑如下:
1.分割成两个元素的左右数组之后,其对比的顺序为什么是流程展示的那样,而不是另外的对比顺序?例如:左数组A:1 4 右数组B:7 8 其对比顺序是 1和7比,然后4再和7比;
为什么不是1和8比,然后再1和7比?其对比顺序是怎么确认的?
2.分割成两个元素的左右数组之后,其对比在这个案例里面只需要两次就将四个数大小比较出来了(剧透一下:归并最多三次就可以比较出来),而正常的四个数比较大小,需要最多六次,例如冒泡排序最坏的情况(n*(n-1)/2就是最多比较次数计算)要比六次。还是1 4 7 8 我们正常比较大小,最坏情况需要每个数跟另外三个数都对比才能比较出大小:
1 9 7 8
1 9 -> 1
1 7 -> 1
1 8 -> 1
9 7 -> 7
9 8 -> 8
7 8 -> 7
冯·诺依曼大佬是怎么确定只要按照归并的排序顺序就可以确定一定能对比出大小,而不会遗漏其他的对比可能? 我相信网上即使看过归并排序文章,并且能够写出归并排序的人,也不一定能够回答出我提出的两个问题,因为正如我前面说的那样:归并排序这个算法流程是不难的,但是要理解这个算法的思想却并不是那么容易的; 而我就深陷这两个疑问,查找了许多网上的教程,然而结果不尽人意。。。
我百思不得其解,疑惑了一两个小时.后来在归并排序实现代码里面,我发现了其对比的顺序是有规可循的。例如还是:
左数组A:1 4 7 8
右数组B:3 6 9
如下对比图:
发现没有,每次对比之后下次对比大小的时候都是从A和B数组首位进行对比的!
也就是说归并排序对比的时候,每次都是按照对比左右数组第一个下标所处元素进行对比以此来确定其对比的顺序的!!!
好了,解决了第一个问题了,其对比顺序是按照每次都对比左右数组的首位来对比的。那么问题来了,为什么左右数组都采用首位进行对比呢,而不是左右数组第二位,第三位来对比呢?
对于这个问题,我也想了一会,当然不像第一个问题那么难想明白了。。。
如图:
还记得前面归并排序的定义吗?
将问题分成一些小问题,然后再将小问题进行解决
因为首位对比的时候,我们可以将其看成两个数组对比!!!例如假设我们要排序1和3,将这两个数放进数组里面,那么对于数组来说,这两个就是第一个下标所在的元素,而对比1和3来说,我们只需要对比一次就可以了!!!也就是说在归并排序里面,我们每次对比都可以看成是两个单独的一个元素所在的数组在进行对比,而这样的对比我们不需要考虑会不会排序错误,因为每次对比一次就可以得到结果(最少排序元素一定是两个元素)。通过多次小问题的解决,也就是两个元素的对比,然后依次对比完所有的元素,这样就将这个问题解决了!那么,难道给定的任意个元素的两个数组,都可以按照上面说的依次对比完首位元素,就可以完成排序吗?当然不能。。。。。。
例如:
左数组A:1 4 7 8
右数组B:6 3 9
按照首位依次对比出来的结果根本不是有序的。
有一个前提,在归并排序中:可以将每次多元素的数组首位对比出来实现排序的原因是因为任意多元素的数组都是有序的,只有在这个前提下才能按照将每次对比都可以看成是两个单独的一个元素所在的数组在进行对比。
所以到这里,解决了第二个问题。因为在归并排序形成的多元素数组里面都是有序的,依次排序好的,所以每次首位对比的时候,都可以看成是最简单的两个元素的对比,而依照这个的对比顺序,可以保证整个对比顺序都一定是有序的。
感言
在思考上述思想的过程中,我一直在想,这个算法思想的形成,冯·诺依曼大佬应该不是一蹴而就的吧。以一个普通人的视角来看,其实现一定经历了,对排序实现的观察(两个具有多元素的有序排序该怎么对比),以及对排序思想的想象和思考(最少元素对比是否能够延伸,而延伸又需要怎样的条件?)。当然,可能大佬思想牛,指不定就是一会就想到了,不过不管怎样,其在分治的思想下,能如此实现一个排序算法,确实牛啊。
实现
基础版
实现归并排序的代码,如下:
public class MergeSortTest1 {
public static void main(String[] args) {
// 定义测试数组
int[] arr = {1,4,7,8,3,6,9};
merge(arr);
}
private static void merge(int[] arr) {
//将整个数组的下标分一半,也就是将整个数组分两部分
int half = arr.length / 2;
// 创建一个存放排序后数字的数组
int[] temp = new int[arr.length];
// 定义左边数组的第一个下标
int i = 0;
// 定义右边数组的第一个下标
int j = half + 1;
// 定义新数组的第一个下标
int k = 0;
while (i <= half && j < arr.length){
// 在左半部分的时候,当第一个数字小于第四个数字的时候,就将第一个数字放在新数组的第一个
// 否则,就是第四个数字小于第一个数字,就将第四个数字放在新数组的第一个
if (arr[i] <= arr[j]){
/*System.out.println(h +"此时的i为: "+i
+" " +"此时的j为:"+j
+" "+"此时的arr[i]为:"+arr[i]
+" "+"此时的arr[j]为: "+arr[j]);*/
temp[k] = arr[i];
i++;
// k++;
} else {
temp[k] = arr[j];
j++;
// k++;
}
k++;
}
System.out.println("-----------------------------------"+"\n");
System.out.println("输出的i为: "+i+
" 输出的half为:"+half+
" 输出的j为:"+j);
// i=4 half=3 j=6
// 将左边剩余的归并
while(i <= half){
temp[k++]= arr[i++];
}
// 将右边剩余的归并
while (j < arr.length){
temp[k++]= arr[j++];
}
for (int l = 0; l < temp.length; l++) {
System.out.println("排序后输出的新数组为: "+ temp[l]);
}
}
}
以上代码并不是很好理解,需要剖析一下。
第一点:前提
在这个基础版里面。
//将整个数组的下标分一半,也就是将整个数组分两部分
int half = arr.length / 2;
可以看到该段代码并没有将整个数组拆分为只剩单元素的数组,只是单纯的将原有的数组拆分为左右两个数组,并且该段代码的测试数组{1,4,7,8,3,6,9}拆分为两个数组之后的左右数组都是有序的:{1,4,7,8}和{3,6,9}。
以上是该段代码能够实现排序的前提,或者说条件,因而言该段代码并不具有普遍性,只是让初学者更好理解算法的案例
ps:我一开始学归并排序的时候就是看这个代码,这个代码基于前面思想篇疑惑了很久,并且我初学那会还以为这样实现具有普遍性觉得无论给出咋样的数组都可以实现排序。。。。。作为初学者一定要理解该段代码(没有半点疑惑的理解这个代码),不然后面版本更加理解不了。
第二点:拆分
// 创建一个存放排序后数字的数组
int[] temp = new int[arr.length];
// 定义左边数组的第一个下标
int i = 0;
// 定义右边数组的第一个下标
int j = half + 1;
// 定义新数组的第一个下标
int k = 0;
以下是关于上面代码的解析:
第三点:排序
while (i <= half && j < arr.length){
// 在左半部分的时候,当第一个数字小于第四个数字的时候,就将第一个数字放在新数组的第一个
// 否则,就是第四个数字小于第一个数字,就将第四数字放在新数组的第一个
if (arr[i] <= arr[j]){
/*System.out.println(h +"此时的i为: "+i
+" " +"此时的j为:"+j
+" "+"此时的arr[i]为:"+arr[i]
+" "+"此时的arr[j]为: "+arr[j]);*/
temp[k] = arr[i];
i++;
// k++;
} else {
temp[k] = arr[j];
j++;
// k++;
}
k++;
}
以下是关于上面代码的解析:
第四点:排序最后元素
第四点理解有一定难度。当然看完思想篇以及前面三点解析之后会觉得并不难,但是要是上来就让你看下面的代码,你可能就不理解了。
// i=4 half=3 j=6
// 将左边剩余的归并
while(i <= half){
temp[k++]= arr[i++];
}
// 将右边剩余的归并
while (j < arr.length){
temp[k++]= arr[j++];
}
以下是关于上面代码的解析:
在这点里面,其实可以看到:在归并排序中,当对比元素的时候无论给定怎样的元素排序,总会剩下一个或者多个元素没有进行排序的。
加入递归
基础版理解核心代码的实现,而加入递归才是真正实现递归排序的精髓。
/**
* 归并排序算法详解2:
* 默认是要求升序排序
* 以下是完整的归并排序算法实现
* */
public class MergeSortTest3 {
public static void main(String[] args) {
// int[] arr = {6, 5, 3, 8, 7, 2, 4, 1};
int[] arr = {1,4,7,8,3,6,9};
int i = 0;
// 原有数组输出
while(i <arr.length){
System.out.print(arr[i] + " ");
i++;
}
System.out.println();
MergeSortTest3 mergeSort = new MergeSortTest3();
mergeSort.mergeSort(arr, 0, arr.length - 1);
// 排序后的数组输出
for (int num : arr) {
System.out.print(num + " ");
}
}
public void mergeSort(int[] arr, int left, int right) {
if (left < right) {
int mid = (left + right) / 2;
// 分割左边子数组
mergeSort(arr, left, mid);
// 分割右边子数组
mergeSort(arr, mid + 1, right);
// 合并两个子数组
merge(arr, left, mid, right);
}
}
public void merge(int[] arr, int left, int mid, int right) {
int[] temp = new int[right - left + 1];
int i = left;
int j = mid + 1;
int k = 0;
// 合并两个子数组
while (i <= mid && j <= right) {
System.out.println("此时的i为: "+i
+" " +"此时的j为:"+j
+" "+"此时的arr[i]为:"+arr[i]
+" "+"此时的arr[j]为: "+arr[j]);
if (arr[i] <= arr[j]) {
temp[k++] = arr[i++];
} else {
temp[k++] = arr[j++];
}
}
// 将剩余元素拷贝到temp数组中
while (i <= mid) {
temp[k++] = arr[i++];
}
while (j <= right) {
temp[k++] = arr[j++];
}
// 将temp数组拷贝回原数组
for (int m = 0; m < temp.length; m++) {
arr[left + m] = temp[m];
}
}
}
递归理解
上面是对mergeSort递归函数的理解,至于merge函数,跟基础版一样的,因此不再赘述;
最终实现
这个版本只是对递归一些代码的合并,并且简化了一些代码实现,其逻辑跟加入递归版本的逻辑是一样的,故而不再赘述实现。
如下:
/**
* 归并排序算法详解2:
* 默认是要求升序排序
* 以下是完整的归并排序算法实现
* */
public class MergeSortTest4 {
public static void main(String[] args) {
// 定义一个要排序的数组
int[] oldArray = {1,4,7,8,3,6,9};
// 输出原有数组数组
System.out.println(Arrays.toString(oldArray) +"\n");
System.out.println(Arrays.toString(mergeSort(oldArray,0,oldArray.length -1)));
}
/**
* 拆分数组的自定义递归方法
* @param oldArray 需要排序的原来数组
* @param start 被拆分区间的第一个元素下标
* @param end 被拆分区间的最后一个元素下标
* @return 有序的新数组
*/
private static int[] mergeSort(int[] oldArray,int start,int end) {
if(start == end){
return new int[]{oldArray[start]};
}
// 定义被拆分区间的中间点:为下一次拆分做准备
int mid = start + (end - start) / 2;
// 定义拆分之后的左边数组和右边数组
// 左边数组
int[] leftArray = mergeSort(oldArray,start,mid);
// 右边数组
int[] rightArray = mergeSort(oldArray,mid + 1,end);
// 存储每一次排序,并且合并后有序的新数组
int[] newArray = new int[leftArray.length + rightArray.length];
// 定义左边区域数组第一个元素的下标变量
int l = 0;
// 定义右边区域数组的第一个元素的下标变量
int r = 0;
// 定义新数组用于存储排序后数组的下标变量
int n = 0;
while(l < leftArray.length && r < rightArray.length){
// 对比两个数组中的元素
// 将左边数组还剩余的元素放进新数组里面
while(l < leftArray.length){
newArray[n++] = leftArray[l++];
}
// 将右边数组还剩余的元素放进新数组里面
while(r < rightArray.length){
newArray[n++] = rightArray[r++];
}
return newArray;
}
}
拆分探究
在上面完成归并排序代码里面拆分的数组 {1,4,7,8,3,6,9},并不是能够平等拆分的,拆分的结果是左边数组含有四个元素,右边的数组含有三个元素。对于此,我也产生了问题:
1.对于这种无法平分的情况,可不可以拆成左边数组含有三个元素,右边数组含有四个元素这样?2.拆分是一定是要拆分后的左边数组元素数量多于右边数组元素数量这种拆法吗?不可以反过来吗?
首先咱们来看上述成功拆分的方式。
在加入递归版本中,其拆分策略方式:
在最终实现的版本中,其拆分方式如下图:
这种拆分方式都是按照左边数组的元素多于右边数组的元素,是可以成功拆分并且不影响排序的,不做赘述。
然后我们看按照数组左边元素个数小于右边元素个数的方式拆分,如下:
方式一:
方式二:
方式三:
方式四:
拆分总结
上面四种方式就是拆分按照左边数组元素数量少于右边数组元素数量的情况,以逻辑来说,只有这四种情况;无论怎么拆分,这个时候需要除于2后得到的商-1,因为左边元素下标需要向前移动一位。由上可得:
对于这种不平均的拆分,不可以按照左边数组元素数量少于右边数组元素数量的拆分;因为如果按照左边数组元素数量少于右边数组元素数量的拆分会导致后面的拆分无法继续下去,无法准确的算出每一个数组的中间点,故而言:拆分方式必须要按照左边数组元素数量多于右边数组元素数量的拆分方式进行下去!!!
所以,在归并排序里面非平均拆分的时候,必然是拆分后左边数组元素数量多于右边数组元素数量。
总结
1.在归并排序中,基于每次拆分又合并后的多元素左右数组都是有序的原因,所以左右两个数组只要每次都将第一个下标所在的元素进行对比,那么就一定可以正确排序出来;2.在归并排序中,拆分方式必须要按照拆分后左边数组元素数量多于右边数组元素数量的方式进行拆分;
鸣谢
还有不少文章也看了,但是作用不大并且不记得链接了。。。。。。。