YUAN 2019-07-01
本周讲解两个50多年前发明,但今天仍然很重要的经典算法 (归并排序和快速排序) 之一 -- 归并排序,几乎每个软件系统中都可以找到其中一个或两个的实现,并研究这些经典方法的新变革。我们的涉及范围从数学模型中解释为什么这些方法有效到使这些算法适应现代系统的实际应用的细节。
Mergesort。我们研究 mergesort 算法,并证明它保证对 n 项的任何数组进行排序,最多只能进行 nlgn 次的比较。我们还考虑一个非递归的自下而上版本。我们证明,在最坏的情况下,任何基于比较的排序算法必须至少进行 ~nlgn 的比较。我们讨论对我们正在排序的对象使用不同的排序以及相关的稳定性概念。
上一篇:基本数据类型
下一篇:快速排序
这章我们讨论归并排序,这是计算基础中的两个重要排序算法之一
我们已经对一些算法有了科学全面的认知,这些算法被大量运用在系统排序和应用内排序超过50多年,我们之后所要看到的快速排序更是被在科学和工程中被誉为20世纪10大算法之一
所以归并排序到底是什么样的?
基本计划流程:
它的思想其实很简单, 只要把数组一分为二, 然后再不断将小数组递归地一分为二下去, 经过一些排序再将它们合并起来, 这就是归并排序的大致思想, 这是人们在计算机上实现的最早的算法之一.
(EDVAC 计算机是最早的通用型计算机之一, 冯诺依曼认为在他的 EDVAC 中需要一种排序算法, 于是他提出了归并排序, 因此他被公认为是归并排序之父)
归并排序的核心就是“并”。所以要理解如何归并,先考虑一种抽象的“原位归并”。
也叫 Top-down mergesort. 下边还有归并的另一种实现,叫 Bottom-up mergesort.
目标 给定一个数组,它的前一半(a[lo]-[mid]) 和 后一半([mid + 1]-[hi]) 已是排好序的,我们所要做的就是将这两个子数组合并成一个大的排好序的数组
看一个抽象原位归并演示
1.在排序之前我们需要一个辅助数组,用于记录数据,这是实现归并的最简单的方式
2.首先将原数组中所有东西拷贝进辅助数组,之后我们就要以排好的顺序将它们拷贝回原数组
这时我们需要三个下标:i 用于指向左边子数组;j 指向右边子数组;k指向原数组即排好序的数组。
3.首先取 i 和 j 所指数字中取其中小的放入原数组k的位置,当一个被拿走之后,拿走位置的指针 (这次是 j) 和 k 递增
4.同样取 i 和 j 中小的那个移向 k 的位置,再同时增加移动位置的指针(这次还是 j 和 k)
以此类推。完整演示地址:在此
这就是一种归并方式: 用了一个辅助数组,将它们移出来又排好序放回去。
这就是归并部分的代码,完全依着之前的演示
public class Merge { private static void merge(Comparable[] a, Comparable[] aux, int lo, int mid, int hi) { /** * assertion功能: 方便我们找出漏洞并且确定算法的正确 * 想确定a[lo] 到 a[mid] 和 a[mid+1] 到 a[hi] 是否已是排好序的 */ assert isSorted(a, lo, mid); assert isSorted(a, mid + 1, hi); //拷贝所有东西进辅助数组 for (int k = lo; k <= hi; k++) aux[k] = a[k]; /** * 完成归并 * 初始化 i 在左半边的最左端 * j 在右半边最左端 * 指针 k 从 lo 开始 * 比较辅助数组中 i 和 j 谁更小,并将小的那个的值移向 k **/ int i = lo, j = mid + 1; for (int k = lo; k <= hi; k++) { //如果 i 走到边界了,就只将 j 的值都移上去 if (i > mid) a[k] = aux[j++]; //如果 j 走到边界了,就只将 i 的值都移上去 else if (j > hi) a[k] = aux[i++]; else if (less(aux[j], aux[i])) a[k] = aux[j++]; else a[k] = aux[i++]; } //最后再检查最终合并后的时候排好序 assert isSorted(a, lo, hi); } // 递归的 sort 方法 private static void sort(Comparable[] a, Comparable[] aux, int lo, int hi) { if (hi <= lo) return; int mid = lo + (hi - lo) / 2; sort(a, aux, lo, mid); sort(a, aux, mid + 1, hi); merge(a, aux, lo, mid, hi); } // 对外提供接口中 sort 函数 public static void sort(Comparable[] a) { //创建辅助数组 Comparable[] aux = new Comparable[a.length]; sort(a, aux, 0, a.length - 1); } }
完整“原位”归并代码:在此
在这个简单的实现中传入了 Comparable 类型的原数组 a[] 和 辅助数组 aux[], 还有三个参数 lo, mid, and hi.
lo指向的是两个将要合并的子数组的头部 mid指向前一个子数组的末端 所以我们的前提是lo到mid时排好的 从mid+1到hi也是排好的
有了归并,排序中递归的就简单多了。
sort() 在递归调用前先检查下标,然后像二分查找那样计算中点值。sort前半部分,再sort后半部分,然后merge
对外提供接口中 sort 函数只接收一个参数,创建辅助数组的任务就交给这个 sort()
这里关键在于不要将辅助数组在递归的 sort() 中创建, 因为那会多出许多额外的小数组的花费, 如果一个归并排序效率很低通常都是由这引起 这是一个很直接的实现方式。也是依据了我们看到多次的一个思想--分治法:即解决问题时将其一分为二,分别解决两个小问题,再将它们合并起来
一般来说Java程序员,认为加入这些 assert 是有益的:
这个归并代码就是很好的例子,如此以代码的形式加入 assert 语句表明了接下来你想做什么,在代码最后加上 assert 语句表明了你做了什么。
你不仅确定了代码的正确,也告诉阅读代码的人你所干的事情。
Java 中 asset 语句接受一个 boolean 值。isSorted 函数前面已经写过了(请回复 -- 基本排序),如果排好序返回 true,反之返回 false. assert 在验证到没正确排序时会抛出异常.
assert 可以在运行时禁用.
这很有用因为你可以把 asset 语句一直放在代码中, 编程时供自己所需, 禁用后在最终上线程序中不会有额外代码。因此 assertion 默认是禁用的。出错的时候人们还可以启用assertion然后找到错误所在。
java -ea MyProgram //启用 assertions java -da MyProgram //禁用 assertions(默认)
所以平时最好像之前的例子那样加入assert语句,并且不让他们出现在产品代码中,而且不要用额外的参数来做检查。
这幅图显示了每次调用 merge 时的操作。
我们将一个大的问题对半分,再将其中的一半对半分,对于那些分到不能再分单个元素,我们做的就是两两间的比较。
两个单元素数组的合并实际就是对这两个数进行了排序,即 M-E 变为 E-M,同样再对后一组的两个数归并排序,即 R-G 变为 G-R,再将两单元数组归并成四单元数组,即 E-M 和 G-R 归并为 E-G-M-R。
同样再对后两对归并(E-S,O-R),这样就得到两个四单元数组(E-G-M-R 和 E-O-R-S), 再归并得到八单元组(E-E-G-M-O-R-R-S).
右边的一半也是同理,最终两个八单元合并,得到最终的结果.
观察这个轨迹图对于学习递归算法是很有帮助的.
Q. 以下哪种子数组长度会在对长度为 12 的数组进行归并排序时出现?
A. { 1, 2, 3, 4, 6, 8, 12 }
B. { 1, 2, 3, 6, 12 }
C. { 1, 2, 4, 8, 12 }
D. { 1, 3, 6, 9, 12 }
运行时间估计:
可以将归并排序用在大量数据中,这是个非常高效的算法。如表中所示,如果要对大量数据进行插入排序,假设有十亿个元素,用家里的电脑要花几个世纪。就算目前的超级计算机也要花费一个星期或更多。
但是拥有一个高效的算法,你对十亿个元素排序,家用电脑也只需半小时,超级计算机更是一瞬间即可完成,一些小型的问题PC也可迅速完成。因此要么你有很多钱和时间,要么你要有一个好的算法。这是我们在这门课中的核心主题,即一个好的算法远比差的算法所花时间和金钱高效得多。
这些数学的东西才能展示出分治法的强大 展示出归并算法如何在 nlogn 时间中解决了选择排序和插入排序需要 N^2 时间才能解决的问题。
比较次数
命题:对于大小为 n 的数组,归并排序需要最多 nlogn 次比较 和 6nlogn 次数组访问
证明:证明这个结论就是需要从之前的代码中得出递推关系式, 这便是代码所反映的数学问题。
如果对 n 个元素排序,用关于 n 的函数 C(n) 来表示需要比较的次数
归并时左半部分和右半部分元素个数就用 n/2 上取整 和 n/2 下取整来表示, 这就是两个子数组的大小. 因为我们递归地调用函数, 所以括号里就是每次递归时分割后子数组的大小, 于是整个一项就是子数组中这些数排序需要的比较次数.
对于左半部分比较次数, 就是关于 n/2 上取整的函数 C(n/2); 对于右边同理. 二合并时我们需要至多 n-1 次比较
因为如果左右没有一边提前排完,就需要 n-1 次比较. 这也只是 n 大于等于 1 的情况. 如果只有一个单元, 是不需要任何比较的, C(1) = 0.
于是这个从代码中考查得来的公式就能精确计算所需要的比较次数上界.
关于这些求这些复杂公式的通项,具体可以回顾离散数学
我们可以看一下当 n 为 2 的幂时的情况(但结论是对 n 为任意数都成立的, 我们可以通过数学归纳法来证明)
D(n) = 2 D(n / 2) + n, for n > 1, with D(1) = 0.
和前面相似的递推关系式, 我们将展示一种证明方法.
分治递归
都假设 n 为 2 的幂次,那 n^2 除以二也是 2 的幂, 这是显然的。
命题: 当 n 是 2 的幂次时的情况, 即,如果 D(n) 满足 D(n) = 2 D(n / 2) + n,当 n > 1, 当且仅当 n=1 时 D(1)=0,通项 D(n) = nlogn.
图示法
!
可以看到每次归并,对于一整层的比较次数都是 N 次,所以共有多少层? 将 N 不断除 2 一直到等于2,一共有 logN 层(以2为底), 所以总共有 NlogN 次比较。归并的全部开销就在于比较次数, 也就是 NlogN. 这就是用图示法来计算递推式.
数组访问
命题:对于大小为 n 的数组,归并排序使用 ≤ 6nlgn 个数组访问来排序数组
对于数组访问次数的计算相似, 只是在归并的时候后面加上的是 6n
A(n) ≤ A(⎡n / 2⎤) + A(⎣n / 2⎦) + 6n for n > 1, with A(1) = 0.
Key point. 任何具有以下结构的算法都需要 nlogn 时间
命题: Mergesort 使用与 n 成比例的额外空间
归并排序的一大特点就是它需要随 n 增大而增大的额外空间, 因为有那个额外的辅助数组.
证明: 对于最后一次合并,数组aux []的长度必须为n。
我们将两个子数组看似原地排序, 但实际上并不是真正的“原地”, 因为我们用到了额外的数组。
如果使用 ≤ clogn 的额外内存,则排序算法就是原地排序,例如:
插入排序,选择排序,和 希尔排序
这些排序算法不需要额外空间,但归并排序你只能放一半,另一半要留给辅助数组。
如果你觉得现在所学的太简单,而在思考一种真正的原地归,其实人们已经有一些方法来完成,但只是理论上可行,实践太过繁琐,而没有能被运用,也许存有简单的方式实现原地归并,这就有待我们去发现。
不过现在有些切实可行的改进,能让归并算法变得高效,这就来看一下因为这种技巧也能用于其他算法:
private static void sort(Comparable[] a, Comparable[] aux, int lo, int hi) { //Cutoff to insertion sort for ≈ 7 items. if (hi <= lo + CUTOFF - 1) { Insertion.sort(a, lo, hi); return; } int mid = lo + (hi - lo) / 2; sort (a, aux, lo, mid); sort (a, aux, mid+1, hi); merge(a, aux, lo, mid, hi); }
private static void sort(Comparable[] a, Comparable[] aux, int lo, int hi) { if (hi <= lo) return; int mid = lo + (hi - lo) / 2; sort (a, aux, lo, mid); sort (a, aux, mid+1, hi); //are subarrays sorted? if (!less(a[mid+1], a[mid])) return; merge(a, aux, lo, mid, hi); }
另一个可以改进的比较费解, 所以只推荐于专业人士.改进在于节省下拷贝到辅助数组的时间(不是空间)。这种改进相当于每一轮递归时转换一下原数组和辅助数组的角色,不过还是需那个辅助数组。代码如下:
将sort结果放入另一数组,将merge结果合并回原数组,所以递归函数同时也完成了交换两个数组角色的任务,这就意味着不用花时间拷贝元素进辅助数组,就节省下了一点时间。
完整代码:在此
我们上诉实现的归并排序是稳定的吗?是稳定的。
稳定性又是指什么。请查看前一章:基本排序
归并排序是稳定的,只要 merge() 操作是稳定的,它就是稳定的。
public class Merge { private static void merge(...) { /* as before */ } private static void sort(Comparable[] a, Comparable[] aux, int lo, int hi) { if (hi <= lo) return; int mid = lo + (hi - lo) / 2; sort(a, aux, lo, mid); sort(a, aux, mid+1, hi); //这个操作是稳定的 merge(a, aux, lo, mid, hi); } public static void sort(Comparable[] a) { /* as before */ } }
这些操作是否稳定取决于我们的代码怎么写。在我们的代码中,
private static void merge(...) { for (int k = lo; k <= hi; k++) aux[k] = a[k]; int i = lo, j = mid+1; for (int 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++]; } }
如果两个键是相等的,它取来自左边子数组的值,那么这意味着如果有两组相等的键,它将总是保留它们的相对顺序,先左再右,这就足够表示归并操作是稳定的了,因此归并排序是稳定的。稳定性是排序算法中一个重要的性质。归并算法不仅高效而且也是稳定的。
这是一种简单,没有递归的,归并排序的实现方法
接下来,我们将看从下往上方式的归并排序。
归并排序作为递归程序是简单易理解的。虽然这个从下往上的方式不是递归,但也比较容易理解。
其基本方法为:
这样做的好处是这一操作遍历整个序列并且不需要递归。
public class MergeBU { private static void merge(...) { /* as before */ } public static void sort(Comparable[] a) { int n = a.length; Comparable[] aux = new Comparable[n]; for (int sz = 1; sz < n; sz = sz+sz) for (int lo = 0; lo < n-sz; lo += sz+sz) merge(a, aux, lo, lo+sz-1, Math.min(lo+sz+sz-1, n-1)); } }
完整代码:在此
从以上代码可以看出它非常容易编写,
这就是一个完全达到业界标准的排序代码,相对普通归并排序,它的唯一负面影响在于需要额外存储空间,大小与序列长度有关。
除了这点外这是一个很好的归并排序方法。
以上是从下往上的归并排序。无论大小,从下往上的归并排序 时间复杂度为 logN。而每一轮需要进行N次比较,因此总复杂度为 NlogN
学习归并排序能很好的来帮助理解排序问题自身存在的困难性,现在把这个困难度称为复杂度,接下来我们将会看关于复杂度的问题。
计算复杂性: 研究解决特定问题 X 的(所有)算法效率的框架.
而为了使其易于理解,我们需要建立所谓的计算模型,即
计算模型: 算法允许执行的操作
对于那种直截了当的排序,我们要做的是建立一个成本模型来计算比较次数。
成本模型: 操作计数。
现在,在问题复杂度的框架内我们只有两样东西:
上限: 算法所用开销/成本的保证,它是由一些(!!)为了解决问题而设计的算法提供。这个上限就表示解决这个问题有多难,我们有个算法可以解决它,并且这是最简单的。
下限:下限,这是对所有算法的成本/开销保证的限制。 没有算法的下限比这个下线做得更好了。
然后我们寻求所谓的最优的算法,就是解决问题“最优的”算法。
最优算法:待解问题的最佳成本保证的算法。也可以说是算法的上限和下限是几乎相同的(upper bound ~ lower bound),这是解决任何问题的最理想目标
因此,对于排序,让我们看看这各部分分别是什么。
假设我们访问数据的唯一方式是通过比较操作,我们所有能使用的只有比较操作,那么一下就是用于分析排序复杂度的框架:
举例:排序问题
计算复杂性(框架)
计算模型 model of computation:comparison tree (旧版本的讲义decision tree)
成本模型 cost model:比较的次数
上界upper bound:~ n lg n from mergesort.
以下是证明排序下界的基本思想
比方说,我们有3个不同的项,a, b 和 c。不论使用什么算法我们首先要做的是比较三项中的两项。
分解
比如说,这里是a 和 b。比较之后,有两种情况 b < c / a < c, 也就是说,它们是有区别的, 在比较中间会有一些代码,但不管怎样接下来里有不同的比较。
在这种情况下,如果你从树的顶部到尾部使用至多三次比较你就可以确定三个不同元素的顺序。
用下限的观点概括就是你需要找到一个最小的比较次数来确定N个元素的顺序。
现在,树的高度,树的高度,正如我刚刚提到的,是最差情况下比较的次数。
在所有排序中即使是考虑最差情况下的树,无论输入是什么,这棵树告诉我们一个边界,以及算法的比较次数。
在每一个可能的顺序中都至少有一个顺序,如果有一个顺序没有出现在针对特定算法的树中,那么这个算法就不能排序,不能告诉你两种不同顺序中间的差别。
作为命题的下界,使用比较树来证明任何基于排序算法的比较在最差情况下不得不使用至少 log2(N) 因子的比较次数
并且,通过斯特林近似公式,我们知道 lg(N!) 与 Nlg(N) 成正比。
命题:任何基于比较的排序算法,在最坏的情况下, 必须至少做出 lg(n!)~nlgn 次比较。
证明:
h 是最会情况下,也就是拥有最多叶子的情况下的高度
这推导出:树的高度大于等于log2(N!),根据斯特林公式,那是正比于 NlogN
这就是排序算法复杂度的下限。那么上限的话,根据上边排序问题的计算复杂性(框架),已经知道上限是 NlogN, 那意味着归并排序就是一个最优算法(上限 = 下线)
算法设计的首要目标:尝试给我们要解决的问题找到最优算法
通过复杂性分析得出的上下文结果:
我们真正证明的是:
归并排序,就比较的次数而言,是最优的
但是它就空间使用并非最优,归并排序使用多一倍的额外空间,正比于它要处理的数组的大小。而简单的算法,比如插入或其他排序,他们根本不适用任何额外的空间。
所以,当我们关注实现并尝试解决实际问题时,我们把这些理论结果用作一个指导。
在这个例子里,它告诉我们的是:
比如,不要尝试设计一个排序算法保证大体上比归并排序,在比较次数上,更好的算法,比方说,1/2NlogN。有方法使用 1/2NlogN次比较的吗?下限说,没有;
再比如,也许有一个算法,使用 NlogN 次比较,同时也有最优的空间利用率。不仅在时间上,也在空间上都是最优的。我们即将看到在下面谈论这样的算法。
另一件事是,特定模型下的下限是针对正在研究的特定计算模型得出的,在这个例子中是比较的次数。如果算法有关于键值的更多信息,它可能不成立。如果算法可以利用以下优势,则下限可能不成立:
输入数组的初始顺序
键值的分布
键的表示
计算复杂度是一个非常有用的方法来帮助我们理解算法的性质并帮助指导我们的设计决策。
Q. 以下哪种子数组长度会在对长度为 12 的数组进行归并排序时出现?
B. { 1, 2, 3, 6, 12 }
对上下界理解的补充
到目前为止,我们一直关注这个问题:“给定一些问题X,我们能否构建一个在大小为n的输入上运行时间O(f(n))的算法?”
这通常被称为上限问题,因为我们正在确定问题X的固有难度的上界,我们的目标是使f(n)尽可能小。
下界问题, 这里,目标是证明任何算法必须花费时间 Ω(g(n))时间来解决问题,现在我们的目标让 g(n)尽可能大。
下限帮助我们理解我们与某个问题的最佳解决方案有多接近:
例如,如果我们有一个在上界时间 O(n log^2 n) 和 下界Ω(n log n) 运行的算法,那么我们的算法有log(n) 的 “差距”:我们希望通过改进算法缩小这个差距。
通常,我们将在限制的计算模型中证明下限,指定可以对输入执行什么类型的操作以及执行什么开销。因此,这种模型的下限意味着如果我们想要算法做得更好,我们需要以某种方式在模型之外做一些事情。
今天我们考虑基于比较的排序算法类。这些排序算法仅通过比较一对键值对输入数组进行操作,在比较的基础上移动元素。
排序是将一串数据按照其某个或者某些关键字的大小进行递增或递减排列的操作我,通常指的排序是升序,排序方式是原地排序。即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并