分治法是将一个规模较大的问题分解为多个规模较小但和原问题是相同类型的问题,解决了规模小的问题之后,合并这些小问题的解就可以得到原问题的解。
它的关键是怎么把大问题分解成小问题,而且这些小问题和原问题是相同类型的。
比如前面讨论过的hanoi塔就使用了分治法。正确的算法实现是:
public void Method1(int n,char X,char Y,char Z){
if(n==1)
MoveDisk(1,X,Z);
else{
Method1(n-1,X,Z,Y);
MoveDisk(n,X,Z);
Method1(n-1,Y,X,Z);
}
}
而前面提过错误的算法是:
public void ErrorMethod(int from,int to,char X,char Y,char Z){
if(from==to)
MoveDisk(1,X,Z);
else{
int mid=(from+to)/2;
ErrorMethod(from,mid,X,Z,Y);
ErrorMethod(mid+1,to,X,Y,Z);
ErrorMethod(from,mid,Y,X,Z);
}
}
先看看ErrorMethod为什么没有正确的使用分治法。ErrorMethod是错误的证明前面已经讨论过了。这里只讨论为什么它不是正确的分治法,即小问题而原来的问题是不同类型的。一般书上解释这个算法是这样的:要想把n个盘从X移到Z,可以先借助Z,把前n-1个盘从X移到Y,然后把第n个盘从X移到Z,最后借助X把前n-1个盘从Y移到Z。
那么按照这种思路我们来解释ErrorMethod。要想把n个盘从X移到Z,可以借助Z,把1到n/2号盘从X移到Y,借助Y把n/2+1到n号盘从X移到Z,然后借助X把1到n/2号盘从Y移到Z。似乎也能解释的通,但上次已经证明这个算法是错误的了。错在哪呢?如果在任何情况下都可以借助某个盘柱把从小到大的一些盘从另外两个盘柱的一个移到另一个的话,那么ErrorMethod是对的,也就是说ErrorMethod把规模为n的问题分解成了三个这样的问题:借助某个盘柱(这个盘柱上可能已经有一些盘了)把从小到大的一些盘从另外两个盘柱的一个移到另一个,并且被借助的盘柱上的盘是不移动的。这些小问题却不一定有解。比如n=4时,把1,2号盘移到Y我们假设可行。现在要在不移到1,2号盘的前提下把3,4号盘借助Y从X移到Z,这显然是不行的,因为3,4比1,2大,Y其实是不可借助的,也就是变成了要把3,4号盘直接从X移到Z。这是一个新的问题,而且是无解的(除非n=3,把3号盘直接从X移到Z是有解的)。
知道了错误的,我们现在就知道正确解释Method1了。
要想把n个盘(从1到n排列)借助Y(Y上没盘)从X移到Z(Z上没盘),可以先借助Z(Z上也没盘),把前n-1个盘(从1到n-1排列)从X移到Y(Y上没盘),然后把第n个盘从X移到Z,最后借助X(X上没盘)把前n-1个盘(从1到n-1排列)从Y移到Z(Z上有n号盘)。注意最后一步其实和原来问题不同了,但是由于Z柱上的n号盘不被移动,且不影响前n-1个盘的移动,所以仍然可以把它看成和原问题一样的问题。
上面说明时用了一大堆括号,主要原因是说话时总会漏掉一些条件,而我们汉语又没有使用定语从句的语法(近代汉语的破折号有类似的作用,但看起来也不太舒服)。从这点看英语比汉语要圆滑。比如你说:“老子天下第一,谁也不怕……”,回头发现老婆站在身后,笑呵呵的问你“是吗?”。 如果你反应快的话可能加上半句“除了你之外”。不过即使这样,她也不会相信。因为虽然在现代汉语中允许这种语法,但毕竟不太自然,而且又在这种语境之中。如果是英文“I fear nobody except ……”是非常自然的,不这么说反而不自然。不过汉语口语中也有类似的用的比较自然的。比如单田芳讲评书经常使用的一招:某人被打败了,大叫“我不服……”,一看刀架到脖子上了又冒出半句“也得服”。
闲话少说,书归正传。Method1除了上面的解释方法,还有一种。
把原问题看作使用最少的次数把n个盘借助Y从X移到Z。移动时除了不能大盘压小盘外没有任何限制。原问题分三步:首先用最少的次数把n-1个盘借助Z从X移到Y;把n号盘从X移到Z;最后用最少的次数把前n-1个盘借助X从Y移到Z。为什么这样三个步骤得到的就是n个盘移动的最少次数呢?因为要把n号盘移到Z(不管是从X还是Y)都至少要一次;移到n从X(或Y)到Z的前提是前n-1个盘在Y(X),Z上没有任何盘。如果n盘是从X到Z,说明前面已经把n-1个盘从X移到Y了,最少需要步骤一那么多的移动。如果是从Y到Z,说明前面已经把n号盘从X移到了Y;而把X移到Y说明前n-1个盘已经移到了Z,移动前n-1个盘需要至少步骤一那么多次数的移动。同理,任何正确的移动方法在把n号盘最后一次移到Z之后(可能有移到Z又移动走的情况,最后一次移到Z后n号盘就不会再动了),要把前n-1个盘移到Z上面。这n-1个盘的移动次数必须是最少的,否则整个过程就不是最少的移动过程。
当然除了次数最少的移动方法外还有其它的方法,比如
public void Method4(int n,char X,char Y,char Z){
if(n==1)
MoveDisk(1,X,Z);
else{
Method4(n-1,X,Z,Y);
MoveDisk(n,X,Z);
MoveDisk(n,Z,X);
MoveDisk(n,X,Z);
Method4(n-1,Y,X,Z);
}
}
看起来似乎很无聊,把n号盘从X移动Z又移回到X又移回到Z。你也可以把在第一步把n-1个盘从X移到Y,然后又移到X最后又移到Y。不管怎么样,都是解决问题的一种方法。如果我们不知道正确的解法的时候尝试移动时也可能移动后发现不对又移回去的。
下面看一个不太无聊的解。问题和原来有点区别,除了不能大盘压小盘外,还不能直接把盘从X移到Z。即如果想把一个盘从X移到Z,则要借助Y,先把盘从X移到Y,然后把盘从Y移到Z。正确的解法是:
public void Method3(int n,char X,char Y,char Z){
if(n==1){
if(Y=='y'){
MoveDisk(1,X,Y);
MoveDisk(1,Y,Z);
}
else
MoveDisk(1,X,Z);
}
else{
Method3(n-1,X,Y,Z);
MoveDisk(n,X,Y);
Method3(n-1,Z,Y,X);
MoveDisk(n,Y,Z);
Method3(n-1,X,Y,Z);
}
}
解释方法就省了。这个算法没有什么特别的地方,但我们发现在递归的出口n==1的地方有些麻烦。因为n==1就是要把盘从X移到Z,如果Y==’y’,说明我们是要把盘从x移到z,这是规则不允许的,要借助y。这么一来看起来程序不太美观。如果我们把递归出口改成n==0回怎么样呢?也就是说把1个盘从X移到Z能不能套用递归
else{
Method3(n-1,X,Y,Z);
MoveDisk(n,X,Y);
Method3(n-1,Z,Y,X);
MoveDisk(n,Y,Z);
Method3(n-1,X,Y,Z);
}
呢?代入n=1得到:
else{
Method3(0,X,Y,Z);
MoveDisk(1,X,Y);
Method3(0,Z,Y,X);
MoveDisk(1,Y,Z);
Method3(0,X,Y,Z);
}
把0号盘(0号盘在哪?)从某柱移到某柱是什么意思?我们没办法移动不存在的盘,那只好什么也不干。则else里面变成:
MoveDisk(1,X,Y);
MoveDisk(1,Y,Z);
这正是把1号盘从X移到Z的办法。
所以修改一下:
public void Method2(int n,char X,char Y,char Z){
if(n==0)
return;
else{
Method2(n-1,X,Y,Z);
MoveDisk(n,X,Y);
Method2(n-1,Z,Y,X);
MoveDisk(n,Y,Z);
Method2(n-1,X,Y,Z);
}
}
看起来是否简洁多了呢?其实这种方法在递归中经常使用。比如用递归求一颗二叉树的高度时我们可以把递归出口定义为空树,并定义空树的高度为0。此外在数学中,有的函数在某点没有定义,可以补充定义,使得函数在这点连续。
再来看一个问题,这是北大2006 CS考研的一道题。
已知A1,A2,…,An是n个非降序排列的,B1,B2,…,Bn也是非降序排列的。求这2n个数中从小到大排序中第n大的数。要求最坏的时间复杂度是O(lgn)。
其实时间复杂度的要求是一个提示,O(lgn)的算法不多,只有类似折半查找的算法可以达到。如果“A数组和B数组分布的比较均匀的话”,即A1<B1<A2<B2<……<An<Bn,那就好了,第n大的就是Bn/2(或A(n/2+1)n是奇数时,这里先考虑n是偶数的情况)。假设我们想验证Bn/2是不是第n大的数,则我们要证明n在A数组中是不是第n/2+1大的数,如果恰好猜中了,则Bn/2是第n大的数。因为在B数组中有且仅有n/2-1个数比Bn/2小。加上A数组中有且仅有n/2个比它小的数,共有且仅有n-1个数比它小,所以Bn/2是第n大的数。要求Bn/2在A中的位置,很自然的想到折半查找,要lgn的比较次数。
如果很不幸Bn/2在A数组中的位置不那么靠近中间(n/2+1)呢?假设Bn/2在A中的位置是i。
A 1,2,…,i,…,n
说明Bn/2在所有2n个数中的位置是n/2+i-1,如果i=n/2+1,就是上面的特殊情况。如果i<n/2+1,则说明Bn/2在2n个数的位置<n,那么B1,B2,….,B(n/2-1)都不可能是第n大的数。同理A1,A2,…,Ai-1也不可能是第n大的数。这样第n大的数只能在B(n/2+1),…,B(n)和Ai,…,An中。在2n个数中找第n大的其实也就是在这些数中找第t=n/2-i+1大的数。因为比这个数小的有n/2+i-1(Bn/2的位置)+n/2-i+1=n。在B(n/2+1),…Bn中找第t大的数,只要考虑B(n/2+1),…,B(n/2+t)就可以了,后面的至少是第t+1大的。同理,Ai,…,An 也只要考虑Ai,…,A(i+t-1)即可。也就是在新的两个数组:
Ai,…,A(i+t-1)
Bn/2+1,…,Bn/2+t
中找第t大的数即可。这恰好就是原来的问题。t=n/2-i+1,因为i>=1,t<=n/2,所用每次至少排除一半的数。2n->n->n/2……,->1,所以最多用lgn次排除,第一次要lgn,第二次要lgn/2,……。最坏要T(n)=lgn+lgn/2+……+1=lg(n(1+1/2+1/4+…))<lg(2n)。
说了半天,可能还没说清楚,来看一个实际的例子。
A=1,4,5,7
B=2,3,6,8
步骤为:把B中靠中间的数3找到它在A中的位置,位置为i=2,说明3在8个数中的是第3大的。我们要找第4大的,就应该在A2,A3,A4,B3,B4这些数中找第n/2-i+1=1大的数。因为A2<A3<A4,B3<B4,所以就是在A2,B3中找第1大的数。
算法似乎不是特别难,但实现起来可不是那么容易。好像流行这么一种说法:“数据结构+算法=程序”。但根据我的经验,在实际的程序设计过程中,光有数据结构和算法离程序还远的很。在实际的工作中,很少会有复杂的算法,数据结构一般编程语言或类库都会提供,但程序并不能很容易的编出来。Bentley在“Writing Correct Programs”说“90%的计算机专家不能在两个小时内写出完全正确的二分法搜索算法。”不知是真是假。不过写出完全正确的算法确实不是想象真那么简单,调试程序时,你总会发现很多事前没有考虑到的地方。除了二分搜索外,快速排序用枢轴来划分也是算法思想容易理解,实现起来很费劲。
下面我们来实现这个算法。
这个算法有两个部分:用二分法找到插入元素的位置,递归求解。
当然,我们也可以直接使用java的Arrays类的binarySearch方法。如果找到的话会返回这个元素的位置,否则返回值return value=-(insertion point) - 1,即插入位置是-return value-1。这里我们还是自己来实现一个。
算法很简单,但要把它说清楚不容易。先把算法的实现放到下面。
由于我们的实际要求是求Bn/2在A数组中的位置,也就是不管A中有没有和Bn/2相同的元素,也要得到它的插入位置。如果有相同的元素,那么相同元素的位置或这个元素后移一个都可以。比如A=1,2,3,4 Bn/2=2,则1或2都是Bn/2在A中的位置(注意:在具体实现程序时我们用的是从0开始的下标)。
public int getInsertPos(int[] array,int left,int right,int value){
if(left>right)
return -1;
int l=left,r=right;
int mid;
while(l<=r){
mid=(l+r)/2;
if(value==array[mid])
return mid;
else if(value<array[mid])
r=mid-1;
else
l=mid+1;
}
return l;
}
/*说明,为了简单,算法的变量用了字母l,但字母l和数字1在计算机屏幕上很难区分。上述算法除了if(left>right) return -1;外,其余都是字母l而不是数字1。*/
怎么解释这个算法实现呢?要在array[left]……array[right]中找x(也就是算法实现里的value)。设le(为了区别数字1),rt分别指示数组的开始和结束。
下标小于le的数我们认为都<x,下标大于rt的都>x。现在首先要把x和mid比较。如果恰好相等,则mid(或mid+1)就是x在A中的位置。否则两种情况:x<array[mid],则表明x只可能在mid左边,所以让rt=mid-1,表明下标大于rt的大于x;x>array[mid],则表明x只可能在mid右边,所以人le=mid+1,表明下标小于le的小于x。当le<=rt,只能说明le左边<x和rt右边的数>x,而它们之间的数还不知道,所以还有继续下去。根据退出while循环的方式,我们把le,rt分成三类。
1,rt>=le+2
mid=(le+rt)/2,则le<mid<rt。如果array[mid]!=x,则le=mid+1或rt=mid-1后le<=rt仍然成立,应该继续比较。
2,rt=le
mid=le=rt
如果x<array[mid],le不变,rt=mid-1。因为le左边的比x小,le(=rt)右边的比x大,并且le(mid)比x大,所以不存在任何和x相等的数(rt<le了,while结束),且x插入位置是le。
如果x>array[mid],rt不变,le=mid+1。因为r(因为le变化了,用rt表示)左边的比x小,rt右边的比x大,array[rt]比x小,所以不存在和x相等的数(rt<le了,while结束),且x的插入位置是rt+1=le。
3,rt=le+1
mid=le
如果x<array[mid],le不变,rt=mid-1。因为le左边的比x小,le+1(rt)右边的比x大,array[le](mid)比x大,显然array[le+1]>=array[le]>x,所以不存在和x相等的数(rt=mid-1=le-1<le,while结束),且x的插入位置是le。
如果x>array[mid],rt不变,le=mid+1。因为rt-1(原le)左边的比x小,rt右边的比x大。array[rt-1]=array[mid]>x。而array[rt]是否和x相等还不知道,应该继续比较(le=mid+1=rt,满足le<=rt,while循环继续)。
写的有点乱,不知道你看清楚了没有,如果还不清楚,可以拿张纸画一画。如果看清楚了,你就比90%的计算机专家都厉害了(呵呵,感觉不错哦,^_^)。
不过接下来还要解决第二部分。
我们还是来看一下实际的例子。
A=2,4,6,8
B=b0,b1,b2,b3。
第1步是找到b1在A中的位置pos。总共5中情况。
1, pos=2 b1就是要找的数。递归出口。
2, pos=1 b1是位置第3的数,要在4,b2两个数(n=1)中找位置第1的数。可以直接递归调用自己。
3, pos=0 b1是位置第2的数,要在4,6 ,b2,b3(n=2)四个数中找位置第2的数。可以直接递归调用自己。
4, pos=3 b1是位置第4的数,要在6,b0中从大到小找位置第1的数,这就有点麻烦了,因为我们的算法是在A1,A2,…,An;B1,B2,…,Bn中找第n的数(从小到大)。那么如果从大到小呢?2n个数从大到小的第n个数应是从小到大的第n+1个数。比如1,2,3,4,5,6 从大到小的第3数是4,从小到大时是第4。即使做了这样的转化仍然不能解决问题,因为原来问题是从A1,A2,…,An;B1,B2,…,Bn中找第n的数(从小到大),现在的问题变成了找第n+1大的数了。一种解决方案是写两个方法,一个找第n大,一个找第n+1大。我在这里是再多一个参数,区别是找第n还是第n+1个数。
5, pos=4 b1是位置第5的数,要在6,8,b0,?中从大到小找第2的数。注意“?”是什么意思。按理应该是b0前面的数(b[-1]?),可惜前面没有了。所以也就是找6,8,b0从大到小的第2个数。如果从小到大应该也是第2个数,但由于A有2个元素,B却只有一个。也不能递归调用。还是回到前面的找6,8,b0从大到小的第2个数。这和在6,8,b0,负无穷4个数中找从大到小的第2个数没有区别,因为负无穷小于任何数,不会影响从大到小的顺序。因此一种解决方案是:在6,8,b[-1],b0中找从小到大第3的数。现在的问题是b[-1]是谁?因为A和B数组每次都会至少减半,如果不是第1次出现这种情况,那么可以把b0前面的数赋值为负无穷即可。但万一第一次就出现呢?那么我们可以在B数组前面多加一个元素,放上负无穷。但它还有一个缺点就是把原来的B数组的值改变的,这可能不是我们想要的结果。这样我们每次在找第n大的数时还得把B数组备份一次。那就花了O(n)的代价了。那么有没有解决方法呢?注意到b1是比6,8,b0都大的数。那么在6,8,b0从大到小找第2的数相当于在6,8,b0,b1中找第3大的数,也就是在6,8,b0,b1中从小到大找第2的数。这样虽然明知b1不是我们要找的数,但这样可以凑成可以递归调用的形式。
我们发现pos=2是最好的,2左边的也很容易,2右边但不是最右边虽然难一点还是可以求出。最难的是pos=n(4)。为什么会出现这种情况呢?可能是由于左右不对称。前面分析的是n是偶数的情况,n是奇数会不会好些呢?令人失望,还是不会。因为在2n个数种找第n个数本来就不对称。在2n-1个数种找第n个数或许会好些吧。
下面把我实现的算法贴下来,再分析最让人头痛的下标。
/**输入 array是升序排列的数组,
* left1(2)<right1(2),
* 0<=pos<=right1-left1+right2-left2+2
* right1-left1=right2-left2。
* 输出 在数组array[left1....right1]和array[left2...right2]中找出
* 升序排列中排在第pos位的数的下标(0开始)。
public int getMid(int[] array,int left1,int right1,
int left2,int right2,int pos,BinaryInserter bi){
if(left1==right1){
if(pos==1){
if(array[left1]<=array[left2])
return left1;
else
return left2;
}
else{//pos==2
if(array[left1]<=array[left2])
return left2;
else
return left1;
}
}
int mid=(left2+right2)/2;
int inspos=bi.getInsertPos(array,left1,right1,array[mid]);
int k=(right2-left2)/2+1+inspos-left1-pos;
if(k==0){
return (right2+left2)/2;
}
else if(k<0){//在左边
return this.getMid(array,inspos,inspos+(-k)-1,mid+1,
mid+(-k),(-k),bi);
}
else{
if(mid-k<left2)
return this.getMid(array, inspos - k, inspos - 1,
left2,
(left2 + right2) / 2, k,bi);
else
return this.getMid(array, inspos - k , inspos-1,
(left2 + right2) / 2 - k,
(left2 + right2) / 2 - 1, k + 1,bi);
}
}
if(left1==right1){是递归的出口。A和B只有一个数,在其中找第pos(1或2)大的数。
int k=(right2-left2)/2+1+inspos-left1-pos; B[mid]在A中的位置是inspos(0开始的下标),B中比B[mid]小的有(left2+right2)/2-left2个,A中比B[mid]小的有inspos-left1个(比如inspos=left1时没有比它小的)。这样B[mid]在所有这2(right1-left1+1)个数中的位置是(right2-left2)/2+1+inspos-left1。把它减去pos就得到它和pos的偏差k。
如果k=0,mid就是要找的数的下标。return (right2+left2)/2;//or return mid;
如果k<0,说明mid比较偏左。
return this.getMid(array,inspos,inspos+(-k)-1,mid+1,
mid+(-k),(-k),bi);
表示要在A数组的inspos,…,inpos+(-k)-1这|k|个数,和B数组的mid+1,…,mid+(-k)这|k|个数中找第|k|大的数。
如果k>0,又分两种情况。
一种是mid-k<left2,也就是出现b[-1]的情况。注意判断的条件是用mid-k<left2,为什么不能用inspos==right1呢?我们在分析的时候也是用的这个条件(inspos=4)啊!注意:我们分析时是找第n大的数,如果是找第n+1大的数就不会出现b[-1]的情况。
比如A=2,4,6,8
B=b0,10,b2,b3。
要找第5大的数。那么就是在8,b0从大到小找第1的数,也就是从小到大找第2的数,并不会出现b[-1]的情况。
return this.getMid(array, inspos - k, inspos - 1,
left2,
(left2 + right2) / 2, k,bi);
表示要在inspos-k,…,inspos-1这k个数和(left2 + right2) / 2 - k + 1,…, (left2 + right2) / 2这k个数中找第k的数。注意(left2 + right2) / 2 - k + 1可用left2代替。也就是说如果出现b[-1],则B数组只会少一个,不会出现b[-2]。为什么不会出现b[-2]?您可以自行分析一下。
否则return this.getMid(array, inspos - k , inspos-1,
(left2 + right2) / 2 - k,
(left2 + right2) / 2 - 1, k + 1,bi);
不知您是否觉得头痛。反正我自己读这段代码时(距离写的时候好几个月了)也是半天才看明白的。看不懂也没有关系。把这段并不美观的代码拿来分析只不过想说明即使算法思想有了,实现起来也不是那么容易的事。
不懂不可强求,你可以想想有没有别的方法(我是一时想不出别的方法了),就算一时想不出也不可不理睬。我觉得学习最忌讳的就是不懂强要自己懂。有些算法确实不是自己能想出来的,如果只是死记硬背别人的算法还不如什么也不会,这样不会使自己的思维受到别人的干扰,过一段时间在来思考,或许就能得到意想不到的好方法。
本文探讨了分治法的基本概念及其在汉诺塔问题中的应用,并深入剖析了一个高效的算法,该算法能在O(lgn)的时间复杂度内找出两个有序数组中第n大的数。
107

被折叠的 条评论
为什么被折叠?



