汉诺塔问题的一个变种
最近碰到的一个有意思的算法题。
问题定义
考虑简化的汉诺塔问题:有三个柱子1、2、3,每个柱子上只要最下面的盘子是最大的,其他盘子可以以任意顺序摆放。用尽量少的步骤将n个按顺序摆放的盘子从柱子1移到柱子3(仍为顺序摆放)。
问题分析
为了将所有盘子放到3上,需要三步:
- 将前n-1个盘子以某种顺序放到2上;
- 将第n个盘子放到3上;
- 将2上的盘子放到3上。
其中步骤1与步骤3的差别在于:步骤1中盘子是第一次从柱子1上移出,而且移出之前是排好序的;步骤3中盘子经过步骤1的摆放可能是乱序的。
下面仔细分析步骤1。假设我们现在要将盘子i从1移动到2,这时柱子2、3上没有比i更大的盘子(因为只有前i-1个被移出了),那么柱子2必须是空的。因此有递归表示:
f(将前i个盘子移动到2) = f(将前i-1个盘子移动到3) + (将i移到2) + g(把前i-1个盘子从3移到2)
其中f在移动过程中必须考虑操作的合法性,g则不需要,所以g可以达到步数下限i-1(直接挨个移动)。这时前面的递推式可以写为
f(将前i个盘子移动到2) = f(将前i-1个盘子移动到3) + 1 + i-1 = f(将前i-1个盘子移动到3) + i
由此推出f(将前n个盘子移动到2)的步数下限为:
n * (n + 1) / 2
以n=7为例,根据这种操作方式将前6个盘子移动到2,其排列方式为
| 7
| 6 4 2 1 3 5
|
到现在为止,我们找到了完成步骤1的最优方法,但还不能确认通过这种方法能够达到全局最优。
现在仔细分析步骤3。经过前两个步骤之后,我们需要将2上的n-1个盘子按顺序放到3上。这时n-1一定被压在最下面,为了将n-1移出,我们需要将2上的n-2个盘子移动到1上。其递归表示为:
h(将n个盘子从2移动到3) = k(将n-1个盘子从2移动到1) + (将n移到3) + h(将n-1个盘子从1移动到3)
其中k需要移动n-1个盘子,其操作步数的下限为n-1。那么这个下限何时能达到呢?显然是当n-1个盘子中最大的n-1正好在最上面的时候(这样可以先把它放到最下面)。即只要柱2上的盘子排列成如下形式就能达到步数下限:
|n n-2 n-4 ... n-3 n-1
我们发现这正好是完成步骤1后形成的状态,我们恰好可以同时在步骤1和步骤3中达到最优解。
这时步骤3的递推表达式成为:
h(将n个盘子从2移动到3上) = k(将n-1个盘子从2移动到1上) + 1 + n-1 = n + h(将n-1个盘子从1移动到3上)
由此推出h(将n个盘子从2移动到3上)的步骤下限为n * (n + 1) / 2。
综合以上,我们整个过程中需要步骤数位:
f(将前n-1个盘子移动到2) + (将第n个盘子放到3上) + h(将n-1个盘子从2移动到3) = n(n-1) + 1
编程实现
有两种实现方式:递归和非递归,其中递归方式的思路与上文所述完全一致。不过如果搞清楚了整个过程,用循环的方式写起来更简单而且更快。
递归方式代码:
#include <cstdio>
void mix(int m, int a)
{
int i;
if(m>0)
{
if(m>1)
mix(m-1, 3-a);
//cout<<0<<" "<<a<<endl;
printf("0 %d\n", a);
for(i = m-1; i > 0; i--)
//cout<<3-a<<" "<<a<<endl;
printf("%d %d\n", 3-a, a);
}
}
void dump(int m, int a)
{
int i;
if(m>0)
{
for(i = m; i > 1; i--)
//cout<<a<<" "<<1-a<<endl;
printf("%d %d\n", a, 1-a);
//cout<<a<<" "<<2<<endl;
printf("%d 2\n", a);
if(m > 1)
dump(m-1, 1-a);
}
}
int main()
{
int n;
int a,b;
//cin>>n;
scanf("%d", &n);
mix(n-1,1);
//cout<<0<<" "<<2<<endl;
printf("0 2\n");
dump(n-1,1);
return 0;
}
非递归方式代码:
#include <iostream>
using namespace std;
int main()
{
int n, i, j, empty;
cin>>n;
empty = 1 + n % 2;
for(i = 0; i < n - 1; i ++)
{
cout<<"0 "<<empty<<endl;
for(j = 0; j < i; j ++)
{
cout<<3 - empty<<' '<<empty<<endl;
}
empty = 3 - empty;
}
cout<<"0 2"<<endl;
empty = 0;
for(i = 0; i < n - 1; i ++)
{
for(j = 0; j < n - 1 - i - 1; j ++)
{
cout<<1 - empty<<' '<<empty<<endl;
}
cout<<1 - empty<<" 2"<<endl;
empty = 1 - empty;
}
return 0;
}
有趣的是:知道了搬运方式之后,我们并不需要维护整个汉诺塔的状态。