数据结构与算法之ACM Fellow-算法 2.2 归并排序
数据结构与算法之ACM Fellow-算法 2.2 归并排序
在本节中我们所讨论的算法都基于 归并 这个简单的操作,即将两个有序的数组归并成一个更大的有序数组。很快人们就根据这个操作发明了一种简单的递归排序算法: 归并排序。要将一个数组排序,可以先(递归地)将它分成两半分别排序,然后将结果归并起来。你将会看到,归并排序最吸引人的性质是它能够保证将任意长度为
的数组排序所需时间和
成正比;它的主要缺点则是它所需的额外空间和
成正比。简单的归并排序如图 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() 方法归并而成的子数组。这棵树正好有
层。对于 0 到
之间的任意
,自顶向下的第
层有
个子数组,每个数组的长度为
,归并最多需要
次比较。因此每层的比较次数为
,
层总共为
。
图 2.2.3
时归并排序中子数组的依赖树
命题 F。对于长度为
的任意数组,自顶向下的归并排序需要 ½
至
次比较。
证明。令
表示将一个长度为
的数组排序时所需要的比较次数。我们有
,对于
,通过递归的
sort()方法我们可以由相应的归纳关系得到比较次数的上限:
右边的第一项是将数组的左半部分排序所用的比较次数,第二项是将数组的右半部分排序所用的比较次数,第三项是归并所用的比较次数。因为归并所需的比较次数最少为
,比较次数的下限是:
当
为 2 的幂(即
)且等号成立时我们能够得到一个解。首先,因为
,可以得到:
将两边同时除以
可得:
用这个公式替换右边的第一项,可得:
将上一步重复
遍可得:
将两边同时乘以
就可以解得:
对于一般的
,得到的准确值要更复杂一些。但对比较次数的上下界不等式使用相同的方法不难证明前面所述的对于任意
的结论。这个结论对于任意输入值和顺序都成立。
命题 G。对于长度为
的任意数组,自顶向下的归并排序最多需要访问数组
次。
证明。每次归并最多需要访问数组
次(
次用来复制,
次用来将排好序的元素移动回去,另外最多比较
次),根据命题 F 即可得到这个命题的结果。
命题 F 和命题 G 告诉我们归并排序所需的时间和
成正比。这和 2.1 节所述的初级排序方法不可同日而语,它表明我们只需要比遍历整个数组多个对数因子的时间就能将一个庞大的数组排序。可以用归并排序处理数百万甚至更大规模的数组,这是插入排序或者选择排序做不到的。归并排序的主要缺点是辅助数组所使用的额外空间和
的大小成正比。另一方面,通过一些细致的思考我们还能够大幅度缩短归并排序的运行时间。
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。对于长度为
的任意数组,自底向上的归并排序需要
至
次比较,最多访问数组
次。
证明。处理一个数组的遍数正好是
。每一遍会访问数组
次,比较次数在
和
之间。
当数组长度为 2 的幂时,自顶向下和自底向上的归并排序所用的比较次数和数组访问次数正好相同,只是顺序不同。其他时候,两种方法的比较和数组访问的次序会有所不同(请见练习 2.2.5)。
自底向上的归并排序比较适合 用链表 组织的数据。想象一下将链表先按大小为 1 的子链表进行排序,然后是大小为 2 的子链表,然后是大小为 4 的子链表等。这种方法只需要重新组织链表链接就能将链表 原地 排序(不需要创建任何新的链表结点)。
用自顶向下或是自底向上的方式实现任何分治类的算法都很自然。归并排序告诉我们,当能够用其中一种方法解决一个问题时,你都应该试试另一种。你是希望像 Merge.sort() 中那样化整为零(然后递归地解决它们)的方式解决问题,还是希望像 MergeBU.sort() 中那样循序渐进地解决问题呢?
2.2.4 排序算法的复杂度
学习归并排序的一个重要原因是它是证明计算复杂性领域的一个重要结论的基础,而计算复杂性能够帮助我们理解排序自身固有的难易程度。计算复杂性在算法设计中扮演着非常重要的角色,而这个结论正是和排序算法的设计直接相关的,因此接下来我们就要详细地讨论它。
研究复杂度的第一步是建立一个计算模型。一般来说,研究者会尽量寻找一个和问题相关的最简单的模型。对排序来说,我们的研究对象是基于比较的算法,它们对数组元素的操作方式是由主键的比较决定的。一个基于比较的算法在两次比较之间可能会进行任意规模的计算,但它只能通过主键之间的比较得到关于某个主键的信息。因为我们局限于实现了 Comparable 接口的对象,本章中的所有算法都属于这一类(注意,我们忽略了访问数组的开销)。在第 5 章中,我们会讨论不局限于 Comparable 元素的算法。
命题 I。没有任何基于比较的算法能够保证使用少于
次比较将长度为
的数组排序。
证明。首先,假设没有重复的主键,因为任何排序算法都必须能够处理这种情况。我们使用二叉树来表示所有的比较。树中的 结点 要么是一片 叶子
,表示排序完成且原输入的排列顺序是
a[i0], a[i1], ..., a[iN-1],要么是一个 内部结点,表示
a[i]和a[j]之间的一次比较操作,它的左子树表示a[i]小于a[j]时进行的其他比较,右子树表示a[i]大于a[j]时进行的其他比较。从根结点到叶子结点每一条路径都对应着算法在建立叶子结点所示的顺序时进行的所有比较。例如,这是一棵时的比较树:
我们从来没有明确地构造这棵树——它只是用来描述算法中的比较的一个数学工具。
从比较树观察得到的第一个重要结论是这棵树应该至少有
个叶子结点,因为
个不同的主键会有
种不同的排列。如果叶子结点少于
,那肯定有一些排列顺序被遗漏了。算法对于那些被遗漏的输入肯定会失败。
从根结点到叶子结点的一条路径上的内部结点的数量即是某种输入下算法进行比较的次数。我们感兴趣的是这种路径能有多长(也就是树的 高度),因为这也就是算法比较次数的最坏情况。二叉树的一个基本的组合学性质就是高度为
的树最多只可能有
个叶子结点,拥有
个结点的树是完美平衡的,或称为 完全树。下图所示的就是一个
的例子。
结合前两段的分析可知,任意基于比较的排序算法都对应着一棵高
的比较树(如下图所示),其中:
叶子结点的数量
的值就是最坏情况下的比较次数,因此对不等式的两边取对数即可得到任意算法的比较次数至少是
。根据斯特灵公式对阶乘函数的近似(见表 1.4.6)可得
。
这个结论告诉了我们在设计排序算法的时候能够达到的最佳效果。例如,如果没有这个结论,我们可能会去尝试设计一个在最坏情况下比较次数只有归并排序的一半的基于比较的算法。命题 I 中的下限告诉我们这种努力是没有意义的——这样的算法不存在。这是一个重要结论,适用于任何我们能够想到的基于比较的算法。
命题 H 表明归并排序在最坏情况下的比较次数为
。这是其他排序算法复杂度的 上限,也就是说更好的算法需要保证使用的比较次数更少。命题 I 说明没有任何排序算法能够用少于
次比较将数组排序,这是其他排序算法复杂度的 下限。也就是说,即使是最好的算法在最坏的情况下也至少需要这么多次比较。将两者结合起来也就意味着:
命题 J。归并排序是一种渐进最优的基于比较排序的算法。
证明。更准确地说,这句话的意思是,归并排序在最坏情况下的比较次数和任意基于比较的排序算法所需的最少比较次数都是
。命题 H 和命题 I 证明了这些结论。
需要强调的是,和计算模型一样,我们需要精确地定义最优算法。例如,我们可以 严格地 认为仅仅只需要
次比较的算法才是最优的排序算法。我们不这么做的原因是,即使对于很大的
,这种算法和(比如说)归并排序之间的差异也并不明显。或者我们也可以放宽最优的定义,使之包含任意在最坏情况下的比较次数都在
的某个常数因子范围之内的排序算法。我们不这么做的原因是对于很大的
,这种算法和归并排序之间的差距还是很明显的。
计算复杂度的概念可能会让人觉得很抽象,但解决可计算问题内在困难的基础性研究则不管怎么说都是非常必要的。而且,在适用的情况下,关键在于计算复杂度会影响优秀软件的开发。首先,准确的上界为软件工程师保证性能提供了空间。很多例子表明,平方级别排序的性能低于线性排序。其次,准确的下界可以为我们节省很多时间,避免因不可能的性能改进而投入资源。
但归并排序的最优性并不是结束,也不代表在实际应用中我们不会考虑其他的方法了,因为本节中的理论还是有许多局限性的,例如:
- 归并排序的空间复杂度不是最优的;
- 在实践中不一定会遇到最坏情况;
- 除了比较,算法的其他操作(例如访问数组)也可能很重要;
- 不进行 比较也能将某些数据排序。
因此在本书中我们还将继续学习其他一些排序算法。
答疑
问 归并排序比希尔排序快吗?
答 在实际应用中,它们的运行时间之间的差距在常数级别之内(希尔排序使用的是像算法 2.3 中那样的经过验证的递增序列),因此相对性能取决于具体的实现。
% java SortCompare Merge Shell 100000
For 100000 random Double values
Merge is 1.2 times faster than Shell
理论上来说,还没有人能够证明希尔排序对于随机数据的运行时间是线性对数级别的,因此存在平均情况下希尔排序的性能的渐进增长率1更高的可能性。在最坏情况下,这种差距的存在已经被证实了,但这对实际应用没有影响。
问 为什么不把数组 aux[] 声明为 merge() 方法的局部变量?
答 这是为了避免每次归并时,即使是归并很小的数组,都创建一个新的数组。如果这么做,那么创建新数组将成为归并排序运行时间的主要部分(请见练习 2.2.26)。更好的解决方案是将 aux[] 变为 sort() 方法的局部变量,并将它作为参数传递给 merge() 方法(为了简化代码我们没有在例子中这么做,请见练习 2.2.9)。
问 当数组中存在重复的元素时归并排序的表现如何?
答 如果所有的元素都相同,那么归并排序的运行时间将是线性的(需要一个额外的测试来避免归并已经有序的数组)。但如果有多个不同的重复值,这样做的性能收益就不是很明显了。例如,假设输入数组的
个奇数位上的元素都是同一个值,另外
个偶数位上的元素都是另一个值,此时算法的运行时间是线性对数的(这样的数组和所有元素都不重复的数组满足了相同的循环条件),而非线性的。
练习
2.2.1 按照本节开头所示轨迹的格式给出原地归并的抽象 merge() 方法是如何将数组 A E Q S U Y E I N O S T 排序的。
2.2.2 按照算法 2.4 所示轨迹的格式给出自顶向下的归并排序是如何将数组 E A S Y Q U E S T I O N 排序的。
2.2.3 用自底向上的归并排序解答练习 2.2.2。
2.2.4 是否当且仅当两个输入的子数组都有序时原地归并的抽象方法才能得到正确的结果?证明你的结论,或者给出一个反例。
2.2.5 当输入数组的大小
时,给出自顶向下和自底向上的归并排序中各次归并子数组的大小及顺序。
2.2.6 编写一个程序来计算自顶向下和自底向上的归并排序访问数组的准确次数。使用这个程序将
至 512 的结果绘成曲线图,并将其和上限
比较。
2.2.7 证明归并排序的比较次数是单调递增的(即对于
,
)。
2.2.8 假设将算法 2.4 修改为:只要 a[mid] <= a[mid+1] 就不调用 merge() 方法,请证明用归并排序处理一个已经有序的数组所需的比较次数是线性级别的。
2.2.9 在库函数中使用 aux[] 这样的静态数组是不妥当的,因为可能会有多个程序同时使用这个类。实现一个不用静态数组的 Merge 类,但也 不要 将 aux[] 变为 merge() 的局部变量(请见本节的答疑部分)。 提示:可以将辅助数组作为参数传递给递归的 sort() 方法。
提高题
2.2.10 快速归并。实现一个 merge() 方法,按 降序 将 a[] 的后半部分复制到 aux[],然后将其归并回 a[] 中。这样就可以去掉内循环中检测某半边是否用尽的代码。 注意:这样的排序产生的结果是不稳定的(请见 2.5.1.8 节)。
2.2.11 改进。实现 2.2.2 节所述的对归并排序的三项改进:加快小数组的排序速度,检测数组是否已经有序以及通过在递归中交换参数来避免数组复制。
2.2.12 次线性的额外空间。用大小 M 将数组分为 N/M 块(简单起见,设 M 是 N 的约数)。实现一个归并方法,使之所需的额外空间减少到
:(i) 可以先将一个块看做一个元素,将块的第一个元素作为块的主键,用选择排序将块排序;(ii) 遍历数组,将第一块和第二块归并,完成后将第二块和第三块归并,等等。
2.2.13 平均情况的下限。请证明任意基于比较的排序算法的预期比较次数至少为
(假设输入元素的所有排列的出现概率是均等的)。 提示:比较次数至少是比较树的外部路径的长度(根结点到所有叶子结点的路径长度之和),当树平衡时该值最小。
2.2.14 归并有序的队列。编写一个静态方法,将两个有序的队列作为参数,返回一个归并后的有序队列。
2.2.15 自底向上的有序队列归并排序。用下面的方法编写一个自底向上的归并排序:给定
个元素,创建
个队列,每个队列包含其中一个元素。创建一个由这
个队列组成的队列,然后不断用练习 2.2.14 中的方法将队列的头两个元素归并,并将结果重新加入到队列结尾,直到队列的队列只剩下一个元素为止。
2.2.16 自然的归并排序。编写一个自底向上的归并排序,当需要将两个子数组排序时能够利用数组中已经有序的部分。首先找到一个有序的子数组(移动指针直到当前元素比上一个元素小为止),然后再找出另一个并将它们归并。根据数组大小和数组中递增子数组的最大长度分析算法的运行时间。
2.2.17 链表排序。实现对链表的自然排序(这是将链表排序的最佳方法,因为它不需要额外的空间,且运行时间是线性对数级别的)。
2.2.18 打乱链表。实现一个分治算法,使用线性对数级别的时间和对数级别的额外空间随机打乱一条链表。
2.2.19 倒置。编写一个线性对数级别的算法统计给定数组中的“倒置”数量(即插入排序所需的交换次数,请见 2.1 节)。这个数量和 Kendal1 tau 距离有关,请见 2.5 节。
2.2.20 间接排序。编写一个不改变数组的归并排序,它返回一个 int[] 数组 perm,其中 perm[i] 的值是原数组中第
小的元素的位置。
2.2.21 一式三份。给定三个列表,每个列表中包含
个名字,编写一个线性对数级别的算法来判定三份列表中是否含有公共的名字,如果有,返回第一个被找到的这种名字。
2.2.22 三向归并排序。假设每次我们是把数组分成三个部分而不是两个部分并将它们分别排序,然后进行三向归并。这种算法的运行时间的增长数量级是多少?
实验题
2.2.23 改进。用实验评估正文中所提到的归并排序的三项改进(请见练习 2.2.11)的效果,并比较正文中实现的归并和练习 2.2.10 所实现的归并之间的性能。根据经验给出应该在何时为子数组切换到插入排序。
2.2.24 改进的有序测试。在实验中用大型随机数组评估练习 2.2.8 所做的修改的效果。根据经验用
(被排序的原始数组的大小)的函数描述条件语句( a[mid] < =a[mid+1])成立(无论数组是否有序)的平均次数。
2.2.25 多向归并排序。实现一个
向(相对双向而言)归并排序程序。分析你的算法,估计最佳的
值并通过实验验证猜想。
2.2.26 创建数组。使用 SortCompare 粗略比较在你的计算机上在 merge() 中和在 sort() 中创建 aux[] 的性能差异。
2.2.27 子数组长度。用归并将大型随机数组排序,根据经验用
(某次归并时两个子数组的长度之和)的函数估计当一个子数组用尽时另一个子数组的平均长度。
2.2.28 自顶向下与自底向上。对于
、
、
和
,使用 SortCompare 比较自顶向下和自底向上的归并排序的性能。
2.2.29 自然的归并排序。对于
、
和
,类型为 Long 的随机主键数组,根据经验给出自然的归并排序(请见练习 2.2.16)所需要的遍数。 提示:不需要实现这个排序(甚至不需要生成所有完整的 64 位主键)也能完成这道练习。
本文由博客一文多发平台 OpenWrite 发布!

表示将一个长度为
,对于 
,比较次数的下限是:
)且等号成立时我们能够得到一个解。首先,因为
,可以得到:
可得:



次(
次用来复制,
至
。每一遍会访问数组
和
次比较将长度为
,表示排序完成且原输入的排列顺序是
,表示
时的比较树:
个叶子结点,因为
的树最多只可能有
个叶子结点,拥有
的例子。
叶子结点的数量 
。
浙公网安备 33010602011771号