本文讨论了Hanoi塔问题的递归方法与非递归方法,给出了java实现的代码,并比较了它们的效率。
法国数学家爱德华·卢卡斯曾编写过一个印度的古老传说:在世界中心贝拿勒斯(在印度北部)的圣庙里,一块黄铜板上插着三根宝石针。印度教的主神梵天在创造世界的时候,在其中一根针上从下到上地穿好了由大到小的64片金片,这就是所谓的汉诺塔。不论白天黑夜,总有一个僧侣在按照下面的法则移动这些金片:一次只移动一片,不管在哪根针上,小片必须在大片上面。僧侣们预言,当所有的金片都从梵天穿好的那根针上移到另外一根针上时,世界就将在一声霹雳中消灭,而梵塔、庙宇和众生也都将同归于尽。
文章中我们假设汉诺塔个数为正整数n,三个盘子为A,B,C,其中C是中介盘,我们要遵守移动规则将A上的盘子要全部通过C移动到B。
如果汉诺塔上盘子个数n=1时显然直接将A上的盘子移动到B即可,当n=2时,方法也很简单,只要将第一块盘子先从A移动到C,再将第二块盘子从A移动到B,再将第一块盘子从C移动到A。实际上,表达的时候不必要强调第几块盘子,而只需要像从A移动到B这样描述,也能清楚的知道意思(因为总是只能移动每个汉诺塔最顶上的盘子)。那么n=2时解决办法的表示就是:A->C,A->B,C->B。下面我们都采用这种简洁明了的表示。
要知道如何将n块盘子从A通过C移动到B,我们可以先将上面的n-1块盘子从A通过B移动到C,再将最大的盘子从A移动到B,这时再将上面的n-1块盘子从C通过A移动到B。这就是递归算法解决Hanoi塔问题的思路。代码如下:
/**
* 将A汉诺塔上的n个盘子通过C移动到B的递归方法
* @param n //汉诺塔上盘子的个数
* @param A //开始时有盘子的汉诺塔
* @param B //要将盘子移动到上面的目标汉诺塔
* @param C //中介汉诺塔
* @throws IllegalArgumentException when n<=0
*/
public static void HanoiTowers1(int n,char A,char B,char C){
if(n<=0){
throw new IllegalArgumentException("n must be >=1");
}
if(n==1){
System.out.println(A+"->"+B);
}
else{
HanoiTowers1(n-1,A,C,B); // 将除去最大的盘子的n个盘子从A通过B移动到C
System.out.println(A+"->"+B);//将最大的盘子从A移动到B
HanoiTowers1(n-1,C,B,A); //将除去最大的盘子的n-1个盘子从C通过A移动到B
}
}//HanoiTowers1(int n,char A,char B,char C)
public static void HanoiTowers1(int n){
HanoiTowers1(n,'A','B','C');
}//HanoiTowers1(int n)
要使用非递归方法,我们必须找到Hanoi塔移动方法的规律。根据递归方法的思想,我们可以求出移动Hanoi塔所需的步骤数为2^n-1。设移动n块盘子需要Hanoi(n)步,则Hanoi(n)=2*Hanoi(n-1)+1,而Hanoi(1)=1,容易求出Hanoi(n)=2^n-1.
我们先来讨论第m步移动的盘子是第几块(盘子按从小到大顺序排序)。首先,容易知道当m=2^(i-1)时,这时一定移动的是第i块盘子。因为要移动A塔上的第i块盘子时,一定得是前面i-1块盘子已经按顺序在另一块盘子上堆好了,而堆好i-1块盘子根据上面所说需要2^(i-1)-1个步骤,故第2^(i-1)第一次移动第i块盘子。而不管要移动的盘子是多少块,将前面盘子摆放好的方法是一样的(这里说的一样是把B和C看作一样的盘子时的一样,不区分中介盘,只考虑移动盘子的次序)比如说n=3和n=4时,前面三步将前两块盘子摆放好的方式是一样的,n=3时前面三步:A->B(第一块盘子),A->C(第二块盘子),B->C(第一块盘子)。n=4时前面三步:A->C(第一块盘子),A->B(第二块盘子),C->B(第一块盘子)。这里可以看出不考虑B,C盘的区别,移动的盘子是一样的。根据这个我们可以将m二进制展开为m=a[i]2^i+…+a[0]*2^0,其中a[i]=0或1,有了这个二进制展开,我们可以知道i从0开始数第一个使a[i]不等于0的i,第i+1块盘子就是第m步要移动的盘子。再一次强调得到这个结论的根据就是移动第N块盘子之前得先让前N-1块盘子保持顺序,而要让N-1块盘子保持顺序得花2^(N-1)-1步。当然这并不是严格的数学证明,但是得到这个结论之后用数学归纳法并不难给出一个完整的证明。我们这里略去严格的证明,只说明结论是:*m用二进制表示的最低位bit为1的位置为p,则第m步移动的是第p块盘子。
上面的讨论我们没有区分B盘和C盘,现在我们要来讨论具体的移动方法了,先来看几个例子:
n=2(括号里代表移动的是第几块盘子):
(1)A->C
(2)A->B
(1)C->B
n=3:
(1)A->B
(2)A->C
(1)B->C
(3)A->B
(1)C->A
(2)C->B
(1)A->B
n=4:
(1)A->C
(2)A->B
(1)C->B
(3)A->C
(1)B->A
(2)B->C
(1)A->C
(4)A->B
(1)C->B
(2)C->A
(1)B->A
(3)C->B
(1)A->C
(2)A->B
(1)C->B
我们来看看(1)的移动情况:n=2时:A->C->B,n=3时:A->B->C->A->B,n=4时:A->C->B->A->C->B。可以发现移动方式其实可以总结为两种1.A->C->B,2.A->B->C接下来就是不停的循环这种移动方式直到停止,可以看到(1)在n为偶数的时候移动方式是A->C->B,在n为奇数时移动方式是A->B->C,如果我们观察(3)会发现移动规律和(1)一样,而(2)正好与它们相反。
这并不是偶然,我们可以总结出规律第奇数块盘子在n为奇数时移动方式为A->B->C,在n为偶数时移动方式为A->C->B,而偶数相反。同样我们这里不会给出严格的数学证明,因为根据前面关于第m步会移动哪一块盘子的讨论当中其实我们就可以发现这个规律,当然我们也可以从之前得讨论之中得到一个严格的数学证明,但我们这里在意的仅仅是结果。更何况,我们可以用实践来检验一下我们的方法。
根据以上讨论的两点结论,我们可以给出非递归算法实现的代码:
/**
* 将A汉诺塔上的n个盘子通过C移动到B的非递归方法
* @param n 汉诺塔上盘子个数
* @throws IllegalArgumentException when n<=0
*/
public static void HanoiTowers2(int n){
if(n<=0){
throw new IllegalArgumentException("n must be >=1");
}
char[] hanoiPlate=new char[n]; //记录n个盘子所在的汉诺塔(hanoiPlate[1]='A'意味着第二个盘子现在在A上)
char[][] next=new char [2][3]; //盘子下次会移动到的盘子的可能性分类
int index[]=new int[n];
//根据奇偶性将盘子分为两类
for(int i=0;i2){
index[i]=0;
}
for(int i=1;i2){
index[i]=1;
}
//一开始所有盘子都在A上
for(int i=0;i'A';
}
//n的奇偶性对移动方式的影响
if(n%2==0){
next[0][0]='C';
next[0][1]='A';
next[0][2]='B';
next[1][0]='B';
next[1][1]='C';
next[1][2]='A';
}
else
{
next[1][0]='C';
next[1][1]='A';
next[1][2]='B';
next[0][0]='B';
next[0][1]='C';
next[0][2]='A';
}
//开始移动
for(int i=1;i<(1<//总共要执行2^n-1(1<
int m=0; //m代表第m块盘子hanoiPlate[m]
//根据步骤数i来判断移动哪块盘子以及如何移动
for(int j=i;j>0;j=j/2){
if(j%2!=0){
System.out.println("("+(m+1)+")"+hanoiPlate[m]+"->"+next[index[m]][hanoiPlate[m]-'A']);
hanoiPlate[m]=next[index[m]][hanoiPlate[m]-'A'];
break; //移动盘子后则退出这层循环
}
m++;
}
}
}
计算的步骤数都是非递归算法要更多,这是因为非递归算法有两个for循环的嵌套,递归算法占用内存更多,我们直接通过实验来对比它们在运算时间上的差异。
实验结果:
n=10时: 递归方法耗时: 0 非递归方法耗时: 0
n=15时: 递归方法耗时: 0 非递归方法耗时: 0
n=20时: 递归方法耗时: 4 非递归方法耗时: 26
n=25时: 递归方法耗时: 154 非递归方法耗时: 861
n=30时: 递归方法耗时: 5059 非递归方法耗时: 29695
通过结果我们可以看到递归算法的时间效率远高于非递归算法。非递归算法耗用了太多时间在for循环,我们可以试着改进一下非递归算法。
将非递归算法中的for循环模块修改为:
for(int i=1;i<(1<//总共要执行2^n-1(1<
int m=0; //m代表第m块盘子hanoiPlate[m]
int j=i;
while((j<<31)>>31==0&&j>0){
j=j>>1;
m++;
}
}
再次进行对比实验,结果如下:
n=10时: 递归方法耗时: 0 非递归方法耗时: 0
n=15时: 递归方法耗时: 0 非递归方法耗时: 0
n=20时: 递归方法耗时: 4 非递归方法耗时: 8
n=25时: 递归方法耗时: 156 非递归方法耗时: 267
n=30时: 递归方法耗时: 5408 非递归方法耗时: 8596
相比修改之前速度快了不少,但是仍然比递归算法要慢。
虽然根据比较的结果,我们可以发现递归算法可能略优于非递归算法,不过非递归算法在回答Hanoi塔问题第m步做什么的时候会比递归算法快,因为非递归算法可以直接求第m步要移动哪一块,从哪里移动到哪里(上文没有给出解释,见附注),而递归算法要一步步模拟到最后才能知道结果,很可能由于n过大,而无法的到结果。
附注:
设m=2^k[1]+2^k[2]+2^k[3]+2^k[s ] , 这里k[s]>k[s-1]>…>k[1]。
那么第m步要移动的就是第k[1]+1块盘子,我们知道k[1]+1块盘子的移动规律,它的移动周期是3,且是送A开始移动的,我们只要知道它是第几次被移动就可以了。它是第2^[(k[2]+k[3]+…+k[s])-(s-1)(k[1]+1)]+1次。可以通过证明第2^i次移动,第i+1块盘子移动1次。第2^(i+p)次,第i+1块盘子移动2^(p-1)次,其中p>=1来得出上面的结论。