什么是归并?即将两个有序的数组归并成一个更大的有序数组。
什么是归并排序?先将要排序的数组递归地分成两半分别排序,然后将结果归并起来。
归并排序能够保证将任意大小为 N 的数组排序所需的时间和 N logN 成正比;缺点是它所需的额外空间和 N 成正比。
1.原地归并的抽象
实现归并的一种直截了当的方法是,创建一个适当大小的数组然后将两个输入数组中的元素从小到大放入这个数组。因为会多次归并,防止每次归并时都创建一个数组,创建数组要放在递归的外面。
而原地归并可以在数组移动元素而不需要使用额外的空间,但是实现非常复杂。下面的归并方法是非原地归并:
public static void Merge(IComparable[] a, int lo, int mid, int hi) { //Console.WriteLine(lo+","+mid+","+hi); int i = lo; //左边部分索引 int j = mid + 1; //右边部分索引 //复制一份数组 for (var k = lo; k <= hi; k++) { aux[k] = a[k]; //Count++; } /* * 一开始拿左边部分和右边部分比较,哪边小就将小的值一次放入数组 a,并将小的索引 + 1 * 表示拿下一个和另一部分比较 * 当某一部分取完时,取另一部分循环放入数组 * */ for (var k = lo; k <= hi; k++) { if (i > mid) a[k] = aux[j++]; else if (j > hi) a[k] = aux[i++]; else if (Less(aux[j], aux[i])) a[k] = aux[j++]; else a[k] = aux[i++]; } }
Merge 方法先将 a[lo ... hi] 复制到 aux[],即第一个循环。然后开始归并(第二个循环),拿左边部分和右边部分比较,哪边小就将小的值一次放入数组 a,并将小的索引 + 1。当某一部分取完时,取另一部分循环放入数组。
2.自顶而下的归并排序
下面的算法通过上面的 Merge 方法实现了自顶而下的归并排序,这个算法设计使用了分治思想。要对 a[lo ... hi] 排序,先将它分为 a[lo ... mid] 和 a[mid+1 ... hi] 两部分,分别通过递归调用单独对它们排序,最后将有序的子数组归并为最终的结果。
public class MergeSort : BaseSort { public static IComparable[] aux = null; public static long usedTimes = 0; public MergeSort() { } public static void Sort(IComparable[] a) { Stopwatch timer = new Stopwatch(); timer.Start(); aux = new IComparable[a.Length]; Sort(a, 0,a.Length-1); timer.Stop(); usedTimes = timer.ElapsedMilliseconds; } //将数组a[lo ... hi]排序 public static void Sort(IComparable[] a, int lo, int hi) { //递归调用Sort(IComparable[] a, int lo, int hi) if (hi<=lo) return; int mid = lo + (hi-lo)/2; Sort(a,lo,mid);//将左边部分排序(递归调用) Sort(a,mid+1,hi);//将右边部分排序(递归调用) //归并排序后的两部分 Merge(a,lo,mid,hi); } public static int Count = 0; public static void Merge(IComparable[] a, int lo, int mid, int hi) { //Console.WriteLine(lo+","+mid+","+hi); int i = lo; //左边部分索引 int j = mid + 1; //右边部分索引 //复制一份数组 for (var k = lo; k <= hi; k++) { aux[k] = a[k]; //Count++; } /* * 一开始拿左边部分和右边部分比较,哪边小就将小的值一次放入数组 a,并将小的索引 + 1 * 表示拿下一个和另一部分比较 * 当某一部分取完时,取另一部分循环放入数组 * */ for (var k = lo; k <= hi; k++) { if (i > mid) a[k] = aux[j++]; else if (j > hi) a[k] = aux[i++]; else if (Less(aux[j], aux[i])) a[k] = aux[j++]; else a[k] = aux[i++]; } } }
如上轨迹所示,要将 a[1...15] 排序,Sort() 方法会调用自己将 a[0...7] 排序,再在其中调用自己将 a[0...3] 和 a[0...1] 排序。在将 a[0] 和 a[1] 分别排序之后,才会开始将 a[0] 和 a[1] 归并。第二次归并是 a[2] 和 a[3] ,一次类推。
用
每个结点表示一个 Sort() 方法通过 Merge 方法归并而成的子数组。这棵树正好有 n(n = logN) 层。对于0 到 n-1 之间的任意 k ,自顶而下的第 k 层有 2^k 个数组,每个数组的长度为 2^ n-k,由 Merge 方法中比较的代码可知比较次数为 2^ n-k。因此每层比较次数为 2^k * 2^n-k = 2^n,n 层总共为 n* 2^n = N logN。
由于并不是每次一分为二子数组不一定均分,总比较次数小于等于 N logN,大于等于 1/2 N logN。
每一层最多需要 6*N 次访问数组,2N 次用来复制数组(读和写),2N 次用来将排好序的元素移动回去,另外最多比较 2N 次(应该最多N+1次),总共最多访问数组 6NlogN 次。
由上可知,归并排序所需的时间和 NlogN 成正比。主要缺点是需要额外空间和 N 大小成正比。
改进
1. 对于小规模数组,递归会使小规模问题中方法的调用过于频繁,可以在处理小规模问题时使用插入排序。一般可以将归并排序的运行时间缩短 10% 左右。
2. 在调用 Merge 之前可以增加判断 ,如果 a[mid] 小于 a[mid+1] ,说明数组已经有序了不需要 Merge 。这个改动不影响排序的调用,但是对于有序的子数组算法的运行时间就变成线性了。
3.不将元素复制到辅助数组,可以节省将数组复制到辅助数组的时间。需要调用两种排序方法:一种将数据从输入数组排序到辅助数组,另一种将数据从辅助数组排序到输入数组。(待确认)
3.自底向上的归并排序
自顶而下的归并排序是将一个大问题分割成小问题分别解决,然后用所有小问题的答案来解决整个大问题。
尽管我们考虑的是归并两个大数组,实际上我们归并的数组大部分都很小。所以我们可以使用另外一种排序方法自底向上,先归并那些小数组,然后再成对归并得到的子数组,最终会将整个数组归并到一起。先两两归并,然后四四归并...
public class MergeSortBu: MergeSort { //static IComparable[] aux = null; public new static long usedTimes = 0; public static void Sort(IComparable[] a) { Stopwatch timer = new Stopwatch(); timer.Start(); aux = new IComparable[a.Length]; int n = a.Length; /* * sz = 1,进行两两归并,归并次数 N/2^1 ;sz = 2,四四归并,归并次数 N/2^2... * 要注意检查是否超出索引,N 不一定是 sz 的倍数 * */ for (var sz = 1; sz < n; sz = sz + sz) { for (int lo = 0; lo < n - sz; lo += sz+sz) Merge(a,lo,lo+sz-1,Math.Min(lo+sz+sz-1,n-1)); } timer.Stop(); usedTimes = timer.ElapsedMilliseconds; } }
自底向上归并排序的比较次数同样小于等于 N logN,大于等于 1/2 N logN。最多访问数组次数 6NlogN 次。
当数组长度是 2 的幂时,自顶向下和自底向上的归并排序所用的比较次数和数组访问次数正好相同,只是顺序不同。其他情况可能会有所不同。
自底向上的归并排序比较适合用链表组织的数据。因为链表可以原地排序,不需要额外的空间。
没有任何基于比较的算法能够保证使用少于 lg( N! ) ~ N lg N 次比较将长度为 N 的数组排序。