详解汉诺塔:递归树与纯函数编程

文章探讨了汉诺塔问题的递归解法,强调了数学论证在理解问题关键点中的重要性,包括为何需要三根柱子、如何合法移动n-1个圆盘以及依赖过程的递归与纯函数编程的区别。作者提供了两种函数实现方式,并展示了它们在不同场景下的应用。
摘要由CSDN通过智能技术生成

1. 汉诺塔问题为什么有解

相信只要接触过编程就会知道什么是汉诺塔问题:

  • 有三根柱子,分别标记为A、B和C。
  • 初始时,在柱子A上按从大到小的顺序堆叠着若干个圆盘。
  • 目标是将所有的圆盘从柱子A移动到柱子C。
  • 在移动过程中可以借助柱子B作为辅助,但有以下限制:
    1. 每次只能移动一个圆盘。
    1. 移动过程中,任何柱子上的圆盘必须保持从大到小的顺序。
    1. 在任意时刻,都不能将一个大圆盘放在一个小圆盘上。

解决汉诺塔问题的一般代码如下:

void hanoi(int n, char from, char to, char aux)
{   // n圆盘数量,from起始柱子,to目标柱子,qux辅助柱子,在全局过程中是动态的
    if(n==1)
    {
        // 只需要移动一个盘子,为问题的下界
        cout << from << "->" << to << endl;
    }
    else
    {
        // 先移动上面n-1个盘子到aux
        hanoi(n - 1, from, aux, to);
        // 再移动最下面大盘子到to
        hanoi(1, from, to, aux);
        // 最后移动n-1个盘子到to
        hanoi(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个,为什么不直接挪动任意个?为什么不能获知圆盘移动的具体情况?……可以说,几乎没有任何人能够第一次就写出这样的代码;正因为这样的困惑没有被解答,才会导致问题变得抽象,直接后果是不会写递归函数或写出来也不知道正确与否。

理解汉诺塔递归的门槛可以归结为以下三个问题:

  1. 为什么解决圆盘有序调度问题恰好需要三根柱子?
  2. 凭什么可以进行违规的一次挪动n-1个圆盘?
  3. 依赖调用过程的递归函数是否可靠?

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. 每次移动都要按照规则,且都是对正确排序的最上方圆盘进行移动,所以每次“移动圆盘”的操作都可以看作一个汉诺塔问题,只不过起始柱子、目标柱子、辅助柱子各不相同
  2. 由1,所有问题都是汉诺塔问题,而每次只能挪动一个圆盘,因此问题规模是以“n-1”下降的
  3. 递归的终止条件是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个盘子到aux
        hanoi(n - 1, from, aux, to);
        // 再移动最下面大盘子到to
        hanoi(1, from, to, aux);
        // 最后移动n-1个盘子到to
        hanoi(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;
}
  • 18
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值