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

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个盘子到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个,为什么不直接挪动任意个?为什么不能获知圆盘移动的具体情况?……可以说,几乎没有任何人能够第一次就写出这样的代码;正因为这样的困惑没有被解答,才会导致问题变得抽象,直接后果是不会写递归函数或写出来也不知道正确与否。

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

  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个盘子到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;
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/682196.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

文件上传-第三方服务阿里云OSS

JAVA后端实现文件上传,比如图片上床功能,有很多实现方案,可以将图片保存到服务器的硬盘上。也可以建立分布式集群,专门的微服务来存储文件常见的技术比如Minio。对于中小型公司&#xff0c;并且上传文件私密性不高的话可以使用第三方的存储服务&#xff0c;比如阿里云、华为云等…

Mysql中关于on,in,as,where的区别

目录 Mysql on,in,as,where的区别 Mysql语句问题解决 1、left join数据筛选问题 2、相同数据重复筛选使用问题 3、根据某个字段排序取每个类别最后三条数据或前三条数据 4、业务逻辑书写位置问题 5、查找另一表内和本表相关字段的数量 6、关于union的使用 7、limit的巧…

波奇学Linux:文件缓冲区

问题导入 文件流输出直接向显示器和重定向文件有不一样的表现 分别向显示器文件输出四个语句&#xff0c;最后fork创建子进程。 当程序运行时和程序重定向到文件中&#xff0c;输出的内容不一样。 重定向时c库函数&#xff08;printf,fprintf,fwrite&#xff09;调用了两次&…

掌握C语言文件操作:从入门到精通的完整指南!

✨✨ 欢迎大家来到贝蒂大讲堂✨✨ &#x1f388;&#x1f388;养成好习惯&#xff0c;先赞后看哦~&#x1f388;&#x1f388; 所属专栏&#xff1a;C语言学习 贝蒂的主页&#xff1a;Betty‘s blog 1. 什么是文件 文件其实是指一组相关数据的有序集合。这个数据集有一个名称&a…

第12讲创建图文投票实现

创建图文投票实现 图文投票和文字投票基本一样&#xff0c;就是在投票选项里面&#xff0c;多了一个选项图片&#xff1b; <view class"option_item" v-for"(item,index) in options" :key"item.id"><view class"option_input&qu…

《金融人工智能:用python实现ai量化交易》

融合了数学、python、深度学习以及金融知识&#xff0c;是本推荐的好书。请收藏本文&#xff0c;读后再给大学总结。

在Meteor Lake平台上使用NPU进行AI推理加速

在Meteor Lake平台上&#xff0c;英特尔通过神经处理单元 (NPU) 将人工智能直接融入芯片中&#xff0c;实现桌面电脑平台的AI推理功能。神经处理单元 (NPU) 是一种专用人工智能引擎&#xff0c;专为运行持续的人工智能推理工作负载而设计。与即将推出的支持深度人工智能集成的 …

Java+SpringBoot构建智能捐赠管理平台

✍✍计算机编程指导师 ⭐⭐个人介绍&#xff1a;自己非常喜欢研究技术问题&#xff01;专业做Java、Python、微信小程序、安卓、大数据、爬虫、Golang、大屏等实战项目。 ⛽⛽实战项目&#xff1a;有源码或者技术上的问题欢迎在评论区一起讨论交流&#xff01; ⚡⚡ Java实战 |…

Java学习18-- Override方法重写【★】

重点&#xff1a;super类 & 方法重写 ★看不明白多看几遍&#xff0c;记住static优先级>>高于override 重写Override methods★ 重写Override&#xff1a;child class可以覆盖father class中的method&#xff0c;即子类child class和父类father class有相同名称、…

Git简单了解

文章目录 1、Git概述2、Git下载与安装3、Git代码托管服务3.1、使用码云托管服务 1、Git概述 什么是Git Git是一个分布式版本控制工具&#xff0c;主要用于管理开发过程中的源代码文件&#xff08;Java类、xml文件、html页面等&#xff09;&#xff0c;在软件开发过程中被广泛使…

