数据结构与算法之ACM Fellow-算法 2.2 归并排序

数据结构与算法之ACM Fellow-算法 2.2 归并排序

在本节中我们所讨论的算法都基于 归并 这个简单的操作,即将两个有序的数组归并成一个更大的有序数组。很快人们就根据这个操作发明了一种简单的递归排序算法: 归并排序。要将一个数组排序,可以先(递归地)将它分成两半分别排序,然后将结果归并起来。你将会看到,归并排序最吸引人的性质是它能够保证将任意长度为 N 的数组排序所需时间和 N\log N 成正比;它的主要缺点则是它所需的额外空间和 N 成正比。简单的归并排序如图 2.2.1 所示。

图 2.2.1 归并排序示意图

2.2.1 原地归并的抽象方法

实现归并的一种直截了当的办法是将两个不同的有序数组归并到第三个数组中,两个数组中的元素应该都实现了 Comparable 接口。实现的方法很简单,创建一个适当大小的数组然后将两个输入数组中的元素一个个从小到大放入这个数组中。

但是,当用归并将一个大数组排序时,我们需要进行很多次归并,因此在每次归并时都创建一个新数组来存储排序结果会带来问题。我们更希望有一种能够在原地归并的方法,这样就可以先将前半部分排序,再将后半部分排序,然后在数组中移动元素而不需要使用额外的空间。你可以先停下来想想应该如何实现这一点,乍一看很容易做到,但实际上已有的实现都非常复杂,尤其是和使用额外空间的方法相比。

尽管如此,将原地归并 抽象化 仍然是有帮助的。与之对应的是我们的方法签名 merge(a, lo, mid, hi),它会将子数组 a[lo..mid]a[mid+1..hi] 归并成一个有序的数组并将结果存放在 a[lo..hi] 中。下面的代码只用几行就实现了这种归并。它将涉及的所有元素复制到一个辅助数组中,再把归并的结果放回原数组中。实现的另一种方法请见练习 2.2.10。

原地归并的抽象方法

public static void merge(Comparable[] a, int lo, int mid, int hi)
{  // 将a[lo..mid] 和 a[mid+1..hi] 归并
int i = lo, j = mid+1;

for (int k = lo; k <= hi; k++)  // 将a[lo..hi]复制到aux[lo..hi]
   aux[k] = a[k];

for (int k = lo; k <= hi; k++)  // 归并回到a[lo..hi]
   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++];
}

该方法先将所有元素复制到 aux[] 中,然后再归并回 a[] 中。方法在归并时(第二个 for 循环)进行了 4 个条件判断:左半边用尽(取右半边的元素)、右半边用尽(取左半边的元素)、右半边的当前元素小于左半边的当前元素(取右半边的元素)以及右半边的当前元素大于等于左半边的当前元素(取左半边的元素)。

原地归并的抽象方法的轨迹

2.2.2 自顶向下的归并排序

对应视频资源地址:资源网盘分享

更多资源资源群 资源群

群满加新共享群:备份群

算法 2.4 基于原地归并的抽象实现了另一种递归归并,这也是应用高效算法设计中 分治思想 的最典型的一个例子。这段递归代码是归纳证明算法能够正确地将数组排序的基础:如果它能将两个子数组排序,它就能够通过归并两个子数组来将整个数组排序。

算法 2.4 自顶向下的归并排序

public class Merge
{
private static Comparable[] aux;      // 归并所需的辅助数组

public static void sort(Comparable[] a)

{
   aux = new Comparable[a.length];    // 一次性分配空间
   sort(a, 0, a.length - 1);
}

private static void sort(Comparable[] a, int lo, int hi)
{  // 将数组a[lo..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);  // 归并结果(代码见“原地归并的抽象方法”)
}
}

要对子数组 a[1o..hi] 进行排序,先将它分为 a[1o..mid]a[mid+1..hi] 两部分,分别通过递归调用将它们单独排序,最后将有序的子数组归并为最终的排序结果。

自顶向下的归并排序中归并结果的轨迹

要理解归并排序就要仔细研究该方法调用的动态情况,如图 2.2.2 中的轨迹所示。要将 a[0..15] 排序, sort() 方法会调用自己将 a[0..7] 排序,再在其中调用自己将 a[0..3]a[0..1] 排序。在将 a[0]a[1] 分别排序之后,终于才会开始将 a[0]a[1] 归并(简单起见,我们在轨迹中把对单个元素的数组进行排序的调用省略了)。第二次归并是 a[2]a[3],然后是 a[0..1]a[2..3],以此类推。从这段轨迹可以看到, sort() 方法的作用其实在于安排多次 merge() 方法调用的正确顺序。后面几节还会用到这个发现。

图 2.2.2 自顶向下的归并排序的调用轨迹

这段代码也是我们分析归并排序的运行时间的基础。因为归并排序是算法设计中分治思想的典型应用,我们会详细对它进行分析。

