第2章 递归与分治策略
2.1 递归的概念
Ackerman函数
并非一切递归函数都能用非递归方式定义。为了对递归函数的复杂性有更多的了解,介绍一个双递归函数——Ackerman函数。
当一个函数以及它的一个变量是由函数自身定义时,称这个函数是双递归函数。
Ackerman函数A(n,m)有两个独立的整变量m>=0和n>=0,其定义如下:
A(1,0)=2
A(0,m)=1 m>=0
A(n,0)=n+2 n>=2
A(n,m)=A(A(n-1,m),m-1) n,m>=1
A(n,4)的增长速度非常快,以至于没有适当的数学式子来表示这一函数。
单变量的Ackerman函数A(n)定义为:A(n)=A(n,n)
。其拟逆函数a(n)在算法复杂性分析中常遇到。它定义为:a(n)=min{k|A(k)>=n}
。即a(n)是使n<=A(k)成立的最小的k值。a(n)的增长速度非常慢,但理论上a(n)没有上界,它以难以想象的速度趋向无穷大。
整数划分问题
将正整数n表示成一系列正整数的和:n=n1+n2+……+nk,其中n1>=n2>=……>=nk>=1,k>=1
。
正整数n的这种表示称为正整数n的划分。正整数n的不同划分个数称为正整数n的划分数,记作p(n)。
例如,正整数6有如下11种不同的划分,所以 p(6)=11。
6;
5+1;
4+2,4+1+1;
3+3,3+2+1,3+1+1+1;
2+2+2,2+2+1+1,2+1+1+1+1;
1+1+1+1+1+1。
在正整数n的所有不同的划分中,将最大加数n1不大于m的划分个数记作q(n,m)。可以建立q(n,m)的如下递归关系。
(1) q(n,1)=1,n>=1
当最大加数n1不大于1时,任何正整数n只有一种划分形式,即n是n个1相加。
(2) q(n,m)=q(n,n),m>=n
最大加数n1实际上不能大于n。因此,q(1,m)=1
(3) q(n,n)=1+q(n,n-1)
正整数n的划分由n1=n的划分和n1<=n-1的划分组成。
(4) q(n,m)=q(n,m-1)+q(n-m,m),n>m>1
正整数n的最大加数n1不大于m的划分由n1=m的划分和n1<=m-1的划分组成。
2.2 分治法的基本思想
2.3 二分搜索技术
2.4 大整数的乘法
设X和Y都是n位的二进制整数,现在要计算它们的乘积XY。可以用小学所学的方法来设计计算乘积XY的算法,但这样做计算步骤太多,效率太低。如果将每2个1位数的乘法或加法看做一步运算,那么这种方法要进行O(n^2)步运算才能算出乘积XY。下面用分治法来设计更有效的大整数乘积算法。
将n位二进制整数X和Y都分为2段,每段的长为n/2位(为简单起见,假设n是2的幂),X=A(n/2位)B(n/2位);Y=C(n/2位)D(n/2位)
由此,X=A*2(n/2)+B,Y=C*2(n/2)+D, X和Y的乘积为 XY = (A*2^(n/2)+B)(C*2^(n/2)+D) = AC*2^n + (AD + CB)*2^(n/2) + BD
如果按此式计算XY,则必须进行4次n/2位整数的乘法(AC,AD,BC,BD),以及3次不超过2n位的整数加法(分别对应于式中的加号),此外还要进行2次移位(分别对应于式中的乘2n和乘2(n/2))。所有这些加法和移位共用O(n)步运算。设T(n)是2个n位整数相乘所需的运算总数,则有
T(n)= O(1) n=1
4T(n/2)+O(n) n>1
由此可得T(n)=O(n^2)。因此,直接用此式来计算X和Y的乘积并不比小学生的方法更有效。要想改进算法的计算复杂性,必须减少乘法次数。
下面把XY写成另一种形式 XY = AC*2^n + ((A-B)(D-C) + AC + BD)*2^(n/2) + BD
此式看起来似乎复杂些,但它仅需做3次n/2位整数的乘法(AC,BD和(A-B)(D-C)),6次加、减法和2次移位。由此可得
T(n) = O(1) n=1
3T(n/2)+O(n) n>1
容易求得其解为T(n)=O(nlog3)=O(n1.59)。这是一个较大的改进
2.5 Strassen矩阵乘法
设A和B是2个n*n矩阵,它们的乘积AB同样是一个n*n矩阵。A和B的乘积矩阵C中元素C[i][j]定义为C[i][j]=∑A[i][k]B[k][j]
。
若依次定义来计算A和B的乘积矩阵C,则每计算C的一个元素C[i][j],需要做n次乘法运算和n-1次加法计算。因此,算出矩阵C的n2个元素所需的计算时间为O(n3)。
20世纪60年代末期,Strassen采用了类似于在大整数乘法中用过的分治技术,将计算2个n阶矩阵乘积所需的计算时间改进到O(nlog7)=O(n2.81),其基本思想还是分治法。
首先,仍假设n是2的幂。将矩阵A, B
和C中每一矩阵都分块成4个大小相等的矩阵,每个矩阵都是(n/2)*(n/2)的方阵。由此可将方程C=AB重写为
[C11 C12 = [A11 A12 [B11 B12
C21 C22] A21 A22] B21 B22]
由此可得
C11 = A11 B11 + A12 B21
C12 = A11 B12 + A12 B22
C21 = A21 B11 + A22 B21
C22 = A21 B12 + A22 B22
如果n=2,则2个2阶方阵的乘积可以直接计算出来,共需8次乘法和4次加法。当子矩阵的阶大于2时,为求2个子矩阵的积,可以继续将子矩阵分块,直到子矩阵的阶降为2.由此产生分治降阶的递归算法。以此算法,计算2个n阶方程的乘积和4个n/2阶方阵的加法。2个(n/2)*(n/2)矩阵的加法显然可以在O(n^2)时间内完成。因此,上述分治法的计算时间耗费T(n)应满足
T(n) = O(1) n=2
8T(n/2)+O(n^2) n>2
这个递归方程的解仍然是T(n)=O(n^3).因此,该方法并不比用原始定义直接计算更有效。究其原因,乃是由于该方法没有减少矩阵的乘法次数。
Strassen提出了一种新算法来计算2个2阶方阵的乘积。他的算法只用了7次乘法运算,但增加了加、减法的运算次数。这7次乘法运算是
M1 = A11 (B12 - B22)
M2 = (A11 + A12) B22
M3 = (A21 + A22) B11
M4 = A22 (B21 - B11)
M5 = (A11 + A22)(B11 + B22)
M6 = (A12 - A22)(B21 + B22)
M7 = (A11 - A21)(B11 + B12)
做了这7次乘法运算后,再做若干次加、减法运算就可以得到
C11 = M5 + M4 - M2 + M6
C12 = M1 + M2
C21 = M3 + M4
C22 = M5 + M1 - M3 - M7
以上计算的正确性很容易验证。
Strassen矩阵乘法中,用了7次对于n/2阶矩阵乘的递归调用和18次n/2阶矩阵的加减运算。由此可知,该算法所需的计算时间T(n)满足如下的递归方程
T(n) = O(1) n=2
7T(n/2)+O(n^2) n>2
解此递归方程得T(n)=O(nlog7)=O(n2.81).由此可见,Strassen矩阵乘法的计算时间复杂性比普通矩阵乘法有较大改进。
2.6 棋盘覆盖
在一个2k*2k个方格组成的棋盘中,恰有一个方格与其他方格不同,称该方格为一特殊方格,且称该棋盘为一特殊棋盘。显然特俗方格在棋盘上出现的位置有4^k种情形。
在棋盘覆盖问题中,要用每张可以覆盖3格的L型骨牌覆盖给定的特殊棋盘上除特俗方格以外的所有方格,且任何两个L型骨牌不得重叠覆盖。
用分治策略,可以设计出解棋盘覆盖问题的简洁算法。
当k>0时,将2k*2k棋盘分割为四个2(k-1)*2(k-1)子棋盘。
为了将这三个无特殊方格的子棋盘转化为特殊棋盘,可以用一个L型骨牌覆盖这3个较小棋盘的会合处。这3个子棋盘上被L型骨牌覆盖的方格就成为该棋盘上的特殊方格,从而将原问题转化为4个较小规模的棋盘覆盖问题。递归地使用过这种分割,直至棋盘简化为1*1棋盘。
设T(k)是算法chessBoard覆盖一个2k*2k棋盘所需的时间。从算法的分割策略可知,T(k)满足如下递归方程
T(k) = O(1) k=0
4T(k-1)+O(1) k>0
解此递归方程可得T(k)=O(4k).由于覆盖2k*2k棋盘所需的L型骨牌个数为(4k-1)/3,故算法chessBoard是一个在渐进意义下最优的算法。
2.7 合并排序
合并排序算法递归的描述:将待排序元素分成大小大致相同的2个子集合,分别对2个子集合进行排序,最终将排好序的子集合合并成为所要求的排好序的集合。
改进:消去递归的合并排序算法。可以将数组a中国相邻元素两两配对。用合并算法将它们排序,构成n/2组长度为2的排好序的子数组段,然后再将它们排序成长度为4的排好序的子数组段,如此继续下去,直至整个数组排好序。
自然合并排序是上述合并排序算法mergeSort的变形。在上述合并排序算法中,第一步合并相邻长度为1的子数组段,这是因为长度为1的子数组段是已排好序的。事实上,对于初始给定的数组a,通常存在多个长度大于1的已自然排好序的子数组段。例如,若数组a中元素为{4,8,3,7,1,5,6,2},则自然排好序的子数组段有{4,8},{3,7},{1,5,6}和{2}。用1次对数组a的线性扫描就足以找出所有这些排好序的子数组段。然后将相邻的排好序的子数组段两两合并,构成更大的排好序的子数组段。对上面的例子,经一次合并得到两个合并后的子数组段{3,4,7,8}和{1,2,5,6}。继续合并相邻排好序的子数组段,直至整个数组排好序。上面这两个数组段再合并后就得到{1,2,3,4,5,6,7,8}
2.8 快速排序
快速排序基本思想是,对于输入的子数组a[p:r],按以下3个步骤进行排序。
(1)分解:以a[p]为基准元素将a[p:r]划分成3段a[p:q-1],a[q]和a[q+1:r],使得a[p:q-1]中任何元素小于等于a[q],a[q+1:r]中任何元素大于等于a[q]。下标q在划分过程中确定。
(2)递归分解:通过递归调用快速排序算法,分别对a[p:q-1]和a[q+1:r]进行排序。
(3)合并:由于a[p:q-1]和a[q+1:r]的排序是就地进行的,所以在a[p:q-1]和a[q+1:r]都已经排好序后不需要执行任何计算,a[p:r]就已排好序。
快速排序算法在平均情况下时间复杂性也是O(nlogn),这在基于比较的排序算法类中算是快速的了,快速排序也因此而得名。
快速排序算法的性能取决于划分的对称性。通过修改算法partition,可以设计出采用随机选择策略的快速排序算法。
2.9 线性时间选择
本节讨论与排序问题类似的元素选择问题。元素选择问题的一般提法是:给定线性序集中n个元素和一个整数k,1<=k<=n,要求找出这n个元素中第k小的元素,即如果将这n个元素依其线性序排列时,排在第k个的元素即要找的元素。当k=1时,即要找最小元素;k=n时,即要找最大元素;当k=(n+1)/2时,称为找中位数。
在某些特殊情况下,很容易设计出解选择问题的线性时间算法。例如,找n个元素的最小元素和最大元素显然可以在O(n)时间完成。如果k<=n/logn,通过堆排序算法可以在O(n+klogn)=O(n)时间内找出第k小的元素。当k>=n-n/logn时也一样。
一般的选择问题,特别是中位数的选择问题似乎比找最小元素要难,但事实上,从渐进阶的意义上看,它们是一样的。一般的选择问题也可以在O(n)时间内得到解决。下面要讨论解一般的选择问题的分治算法randomizedSelect。该算法实际上是模仿快速排序算法设计出来的。其基本思想也是对输入数组进行递归划分。与快速排序算法不同的是,它只对划分出的子数组之一进行递归处理。
private static Comparable randomizedSelect(int p, int r, int k)
{
if(p==r) return a[p];
int i=randomizedPartition(p,r),j=i-p+1;
if(k<=j) return randomizedSelect(p,i,k);
else return randomizedSelect(i+1,r,k-j);
}
可以看出,在最坏情况下,算法randomizedSelect需要Ω(n^2)计算时间。例如在找最小元素时,总是在最大元素处划分。尽管如此,该算法的平均性能很好。
下面来讨论类似于算法randomizedSelect但可以在最坏情况下用O(n)时间就完成选择任务的算法select。如果能在线性时间内找到一个划分基准,使得按这个基准所划分出的两个子数组的长度都至少为原数组长度的ε倍(0<ε<1是某个正常数),那么就可以在最坏情况下用O(n)时间完成选择任务。例如ε=9/10,算法递归调用所产生的子数组的长度至少缩短1/10。所以在最坏情况下,算法所需的计算时间T(n)满足递归式T(n)<=T(9n/10)+O(n).由此可得T(n)=O(n).
按以下步骤可以找到满足要求的划分基准:
(1)将n个输入元素划分成[n/5](向上取整)个组,每组5个元素,只可能有一个组不是5个元素。用任意一种排序算法,将每组中的元素排好序,并取出每组的中位数,共[n/5](向上取整)个。
(2)递归调用算法select来找出这[n/5](向上取整)个元素的中位数。如果[n/5](向上取整)是偶数,就找它的两个中位数中较大的一个。以这个元素作为划分基准。
2.10 最接近点对问题
最接近点对问题的提法是:给定平面上n个点,找其中一对点,使得在n个点组成的所有点对中,该点对间的距离最小。
一维情形:显然可以先将n个点排序,然后一次线性扫描就可以找出最接近点对。耗时O(nlogn)。然而这种方法无法直接推广到二维的情形。因此,对一维的简单情形,还是尝试用分治法来求解,并希望推广到二维的情形。
public static double cpair1(S)
{
n=|S|;
if (n<2) return inf;
m=S中各点坐标的中位数;
构造S1和S2;
//S1={x∈S|s<=m},S2={x∈S|x>m}
d1=cpair1(S1);
d2=cpair2(S2);
p=max(S1);
q=min(S2);
d=min(d1,d2,q-p);
return d;
}
由以上分析可知,该算法的分割步骤和合并步骤总共耗时O(n)。因此,算法耗费的计算时间T(n)满足递归方程
T(n) = O(1) n<4
2T(n/2)+O(n) n>=4
解此递归方程可得T(n)=O(nlogn)。
这个算法看上去比用排序加扫描的算法复杂,然而它可以推广到二维的情形。
public static double cpair2(S)
{
n=|S|;
if(n<2) return inf;
1. m=S中各点x间坐标的中位数;
构造S1和S2;
//S1={p∈S|x(p)<=m},S2={p∈S|x(p)>m}
2. d1=cpair2(S1);
d2=cpair2(S2);
3. dm=min(d1,d2);
4. 设P1是S1中距垂直分割线l的距离在dm之内的所有点组成的集合;
P2是S2中距垂直分割线l的距离在dm之内的所有点组成的集合;
将P1和P2中的点依其y坐标排序;
并设X和Y是相应的已排好序的点列;
5. 通过扫描X以及对于X中每个店检查Y中与其距离在dm之内的所有点(最多6个)可以完成合并;
当X中的扫描指针逐次向上移动时,Y中的扫描指针可在宽为2dm的区间内移动;
6. d=min(dm,dl);
return d;
}
下面分析算法cpair2的计算复杂性。设对于n个点的平面点集S,算法耗时T(n)。算法的第1步和第5步用了O(n)时间。第3步和第6步用了常数时间。第2步用了2T(n/2)时间。若在每次执行第4步时进行排序,则在最坏情况下第4步要用O(nlogn)时间。这不符合要求,因此要做技术处理。采用设计算法时常用的预排序技术,即在使用分治法之前,预先将S中n个点依其y坐标值排好序,设排好序的点列P*。在执行分治法的第4步时,只要对P*做一次线性扫描,即可抽取出所需要的排好序的点列X和Y。然后,在第5步中再对X做一次线性扫描,即可求得dl。因此,第4步和第5步的两遍扫描合在一起只要O(n)时间。由此可知,经过预排序处理后的算法cpair2所需的计算时间T(n)满足递归方程
T(n) = O(1) n<4
2T(n/2)+O(n) n>=4
由此易知,T(n)=O(nlogn)。预排序所需的计算时间显然为O(nlogn).因此,整个算法所需的计算时间为O(nlogn)。在渐进的意义下,此算法已是最优的了。
2.11 循环赛日程表
分治策略,可以将所有的选手分为两半,n个选手的比赛日程表就可以通过为n/2个选手射击的比赛日程表来决定。递归地用这种一分为而的策略对选手进行分割,直到只剩下两个选手时,比赛日程表的指定就变得很简单。这时只要让这2个选手进行比赛就可以了。