3.5 分治算法策略
3.5 分治算法策略
所谓分治法就是将问题分而治之。将问题一分为二、一分为三或一分为N等份。对每一等份分别进行解决后,原问题就可以很快得以解决。因此一个问题能否用分治法解决,关键是看该问题是否能将原问题分成n个规模较小而结构与原问题相似的子问题。递归的解决这些子问题,然后合并其结果就得到原问题的解。当n=2时的分治法又称二分法。
分治法解题的一般步骤:
(1)分解,将要解决的问题划分成若干规模较小的同类问题;
(2)求解,当子问题划分得足够小时,用较简单的方法解决;
(3)合并,按原问题的要求,将子问题的解逐层合并构成原问题的解。
当我们求解某些问题时,由于这些问题要处理的数据相当多,或求解过程相当复杂,使得直接求解法在时间上相当长,或者根本无法直接求出。对于这类问题,我们往往先把它分解成几个子问题,找到求出这几个子问题的解法后,再找到合适的方法,把它们组合成求整个问题的解法。如果这些子问题还较大,难以解决,可以再把它们分成几个更小的子问题,以此类推,直至可以直接求出解为止。这就是分治策略的基本思想。
使用分治策略的问题常常要借助递归的结构,逐层求解,当问题规模达到某个简单情况时,解容易直接得出,而不必继续分解。其过程大致如下:
if 问题不可分then
begin
直接求解;
返回问题的解;
end
else begin
从原问题中划出含1/n运算对象的子问题1;
递归调用分治法过程,求出解1;
从原问题中划出含1/n运算对象的子问题2;
递归调用分治法过程,求出解2;
…………
从原问题中划出含1/n运算对象的子问题n;
递归调用分治法过程,求出解n;
将解1、解2、……、解n组合成整个问题的解;
end;
根据分治法的分割原则,原问题应该分为多少个子问题才较适宜?大量实践发现:在用分治法设计算法时,最好是子问题的规模大致相同。通常可以采取二分法,因为这么划分简单而且均匀。
例21:赛程问题。
有n个编号为1到n的运动员参加某项运动的单循环比赛,即每个运动员要和所有其他运动员进行一次比赛。试为这n个运动员安排一个比赛日程,使得每个运动员每天只进行一场比赛,且整个比赛在n-1天内结束。输入运动员人数n(n<=10000),输出一个n阶方阵A[1..N,0..N-1],当J>0时,A[1,J]表示第1名运动员在第J天的比赛对手。
【分析提示】由于N个运动员要进行单循环比赛,且在N-1天内要结束全部比赛,经过分析,当且仅当N为2的整次幂时,问题才有解,当然解是不惟一的。这样可以将运动员分成两组:1,2,…,N/2和N/2+1,N/2+2,…,N。给第一组运动员安排一个比赛日程,得到一个N/2阶的方阵A1;同时给第二组的运动员安排一个比赛日程,同样会得到一个N/2阶的一个方阵A2。考虑到比赛的性质,设定第1个运动员在某一天的比赛对手为第K个运动员,则第K个运动员在同一天的比赛对手必然是第1个运动员,即若有A[1,J]=K,则A[K,J]=1。因此原问题的 解(一个N阶方阵)可以由分解后的两个子问题的解,按下图所示形式合并起来。同时每一个子问题又可以按照上述的二分法分解下去,直至每个组中仅有2个运动员时为止。
{$r+}
const num=8;
var i,j:integer; a:array [1..num,0..num-1] of integer;
procedure arrange(k,n:integer);
var i,j:integer;
begin
if n=2 then begin a[k,0]:=k;a[k,1]:=k+1;a[k+1,0]:=k+1;a[k+1,1]:=k;end else
begin
arrange(k,n div 2);
arrange(k+n div 2,n div 2);
for i:=k to k+n div 2 -1 do
for j:=n div 2 to n-1 do
a[i,j]:=a[i+n div 2,j-n div 2];
for i:=k+n div 2 to k+n-1 do
for j:=n div 2 to n-1 do
a[i,j]:=a[i-n div 2,j-n div 2];
end;
end;
begin
arrange(1,num);
for i:=1 to num do
begin for j:=0 to num-1 do write(a[i,j],' '); writeln; end;
end.
例22:求一组数中的最大值和最小值。
我们可以把这n个元素放在一个数组中,用直接比较法求出。算法如下:
BEGIN
MIN:=A[1]:MAX:=A[1];
FOR I:=2 TO N DO
BEGIN
IF A[I] > MAX THEN MAX:=A[I];
IF A[I] < MIN THEN MIN:=A[I];
END.
上面这个算法需比较2(N-1)次,即时间复杂度是2(N-1)。能否找到更好的算法呢?我们用分治策略来讨论。
我们把n个元素分成:
A1={A[1],...,A[int(n/2)]}和A2={A[INT(N/2)+1],...,A[N]}两组,分别求这两组的最大值和最小值,然后分别将这两组的最大值和最小值相比较,求出全部元素的最大值和最小值。
如果A1和A2中的元素多于两个,则再用上述方法各分为两个子集。直至子集中元素至多两个元素为止。
例如有下面一组元素:
-13,13,9,-5,7,23,0,5。用分治策略比较的过程如下:
图中每个方框中,左边是最小值,右边是最大值。从图中看出,用这种方法一共比较了10次,比直接比较法的14次减少4次,即约减少了1/3。
算法如下:
procedure maxmin(i,j,max,min);
begin
case j-i of
0:max:=a[i];min:=a[i];
1:if a[i]<a[j] then begin min:=a[i];max:=a[j];end else begin max:=a[i];min:=a[j];end;
else
begin
mid:=(i+j) div 2;
maxmin(i,mid,max1,min1);
maxmin(mid+1,j,max2,min2);
IF max1>max2 THEN MAX:=max1 else max:=max2;
IF min1<min2 THEN MIN:=min1 else min:=min2;
end;
end;
例:归并排序
某数列存储在对序列A[1],A[2],……,A[n],现采用归并思想进行排序。
分析:
这里我们采用二分法。先将n个元素分成两个各含(表示“不大于x的最大整数”)个元素的子序列;再用归并排序法对两个子序列递归的排序;最后合并两个已排序的子序列以得到排序结果。在对子序列排序时,当其长度为1时递归结束。单个元素被视为是已经排好的序列。
下面我们来分析一下对两个已排好序的子序列A[P..Q]和A[Q+1..R],将它们合并成一个已排好的子序列[P..R]。
引入一个辅助过程merge(A,P,Q,R)来完成这一合并工作,其中A是数组,P,Q,R是下标。其方法是:每次选两个子序列中较小的一个元素加入到目标序列中,直到某一个子序列为空,最后把另一子序列中剩下的元素加入到目标序列中。
procedure Merge(var A: ListType; P, Q, R: Integer);
{将A[P..Q]和A[Q+1..R],合并到序列A[P..R]}
var
I, {左子序列指针}
J, {右子序列指针}
T: Integer; {合并后的序列的指针}
Lt: ListType; {暂存合并的序列}
begin
I := P; J := Q + 1;
for T:=P to R do begin
{若左序列剩有元素并且右序列元素全部合并或
左序列的首元素小于等于右序列的首元素,则左序列的首元素进入合并序列}
if (J>R) or ((I<Q) and (A[I]<=A[J])) then begin
Lt[t]:=A[I]; Inc(I); end
else
begin {否则右序列的首元素进入合并序列}
Lt[t]:=A[J]; Inc(J);
end;
end; {for}
A:=Lt; {合并后的序列赋给A}
end;
下面我们来看看分治过程。利用mergesort(A,P,R)对数组A[P..R]进行排序。若P=R, 则子序列只有一个元素,分解完毕。否则,计算出中间下标Q,将A[P..R]分成A[P..Q]和A[Q+1..R]。
procedure MergeSort(var A: ListType; P, R: Integer);
var
Q: Integer;
begin
if P <> R then begin {若子序列A中不止一个元素}
Q:=(P+R) div 2; {计算中间下标Q}
MergeSort(A, P, Q); {继续对左子序列A[P..Q]递归排序}
MergeSort(A, Q + 1, R); {继续对左子序列A[Q+1..R]递归排序}
Merge(A, P, Q, R) {对左子序列和右子序列归并排序}
end;
end;
用Mergesort(A,1,N)便可对整个序列进行归并排序。如果我们自底向上来看这个过程的操作时,算法将两个长度为1的序列合并成排好序的长度为2的序列,继而合并成长度为4的序列……,依次类推。随着算法自底向上执行,被合并的排序序列长度逐渐增加,一直进行到将两个长度为n/2的序列合并成最终排好序的长度为n的序列。图25列出了对序列(5,1,4,6,2,3,2,6)进行归并排序的过程。
例30:剔除多余括号
键盘输入一个含有括号的四则运算表达式,可能含有多余的括号,编程整理该表达式,去掉所有多余的括号,原表达式中所有变量和运算符相对位置保持不变,并保持与原表达式等价。
例如:
输入表达式 |
应输出表达式 |
A+(b+c) |
A+b+c |
(a*b)+c/(d*e) |
A*b+a/(d*e) |
A+b/(c-d) |
A+b/(c-d) |
注意输入a+b时不能输出b+a。
表达式以字符串输入,长度不超过255,输入不需要判错。
所有变量为单个小写字母。只是要求去掉所有多余括号,不要求对表达式简化。
分析:
对于四则运算表达式,我们分析一下哪些括号可以去掉。
设待整理的表达式为(s1 op s2);op为括号内优先级最低的运算符(“+”,“-”或“*”,“/”);
① 左邻括号的运算符为“/”,则括号必须保留,即…/(s1 op s2)…形式。
② 左邻括号的运算符为“*”或“-”。而op为“+”或“-”,则保留括号,即… *(s1+s2)…或
… -(s1+s2)…或…*(s1-s2)…或…-(s1-s2)…。
③ 右邻括号的运算符为“*”或“/”,而op为“+”或“-”,原式中的op运算必须优先进行,因此括号不去除,即(s1+s2)*…
除上述情况外,可以括号去除,即…s1 op s2…等价于…(s1 op s2)…
我们从最里层嵌套的括号开始,依据上述规律逐步向外进行括号整理,直至最外层的括号保留或去除为止。这个整理过程可以用一个递归过程来实现。
例如,剔除表达式“((a+b)*f)-(i/j)”中多余的括号。依据上述算法进行整理的过程如图所示。
最后,自底向上得到整理结果:(a+b)*f-i/j。
程序如下:
program CTSC94_1;
const
Inp = 'input.txt';
Outp = 'output.txt';
var
Ch: Char;
Expr: string;
function RightBracket(S:string;I:Byte):Byte;
{在S串中找到下一个运算符的位置}
var
Q: Byte; {Q用来记录括号层数}
begin
Q := 1;
repeat
Inc(I);
if S[I] = '(' then
Inc(Q)
else
if S[I] = ')' then Dec(Q);
until Q = 0;
RightBracket := I;
end;
function Find(S: string): Byte;
{找到优先级别最低的运算符的位置}
var
I, K: Byte;
begin
I := 1; K:= 0;
while I <= Length(S) do
begin
if (S[I] = '+') or (S[I] = '-') then
begin Find := I; Exit; end;
if (K = 0) and ((S[I] = '*') or (S[I] = '/')) then K := I;
if S[I] = '(' then I := RightBracket(S, I);
Inc(I);
end;
Find := K;
end;
function DeleteBracket(S: string; var P: Char): string;
{剔除多余括号,S表示要处理的表达式;P表示表达式中最后一个运算符}
var
I: Byte;
Ch1, Ch2: Char;
Left, Right: string;
begin
if Length(S) = 1 then begin {当表达式中无运算符}
DeleteBracket := S; P := ' ';
Exit;
end;
if (S[1] = '(') and (RightBracket(S, 1) = Length(S)) then begin
{当表达式最外层有括号}
DeleteBracket := DeleteBracket(Copy(S, 2,Length(S)- 2), P);
Exit; end;
I := Find(S); {找到最低运算符}
P := S[I];
Left := DeleteBracket(Copy(S,1,I-1), Ch1);
{递归处理运算左边}
Right := DeleteBracket(Copy(S,I+1,Length(S)-I),Ch2);
{递归处理运算右边}
if (P in ['*', '/']) and (Ch1 in ['+', '-']) then
Left := '(' + Left + ')';
if (P in ['*','-']) and (Ch2 in ['+','-']) or (P ='/')and(Ch2 <>' ') then
Right := '(' + Right + ')';
DeleteBracket := Left + P + Right;
end;
Begin
Assign(Input, Inp); Reset(Input);
Readln(Expr);
Close(Input);
Assign(Output, Outp); Rewrite(Output);
Writeln(DeleteBracket(Expr, Ch));
Close(Output);
End.
分治策略在计算机算法中经常应用,而且大多数分为2个子问题,因此也叫做二分法。例如二分法检索、求方程的根等。
从上述的分治思想来看,运用分治策略解决的问题一般来说具有以下特点:
1、原问题可以分解为多个子问题,这些子问题与原问题相比,只是问题的规模有所降低,其结构和求解方法与原问题相同或相似。
2、原问题在分解过程中,递归地求解子问题,由于递归都必须有一个终止条件,因此,当分解后的子问题规模足够小时,应能够直接求解。
3、在求解并得到各个子问题的解后,应能够采用某种方式、方法合并或构造出原问题的解。
利于分治策略求解时,所需时间取决于分解后子问题的个数、子问题的规模大小等因素,而二分法,由于其划分的简单和均匀的特点,是经常采用的一种有效的方法。