在大型公司的面试过程中,排序是必问的知识。本篇内容来自《算法(第4版)》 — — Robert Sedgewick, Kevin Wayne
归并排序的实现我是这样来描述的:先对少数几个元素通过两两合并的方式进行排序,形成一个长度稍大一些的有序序列。然后在此基础上,对两个长度稍大一些的有序序列再进行两两合并,形成一个长度更大的有序序列,有序序列的的长度不断增长,直到覆盖整个数组的大小为止,归并排序就完成了。
归并排序有两种实现方式: 基于递归的归并排序和基于循环的归并排序。(也叫自顶向下的归并排序和自底向上的归并排序)
这两种归并算法虽然实现方式不同,但还是有共同之处的:
下面我先介绍两种不同归并算法调用的公共方法, 即完成单趟归并的算法。(两个已经有序的数组序列合并成一个更大的有序数组序列)
在开始排序前创建有一个和原数组a长度相同的空的辅助数组aux
单趟归并的过程如下:
结合上面的过程3, 比较 i 和 j 当前所指的aux中的元素的大小, 取得其中比较大的那个元素(例如上图中的i),将其放入数组a中, 此时(在图中假设情况下): i加1,左游标右移。 同时k也加1, k游标也向右移动。
结合上面的过程4, 在 i 和 j 都向右移动的过程中, 在图中假设情况下,因为j当前所指的元素(图中位置)大于左半边即a[low…mid]的所有元素,导致 i 不断增加(右移)且越过了边界(mid), 所以这时候就不需要比较了,只要把j当前所指位置到high的元素都搬到原数组中,填满原数组中剩下的位置, 单趟归并就完成了, 在这一段过程中 j 连续加1,右游标连续右移。 同时k也连续加1, k游标也连续右移, 直到 j == high且k == high为止
基于上面的表述, 总结出单趟归并算法中最关键的4个条件判断情形:
有了上面的解释,代码实现就不难了。
为了更详细的描述单趟排序的过程,下面在上面的图A和图B的基础上给出每一步的图解:
我们要排序的序列是 2 4 5 9 1 3 6 7, 合并的前提是2 4 5 9 和 1 3 6 7都是有序的。 先比较aux中2和1的大小,因为2>1,所以将1放入a[0]。这时, 游标 i 不动, 游标 j 右移, 游标 k 右移。
比较aux中2和3的大小,因为2<3,所以将2放入a[1]。这时, 游标 j 不动, 游标 i 右移, 游标 k 右移。
比较aux中4和3的大小,因为3<4,所以将3放入a[2]。这时, 游标 i 不动, 游标 j 右移, 游标 k 右移。
注意, 这这里 j 增加导致 j> high, 现在的情形是“右半边用尽”, 所以将aux左半边剩余的元素9放入a剩下的部分a[7]中, 单趟排序完成。
注:上面这个例子中的序列只是数组的一部分, 并不一定是整个数组。
基于递归的归并排序又叫做自顶向下的归并排序。
最关键的是sort(int a [], int low,int high)方法里面的三行代码:
sort(a,low,mid);
sort(a,mid+1,high);
merge(a,low,mid,high);
分别表示对左半边序列递归、对右半边序列递归、单趟合并操作。代码实现如下:
接下来,测试下代码:
运行结果为:
递归导致的结果是,形成了一系列有层次、有先后调用顺序的merge, 如下图左边的写入编号的merge列表。
从上到下,是各个merge的先后调用顺序,1最先调用, 15最后调用。
从右到左, 递归栈由深到浅,例如 1,2,4,5的递归深度是相同的, 而3比它们浅一个层次。
对上图可根据代码来理解。
首先,在第一层递归的时候,先进入的是第一行的sort方法里(A处),然后紧接着又进入了第二层递归的第一行sort方法(A处), 如此继续,由(a, low,mid)的参数列表可知其递归的趋势是一直向左移动的,直到最后一层递归,所以最先执行merge的对象是a[0]和a[1](上图编号1),再然后执行的是最后一层递归的第二行代码(B处),这时候merge的对象是a[2]和a[3](上图编号2)。 再然后, 返回上一层递归,对已经有序的a[0]、a[1]和a[2]、a[3]进行merge。(上图编号3)如此继续,递归的深度不断变浅, 直到对整个数组的左右两半进行merge。 (上图编号3)
(下面展示的归并进行了一些优化,对小数组使用插入排序)
根据上文所讲的递归栈和调用顺序, 下面的轨迹图像就不难理解了: 从最左边的元素开始合并,而且左边的数组序列在第一轮合并后,相邻右边的数组按同样的轨迹进行合并, 直到合并出和左边相同长度的序列后,才和左边合并(递归栈上升一层)。
用不同的方法处理小规模问题能改进大多数递归算法的性能,因为递归会使小规模问题中方法调用太过频繁,所以改进对它们的处理方法就能改进整个算法。因为插入排序非常简单, 因此一般来说在小数组上比归并排序更快。 这种优化能使归并排序的运行时间缩短10%到15%; 可以将下面的代码
改为
这样的话,这条语句就具有了两个功能: 1. 在适当时候终止递归
通过测试待排序序列中左右半边是否已经有序, 在有序的情况下避免合并方法的调用。 例如对单趟合并,我们对a[low…high]中的a[low…mid]和a[mid…high]进行合并。因为a[low…mid]和a[mid…high]本来就是有序的,存在a[low]
在上面介绍的基于递归的归并排序的代码中, 我们在每次调用merge方法时候,我们都把a对应的序列拷贝到辅助数组aux中来,即:
实际上,我们可以通过一种看起来比较逆天的方式把这个拷贝过程给去除掉。。。。。
为了达到这一点,我们要在递归调用的每个层次交换输入数组和输出数组的角色,从而不断地把输入数组排序到辅助数组,再将数据从辅助数组排序到输入数组。
在排序前拷贝一个和原数组元素完全一样的辅助数组(不再是创建一个空数组了!)。 在递归调用的每个层次交换输入数组和输出数组的角色。
注意:外部的sort方法和内部sort方法接收的a和aux参数刚好是相反的。
这样做的话, 我们就可以去除原数组序列到辅助数组的拷贝了!但是读者可能会有疑问:我们要排序的可是原数组a啊! 你不怕一不小心最后完全排序的是辅助数组aux而不是原数组a吗?
由图示易知, 因为外部sort和merge的参数顺序是相同的, 所以,无论递归过程中辅助数组和原数组的角色如何替换,对最后一次调用的merge而言(将整个数组左右半边合为有序的操作), 最终被排为有序的都是原数组,而不是辅助数组!
基于循环的归并排序又叫做自底向上的归并排序。