汉诺塔问题及其变形算法分
写在前面:
本文章属于小编从网上整合而来!
引言
汉诺塔算法一直是算法设计科目的最具代表性的研究问题,本文关注于如何设计多柱汉诺塔最优算法的探究。最简单的汉诺塔是三个柱子(A、B、C),因此多柱汉诺塔的柱子个数M≥3。下面从三柱汉诺塔说起,慢慢深入我们要关心的问题。
1. 三柱汉诺塔
三柱汉诺塔是经典的汉诺塔问题,在算法设计中是递归算法的典型问题。其算法是这样的: 首先把A 柱上面的n- 1 个碟子通过C 柱移到B 柱上【T(n-1)步】,然后把A 柱剩下的一个碟子移到C 柱上【1步】, 最后把B 柱上所有的碟子通过A 柱移到C 柱上【T(n-1)步】。很容易得到算法的递归方程为:T(n)=2*T(n-1)+1,因此,不难算出步数是T(n)=2^n-1。对于三柱汉诺塔的算法的正确性自然是毫无争议的,我们需要的是从三柱汉诺塔的设计中引申出多柱汉诺塔的设计方法。
2. 四柱汉诺塔
四柱汉诺塔并不是仅仅是多了一根柱子那么简单,所以我们先尝试从正常的思维出发来探究如何使移动步数最少。
首先我们会想到,三柱汉诺塔需要借助另一个柱子存放前n-1个盘子,再把第n个盘子移动到目的位置。顺其自然的,四柱汉诺塔由于多了一个柱子,所以移动起来就更方便了,我们可以多留下一个盘子n-2,而不让它借位到其他柱子直接移动到目的位置。这样我们就得出算法的基本流程:
(1) 从A借助C、D将 n-2个盘子移动到B上。
(2) 将n-2移动到C上。
(3) 将n-1移动到D上。
(4) 将n-2移动到D上。
(5) 从B借助A、C将 n-2个盘子移动到D上。
另外,这么设计是符合正常思维原则的。以为随着柱子的个数增多,我们希望每次移动的时候盘子尽可能不发生折叠,也就是说我们希望除了需要借助存放n-2个盘子的柱子。那么剩下的两个柱子可以允许至多两个盘子不发生折叠就能直接移动到目的位置,这样才使得移动起来比较方便,步骤也会比较少。事实真的是如此吗?我们具体分析一下算法。
按照以上设计的算法流程,我们得到递归方程:F(n)=2*F(n-2)+3。因此得到移动步数为:F(n)=4*2^(n/2)-3:n为奇数;F(n)=6*2^(n/2-1)-3:n为偶数。下边列出6个盘子的移动步数:
n 1 2 3 4 5 6
F(n) 1 3 5 9 13 21
到这里,我们已经看出我们的设计的算法已经和经典的汉诺塔算法几乎如出一辙了,甚至是如此的对称和谐!基于此我们甚至可以推广到M(M≥3)个柱子的情况,来得到我们希望的最优解,假设柱子编号为1,2,3…M算法主题框架流程应该如下:
(1)从1柱借助3…M柱子将n-(M-2)个盘子移动到2柱上。
(2)将M-2个通过3…M-1柱简单的移动到M柱上【2*(M-2)-1步骤】。
(3)从2柱借助1,3…M-1柱子将n-(M-2)个盘子移动到M柱上。
具体步骤和四柱类似,不再做具体分析。这样我们看到我们自己亲手构建的算法模式如此完美,我们甚至不忍心去破坏它。但是我很遗憾的告诉自己,这种算法虽然正确,却不是最优!!!比如,对于6个盘子4个柱子的汉诺塔,按照我们的想法是保留2个盘子进行移动。现在假如我们保留3个盘子,因此上边的三个盘子按照4柱汉诺塔规则移动到B,步数应该是5(已经算出,可以验证),剩下三个盘子按照3柱汉诺塔规则移动到D上,步数应该是2^3-1=7步,然后B上的三个盘子移动到D上仍然是5步,总步数为5+7+5=17步<21步!现在我们可以确信的告诉自己,我们的想法太“天真”了。虽然我们想到让盘子尽量不发生重叠来保证步数的最少,但是这并不能绝对保证。或许在盘子较少的情况下是可行的,但是盘子增多时,那些多余的只有一个盘子的柱子是可以加以利用的。虽然这么做加多了每次的移动步数,但是却从另一个侧面减少了递归的数量,因此我们需要从这里边找一个平衡点。
从上边的例子中,我们得到一个启示:在递归程序中剩余盘子的个数并不一定是M-2,也有可能是M-1,我们假设剩余盘子是M-r,那么r到底取得多少才合适呢?其实,早在1941年,一位名叫J. S.Frame的人在《美国数学月刊》上提出了一种解决四柱汉诺塔问题的算法,这是人们熟知的Frame算法:
(1)用4柱汉诺塔算法把A柱上部分的n- r个碟子通过C柱和D柱移到B柱上【F( n- r)步】。
(2)用3柱汉诺塔经典算法把A柱上剩余的r个碟子通过C柱移到D柱上【2^r-1步】。
(3)用4柱汉诺塔算法把B柱上的n-r个碟子通过A柱和C柱移到D柱上【F(n-r)步】。
(4)依据上边规则求出所有r(1≤r≤n)情况下步数f(n),取最小值得最终解。
因此Frame算法的递归方程如下:
F(n)=min(2*F(n-r)+2^r-1),(1≤r≤n)。
通过这个方程我们能得到所有4柱汉诺塔的步骤个数,同时也有人证明[1]了,对于四柱汉诺塔,当r=(sqrt(8*n+1)-1)/2时,能保证f(n)取得最小值F(n)=(n-(r^2-r+2)/2)*2^r+1。所以算法的复杂度是F(n)=o(sqrt(2*n)*2^ sqrt(2*n))。从这这个方程中也可以看出,在n<6的时候,我们可以验证是和我们起初的构想的结构是相同的,但是当n再增多时就不是当初想的那样了。
3. 多柱汉诺塔
基于四柱汉诺塔的Frame算法,我们可以引申到多柱(M柱)汉诺塔的情况,我们简称M柱汉诺塔算法:
(1)用M柱汉诺塔算法把1柱上部分的n-r个碟子通过3…M柱移到2柱上【M( n- r )步】。
(2)用M-1柱汉诺塔算法把1柱上剩余的r个碟子通过3…M-1柱移到M柱上【<M-1>(r)步】。
(3)用M柱汉诺塔算法把2柱上的n-r个碟子通过1柱和3…M柱移到M柱上【M(n- r )步】。
(4)依据上边规则求出所有r(1≤r≤n)情况下步数m(n),取最小值得最终解M(n)。
从4柱汉诺塔的递归方程和结果公示中我们可以看出,随着柱子数量的增加,算法的复杂程度也是不断地增加。对于解决M柱汉诺塔问题需要使用M-1柱汉诺塔的算法,因此除了算法解决问题需要递归外,算法的流程本身也需要递归,这种递归结构已经远远地复杂于当前所接触的递归算法。如果有兴趣可以尝试去设计这种算法,算法所涉及的参数应该有盘子的个数n、柱子的个数m、算法的编号num、参数r等信息。因为需要根据不同柱子情况下通过循环和递归找出最合适的r值,所以这种算法的复杂度肯定相当高。不过我们仅仅是为了探究如何取得最优算法,所以具体实现就不再赘述了。
总结
通过以上的讨论,我们从一般的思维——不折叠盘子,出发去找多柱汉诺塔的最优解,但是结果并没有成功——盘子多时有可能柱子没有充分利用。后来通过前人提出的Frame算法引申出多柱汉诺塔算法,并大致描述了多柱汉诺塔算法的双重嵌套递归结构——算法问题的递归以及算法本身的递归实现。这种罕见的递归程序结构给我们在算法设计方面开阔了新的视野,希望不久的将来能找到更好地算法设计方法来解决多柱汉诺塔的问题。
参考文献
1.《四柱汉诺塔之初步探究》杨楷 徐川( 北京大学计算机科学与技术系, 北京, 100871) 北京大学学报( 自然科学版) , 第40 卷, 第1 期, 2004 年1 月
参考维基百科更加清楚:http://zh.wikipedia.org/wiki/%E6%B1%89%E8%AF%BA%E5%A1%94
以四柱汉诺塔为例:
先给出网上的Java代码
import java.util.Scanner; /** * 问题描述: * 由原来的三根柱子,变为四根柱子,最终要把a柱子上的全部移到b柱子上 * * 思路分析: * 假设有n个圆盘,三根柱子,a,b,c,需要把n个盘子(从下往上按照大小顺序摞着)从a柱移动到b柱。 * 再找来了一根一模一样的柱子d,通过这个柱子来更快的把所有的盘子移到第三个柱子上。 * 这道题和之前都有很大的不同,加了一根柱子,意味着有的时候可用3根柱子,有的时候可用4根柱子, * 当把j个小盘子移动到d盘上时,有四根柱子可用,而当把n-j个盘子从a移动到b时,仅有三根柱子可用。 * 这里我们就要找到j的值,使所有移动的次数和最小。 * * 解决方法: * 依然采用分治法。 * 首先把j个盘子移动到d柱子上(通过四个柱子可用的算法),需要B[j]次移动, * 然后把n-j个盘子移动到b柱子上(通过三个柱子可用的算法),需要A[n-j]次移动, * 然后把d中的j个盘子移动到b柱子上,需要B[j]次移动。 * 我们可以用j的大小循环,找到移动次数最小的j。 * 首先我们先计算移动的次数: * 核心公式为:count4(4柱子时总移动次数)=2*B[j]+A[i-j],即 * j个盘子移动到第四个柱子,然后把剩下的i-j个在第四个不能用的情况下移到第三个 * * 补充: * 三根柱子时的次数计算 * 假设移动n个盘子需要移动f(n)次,所以把n-1个盘子移动到b柱子上,需要移动f(n-1)次, * 然后把第n个盘子移动到c柱子上,需要移动1次,最后把n-1个盘子移动到c柱子上,需要移动f(n-1)次, * 综上所述,一共移动了f(n)=2f(n-1)+1次 */ public class Hanoi { static int count = 0; //统计移动次数(可不需要,因为最少次数已经与n的值对应的记录在数组B中,即B[n]) /** * 主函数 */ public static void main(String[] args) { int n; //盘子数 int flag_j; //记录找到的j的值 int[] A = new int[65]; // 数组A:用来记录未加第四个柱子时候的移动次数情况 int[] B = new int[65]; // 数组B:用来记录加了第四个柱子的情况 /*根据三个柱子移动策略给数组A赋值(下面描述是按照将a柱上盘子移动到c柱上的问题来叙述的),即 * 假设移动n个盘子需要移动f(n)次,所以把n-1个盘子移动到c柱子上,需要移动f(n-1)次, * 然后把第n个盘子移动到c柱子上,需要移动1次, * 最后把n-1个盘子移动到c柱子上,需要移动f(n-1)次,综上所述,一共移动了 f(n)=2f(n-1)+1 次 */ A[1] = 1; // 即三个柱子时,当i=1的时候,表示移动一个盘子,只需要移动一次 for (int i = 2; i < 65; i++) {// 从i=2开始 A[i] = 2 * A[i - 1] + 1; // f(n)=2f(n-1)+1 } /* * 将n个盘子分为两部分,即前j个 和 后n-j个 * 且把前 j个用四个柱子的方法,后i-j个用三个柱子的方法 * 下面主要是找到使移动次数最少的j值 */ int count4; //记录四根柱子时,移动的总次数 int min; //移动的最少次数,以用来和四个柱子时的其他情况进行比较 int[] C = new int[65]; // 数组C:用来记录当前i下找到的的j值 C[1] = 0; // 设置i=1时,初始值为0,即只有一个盘子时,令j=0 C[2] = 0; // 设置i=2时,初始值为0,即只有两个盘子时,令j=0 //注意:此时的i相当于盘子数n for (int i = 3; i <= 64; i++) { min = A[i]; // 假设没加第四个柱子的结果次数为min的初值 B[1] = 1; //可知 i=1 时,即一个盘子从柱子a->d,移动次数为1次 B[2] = 3; //i=2时,即两个盘子从柱子a->d,移动次数为3次 flag_j = 0; for (int j = 1; j < i; j++) { count4 = 2 * B[j] + A[i - j]; // j个移动到第四个柱子,然后把剩下的i-j个在第四个柱子不能用的情况下,移到第三个柱子 /* * 如果三根柱子时的次数min 大于 四根柱子时的次数flag,则用flag更新min * 并记录下此时j的值,即得到了怎么分割盘子,才能使最终的移动次数最少 */ if (min > count4) { min = count4; flag_j = j; } B[i] = min; // 将min赋给B[i],即四根柱子时,i个盘子从a->d 的次数 C[i] = flag_j; // 找到了当前i下的j值 } } Scanner scanner = new Scanner(System.in); while (true) { System.out.print("请输入一个n值(应为1-64之间的整数,输入0结束程序):"); n = scanner.nextInt(); if(n == 0) { System.out.println("ByeBye"); break; } if(n > 64 || n < 1) { System.out.println("输入的n有误,请重新输入"); continue; } char a = 'a', b = 'b', c = 'c', d = 'd'; hanoi(n, a, b, c, d, C); // 把n个盘子从a柱子移动到b柱子 System.out.println("共移动了: " + B[n] + " 次"); System.out.println("共移动了: " + count + " 次");//与B[n]的值是一样的 count = 0;//次数置零 } } /** * 移动(使用四个柱子的移动方式) */ public static void hanoi(int n, char a, char b, char c, char d, int C[]){ int j = C[n]; //j个盘子使用四个柱子的移动方式 if (n > 0) { hanoi(j, a, d, b, c, C);// 把j个盘子移动到d柱子上 hanoi_basic_3(n - j, a, b, c);// 把n-j个盘子移动到b柱子上(使用三个柱子的移动方式) hanoi(j, d, b, a, c, C); // 把j个盘子移动到b柱子上 } } /** * 把n-j个盘子移动到b柱子上(使用三个柱子的移动方式) */ public static void hanoi_basic_3(int n, char a, char c, char b){ if(n > 0) { hanoi_basic_3(n - 1, a, b, c);// 把n-1个盘子移动到c柱子上 move(n, a, c); // 把a移动到c hanoi_basic_3(n - 1, b, c, a); // 把第n个盘子移动到c柱子上 } } /** * 在控制台打印移动情况 */ public static void move(int n, char a, char c){ System.out.println(a + "->" + c); count++;//记录次数 } }