汉诺塔问题
问题描述
相传在古印度圣庙中,有一种被称为汉诺塔 (Hanoi) 的游戏。该游戏是在一块铜板装置上,有三根杆(编号 A、B、C),在 A 杆自下而上、由大到小按顺序放置 64 个金盘。游戏的目标:把 A 杆上的金盘全部移到 C 杆上,并仍保持原有顺序叠好。
操作规则:每次只能移动一个盘子,并且在移动过程中三根杆上都始终保持大盘在下,小盘在上,操作过程中盘子可以置于 A、B、C 任一杆上。
输入格式
输入数据为一个正整数,表示当前有 n n n 个金盘。
输出格式
输出为多行。
第一行为一个正整数 w w w,表示将这 n n n 个金盘从 A 杆移动到 C 杆的最小步骤数。
接下来为 w w w 行,每行的格式为from x to y
。其中 x 和 y 为 A、B、C 其中之一。输入样例
2
输出样例
3
from A to B
from A to C
from B to C
算法分析
汉诺塔问题是理解分治算法最经典的一道题。根据分治算法的思想,第一步我们必须对原问题进行分解。在此之前,对大家进行一个提问:把大象装进冰箱需要几步?
答案是:三步。打开冰箱门、把大象装进去、关闭冰箱门。
现在我要告诉你,求解汉诺塔问题也是三步。根据汉诺塔游戏的机制我们知道,必须先将第n个盘子移动到C杆上。但如果直接将第 n n n 个盘子移动到目标位置又是不行的,因为在这第 n n n 个盘子前还有 n − 1 n-1 n−1 个盘子。此时,我们可以假设有一个函数 function(),它可以帮我们将前面 n − 1 n-1 n−1 个盘子移动到除目标位置以及起始位置外的那个杆子处(即 B 杆)。这样一来,我们就只需要做一件工作,将第 n n n 个盘子移动到 C 杆上。还没结束!因为我们还需要将刚才放到 B 杆上的前 n − 1 n-1 n−1 层盘子也放到 C 杆上。即:
- 移走前 n − 1 n-1 n−1 层盘子;
- 将第 n n n 层盘子移至目标位置;
- 将前 n − 1 n-1 n−1 层盘子移至目标位置。
接下来,当我们接手 “搬第 n − 1 n-1 n−1 层到C杆” 这任务时也会面临同样的问题——前面的 n − 2 n-2 n−2 层怎么办?一样的,交给函数function(),他会负责将前面 n − 2 n-2 n−2 层搬到除目标位置以及起始位置外的那个杆子处(即 A 杆),此时,我们同样只需要将第 n − 1 n-1 n−1 个盘子移动到 C 杆,并将前 n − 2 n-2 n−2 层盘子也移至 C 杆即可……以此类推,层层划分下去,最终总会有一个人接到 “搬第1层” 的任务。第 1 层怎么搬?很简单,你想搬到哪儿就般到哪儿。换句话说,到这一步就不能再分了,这里可以直接给出答案。
现在我们把重心放在函数 function() 上。对于接到 “搬第 i i i 层到 x x x 杆” 这个任务的人而言,他实际上需要关心的问题就3个:我是谁?我在哪儿?要到哪儿去?(哲学的经典三问)。对应到具体的题目中则是:要搬第几层?该层的所在位置?该层的目标位置?而这三个信息对是会随着当前层次的不同而发生改变,因此,这三个信息构成了函数 function() 的形参表。在函数 function() 中,当搬运的层次位于第 1 层时,其无需再划分,而是直接执行。这实际上就是函数 function() 的返回条件。其余任意层次,则都需要进行划分(实际上,表现在代码中就是递归调用 function() 自身),划分后就只需要执行三步:外包(移开前 n − 1 n-1 n−1 层)、做自己的活、外包(处理前 n − 1 n-1 n−1 层)。最后,对于任意输入,调用该函数即可求解整个问题。
最后,关于汉诺塔问题的执行次数,这其实是一个数学归纳问题:
证:设移动 n n n 个圆盘所需的最少步数为 F ( n ) F(n) F(n),若要把 n n n 个圆盘从 A 柱移至 C 柱,则可拆解为如下步骤:
- 将 n − 1 n-1 n−1 个圆盘从 A 柱移至 B 柱,需要 F ( n − 1 ) F(n−1) F(n−1) 步;
- 将余下的最大圆盘从 A 柱移至 C 柱,需要 1 步;
- 将 n − 1 n-1 n−1 个圆盘从 B 柱移至 C 柱,需要 F ( n − 1 ) F(n−1) F(n−1) 步。
于是可得到最少移动步数的递推公式:
F ( n ) = 2 F ( n − 1 ) + 1 F(n) = 2F(n-1) + 1 F(n)=2F(n−1)+1
易知 F ( 1 ) = 1 F(1)=1 F(1)=1,则可得通项公式:
F ( n ) = 2 F ( n − 1 ) + 1 = 2 ( F ( n − 2 ) + 1 ) + 2 + 1 = 2 2 F ( n − 2 ) + 2 + 1 … = 2 n − 1 + 2 n − 2 + … + 2 + 1 = 2 n − 1 \begin{align*} F(n) &= 2F(n-1) + 1 \\ &= 2\left(F(n-2)+1\right) + 2 + 1 \\ &=2^2F(n-2)+2+1 \\ &… \\ &= 2^{n-1} + 2^{n-2} + … + 2 + 1 \\ &= 2^n-1 \end{align*} F(n)=2F(n−1)+1=2(F(n−2)+1)+2+1=22F(n−2)+2+1…=2n−1+2n−2+…+2+1=2n−1
下面给出求解本体的完整代码:
#include<iostream>
#include<cmath>
using namespace std;
// n表示当前盘子上还有多少的盘子,a表示初始位置,b表示中间的过渡位置,c表示目标位置
void hanoi(int n,char a,char b,char c)
{
if(n==1) cout<<a<<"->"<<c<<endl; // 如果当前只有1个盘子,就直接移动过去
else{ // 否则:外包、做自己的活、回归
hanoi(n-1,a,c,b); // 外包:首先把当前a位置上的n-1个盘子过渡到b上
cout<<a<<"->"<<c<<endl; // 干自己的活:然后将第n个盘子移动到c上
hanoi(n-1,b,a,c); // 外包: 最后再将前n-1层放在c上
}
}
int main()
{
int n;cin>>n;
cout<<pow(2,n)-1<<endl;
hanoi(n,'A','B','C');
return 0;
}
进阶题目
【洛谷】 P1242 新汉诺塔
问题描述
设有 n ( n ≤ 45 ) n (n≤45) n(n≤45) 个大小不等的中空圆盘,按从小到大的顺序从 1 到 n n n 编号。将这 n n n 个圆盘任意的迭套在三根立柱上,立柱的编号分别为 A、B、C,这个状态称为初始状态。
现在要求找到一种步数最少的移动方案,使得从初始状态转变为目标状态。
移动时有如下要求:
- 一次只能移一个盘;
- 不允许把大盘移到小盘上面。
输入格式
文件第一行是状态中圆盘总数;
第二到第四行分别是初始状态中 A、B、C 柱上圆盘的个数和从下到上每个圆盘的编号;
第五到第七行分别是目标状态中 A、B、C 柱上圆盘的个数和从下到上每个圆盘的编号。输出格式
每行一步移动方案,格式为:
move I from P to Q
最后一行输出最少的步数。样例输入
5
3 3 2 1
2 5 4
0
1 2
3 5 4 3
1 1样例输出
move 1 from A to B
move 2 from A to C
move 1 from B to C
move 3 from A to B
move 1 from C to B
move 2 from C to A
move 1 from B to C
7
算法分析
本题相较于经典的汉诺塔问题新增加了三个难点:
- 待移动的盘子并不仅仅在某个柱子上,而是离散分布的;
- 盘子移动的目标状态也可能是离散分布的;
- 求解最优方案(即步数最小的方案)。
这时,由于盘子不仅仅分布于某根柱子上,且目标状态也是离散的,这就使得我们需要设计出一种数据结构来保存这两种状态,并且要求这种数据结构能够参与到算法在执行时的状态更替中。一种很直接的办法是用二维数组来1:1还原。例如,对于样例数据,可以有(设所有的索引都从1开始):
// 原始状态
original[1][]={“0”, “3”, “2”, “1”};
original[2][]={“0”, “5”, “4”};
original[3][]={“0”};
// 目的状态
target[1][]={“0”, “2” };
target[2][]={“0”, “5” , “4” , “3”};
target[3][]={“0”, “1” };
二维数组的优点是可以很直观地能看到每根柱子上盘子的分布情况,但是缺点很明显。在前面的经典汉诺塔问题中,我们知道每次在划分问题时,目标都是找最大的那个圆盘来进行位置变更。在那种情况下,由于圆盘的初始放置情况仅在一根柱子上,所以递归函数在设计时可以将问题划分为三步:
- 先将当前盘子的前 n − 1 n-1 n−1 个盘子移至过渡柱子;
- 再移动当前盘子的柱子;
- 最后将前 n − 1 n-1 n−1 个盘子移至目的柱子。
这样一来,该递归函数就变得十分简单(只需三步)。在本题中,盘子的放置并不一定满足这样的规则。此时,如果仍然采用1:1还原初始情况的二维数组来作为本题的数据结构,那会使得递归算法的设计变得相当困难(在original[ ]数组中寻找最大盘子只能遍历枚举,浪费时间)。因此,我们必须转变思路,想办法设计一种合理的数据结构来将盘子的离散分布给“屏蔽”掉(这里的屏蔽是指,不关心盘子的分布,而将重点放在“如何去找当前未被放置到正确位置上的最大盘子”)。
试想,如果我们站在盘子的角度,用盘子编号作为数组索引,用数组值来表示该盘子所在的柱子。这样一来我们就可以根据数组索引直接逆序遍历该数组,并进行位置变更。此时,因为盘子编号与其大小相对应(且唯一),则逆序遍历时先遇到的索引就表达着“当前盘子是目前最大的”这一含义(根据贪心的思想,由于题目要求最优解,故每次都需要先安排最大圆盘)。这样一来,我们在设计递归函数时就可以采用和经典汉诺塔问题相似的3步。稍有不同的是,由于本题中盘子的初始和目的状态都是离散的,因此在对我们设计的数组进行递归遍历时,需要用一层外循环来控制遍历过程,达到“屏蔽”盘子离散分布的目的。
下面给出本题的完整代码:
#include<iostream>
using namespace std;
// first[i],last[i]数组分别存放盘子i所在的初始柱子和目的柱子
int first[50],last[50],ans=0;
// ch[]数组将柱子的编号(A、B、C)转换为数字(1、2、3)
char ch[]={'0','A','B','C'};
// 编号为n的盘子要去tar柱子
void dfs(int n,int tar)
{
// 初始位置是目标位置则不用移动
if(first[n]==tar) return;
// 先移动编号为1~n-1的这些盘子到除当前盘子所在位置和目标位置之外的第三个位置
// 由于这三个位置的数字代号满足1+2+3=6,因此知道其中任意两个参数便可以求出第三个位置
for(int i=n-1;i>=1;i--) dfs(i,6-first[n]-tar);
// 打印信息
cout<<"move "<<n<<" from "<<ch[first[n]]<<" to "<<ch[tar]<<endl;
//移动之后需要将其位置改变,并且计下步数
first[n]=tar,ans++;
}
int main()
{
int n,m,x;
cin>>n;
// 记录初始状态
for(int i=1;i<=3;i++){
cin>>m;
for(int j=1;j<=m;j++)
cin>>x,first[x]=i;
}
// 记录目标状态
for(int i=1;i<=3;i++) {
cin>>m;
for(int j=1;j<=m;j++)
cin>>x,last[x]=i;
}
// 要求最优解,则最小步数必须是从最大盘子开始(贪心)
for(int i=n;i>=1;i--)
dfs(i,last[i]);
cout<<ans<<endl;
return 0;
}