我们也可以通过图 2.2.3 所示的树状图来理解命题 F。每个结点都表示一个 sort() 方法通过 merge() 方法归并而成的子数组。这棵树正好有 n 层。对于 0 到 n-1 之间的任意 k,自顶向下的第 k 层有 2^k 个子数组,每个数组的长度为 2^,归并最多需要 2^ 次比较。因此每层的比较次数为 2k\times2=2^nn 层总共为 n2^n=N\lg N

图 2.2.3 N=16 时归并排序中子数组的依赖树

命题 F。对于长度为 N 的任意数组,自顶向下的归并排序需要 ½N\lg NN\lg N 次比较。

证明。令 C(N) 表示将一个长度为 N 的数组排序时所需要的比较次数。我们有 C(0)=C(1)=0,对于 N>0,通过递归的 sort() 方法我们可以由相应的归纳关系得到比较次数的上限:

C(N)\leqslant C(\lceil N/2\rceil)+C(\lfloor N/2\rfloor)+N

右边的第一项是将数组的左半部分排序所用的比较次数,第二项是将数组的右半部分排序所用的比较次数,第三项是归并所用的比较次数。因为归并所需的比较次数最少为 \lfloor N/2\rfloor,比较次数的下限是:

C(N)\geqslant C(\lceil N/2\rceil)+C(\lfloor N/2\rfloor)+\lfloor N/2\rfloor

N 为 2 的幂(即 N=2^n)且等号成立时我们能够得到一个解。首先,因为 \lfloor N/2\rfloor=\lceil N/2\rceil=2^,可以得到:

C(2n)=2C(2)+2^n

将两边同时除以 2^n 可得:

C(2n)/2n=C(2))/2+1

用这个公式替换右边的第一项,可得:

C(2n)/2n=C(2))/2+1+1

将上一步重复 n-1 遍可得:

C(2n)/2n=C(20)/20+n

将两边同时乘以 2^n 就可以解得:

C(N)=C(2n)=n2n=N\lg N

对于一般的 N,得到的准确值要更复杂一些。但对比较次数的上下界不等式使用相同的方法不难证明前面所述的对于任意 N 的结论。这个结论对于任意输入值和顺序都成立。

命题 G。对于长度为 N 的任意数组,自顶向下的归并排序最多需要访问数组 6N\lg N 次。

证明。每次归并最多需要访问数组 6N 次(2N 次用来复制,2N 次用来将排好序的元素移动回去,另外最多比较 2N 次),根据命题 F 即可得到这个命题的结果。

命题 F 和命题 G 告诉我们归并排序所需的时间和 N\lg N 成正比。这和 2.1 节所述的初级排序方法不可同日而语,它表明我们只需要比遍历整个数组多个对数因子的时间就能将一个庞大的数组排序。可以用归并排序处理数百万甚至更大规模的数组,这是插入排序或者选择排序做不到的。归并排序的主要缺点是辅助数组所使用的额外空间和 N 的大小成正比。另一方面,通过一些细致的思考我们还能够大幅度缩短归并排序的运行时间。

2.2.2.1 对小规模子数组使用插入排序

用不同的方法处理小规模问题能改进大多数递归算法的性能,因为递归会使小规模问题中方法的调用过于频繁,所以改进对它们的处理方法就能改进整个算法。对排序来说,我们已经知道插入排序(或者选择排序)非常简单,因此很可能在小数组上比归并排序更快。和之前一样,一幅可视轨迹图能够很好地说明归并排序的行为方式。图 2.2.4 中的可视轨迹图显示的是改良后的归并排序的所有操作。使用插入排序处理小规模的子数组(比如长度小于 15)一般可以将归并排序的运行时间缩短 10% ~ 15%(请见练习 2.2.23)。

图 2.2.4 改进了小规模子数组排序方法后的自顶向下的归并排序的可视轨迹

2.2.2.2 测试数组是否已经有序

我们可以添加一个判断条件,如果 a[mid] 小于等于 a[mid+1],我们就认为数组已经是有序的并跳过 merge() 方法。这个改动不影响排序的递归调用,但是任意有序的子数组算法的运行时间就变为线性的了(请见练习 2.2.8)。

2.2.2.3 不将元素复制到辅助数组

我们可以节省将数组元素复制到用于归并的辅助数组所用的时间(但空间不行)。要做到这一点我们要调用两种排序方法,一种将数据从输入数组排序到辅助数组,一种将数据从辅助数组排序到输入数组。这种方法需要一些技巧,我们要在递归调用的每个层次交换输入数组和辅助数组的角色(请见练习 2.2.11)。

