1. 汉诺塔问题为什么有解
相信只要接触过编程就会知道什么是汉诺塔问题:
- 有三根柱子,分别标记为A、B和C。
- 初始时,在柱子A上按从大到小的顺序堆叠着若干个圆盘。
- 目标是将所有的圆盘从柱子A移动到柱子C。
- 在移动过程中可以借助柱子B作为辅助,但有以下限制:
-
- 每次只能移动一个圆盘。
-
- 移动过程中,任何柱子上的圆盘必须保持从大到小的顺序。
-
- 在任意时刻,都不能将一个大圆盘放在一个小圆盘上。
解决汉诺塔问题的一般代码如下:
void hanoi(int n, char from, char to, char aux)
{ // n圆盘数量,from起始柱子,to目标柱子,qux辅助柱子,在全局过程中是动态的if(n==1){// 只需要移动一个盘子,为问题的下界cout << from << "->" << to << endl;}else{// 先移动上面n-1个盘子到auxhanoi(n - 1, from, aux, to);// 再移动最下面大盘子到tohanoi(1, from, to, aux);// 最后移动n-1个盘子到tohanoi(n - 1, aux, to, from);}
}
这种解法的思路是,每次将要移动圆盘(进行一次汉诺塔算法)时,对于当前柱子x上的n(n>0)个大小圆盘有序序列,首先尝试将x上n-1个圆盘挪动到y,再将x上的最后一个圆盘挪动到C ( x , y ∈ { A , B } ) (x,y\in \lbrace A, B \rbrace) (x,y∈{A,B}),通过问题规模的不断缩小,最终解决问题。
以上看起来严谨的数学推导实际是最不被人脑接受的内容,因为它极其反直觉:为什么只在n==1时函数有输出?为什么可以一次挪动n-1个圆盘?既然一次可以挪动n-1个,为什么不直接挪动任意个?为什么不能获知圆盘移动的具体情况?……可以说,几乎没有任何人能够第一次就写出这样的代码;正因为这样的困惑没有被解答,才会导致问题变得抽象,直接后果是不会写递归函数或写出来也不知道正确与否。
理解汉诺塔递归的门槛可以归结为以下三个问题:
- 为什么解决圆盘有序调度问题恰好需要三根柱子?
- 凭什么可以进行违规的一次挪动n-1个圆盘?
- 依赖调用过程的递归函数是否可靠?
2. 建立数学论证
要获得正确和可靠的算法,就必须进行合理的数学论证,学计算机不能畏惧数学。
关于问题1和2:
- 首先,显然,仅有两个柱子(起始和目标)是无法完成任务的,需要一个或以上柱子来作为辅助柱子实现始终大小正序的移动。
- 判断辅助柱子的个数在1~n-1之间(当然可以更多,但没必要)。
- 注意到,对于第一个(最小的)圆盘,它始终位于当前柱子的最上方,每个柱子对于它而言都是可放置的,因此,对它的移动操作是可撤回的,即假设把它从A住移动到C柱,稍后总是可以把它再放回A柱。
- 同时考虑第一个和第二个圆盘,在开始时先把1移到B,再把2移到C,最后把1移到C;此时12圆盘可视作一个整体,因为对于它们而言,可以撤回到一开始的A柱,也可以通过相同的步骤移动到B柱,说明它们是一个“最小圆盘”。
- 把A柱上的次小圆盘(3号圆盘)移动到B柱,“最小圆盘”的12组合可以通过简单重复的步骤移动到B柱上,123组合处于最小的地位,”最小圆盘“得到扩大,不断扩大这个整体就得到了最终的目标
- 结论:用一个辅助柱子就可以完成任务;之所以可以一次挪动n-1个柱子,是因为它们在柱子上方,任何位置都是可以逐步放置和撤回的,因此它们相当于一个最小的圆盘,而不是在挪动的时候违反规则什么都不管。
根据以上结论,可以得知:
- 每次移动都要按照规则,且都是对正确排序的最上方圆盘进行移动,所以每次“移动圆盘”的操作都可以看作一个汉诺塔问题,只不过起始柱子、目标柱子、辅助柱子各不相同
- 由1,所有问题都是汉诺塔问题,而每次只能挪动一个圆盘,因此问题规模是以“n-1”下降的
- 递归的终止条件是n==1,即当真的只有一个圆盘的时候才可以直接移动;而每次移动也只能移动一个圆盘,也就是说,真正的操作步骤只在n=1内,其他时候都在进行函数的反复调用。
由此,根据终止条件和数学递推,在正确推导的前提下可以保证函数结果正确……吗?
3. 依赖过程的递归和纯函数编程
上述递归函数运行时的状态几乎是不可知的,比如在不适用全局变量的条件下,无法输出当前的调用深度及实际状态(移动的是哪个盘子);且输出结果极度依赖调用顺序(必须先移动n-1,再移动1,最后还要n-1,函数本身是不知道所谓小在上大在下的规则要求的)
针对以上问题,可以给出一种滴水不漏的解决方案,即使用纯函数编程,将函数所处理的当前状态作为参数之一:
// 纯函数递归写法
using State = array<vector<int>, 3>;
using Result = vector<State>;
State move_disk(const State s0, int n, int from, int to)
{State s = s0;vector<int> disks;for (int i = 0; i < n; i++){disks.push_back(s[from].back());s[from].pop_back();}for (int i = n-1; i >= 0; i--){s[to].push_back(disks[i]);}return s;
}
void append(Result& res, const Result& step)
{res.insert(res.end(), step.begin(), step.end());
}
Result hanoi_r(State s0, int n, int from, int to)
{if (n == 1)return {move_disk(s0, 1, from, to)};int aux = 3 - from - to;State s1 = move_disk(s0, n - 1, from, aux);State s2 = move_disk(s1, 1, from, to);State s3 = move_disk(s0, n, from, to);auto&& step1 = hanoi_r(s0, n-1, from, aux);auto&& step2 = hanoi_r(s1, 1, from, to);auto&& step3 = hanoi_r(s2, n-1, aux, to);assert(step1.back() == s1);assert(step2.back() == s2);assert(step3.back() == s3);Result res;append(res, step1);append(res, step2);append(res, step3);return res;
}
纯函数编程的好处是,对于同样的输入,函数总会给出同样的输出,而不依赖特定的调用顺序(如step1~step3处可以任意打乱,对调用顺序没有要求,只需要符合数学推导就可以),即程序运行时的特定生产过程,就像数学公式一样;当然,这也就要求更完整的输入参数和对实际问题的适度模拟。
第一种递归写法中,过程信息全部丢失,只有n==1的cout,相当于只有一个活跃的State& s,每次传递都对其进行修改,因此涉及到调用深度问题无法读取;而在纯函数写法中,生成的是State s临时变量,相当于有多个短暂的副本,每次函数的运行状态都得到了读取和拼接。汉诺塔问题只有三次调用自身,如果遇到更复杂的问题,恐怕是很难按照逻辑顺序写出非纯函数的。
n=3时,两种写法的对比如下:
此外,更经济的做法是直接用栈(或者一个全局变量)来记录(或删除)当前经历的状态,这种做法也更常用于算法竞赛等场景,加入边界条件和搜索剪枝等就可以解决动态规划等问题:
Result Stack;
void hanoi_s(State& s, int n, int from, int to)
{ // n圆盘数量,from起始柱子,to目标柱子int aux = 3 - from - to;if(n==1){// 只需要移动一个盘子,为问题的下界s[to].push_back(s[from].back());s[from].pop_back();return;}else{hanoi_s(s, n-1, from, aux);if(Stack.empty() || s != Stack.back())Stack.push_back(s);hanoi_s(s, 1, from, to);if(Stack.empty() || s != Stack.back())Stack.push_back(s);hanoi_s(s, n-1, aux, to);if(Stack.empty() || s != Stack.back())Stack.push_back(s);}
}
4. 测试用全部代码
#include<iostream>
#include<string>
#include<vector>
#include<array>
#include<assert.h>using namespace std;void hanoi(int n, char from, char to, char aux)
{ // n圆盘数量,from起始柱子,to目标柱子,qux辅助柱子,在全局过程中是动态的if(n==1){// 只需要移动一个盘子,为问题的下界cout << from << "->" << to << endl;}else{// 先移动上面n-1个盘子到auxhanoi(n - 1, from, aux, to);// 再移动最下面大盘子到tohanoi(1, from, to, aux);// 最后移动n-1个盘子到tohanoi(n - 1, aux, to, from);}
} // 自调用递归写法// 问题:无法得知当前动的是该柱子上的几号盘子,除非使用全局变量否则无法输出// cout只输出了从哪个柱子到哪个柱子,同时,由于cout的存在(表示当前状态)// 一旦依赖树状调用顺序的else部分受到打乱,运行即出错(或者说结果是错的)// 纯函数递归写法
using State = array<vector<int>, 3>;
using Result = vector<State>;
State move_disk(const State s0, int n, int from, int to)
{State s = s0;vector<int> disks;for (int i = 0; i < n; i++){disks.push_back(s[from].back());s[from].pop_back();}for (int i = n-1; i >= 0; i--){s[to].push_back(disks[i]);}return s;
}
void append(Result& res, const Result& step)
{res.insert(res.end(), step.begin(), step.end());
}
Result hanoi_r(State s0, int n, int from, int to)
{if (n == 1)return {move_disk(s0, 1, from, to)};int aux = 3 - from - to;State s1 = move_disk(s0, n - 1, from, aux);State s2 = move_disk(s1, 1, from, to);State s3 = move_disk(s0, n, from, to);auto&& step1 = hanoi_r(s0, n-1, from, aux);auto&& step2 = hanoi_r(s1, 1, from, to);auto&& step3 = hanoi_r(s2, n-1, aux, to);assert(step1.back() == s1);assert(step2.back() == s2);assert(step3.back() == s3);Result res;append(res, step1);append(res, step2);append(res, step3);return res;
}Result Stack;
void hanoi_s(State& s, int n, int from, int to)
{ // n圆盘数量,from起始柱子,to目标柱子int aux = 3 - from - to;if(n==1){// 只需要移动一个盘子,为问题的下界s[to].push_back(s[from].back());s[from].pop_back();return;}else{hanoi_s(s, n-1, from, aux);if(Stack.empty() || s != Stack.back())Stack.push_back(s);hanoi_s(s, 1, from, to);if(Stack.empty() || s != Stack.back())Stack.push_back(s);hanoi_s(s, n-1, aux, to);if(Stack.empty() || s != Stack.back())Stack.push_back(s);}
} int main()
{// 测试 hanoi 函数cout << "Recursive Hanoi Function:" << endl;hanoi(3, 'A', 'C', 'B');cout << "---------------------------------------" << endl;// 测试 hanoi_r 函数cout << "Pure Recursive Hanoi Function:" << endl;vector<int> A = {3, 2, 1};vector<int> B, C = {};State initial_state = {A, B, C}; // 初始状态,3个盘子在A柱Result result = hanoi_r(initial_state, 3, 0, 2);hanoi_s(initial_state, 3, 0, 2);result = Stack;// 输出每一步的状态for (const auto& state : result) {for (const auto& peg : state) {cout << " [ ";for (const auto& disk : peg) {cout << disk << " ";}cout << "] ";}cout << endl;}return 0;
}