目录
[合并排序(归并排序)](#合并排序(归并排序))
分治法(divide-and-conquer)是按照如下方式工作的:
1.将一个问题划分为同一类型的若干子问题,子问题最好规模相同。
2.对这些子问题求解(一般使用递归方法)。
3.有必要的话,合并这些子问题的解,以得到原始问题的答案。
在一般情况下,一个规模为n的实例可以划分为b个规模为n/b的实例,其中a个实例需求求解(这里,a和b是常量,a≥1,b>1)。为了简化分析,我们假设n是b的幂,对于算法的运行时间T(n),我们有下列递推式(通用分治递推式):
其中,是一个函数,表示将问题分解为小问题和将结果合并起来所消耗的时间。显然,T(n)的增长次数取决于常量a和b的值以及函数
的增长次数。在分析许多分治算法的效率时,可以应用下列定理来大大简化我们的工作:
主定理:如果在通用分治递推式中
,其中d≥0,那么有:
值得一提的是:对O和Ω符号来说类似的结论也是成立的。
假设待解问题是计算n个数字 的和。如果n>1,我们可以把该问题分解为它的两个实例:计算前⌊n/2⌋个数字的和以及计算后⌈n/2⌉个数字的和(如果n=1呢?n=1时,直接返回结果)。于是有:
此时问题规模便被转化了,但显然这个算法的效率并没有比蛮力法要高。这说明了一个重要事实:不是所有的分治算法都比蛮力法更高效。如果将这个问题代入主定理进行计算,此时a=2、b=2、d=0,注意到a>b d ,因此该算法。注意通过主定理,我们没有得到递推式的精确解,仅仅得到了算法的增长次数。
合并排序(归并排序)
对于一个需要排序的数组A[0..n−1],将其一分为二:A[0..⌊n/2⌋−1]和A[⌊n/2⌋..n−1],并对每个子数组递归排序,然后把这两个排好序的子数组合并成为一个有序数组。
Mergesort(A[0..n-1]) //递归调用mergesort来对数组A[0..n-1]排序
//输入:一个可排序数组A[0.n-1]
//输出:非降序排列的数组A[0..n-1]
if n>1
copy A[0..⌊n/2⌋-1] to B[0..⌊n/2⌋-1]
copy A[⌊n/2⌋..n-1] to C[0..⌈n/2⌉-1]
Mergesort(B[0..⌊n/2⌋-1])
Mergesort(C[0..⌈n/2⌉-1])
Merge(B,C,A)
对两个有序数组的合并可以通过下面的算法完成。初始状态下,两个指针(数组下标)分别指向两个待合并数组的第一个元素。然后比较这两个元素的大小,将较小的元素添加到一个新创建的数组中。接着,被复制数组中的指针后移,指向该较小元素的后继元素。上述操作一直持续到两个数组中的一个被处理完为止。然后,在未处理完的数组中,剩下的元素被复制到新数组的尾部。
Merge(B[0..p-1],C[0..q-1],A[0..p+q-1]) //将两个有序数组合并为一个有序数组
//输入:两个有序数组B[0..p-1]和C[0..q-1]
//输出:A[0.p+q-1]中已经有序存放了B和C中的元素
i←0;j←0;k←0
while i<p and j<q do
if B[i]<=C[j]
A[k]←B[i];
i←i+1
else A[k]←C[j]; j←j+1
k←k+1
if i=p
copy C[j..q-1] to A[k..p+q-1]
else copy B[i..p-1] to A[k..p+q-1]
算法执行过程实例如图所示:

假设n是2的幂,则键比较次数,且有初始条件C(1)=0。分析
,即合并阶段进行键值比较的次数。在前面的伪代码中,每次向结果数组中添加一个元素都要进行一次比较,然后两个数组中尚需处理的元素总个数减1。最坏情况下,无论哪个数组都不会为空,除非另一个数组只剩下最后一个元素(举例来说,最小的元素轮流来自于不同的数组)。最坏情况下,
,然后得到如下递推式:
因此,根据主定理,a=2,b=2,d=1 显然a=b^d,所以:
实际上如果n=2 k ,可以得到最差效率递推式的精确解:
合并排序在最坏情况下的键值比较次数十分接近基于比较的排序算法在理论上能够达到的最少次数(这个值为)。相比于其他算法(快速排序和堆排序),合并排序的一个显著优点是其稳定性。合并排序的主要缺点是其Θ(n)的额外开销,虽然也能做到Θ(1),但由于其实现较为复杂, 因此只具有理论意义。
快速排序
减治法中我们介绍了Lomuto划分,这里回顾一下划分的定义:划分是对给定数组中的元素的重新排列,使得A[s]左边的元素都小于等于A[s],而所有A[s]右边的元素都大于等于A[s],如图所示。

显然建立一个划分后,A[s]已经位于它在有序数组中的最终位置,接下来我们可以继续对A[s]前和A[s]后的子数组分别进行排序。注意,它与合并排序的不同之处在于:在合并排序算法中,将问题划分成两个子问题是很快的,算法的主要工作在于合并子问题的解;而在快速排序中,算法的主要工作在于划分阶段,而不需要再去合并子问题的解了。
下面给出快速排序的伪代码:
Quicksort(A[l..r])
//用Quicksort对子数组排序
//输入:数组A[0..n-1]中的子数组A[l..r],由左右下标l和r定义
//输出:非降序排列的子数组A[l..r]
if l<r
s←Partition(A[l..r])//s是分裂位置
Quicksort(A[l..s-1])
Quicksort(A[s+1..r])
除了Lomuto划分之外,这里介绍Hoare划分:我们将分别从子数组的两端进行扫描,并且将扫描到的元素与中轴相比较。从左到右的扫描(下面用指针i表示)从第二个元素开始。因为我们希望小于中轴的元素位于子数组的左半部分,扫描会忽略小于中轴的元素,直到遇到第一个大于等于中轴的元素才会停止。从右到左的扫描(下面用指针j表示)从最后一个元素开始。因为我们希望大于中轴的元素位于子数组的右半部分,扫描会忽略大于中轴的元素,直到遇到第一个小于等于中轴的元素才会停止。
为什么当遇到与中轴元素相等的元素时值得停止扫描?因为当遇到有很多相同元素的数组时,这个方法可以将数组分得更加平均,从而使算法运行得更快。如果我们遇到相等元素时继续扫描,对于一个具有n个相同元素的数组来说,划分后得到的两个子数组的长度可能分别是n−1和1,从而在扫描了整个数组后只将问题的规模减1(例如,从左一直扫描直到i=j)。
两侧扫描全部停止以后,会发生3种不同的情况:
1.如果扫描指针不相交(i<j),交换A[i]和A[j],再对i加1,对j减1,然后继续开始扫描。

2.如果扫描指针相交(i>j),把中轴和A[j]交换以后,我们得到了该数组的一个划分。

3.如果指针重合(i=j),此时A[i]=A[j]=p,因此交换中轴和A[j]也会得到一个划分。

其中第二和第三种情况可以合并,只要i≥j,就交换中轴和A[j]的位置。下面给出Hoare划分的伪代码:
HoarePartition(A[l..r])
//以第一个元素为中轴,对子数组进行划分
//输入:数组A[0..n-1]中的子数组A[l..r],由左右下标I和r定义
//输出:A[l..r]的一个划分,分裂点的位置作为函数的返回值
p←A[l]
i←l; j←r+1
repeat
repeat i←i+1 until A[i]>=p
repeat j←j-1 until A[j]<=p
swap(A[i],A[j])
until i>=j
swap(A[i],A[j]) //当i>=j撤销最后一次交换
swap(A[l],A[j])
return j
下面给出用Hoare划分对数组5,3,1,9,8,2,4,7进行划分的例子:

其递归调用树如下图所示:

注意到,如果指针交叉,建立划分之前进行的比较次数为n+1;如果指针重合,建立划分之前进行的比较次数为n(为什么?
对于原本的n个元素,从左到右遍历也就比较n-1次,指针重合时,也就是有个元素都比较两次,自然是n,当交叉时,j和i本质上同时遍历了两个元素两遍,那就是n+1
)。根据递归调用树可以推测,最优情况下每次划分都从子数组中间进行(调用树更宽),此时有递推式。根据主定理,
,若n=2 k 可得精确解
。类似地,最坏情况下每次划分都从子数组边缘进行(调用树更深),也即是说待排序数组已经有序,此时经过n+1次比较建立的划分仅使问题规模减小1,直到问题规模变为n=1时(比较次数为0),于是有:
。
平均情况下,假设数组随机排列,在经过n+1次比较后,划分分裂点可能出现在任意位置s处,其中0≤s≤n−1。划分结束后,所获得左右子数组的大小分别是s和n−1−s。假设分裂点位于每个位置的概率都是1/n,我们得到下面的递推关系式:
解得。因此,快速排序在平均情况下仅比最优情况多执行39%的比较操作。此外,它的最内层循环效率非常高,使得快速排序在处理随机排列的数组时速度比合并排序快。但需要注意。快速排序是不稳定的,同时它还需要一个堆栈来存储那些还没有被排序的子数组的参数。
快速排序的改进方法
更好的中轴选择方法,例如随机快速排序,它使用随机的元素作为中轴;三平均划分法,它以数组最左边、最右边和最中间的元素的中位数作为中轴。
当子数组足够小时改用插入排序方法(足够小一般指元素数为5~15),或者根本就不再对小数组进行排序,而是在快速排序结束后再使用插入排序的方法对整个近似有序的数组进行排序。
一些划分方法的改进,例如三路划分,将数组分成三段,每段的元素分别小于、等于、大于中轴元素。据说(权威)同时应用上述三种改进方法,可以将快速排序的运行时间削减20%~30%。
二叉树遍历及其相关特性
如何实现计算二叉树高度的递归算法?树的高度定义为从叶子到根之间的最长路径长度。所以,二叉树的高度是根的左、右子树的最大高度加1。注意,为了方便起见,我们把空树的高度定义为−1。
Height(T)
//递归计算二叉树的高度1
//输入:一棵二叉树T
//输出:T的高度
if T=∅
return -1
else
return max{Height(T_left), Height(T_right)}+1
以给定的二叉树节点数n(T)来度量问题的规模(注意,不依赖于树结构,因此求解可以用特例),为了计算两数中的较大值,算法执行比较次数等于算法执行的加法操作次数,且A(0)=0(如何求解?
本质是「每个非空节点对应 1 次加法操作(合并左右结果)」,因此加法操作次数 = 非空节点数 - 1。
)。但需注意,加法并非此算法中最频繁的操作,判空才是。例如对于一棵单节点的树而言,加法运算进行0次,判空操作进行3次。

上图给出树的扩展表示法,其中圆形表示内部节点,方形表示外部节点(扩展)。**对于扩展树的每一个内部节点,算法都要执行一次加法运算,且无论是内部节点还是外部节点,算法都要执行一次判空操作。**因此,为了确定算法的效率,我们需要知道一棵包含n个节点(内部节点)的二叉树有几个外部节点。根据数学归纳法很容易得出,外部节点个数x=n+1。或者从另一个角度来说,在扩展二叉树中,每个内部节点都有两个子节点,因此2n+1=n+x,也可以解出外部节点个数。因此,在求解树高度的算法中,判空操作进行的比较次数,加法次数A(n)=n。
在递归调用左右子树的算法中,最重要的是三种遍历算法:
前序遍历:根在访问左右子树之前被访问;
中序遍历:根在访问左子树之后、右子树之前被访问;
后序遍历:根在访问左右子树之后被访问。
这三种算法的效率分析和求树高度的分析是一致的,因为对于扩展二叉树的每一个节点,都需要做一次递归调用。

大整数乘法和Strassen矩阵乘法
大整数乘法
如果我们使用经典的笔算算法来对两个n位整数相乘,第一个数中的n个数字都分别要被第二个数中的n个数字相乘,这样就一共要做次位乘(如果一个数的位数比另一个数少,可以在较短的数前补零,使得两个数的位数相同)。能否设计一个乘法次数小于
的算法?
考察一个相乘的实际案例:
然后将两数相乘,
最终结果为正确的322,但它和笔算一样都只进行了4次位乘。不太容易注意到,2×4+3×1的结果可以利用2×1和3×4来表示:
此时我们只需要计算2×1、3×4以及(2+3)×(1+4)三次乘法和若干次加减法。
因此,对于任意两位数 和
,它们的乘积
,其中
、
、
。现在我们可以利用这个技巧来计算两个n位整数a和b的乘积,从中间把两个数分开,即
、
。
如果n是2的幂,则的计算方式和之前完全相同。该算法的位乘次数M(n)=3M(n/2)且M(1)=1。当n=
时,可以用反向替换法进行求解,得到
。同时,该算法每一步中还需要5次加法和1次减法,即A(n)=3A(n/2)+cn且A(1)=1,根据主定理,A(n)∈Θ(
),这意味着,该算法中加法具有和乘法相同的增长次数。
Strassen矩阵乘法
Strassen在矩阵乘法中运用了类似的思路,他发现计算两个2×2矩阵的乘积只需要进行7次乘法运算,而非蛮力法所需要的8次,如下所示:

其中:

因此,对两个2×2矩阵相乘只需要做7次乘法和18次加减法,而蛮力法需要进行8次乘法和4次加法,这似乎不足以成为我们使用Strassen算法的理由,我们重点应该关注的是算法规模趋于无穷时所表现出的卓越的渐进效率。
假设n是2的幂,A和B是两个n×n的矩阵(如果n不是2的幂,矩阵可以用全0行或者列来进行填充),此时可以利用矩阵块乘的性质将A、B、C分别划分为4个(n/2)×(n/2)的矩阵:

此时:

Strassen算法中执行的乘法次数且
,当n=
时,可以解出
因为减少乘法次数是以额外的加法次数为代价的,该算法中加法次数
且A(1)=0,根据主定理可得A(n)∈Θ( )。
目前为止,矩阵相乘最快的算法由Coopersmith和Winograd提出,它的效率为O( )。
用分治法解最近对问题和凸包问题
最近对问题
令P为笛卡儿平面上n>1个点构成的集合(假设没有重合点),其中点按照x轴坐标升序排列。方便起见,我们还需一个集合Q,其中点按照y轴坐标升序排列。
当2≤n≤3时,问题直接通过蛮力算法求解。
当n>3时,可以利用点集在x轴方向上的中位数m,在该处作一条垂线,将点集分成大小分别为⌈n/2⌉和⌊n/2⌋的两个子集和
,其中⌈n/2⌉个点位于线的左边或线上,⌊n/2⌋个点位于线的右边或线上。然后就可以通过递归求解子问题
和
来得到最近点对问题的解。其中
和
分别表示在
和
中的最近对距离,并定义d=min{
,
}。
此时得到的d并不一定是问题的最终解,因为最近点对的两个点可能分布在垂线的两侧。因此,在合并较小规模问题的解时,需要检查是否存在这样的点。显然,我们只需要关注以分割线对称的、宽度为2d的垂直带中的点,其他点对的距离都至少为d,这是因为对于两个点集来说,已知左边和右边的两点最短是d,那最极端的情况下就是存在一个点在中线M上,然后以水平距离d到达一端的点,而两端都可能有这么一个点,所以是2d。

设S是位于分隔带中点组成的列表,由于Q是按照点y轴坐标升序排列的,因此S也有此特征,初始情况下,对该列表进行扫描,如果存在更近的点对,则更新目前为止的最小距离
。设p为列表中的点,如果另一个点p ′ 和p的距离小于
,那么在列表S中,p ′ 一定在p后面(为什么?
因为S的点也是从下到上依次排列的,则在当前2d这个区域时所得到的d一定是最小的,此时出现了新的比还短的距离d,只能是处于遍历S的过程中,因此也就只能有p`在p后面的结论
),并且两点在y轴上的距离一定要小于(为什么?
因为d的组成是根号下两个点的x的差值的平方加上y的差值的平方,因此,一定是小于等于
的
)。在几何上,这就意味着p ′ 一定在p上方的一个宽为2d、高为d min 的矩形中,如图所示:

下面给出该算法的伪代码:
EfficientClosestPair(P,Q)
//使用分治算法来求解最近点对问题
//输入:数组P中存储了平面上的n>=2个点,并且按照这些点的x轴坐标升序排列
// 数组Q存储了与P相同的点,只是它是按照这点的y轴坐标升序排列
//输出:最近点对之间的欧几里得距离
if n<=3
返回由蛮力算法求出的最小距离
else
将P的前⌈n/2⌉个点复制到Pl
将Q的前⌈n/2⌉个点复制到Ql
将P的后⌊n/2⌋个点复制到Pr
将Q的后⌊n/2⌋个点复制到Qr
dl←EfficientClosestPair(Pl,Ql)
dr←EfficientClosestPair(Pr,Qr)
d←min{dl,dr}
m←P[⌈n/2⌉-1].x
将Q中所有|x-m|<d的点复制到数组S[0..num-1]
dminsq←d^2
for i←0 to num-2 do
k←i+1
while k<=num-1 and (S[k].y-S[i].y)^2<dminsq
dminsq←min((S[k].x-S[i].x)^2+(S[k].y-S[i].y)^2,dminsq)
k←k+1
return sqrt(dminsq)
假设n是2的幂,算法运行时间,其中f(n)∈Θ(n),根据主定理可得
。如果用O(nlogn)的算法对随机给定点集排序来得到P和Q,则不会影响整体的效率类型。实际上,该算法是能得到的最好的求解最近点对的效率,已经证明,该问题的任何算法都属于Ω(nlogn)。
凸包问题
快包是凸包问题的分支解决方案。设集合S包含n个点(假设按照x坐标升序排列),不难证明,最左边的点和最右边的点
一定是该集合的凸包顶点。设
是从
到
的直线,这条直线把点集一分为二,
是位于直线左侧或在直线上的点构成的集合,
是位于直线右侧或在直线上的点构成的集合。位于
上的点一定不是凸包顶点,因此后面也不必考虑。
存在一条直线
和点
,如果
构成一个逆/顺时针的回路,则说
在直线左/右侧。
S的凸包边界是由上下两条多边形折线组成的,上边界称为上包(以 、
中的一些点以及
为端点),下边界称为下包(以
、
中的一些点以及
为端点)。下面举例说明如何构造上包,下包同理。
如果为空,上包就是以
和
为端点的线段;如果
不为空,该算法找到
的顶点
,它是距离直线
最远的点;如果距离直线最远的点有多个,就选择使角∠
最大的点(即使得以
、
、
为顶点的三角形面积最大)。然后该算法找出
中所有在直线
左边的点,这些点以及
和
构成了集合
;接着找出
中所有在直线
左边的点,这些点以及
和
构成了集合
。不难证明:
是上包的顶点;
包含在 之中的点不可能是上包的顶点,之后也不必考虑;
同时位于和
两条直线左边的点是不存在的。
因此,该算法可以继续递归构造∪
∪
和
∪
∪
的上包,然后把他们连接起来得到整个集合
∪
∪
的上包。

同时,如果、
、
是平面上的任意三个点,那么三角形
的面积等于下面这个行列式的绝对值的二分之一:

当且仅当 位于直线
左侧时,该表达式的符号为正;位于直线
右侧时,该表达式的符号为负。使用这个公式我们可以在固定的时间内,检查一个点是否位于两端点确定的直线的左/右侧,并且可以求得该点是否为
(三角形面积最大)。
快包有着和快速排序相同的最差效率Θ(),但它的平均效率则好的多。首先,该算法和快速排序一样,一般会把问题平均地分为两个较小的子问题,这会使得效率提高很多。其次,位于
之内的数量可观的点在后续处理时不必考虑。基于自然的假设(给定点均匀分布在某些凸区域内),快包的平均效率可以表现出线性特征(算法极限是Θ(nlogn),但由于筛选的存在,不需要计算所有的log)。