归并排序
在本节中我们所讨论的算法都基于归并这个简单的操作,即将两个有序的数组归并成个更大的有序数组。很快人们就根据这个操作发明了一种简单的递归排序算法:归并排序。要将一个数组排序,可以先(递归地)将它分成两半分别排序,然后将结果归并起来。你将会看到,归并排序最吸引人的性质是它能够保证将任意长度为\(N\)的数组排序所需时间和\(NlogN\)成正比;它的主要缺点则是它所需的额外空间和\(N\)成正比。
原地归并的抽象方法
实现归并的一种直截了当的办法是将两个不同的有序数组归并到第三个数组中,两个数组中的元素应该都实现了Comparable接口。实现的方法很简单,创建一个适当大小的数组然后将两个输人数组中的元素一个个从小到大放 人这个数组中。
但是,当用归并将-个大数组排序时,我们需要进行很多次归并,因此在每次归并时 都创建一个新数组来存储排序结果会带来问题。我们更希望有一种能够在原地归并的方法,这样就可以先将前半部分排序,再将后半部分排序,然后在数组中移动元素而不需要使用额外的空间。你可以先停下来想想应该如何实现这一点,乍一看很容易做到,但实际上已有的实现都非常复杂,尤其是和使用额外空间的方法相比。
尽管如此,将原地归并抽象化仍然是有帮助的。与之对应的是我们的方法签名merge(a, lo,mid, hi), 它会将子数组a[lo. .mid]和a[mid+1..hi]归并成一个有序的数组并将结果存放在a[1o..hi]中。下面的代码只用几行就实现了这种归并。它将涉及的所有元素复制到一个辅助数组中,再把归并的结果放回原数组中。
1 | //将a[lo..mid]和a[mid+1..hi]归并 |
自顶向下的归并排序
下面算法基于原地归并的抽象实现了另一种递归归并,这也是应用高效算法设计中分治思想的最典型的一个例子。这段递归代码是归纳证明算法能够正确地将数组排序的基础:如果它能将两个子数组排序,它就能够通过归并两个子数组来将整个数组排序。
1 | public class Merge { |
对小规模子数组使用插入排序
用不同的方法处理小规模问题能改进大多数递归算法的性能,因为递归会使小规模问题中方法的调用过于频繁,所以改进对它们的处理方法就能改进整个算法。对排序来说,我们已经知道插人排序(或者选择排序)非常简单,因此很可能在小数组上比归并排序更快。和之前一样,一幅可视轨迹图能够很好地说明归并排序的行为方式。下图中的可视轨迹图显示的是改良后的归并排序的所有操作。使用插人排序处理小规模的子数组(比如长度小于15)一般可以将归并排序的运行时间缩短10% ~ 15%。
不将元素复制到辅助数组
我们可以节省将数组元素复制到用于归并的辅助数组所用的时间(但空间不行)。要做到这一点我们要调用两种排序方法,一种将数据从输人数组排序到辅助数组,一种将数据从辅助数组排序到输人数组。这种方法需要一些技巧,我们要在递归调用的每个层次交换输人数组和辅助数组的角色。
自底向上的归并排序
递归实现的归并排序是算法设计中分治思想的典型应用。我们将一个大问题分割成小问题分别解决,然后用所有小问题的答案来解决整个大问题。尽管我们考虑的问题是归并两个数组,实际上我们归并的数组大多数都非常小。实现归并排序的另一种方法是先归并那些微型数组,然后再成对归并得到的子数组,如此这般,直到我们将整个数组归并在一起。这种实现方法比标准递归方法所需要的代码量更少。首先我们进行的是两两归并(把每个元素想象成一个大小为1的数组),然后是四四归并(将两个大小为2的数组归并成一个有4个元素的数组),然后是八八的归并,一直下去。在每轮归并中,最后一次归并的第二个子数组可能比第一个子数组要小(但这对merge()方法不是问题),如果不是的话所有都应该一一样,而在轮中子数组的大小会翻倍。此过程的可视轨迹如下图所示。
1 | public class MergeBU { |
排序算法的复杂度
学习归并排序的一个重要原因是它是证明计算复杂性领域的一个重要结论的基础,而计算复杂性能够帮助我们理解排序自身固有的难易程度。计算复杂性在算法设计中扮演着非常重要的角色,而这个结论正是和排序算法的设计直接相关的,因此接下来我们就要详细地讨论它。
研究复杂度的第一步是建立一个计算模型。一般来说,研究者会尽量寻找一个和问题相关的最简单的模型。对排序来说,我们的研究对象是基于比较的算法,它们对数组元素的操作方式是由主键的比较决定的。一个基于比较的算法在两次比较之间可能会进行任意规模的计算,但它只能通过主键之间的比较得到关于某个主键的信息。因为我们局限于实现了Comparable接口的对象,本章中的所有算法都属于这一类 (注意,我们忽略了访问数组的开销)。在本书第5章中,我们会讨论不局限于Comparable元素的算法。
参考资料
《算法(第4版)》第二章排序2.2归并排序