分治策略
分治策略(Divide and Conquer)是一种常用的算法设计技术,使用分治策略设计的算法通常是递归算法。
分治策略的基本思想
将规模为n的原问题归约为规模减半的子问题(可以是一个子问题,也可以是多个子问题),分别求解每个子问题,然后把子问题的解进行综合,从而得到原问题的解。
注意:
1.子问题与原始问题性质完全一致。
2.子问题之间可以独立求解。
3.递归停止时子问题可直接求解。
简单的体现分治策略基本思想的例子:
二分归并排序算法
设计思想:将被排序的数组分成相等的两个子数组,然后使用相同的算法对两个子数组分别进行排序,最后将两个排好序的子数组归并成一个数组。
MergeSort(A
//输入:数组A[p..r],1<=p<=r<=n
//输出:从A[p]到A[r]按照递增顺序排好序的数组A
1. if p<r
2. then q = (p + r) / 2;
3. MergeSort(A, p, q);
4. MergeSort(A, q + 1, r);
5. Merge(A, p, q, r);
Merge(A,p,q,r)
//输入:按照递增顺序排好序的数组 A[p..q]与 A[q+1..r]
//输出:按照递增顺序排好序的数组 A[p..r]
6. x=q-p+1, y=r-q
7. 将A[p..q]复制到B[1..x],将A[q+1..r]复制到C[1..y]
8. i=1,j=1,k=p
9. While i<=x and j<=y do
10. if B[i]<=C[j]
11. then A[k]=B[i]
12. i=i+1
13. else
14. A[k]=C[j]
15. j=j+1
16. k=k+1
17. if i>x then 将 C[j..y]复制到A[k..r]
18. else 将B[i..x]复制到A[k..r]
设W(n)表示二分归并排序算法在最坏情况下所做的比较次数,W(n)满足如下递归方程:
W ( n ) = 2 W ( n / 2 ) + n − 1 W(n)=2W(n/2)+n-1W(n)=2W(n/2)+n−1;
W ( 1 ) = 0 W(1)=0W(1)=0;
且 W ( n ) = n l o g n − n + 1 W(n) = nlogn - n + 1W(n)=nlogn−n+1
分治算法的一般性描述
设P是待求解的问题,n表示该问题的问题规模,一般的分治算法Divide-and-Conquer的伪码描述如下:
Divide-and-Conquer(P)
1. if n<=c then S(P)
2. divide P into P1,P2,...Pk
3. for i = 1 to k do
4. yi = Divide-and-Conquer(Pi)
5. return Merge(y1,y2,...yk)
分治算法通常都是递归算法,这种算法的时间复杂度分析通常需要求解递推方程。如果原问题的解的输入规模是m,根据上面的伪码,分治算法时间复杂度的递推方程的一般形式是:
W ( n ) = W ( n 1 ) + W ( n 2 ) + . . . + W ( n k ) + f ( n ) W( n ) =W(n1) + W(n2) + ... +W(nk) + f(n)W(n)=W(n1)+W(n2)+...+W(nk)+f(n) ;
W ( c ) = C W( c ) =CW(c)=C ;
上面的C代表直接求解规模为 c 的子问题的工作量,而 f (n) 代表将原问题归约为若干子问题以及将子问题的解综合为原问题的解所需要的总工作量。
改进分治算法的途径
前面已经给出了一些例子,分治算法比起通常的蛮力算法在效率上确实有了明显的改进,但分治策略也不是处处有效的,对有些问题,简单的分治算法对提高求解效率没用,原因主要是在于分治算法的递归调用。
首先是产生的子间题个数较多,问题个数越多,函数的阶就越高,从而时间复杂度就越高,因此减少子问题个数是降低时间复杂度的有效途径。怎样减少子问题个数?一种可行的办法是寻找子问题之间的依赖关系,如果一个子问题的解可以用其他子问题的解通过简单的运算得到,那么在用到这个子问题的解时,不必重新递归计算,而是通过组合其他子问题的解来得到。这样就可以有效减少子问题的个数,从而提高算法效率。
其次,递归过程内的工作量过多也是影响算法效率的一个重要因素。如果在算法设计时,尽量把某些工作提到递归过程之外,作为预处理,从而有效减少递归内部的调用工作量,也是提高算法效率的一个有效途径。
通过代数变换减少子问题的个数
考虑下面的整数乘法问题.
例:设 X , Y X , YX,Y 是两个 n nn 位二进制数, n = 2 k n = 2^kn=2k,求 XY .
以每位乘1次作为1次基本运算,按照通常的乘法, X XX 的每一位都要和 Y YY 的个位相乘,需要乘 n nn 次。由于 X XX 有 n nn 位,总共需要 n 2 n ^ 2n2 次位乘,因此普通乘法的时间复杂度是 O ( n 2 ) O ( n ^ 2)O(n2)。
下面考虑分治算法、将 X XX 和 Y YY 都分成相等的两段,每段 n / 2 n /2n/2 位. X XX 的上半段(高位部分)记作 A AA ,下半段(低位部分)记作 B BB ;类似地, Y YY 的上半段和下半段分别记作 C CC 和 D DD .那么有
X = A 2 n / 2 + B X =A 2^{n/2} + BX=A2n/2+B
Y = C 2 n / 2 + D Y =C 2^{n/2} + DY=C2n/2+D
X Y = A C 2 n + ( A D + B C ) 2 n / 2 + B D XY = AC 2^n +( AD + BC ) 2^{n/2} + BDXY=AC2n+(AD+BC)2n/2+BD
根据这个公式,为了计算 X Y XYXY ,可以分别计算 A C , A D , B C AC , AD , BCAC,AD,BC 和 B D BDBD ,然后把 A C ACAC 乘以2 n 2^n2n,相当于向高位移 n nn 位的操作, A D + B C AD + BCAD+BC 乘以 2 n / 2 2^{n/2}2n/2,即向高位移 n / 2 n /2n/2位,然后把这两个结果与 B D BDBD 相加。计算 A C 、 A D 、 B C AC 、 AD 、 BCAC、AD、BC 和 B D BDBD ,其中毎个乘法都是两个 n / 2 n /2n/2位的整数相乘,相当于规模为 n / 2 n /2n/2的4 44个子问题;附加的移位和相加的操作是从子问题的解综合得到原问题的解的额外工作,这部分工作随 n nn 增加成线性增长,可以记为 c n cncn ,其中 c cc 是某个常数。于是得到时间复杂度的递推方程如下
W ( n ) = 4 W ( n / 2 ) + c n W( n ) = 4W( n/2 ) + cnW(n)=4W(n/2)+cn
W ( 1 ) = 1 W( 1 ) = 1W(1)=1
根据前面的结果得:
W ( n ) = O ( n l o g 4 ) = O ( n 2 ) W ( n ) = O ( n^{log4} ) = O ( n^2 )W(n)=O(nlog4)=O(n2)
这个分治算法与常规乘法的时间复杂度一样.做了这么多工作,算法居然没有丝毫的改善。观察时间复杂度函数的表示, n l o g 4 n^{log4}nlog4 中的4代表子问题个数,如果能把子问题减少为3个,那么算法的时间复杂度就会降低到 O ( n l o g 3 n^{log3}nlog3 ),大约是 O ( n 1.59 n^{1.59}n1.59 ),这就比 O ( n 2 n^2n2 )有明显的改进。
根据代数知识,不难找到这些子问题之间的依赖关系
A D + B C = ( A − B ) ( D − C ) + A C + B D AD + BC = ( A - B )( D - C ) + AC + BDAD+BC=(A−B)(D−C)+AC+BD
假设 AC 和 BD 已经得到,由这个公式可以看出,为得到 A D 十 B C AD 十 BCAD十BC,并不需要计算 AD 和 BC 两个子问题。实际上只需要计算一个新的子问题,即( A − B ) ( D − C ) ( A - B )( D - C )(A−B)(D−C),而 AC 与 BD 可以直接调用原来的结果,额外的加法和减法都只增加 O ( n )的时间,于是把子问题减少到3个,额外的工作仍旧是 O ( n ),只不过 cn 中的常数 c 大了一点就是了于是有递推方程:
W ( n ) = 3 W ( n / 2 ) + c n W ( n ) = 3 W( n/2 ) + cnW(n)=3W(n/2)+cn
W ( 1 ) = 1 W ( 1 ) = 1W(1)=1
解得
W ( n ) = O ( n l o g 2 3 ) = O ( n 1.59 ) W ( n )= O ( n^{log_2^3} )= O ( n^{1.59} )W(n)=O(nlog23)=O(n1.59)
这个例子说明,有时候简单使用分治策略不一定能得到预期的效果,在设计中需要认考虑如何减少子问题个数才能得到好的分治算法 。
利用预处理减少递归内部的计算量
例:设平面上有 n nn 个点 P 1 , P 2 , … , P n , n > 1 P_1 , P_2 ,…, P_n, n >1P1,P2,…,Pn,n>1,P i P_iPi 的直角坐标是( x i , y i ) , i = 1 , 2 , … , n ( xi , yi ), i = 1,2,…, n(xi,yi),i=1,2,…,n.求距离最近的两个点及它们之间的距离,这里的距离指的是通常的距离,即点 P i P_iPi 和 P j P_jPj的
距离是
d ( P i , P j ) = ( x i − x j ) 2 + ( y i − y j ) 2 d ( Pi , Pj ) = \sqrt { ( xi - xj ) ^2 + ( yi - yj ) ^2}d(Pi,Pj)=(xi−xj)2+(yi−yj)2
蛮力算法需要计算每对点之间的距离,从中比较出最小距离,有 n ( n − 1 ) / 2 n ( n -1 ) / 2n(n−1)/2个点对,假设每对点的距离计算需要常数时间,那么,蛮力算法将需要 O ( n 2 n^2n2 )的时间.
下面考虑分治算法.初步的想法是:用一条垂直线 l ll 将整个平面点集 P PP 划分成左半平面 P L P_LPL 和右半平面 P R P_RPR 两部分,使得 P L , P R P_L , P_RPL,PR 的点数近似相等,即
∣ P L ∣ = ⌈ ∣ P ∣ / 2 ⌉ \vert PL\vert =\lceil {\vert P\vert/2} \rceil∣PL∣=⌈∣P∣/2⌉
∣ P R ∣ = ⌊ ∣ P ∣ / 2 ⌋ \vert PR\vert =\lfloor {\vert P\vert/2} \rfloor∣PR∣=⌊∣P∣/2⌋
这里∣ P ∣ \vert P\vert∣P∣、∣ P L ∣ \vert PL\vert∣PL∣、∣ P R ∣ \vert PR\vert∣PR∣ 分别表示各个点集所含的点数。
P PP 中的最邻近点对可能有三种情况:两个点都在 P L P_LPL 中;两个点都在 P R P_RPR 中;或者一个点在 P L P_LPL 中,另一个点在 P R P_RPR 中。算法分别考虑这三种情况,对于前两种情况,可以分别计算 P L P_LPL 和 P R P_RPR 中的最邻近点对,这是两个 n / 2 n /2n/2 规模的子问题;对于第三种情况,算法需要找到由一个 P L P_LPL 中的点和一个 P R P_RPR 中的点所构成的最邻近点对。假设 P L P_LPL 和 P R P_RPR 中的最邻近点对的距离分别是 δ \deltaδL 和 δ \deltaδR ,令= min{ δ \deltaδL , δ \deltaδR }, 那么无论在 P L P_LPL 中的任两点,还是在 P R P_RPR 中的任两点之间的距离都不小于δ \deltaδ 。这就是说,如果出现了第三种情况,即由 P L P_LPL 和与 P R P_RPR 中各取一个点构成 P PP 中的最邻近点对,那么这一对点的距离应该不超过δ \deltaδ 。于是,为找到这样的两个点,只需要检查在直线 l ll 两边距 l ll 不超过 δ \deltaδ 的窄缝内的点即可。
根据这个设计思想,可以给出算法的伪码描述如下:
MinDistance( P , X , Y )
输人: n 个点的集合 P , X 和 Y 分别给出 P 中点的横、纵坐标
输出:最近的两个点及距离
1. 如果 P 中点数小于等于 3 ,则直接计算其中的最小距离
2. 排序 X、Y
3. 做垂直线 l 将 P 近似划分为大小相等的点集 PL 和 PR,PL的点在 l 左边,PR 的点在 l 右边
4. MinDistance( PL,XL,YL ) ; δL = PL 中的最小距离 //递归计算左半平面最邻近点对
5. MinDistance( PR,XR,YR ) ; δR = PR 中的最小距离 //递归计算右半平面最邻近点对
6. δ = min( δL , δR )
7. 对于在线 l 左边距离 δ 范围内的每个点,检查l右边是否有点与它的距离小于δ,如果存在则将 δ 修改为新值
从算法 MinDistance 的伪码描述可以看出,行 4 和行 5 是递归调用,毎个对应于 n /2规模的子问题,行 2 的排序需要 O ( nlogn )时间,行 3 的划分不需要额外的工作量,行 6 需要的时间是常数.我们只要估计出行 7 的计算所需要的时间,就可以列出该算法时间复杂度的递推方程。
下面分析一下行 7 的运算,如图所示
设 P i P_iPi 是在线 l ll 左边的任一点,坐标为( x i , y i ) ( x_i ,y_i )(xi,yi) ,在右边窄缝内距 P i P_iPi 小于 δ \deltaδ 的点其纵坐标一定在 y i + δ y_i + \deltayi+δ 与 y − δ y - \deltay−δ 之间。换句话说,这些点只能位于右边高度不超过 2 δ 2\delta2δ、宽度不超过 δ \deltaδ 的矩形区域内。在这个范围内可能有多少个点呢?如图,将这个矩形分成6 66个相等的小矩形,每个小矩形的宽是 δ / 2 \delta/2δ/2,高是 2 δ / 3 2\delta/32δ/3。根据勾股定理,小矩形的对角线的长度 d dd 是
d = ( δ / 2 ) 2 + ( 2 δ / 3 ) 2 d= \sqrt{ ( \delta/2 ) ^2 + ( 2\delta/3 ) ^2}d=(δ/2)2+(2δ/3)2= δ 1 / 4 + 4 / 9 = 5 δ / 6 =\delta \sqrt{1/4+4/9}=5\delta/6=δ1/4+4/9=5δ/6
这说明在任何小矩形内至多只能有1 11个点。因此,右边与 P i P_iPi 的距离小于 δ \deltaδ 的点数至多可能有 6 66 个对每个点来说,检查另一边是否有点与它的距离小于 δ \deltaδ ,只需要考查常数个点。假设所有距线 l ll 不超过 δ \deltaδ 的窄缝中的点构成集合 S SS 。只要 S SS 中点的纵坐标已经排好序(通过顺序扫描 Y YY ,检查每个点的横坐标,看看它是否距 l ll 小于 δ \deltaδ .如果是,就把它放到 S SS 中。这需要额外的O ( n ) O ( n )O(n)时间,不超过行 2 22 的 O ( n l o g n ) O ( nlogn )O(nlogn)排序时间),我们可以按照 S SS 中点的纵坐标顺序考查,比如说从具有最大纵坐标的点开始,顺序检查每个点。如果这个点的纵坐标是 y yy ,那么只需要检查那些纵坐标不小于 y − δ y - \deltay−δ 的点,看看其中是否存在分布在另一侧,且与该点的距离小于 δ \deltaδ 的点。上面已经证明在另一侧相关区域内的点不超过 6 66 个,而同侧区域的点也不会超过 6 66 个,因此这个检查至多需要考查 12 1212 个纵坐标(如果高度是 δ \deltaδ 准确地说是不超过 8 88 个),这仅需要常数时间。由于 S SS 的点数不超过 n nn ,因而对窄缝中所有点的检查需要O ( n ) O ( n )O(n)时间。而这个时间也不超过行 2 22 的排序时间。于是,除了递归调用外,额外的工作时间是 O ( n l o g n ) O ( nlogn )O(nlogn)。
基于上面的分析,不难写出该算法时间复杂度的递推方程
T ( n ) = 2 T ( n / 2 ) + O ( n l o g n ) T ( n ) = 2T( n/2 ) + O( nlogn )T(n)=2T(n/2)+O(nlogn)
T ( n ) = O ( 1 ) T ( n ) = O (1)T(n)=O(1) n < = 3 n <= 3n<=3
考查这个方程,a = 2 , b = 2 a =2,b=2a=2,b=2,n l o g b a n^{log_b^a}nlogba= n ,尽管函数 n l o g n nlognnlogn 的阶比 n nn 高,但是找不到 ϵ > 0 \epsilon >0ϵ>0,使得 n l o g n = Ω ( n 1 + ϵ ) nlogn = \Omega ( n^{1+\epsilon} )nlogn=Ω(n1+ϵ)
于是分治算法 MinDistance 的时间复杂度是 T ( n ) = O ( n l o g 2 n ) T ( n )= O ( nlog^2n )T(n)=O(nlog2n),比起蛮力算法的 O ( n ) O ( n )O(n)已经有了明显的改进。
这个算法还能不能改进得更好一些?考査上述关于时间复杂度函数的递推方程,不难发现在每次递归调用时主要的消耗在排序上,它需要 O ( n l o g n ) O ( nlogn )O(nlogn)时间。如果能够把排序工作移到递归调用之外,就有可能减少递归调用的工作量,从而提高算法的效率。
但是,在检查左边和右边各一个点的距离时需要当前输人中的点是按坐标排好序的点。
可以在子问题递归调用时,通过对原来已排序的数组 X 和 Y 的简单拆分操作,得到这个子问题的排序数组 X L , Y L , X R , Y R X_L ,Y_L ,X_R,Y_RXL,YL,XR,YR ?
对于横坐标数组 X L X_LXL 和 X R X_RXR 没有问题,因为划分的过程就是把 X XX 从中间截断,但是对按纵坐标排序的数组 Y YY ,哪个数应该分到 Y L Y_LYL ?哪个数应该分到到 Y R Y_RYR ?
与它们在 Y YY 中的位置没有直接关联,没有任何规律可循。回想二分归并排序算法,其中有一个归并的子过程,它把两个 n / 2 n /2n/2规模的排好序的数组合并成一个规模为 n nn 的数组。能不能把这个过程反过来,即从头到尾扫描 Y YY 数组,如果这个点的横坐标属于 X L X_LXL ,就把它的纵坐标放到 Y L Y_LYL ;如果这个点的横坐标属于 X R X_RXR ,就把它的纵坐标放到 Y R Y_RYR。
例如, P PP 中含有4 44个点 P 1 , P 2 , P 3 , P 4 P_1,P_2,P_3, P_4P1,P2,P3,P4,初始输人的 X XX 与 Y YY 数组如表所示。
| P | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| X | 0.5 | 2 | -2 | 1 |
| Y | 2 | 3 | 4 | -1 |
经过排序后的 X XX 与 Y YY 如表下表所示。括号中的数表示在初始输人数组中的下标。如排在 X XX 数组第二位的 0.5 ( 1 ) 0.5(1)0.5(1) 表示 0.5 0.50.5 是 P 1 P_1P1 的横坐标。进人递归调用以后,将 X XX 划分为 X L X_LXL 和 X R X_RXR ,每组两个数;Y YY 划分为 Y L Y_LYL 和 Y R Y_RYR ,每组两个数,当扫描 Y YY 数组时,首先检查− 1 -1−1,下标是4 44,横坐标应该处在 X R X_RXR ,因此− 1 -1−1应该放到 Y R Y_RYR 中;接着是 2 22,下标是 1 11,横坐标应该处在 X L X_LXL ,因此2 22应该分到 Y L Y_LYL 中,照此处理,最后的结果如表所示。
| X | -2(3) | 0.5(1) | 1(4) | 2(2) |
|---|---|---|---|---|
| Y | -1(4) | 2(1) | 3(2) | 4(3) |
| XL | -2(3) | 0.5(1) | XR | 1(4) | 2(2) |
|---|---|---|---|---|---|
| YL | 2(1) | 4(3) | YR | -1(4) | 3(2) |
选择合适的数据结构,在这轮拆分处理中, Y YY 中的每个数只需要常数工作量,因此拆分 Y YY 的总工作量是O ( n ) O( n )O(n)。综上所述,改进的途径就是将算法分成两步:第一步,对 X XX 与 Y YY 进行排序的预处理,第二步,算法进人递归计算。递归计算的过程与 M i n D i s t a n c e MinDistanceMinDistance 基本上相同,只是把行 2 22 对 X XX 和 Y YY 的排序过程改为拆分过程就是了(第一次进人时不必拆分)。假设算法的时间复杂度函数是T ( n ) T ( n )T(n),预处理的时间是 T 1 ( n ) T_1 ( n )T1(n),递归计算的时间是T 2 ( n ) T_2 ( n )T2(n),
那么有
T ( n ) = T 1 ( n ) + T 2 ( n ) T ( n ) = T_1 ( n )+ T_2 ( n )T(n)=T1(n)+T2(n)
T 1 ( n ) = O ( n l o g n ) T_1 ( n ) = O ( nlogn )T1(n)=O(nlogn)
T 2 ( n ) = 2 T 2 ( n / 2 ) + O ( n ) T_2 ( n ) = 2T_2 ( n/2 ) + O ( n )T2(n)=2T2(n/2)+O(n)
T 2 ( n ) = O ( 1 ) T_2 ( n ) = O ( 1 )T2(n)=O(1) n < = 3 n <= 3n<=3
使用前边的分析结果, T ( n ) = O ( n l o g n ) T( n ) = O ( nlogn )T(n)=O(nlogn),在分治算法的基础上,这个算法又降低了一个l o g n lognlogn的因子。
典型实例
快速排序算法
#include <stdio.h>
#include <utility>
int partition(int * array, int start, int end)
{
int comp = array[start];
while (start < end)
{
while (array[end] >= comp && start < end)
{
end--;
}
if (array[end] < comp && start < end)
{
std::swap(array[end], array[start++]);
}
while (array[start] <= comp && start < end)
{
start++;
}
if (array[start] > comp && start < end)
{
std::swap(array[start], array[end--]);
}
}
return start;
}
void quickSort(int * array, int start, int end)
{
if (start < end)
{
int pos = partition(array, start, end);
quickSort(array, start, pos - 1);
quickSort(array, pos + 1, end);
}
}
int main()
{
int arr[] = {34, 7, 16, 31, 26, 49, 11, 8, 1};
quickSort(arr, 0, (sizeof(arr) / sizeof(int)) - 1);
for (int i = 0; i < sizeof(arr) / sizeof(int); i++)
{
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}