这里我们要重新强调第 1 章中提出的一个很容易遗忘的要点。在每一节中,我们会将书中的每个算法都看做某种应用的关键。但在整体上,我们希望学习的是为每种应用找到最合适的算法。我们并不是在推荐读者一定要实现所提到的这些改进方法,而是提醒大家不要对算法初始实现的性能盖棺定论。研究一个新问题时,最好的方法是先实现一个你能想到的最简单的程序,当它成为瓶颈的时候再继续改进它。实现那些只能把运行时间缩短某个常数因子的改进措施可能并不值得。你需要用实验来检验一项改进,正如本书中所有练习所演示的那样。

对于归并排序,刚才列出的三个建议都很容易实现且在应用归并排序时是十分有吸引力的——比如本章最后讨论的情况。

2.2.3 自底向上的归并排序

递归实现的归并排序是算法设计中 分治思想 的典型应用。我们将一个大问题分割成小问题分别解决,然后用所有小问题的答案来解决整个大问题。尽管我们考虑的问题是归并两个大数组,实际上我们归并的数组大多数都非常小。实现归并排序的另一种方法是先归并那些微型数组,然后再成对归并得到的子数组,如此这般,直到我们将整个数组归并在一起。这种实现方法比标准递归方法所需要的代码量更少。首先我们进行的是两两归并(把每个元素想象成一个大小为 1 的数组),然后是四四归并(将两个大小为 2 的数组归并成一个有 4 个元素的数组),然后是八八的归并,一直下去。在每一轮归并中,最后一次归并的第二个子数组可能比第一个子数组要小(但这对 merge() 方法不是问题),如果不是的话所有的归并中两个数组大小都应该一样,而在下一轮中子数组的大小会翻倍。此过程的可视轨迹如图 2.2.5 所示。

图 2.2.5 自底向上的归并排序的可视轨迹

自底向上的归并排序算法的实现如下。

自底向上的归并排序

public class MergeBU
{
private static Comparable[] aux;      // 归并所需的辅助数组
// merge()方法的代码请见“原地归并的抽象方法”
public static void sort(Comparable[] a)
{  //  进行lgN次两两归并
   int N = a.length;
   aux = new Comparable[N];
   for (int sz = 1; sz < N; sz = sz+sz)        // sz子数组大小
      for (int lo = 0; lo < N-sz; lo += sz+sz) // lo:子数组索引
         merge(a, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1));
}
}

自底向上的归并排序会多次遍历整个数组,根据子数组大小进行两两归并。子数组的大小 sz 的初始值为 1,每次加倍。最后一个子数组的大小只有在数组大小是 sz 的偶数倍的时候才会等于 sz(否则它会比 sz 小)。

自底向上的归并排序的归并结果

命题 H。对于长度为 N 的任意数组,自底向上的归并排序需要 1/2N\lg NN\lg N 次比较,最多访问数组 6N\lg N 次。

证明。处理一个数组的遍数正好是 \lceil\lg N\rceil 。每一遍会访问数组 6N 次,比较次数在 N/2N 之间。

当数组长度为 2 的幂时,自顶向下和自底向上的归并排序所用的比较次数和数组访问次数正好相同,只是顺序不同。其他时候,两种方法的比较和数组访问的次序会有所不同(请见练习 2.2.5)。

自底向上的归并排序比较适合 用链表 组织的数据。想象一下将链表先按大小为 1 的子链表进行排序,然后是大小为 2 的子链表,然后是大小为 4 的子链表等。这种方法只需要重新组织链表链接就能将链表 原地 排序(不需要创建任何新的链表结点)。

用自顶向下或是自底向上的方式实现任何分治类的算法都很自然。归并排序告诉我们,当能够用其中一种方法解决一个问题时,你都应该试试另一种。你是希望像 Merge.sort() 中那样化整为零(然后递归地解决它们)的方式解决问题,还是希望像 MergeBU.sort() 中那样循序渐进地解决问题呢?

2.2.4 排序算法的复杂度

学习归并排序的一个重要原因是它是证明计算复杂性领域的一个重要结论的基础,而计算复杂性能够帮助我们理解排序自身固有的难易程度。计算复杂性在算法设计中扮演着非常重要的角色,而这个结论正是和排序算法的设计直接相关的,因此接下来我们就要详细地讨论它。

研究复杂度的第一步是建立一个计算模型。一般来说,研究者会尽量寻找一个和问题相关的最简单的模型。对排序来说,我们的研究对象是基于比较的算法,它们对数组元素的操作方式是由主键的比较决定的。一个基于比较的算法在两次比较之间可能会进行任意规模的计算,但它只能通过主键之间的比较得到关于某个主键的信息。因为我们局限于实现了 Comparable 接口的对象,本章中的所有算法都属于这一类(注意,我们忽略了访问数组的开销)。在第 5 章中,我们会讨论不局限于 Comparable 元素的算法。

命题 I。没有任何基于比较的算法能够保证使用少于 \lg(N!)\sim N\lg N 次比较将长度为 N 的数组排序。