python+vue+django体育场地器材预约管理系统dyn9h

技术栈 后端&#xff1a;python 前端&#xff1a;vue.jselementui 框架&#xff1a;django Python版本&#xff1a;python3.7 数据库&#xff1a;mysql5.7 数据库工具&#xff1a;Navicat 开发软件&#xff1a;PyCharm .体育馆管理系统有管理员和用户两个角色。用户功能有场地…

springboot742餐厅点餐系统

springboot742餐厅点餐系统 获取源码——》公主号&#xff1a;计算机专业毕设大全

面试前的准备

面试前的准备 Java程序员校招与社招的区别 校招和社招都是企业招聘形式的一种&#xff0c;只是面向的对象不同。校招 只允许在校生参加&#xff0c;社招理论上是任何人都能参加的(包括在校生)。 但是&#xff0c;无论是社招还是校招&#xff0c;它的难度都取决于你的水平高低。…

区块链技术与应用 【全国职业院校技能大赛国赛题目解析】第二套区块链系统部署与运维

第二套区块链系统部署与运维题目 环境 : ubuntu20 fisco : 2.8.0 docker: 20.10.21 webase-deploy : 1.5.5 mysql: 8.0.34 子任务1-2-1: 搭建区块链系统并验证(4分) 使用build_chain.sh 脚本文件进行搭建区块链 ,要求: 四节点,默认配置,单机,docker root@192-168-19…

STM32—DHT11温湿度传感器

文章目录 一.温湿度原理1.1 时序图 二.代码 一.温湿度原理 1.1 时序图 (1).下图一是DHT11总的时序图。 (2).图二对应图一的左边黑色部分&#xff0c;图三对应图一的绿色部分&#xff0c;图四的左部分图对应图一的红色部分&#xff0c;图四的右部分对应图一的黄色部分。 (3)…

【Spring原理高级进阶】有Redis为啥不用?深入剖析 Spring Cache:缓存的工作原理、缓存注解的使用方法与最佳实践

&#x1f389;&#x1f389;欢迎光临&#x1f389;&#x1f389; &#x1f3c5;我是苏泽&#xff0c;一位对技术充满热情的探索者和分享者。&#x1f680;&#x1f680; &#x1f31f;特别推荐给大家我的最新专栏《Spring 狂野之旅&#xff1a;底层原理高级进阶》 &#x1f680…

内网穿透 | 推荐两个免费的内网穿透工具

目录 1、简介 2、Ngrok 2.1、下载安装 2.2、运行 2.3、固定域名 2.4、配置多服务 3、cpolar 3.1、下载安装 3.2、运行 &#x1f343;作者介绍&#xff1a;双非本科大三网络工程专业在读&#xff0c;阿里云专家博主&#xff0c;专注于Java领域学习&#xff0c;擅长web应…

CSRNET图像修复,DNN

CSRNET图像修复 CSRNET图像修复&#xff0c;只需要OPENCV的DNN

FastAI 之书(面向程序员的 FastAI)(三)

原文&#xff1a;www.bookstack.cn/read/th-fastai-book 译者&#xff1a;飞龙 协议&#xff1a;CC BY-NC-SA 4.0 第五章&#xff1a;图像分类 原文&#xff1a;www.bookstack.cn/read/th-fastai-book/0661b9d7375f45ab.md 译者&#xff1a;飞龙 协议&#xff1a;CC BY-NC-SA 4…

JAVA-多进程开发-创建等待进程

前言 在项目中&#xff0c;为了实现“并发编程”&#xff08;同时执行多个任务&#xff09;&#xff0c;就引入了“多进程编程”&#xff0c;把一个很大的任务&#xff0c;拆分成若干个很小的任务&#xff0c;创建多个进程&#xff0c;每个进程分别负责其中的一部分任务。 这也…