证明。首先,假设没有重复的主键,因为任何排序算法都必须能够处理这种情况。我们使用二叉树来表示所有的比较。树中的 结点 要么是一片 叶子,表示排序完成且原输入的排列顺序是 a[i0], a[i1], ..., a[iN-1],要么是一个 内部结点,表示 a[i]a[j] 之间的一次比较操作,它的左子树表示 a[i] 小于 a[j] 时进行的其他比较,右子树表示 a[i] 大于 a[j] 时进行的其他比较。从根结点到叶子结点每一条路径都对应着算法在建立叶子结点所示的顺序时进行的所有比较。例如,这是一棵 N=3 时的比较树:

我们从来没有明确地构造这棵树——它只是用来描述算法中的比较的一个数学工具。

从比较树观察得到的第一个重要结论是这棵树应该至少有 N! 个叶子结点,因为 N 个不同的主键会有 N! 种不同的排列。如果叶子结点少于 N!,那肯定有一些排列顺序被遗漏了。算法对于那些被遗漏的输入肯定会失败。

从根结点到叶子结点的一条路径上的内部结点的数量即是某种输入下算法进行比较的次数。我们感兴趣的是这种路径能有多长(也就是树的 高度),因为这也就是算法比较次数的最坏情况。二叉树的一个基本的组合学性质就是高度为 h 的树最多只可能有 2^h 个叶子结点,拥有 2^h 个结点的树是完美平衡的,或称为 完全树。下图所示的就是一个 h=4 的例子。

结合前两段的分析可知,任意基于比较的排序算法都对应着一棵高 h 的比较树(如下图所示),其中:

N!\leqslant 叶子结点的数量 \leqslant2^h

h 的值就是最坏情况下的比较次数,因此对不等式的两边取对数即可得到任意算法的比较次数至少是 \lg N!。根据斯特灵公式对阶乘函数的近似(见表 1.4.6)可得 \lg N!\sim N\lg N

这个结论告诉了我们在设计排序算法的时候能够达到的最佳效果。例如,如果没有这个结论,我们可能会去尝试设计一个在最坏情况下比较次数只有归并排序的一半的基于比较的算法。命题 I 中的下限告诉我们这种努力是没有意义的——这样的算法不存在。这是一个重要结论,适用于任何我们能够想到的基于比较的算法。

命题 H 表明归并排序在最坏情况下的比较次数为 \sim N\lg N。这是其他排序算法复杂度的 上限,也就是说更好的算法需要保证使用的比较次数更少。命题 I 说明没有任何排序算法能够用少于 \sim N\lg N 次比较将数组排序,这是其他排序算法复杂度的 下限。也就是说,即使是最好的算法在最坏的情况下也至少需要这么多次比较。将两者结合起来也就意味着:

命题 J。归并排序是一种渐进最优的基于比较排序的算法。

证明。更准确地说,这句话的意思是,归并排序在最坏情况下的比较次数和任意基于比较的排序算法所需的最少比较次数都是 \sim N\lg N。命题 H 和命题 I 证明了这些结论。

需要强调的是,和计算模型一样,我们需要精确地定义最优算法。例如,我们可以 严格地 认为仅仅只需要 \lg N! 次比较的算法才是最优的排序算法。我们不这么做的原因是,即使对于很大的 N,这种算法和(比如说)归并排序之间的差异也并不明显。或者我们也可以放宽最优的定义,使之包含任意在最坏情况下的比较次数都在 N\lg N 的某个常数因子范围之内的排序算法。我们不这么做的原因是对于很大的 N,这种算法和归并排序之间的差距还是很明显的。

计算复杂度的概念可能会让人觉得很抽象,但解决可计算问题内在困难的基础性研究则不管怎么说都是非常必要的。而且,在适用的情况下,关键在于计算复杂度会影响优秀软件的开发。首先,准确的上界为软件工程师保证性能提供了空间。很多例子表明,平方级别排序的性能低于线性排序。其次,准确的下界可以为我们节省很多时间,避免因不可能的性能改进而投入资源。

但归并排序的最优性并不是结束,也不代表在实际应用中我们不会考虑其他的方法了,因为本节中的理论还是有许多局限性的,例如:

  • 归并排序的空间复杂度不是最优的;
  • 在实践中不一定会遇到最坏情况;
  • 除了比较,算法的其他操作(例如访问数组)也可能很重要;
  • 不进行 比较也能将某些数据排序。

因此在本书中我们还将继续学习其他一些排序算法。

如果您想了解更多技术资源,课件对应视频地址。欢迎点击这里1查看

如果您想了解更多技术资源,欢迎点击这里2查看

本文由博客一文多发平台 OpenWrite 发布!

posted @ 2025-04-11 18:24  牛牛cowcow  阅读(19)  评论(0)    收藏  举报