程序员的数学课15 递归:如何计算汉诺塔问题的移动步数?

news/2024/5/18 22:52:51/文章来源:https://blog.csdn.net/fegus/article/details/127137932

递归是重要的程序开发思想,比如程序源代码缩进、树形数据结构、XML 语法、快速排序法等都有递归的影子。

那么,递归思维的本质到底是什么呢?递归的理念看似隐讳,实则非常清晰明了。

为了让你由浅入深地理解它,这一讲我会先从“汉诺塔问题”入手,带你找出“递归思维”,然后将其应用在两个经典问题中,让你感受递归的作用及其缺点。

最后,你便会发现递归与上一讲所学的循环有相似之处,我便会在这两者的对比辨析中,带你探讨它们的本质差异。

汉诺塔问题及其代码实现

我们先来看下汉诺塔问题的规则。

假设有 A、B、C 三根柱子。其中在 A 柱子上,从下往上有 N 个从大到小叠放的盘子。我们的目标是,希望用尽可能少的移动次数,把所有的盘子由 A 柱移动到 C 柱。过程中,每次只能移动一个盘子,且在任何时候,大盘子都不可以在小盘子上面。

图片1.png

1.汉诺塔问题解密

这个题目需要一定的窍门,否则只能碰运气去乱走了。

我们先脑补这样一个画面:假设 A 柱子上除了最后一个大盘子(代号“大盘子”)以外,其他的 N-1 个小盘子都合并起来,成为一个新的盘子(代号为“合并盘”)。那这个问题就简单了,只需要把“合并盘”移动到 B 柱,再把“大盘子”移动到 C 柱,最后把“合并盘”移动到 C 柱。

上述过程如下图所示:

动图GIF.gif

在这个过程中,问题由全部 N 个盘子由 A 移动到 C,转变为 N-1 个“合并盘”从 A 移动到 B 再移动 C。新的问题和原问题是完全一致的,但盘子数量由 N 个减少为 N-1 个。如果继续用上面的思想,就能把 N-1 个“合并盘”再度减少为 N-2 个,直到只剩一个。

我们用数学重写上面的过程:令 H(x) 表示把某个柱子上的全部 x 个盘子移动到另一个柱子上需要的步数,那么原问题 N 个盘子由 A 柱子移动到 C 柱子的数学表示就是 H(N)。

根据我们第一次的分解可知 H(N)=H(N-1)+1+H(N-1)

也就是,把 N 个盘子从 A 移动到 C=把合并盘从 A 移动到 B + 把大盘子从 A 移动到 C + 把合并盘从 B 移动到 C。

再继续分析,你还会得到 H(N-1)=H(N-2)+1+H(N-2)。

……

直到最终 H(2)=H(1)+1+H(1)=1+1+1=3。

我们把这个问题的计算过程整理到下面的表中,并尝试求解 H(n) 的表达式。
图片4.png

因为 H(N)=1+2H(N-1),所以可以得到 H(N-1)=1+2H(N-2),把这两个等式两边分别进行相减,则可以得到 H(N)-H(N-1)=2(H(N-1)-H(N-2))。

令 aN=H(N)-H(N-1),则有 aN=2aN-1,可见 {aN} 是个首项为 1、公比为 2 的等比数列,通项公式为 aN = 2N-1

接着利用这些信息,我们尝试去推导 H(N),则有
图片2.png

别忘了 H(1)=1,a1=1,所以 H(1)=a1,则有
图片3.png
因此如果盘子的数量是 5 个,将 5 代入这个 2N-1,则最少需要 31 步完成移动。

2.汉诺塔问题的代码实现

我们尝试用程序代码来实现汉诺塔问题。不难发现,这里最高频使用的是,把 n 个盘子从某个柱子 x,移动到另一个柱子 z。因此,考虑对这个功能进行函数化的封装,代码如下:

def hanoi(N,x,y,z):if N == 1:print x + '->' + zelse:hanoi(N - 1, x, z, y)print x + '->' + zhanoi(N - 1, y, x, z)

我们对代码进行走读。

第 2、3 行,如果盘子数量为 1,则直接把盘子从 x 柱子移动到 z 柱子即可;若不为 1,则进行第 4~7 行的处理。

此时盘子数量超过了 1,则拆分为“合并盘”和“大盘子”两部分。

  • 首先,函数调用自己,把“合并盘”从 x 移动到 y;

  • 然后,把“大盘子”从 x 移动到 z;

  • 最后,函数再调用自己,把“合并盘”从 y 移动到 z。

想象着会很复杂的代码,实际上非常简单,在主函数中只要执行

hanoi(3, 'a', 'b', 'c')

就能打印出把 3 个盘子从 a 柱子移动到 c 柱子的详细步骤。

每一步的移动结果如下图,执行后需要 7 步,这和我们数学上的计算完全一致。

Drawing 4.png

递归——自己调用自己的程序开发思想

汉诺塔问题解法的核心步骤就是:移动全部盘子,等价于移动“合并盘”,加上移动“大盘子”,加上再移动“合并盘”,然后你需要重复执行这个步骤。

用函数表达这个过程,就是 f(全部盘子) = f(合并盘) + f(大盘子) + f(合并盘)。

为了代码实现这个功能,我们定义这个函数为hanoi(N,x,y,z), 并且在这个函数中,需要调用自己才能完成“合并盘”的移动,这种会调用自己的编码方式在程序开发中,就叫作递归

严格意义来说,递归并不是个算法,它是一种重要的程序开发思想,是某个算法的实现方式。

在使用递归进行程序开发时,需要注意下面两个关键问题。

  • 第一个问题,递归必须要有终止条件,否则程序就会进入不停调用自己的死循环。

有这样一个故事:从前有座山,山里有个庙,庙里有个和尚讲故事;故事是,从前有座山,山里有个庙,庙里有个和尚讲故事;故事是...

这就是一个典型的没有终止条件的递归。在汉诺塔问题中,我们的终止条件,就是当盘子数量为 1 时,直接从 x 移动到 z,而不用再递归调用自身。

  • 第二个问题,写代码之前需要先写出递归公式
    在汉诺塔问题中,递归公式是H(N)=H(N-1)+1+H(N-1),这也是递归函数代码中除了终止条件以外的部分。

对应于“循环结构”中的循环体,这部分代码对于“递归”而言,偶尔也被人称作“递归体”。

递归代码的基本结构如下:

def fun(N,x):if condition(N):xxxelse:fun(N1,x)

我们对这个代码结构进行解析。
对某个函数 fun(N,x) 而言,如果要用递归实现它,代码中至少包括终止条件递归体两部分。

  • 终止条件的判断基于某个入参 N,如果满足,则函数不再调用自己,终止递归;如果还不满足,则进入到递归体。

  • 在递归体中,终止条件判断的入参 N 一定会发生改变。通常而言,是变成比 N 小的一个数值N1。只有这样,递归才能慢慢向终止条件靠近。在递归体中,基于新的参数 N1,再调用函数自身 fun(N1,x),完成一次递归操作。

接着我们带着递归思维,去看一下“阶乘问题”和“斐波那契序列问题”。

递归思维的应用

1.阶乘问题

数学中,阶乘的定义公式为 n!=1×2×...×(n-2)×(n-1)×n。现在请你用递归来写一个函数,输入是某个正整数n,输出是 n 的阶乘。

利用递归写代码时,需要优先处理递归的两个关键问题,那就是终止条件和递归体。

  • 对于终止条件而言,当 n=1 时,返回的值为 1!=1。

  • 对于递归体而言,需要先写出递归公式。根据阶乘公式的定义可知,当 n>1 时,H(n)=n!=1×2×...×(n-2)×(n-1)×n= [1×2×...×(n-2)×(n-1)]×n=n×(n-1)!= n×H(n-1)。

有了这些信息后,我们可以尝试写出下面的代码:

def jiecheng(n):if n == 1:return 1else:return n * jiecheng(n-1)

我们对代码进行走读。这段代码的代码量非常少,第 2、3 行判断 n 是否为 1。如果是,则返回1;否则,则跳转到第 5 行,根据递归公式返回 n×(n-1)!,即 n×jiecheng(n-1)。

题目中限定了输入参数 n 为正整数,所以一些异常判断可以被忽略。但如果你追求代码的工程完备性,还可以补充 n 为 0、n 为负数、甚至 n 为小数的一些异常判断。

在这里,我们就不展开了。

2.斐波那契序列问题

在数学上,斐波那契数列定义为 1、1、2、3、5、8、13、21、34…… 。简而言之,在斐波那契数列中,除了前两项以外,后续的每一项都是前面两项之和,而前两项的值都定义为 1。

我们用 F(n) 表示斐波那契数列中的第 n 项的值,例如:

F(1)=1

F(2)=1

F(3)=1+1=2

F(4)=1+2=3

现在希望你用递归来写代码,实现的功能是,输入某个正整数 n,输出斐波那契数列中第 n 项的值。

你可以假设输入的 n 都是合法的,不用做异常判断。

围绕递归的开发逻辑,关键问题仍然是终止条件和递归体:

  • 斐波那契数列的终止条件很显然,就是当 n 为 1 或 2 时,返回值就是 1;

  • 而它的递归体可以根据斐波那契数列的定义得到,也就是 F(n)=F(n-1)+F(n-2)。

我们把以上定义直接翻译成代码,则有

def fib(n):if n == 1 or n == 2:return 1else:return fib(n-1) + fib(n-2)

我们对代码进行走读:

  • 在第 2 行,判断 n 是否为 1 或 2。

  • 如果是,则第 3 行返回 1;

  • 反之,则跳转到第 5 行,返回前两项之和,即 fib(n-1)+fib(n-2)。

基于这段代码,主函数中执行 print fib(10),即计算斐波那契数列的第 10 位,如下图所示,运行结果为 55。
图片5.png

而我们手动计算斐波那契数列的前 10 位发现,结果也是 55,说明我们刚刚的代码实现是正确的。
图片6.png

递归的优缺点

讲完了递归思维在“阶乘问题”和“斐波那契序列问题”中的应用后,我们总结以下递归的优缺点。

递归有很多优势,例如代码结构简单、代码量少、阅读方便、维护简单等;然而递归也有一些缺陷和不足,一个明显的问题就是,递归的计算量非常大,而且存在重复计算的可能性。

我们以斐波那契数列问题为例,把代码进行如下修改:

def fib(n):if n == 1 or n == 2:return 1else:print "fib: " + str(n-1)print "fib: " + str(n-2)return fib(n-1) + fib(n-2)

其中,在第 5、6 行插入两个打印的动作。它们的功能,是每次执行递归体之前,打印出要递归计算的内容。

这样,在主函数运行 fib(10) 时,你会看到下面的部分运行结果:
Drawing 6.png

很简单,在执行 fib(9) 时,需要递归计算 fib(8) 和 fib(7);而 fib(8) 的计算,又需要递归计算 fib(7) 和 fib(6)。很可惜,在得到 fib(7) 的时候,结果并不会进行保存;而另一边,也要计算 fib(7),这只能再整体进行一次递归计算。

所以,上图中我们能看到计算 fib(10) 的过程中,存在大量重复的递归计算。

重复计算是递归的一个问题,但也并不是绝对会发生,这就需要程序员去综合分析你遇到的具体问题了。

在后面的《17 | 动态规划:如何利用最优子结构解决问题?》我会采用“设置全局变量来缓存中间结果”的方式来避免重复计算,减少计算量。

小结——递归与循环

学完这一讲,你可能会发现,递归和循环比较相像。确实,递归和循环都是通过解决若干个简单问题来解决复杂问题的,它们也都有自己的终止条件和循环体/递归体,都是重复进行某个步骤。

然而,它们也有很多差异性,主要体现在以下两方面。

迭代次数

  • 循环对于迭代的次数更敏感,绝大多数场景会定义一个用来计数的变量 i,来控制循环的次数;

  • 递归对于迭代次数不敏感,取决于什么时候满足终止条件。

问题复杂性

不管是循环还是递归,每一轮迭代处理的问题类型都是非常趋同的,但问题的复杂性却不一样。

  • 对于循环而言,每一轮处理的问题难度几乎是一样的;

  • 递归则是缩小搜索范围(例如二分查找)的思路,一般而言,每轮处理的问题相对上一轮而言是更简单的。

最后,我们留一个练习题:利用递归写出下面的函数,输入是个正整数 n,输出是从 3 到 n 的求和。

下一讲,我将介绍“二分法:如何利用指数爆炸优化程序?”别忘来听课~


精选评论

**柠檬:

static long fun(int n) { if (n == 1) { return 6; } else if (n == 2) { return 5; } else if (n == 3) { return 3; } else { return n + fun(n - 1); }}

    讲师回复:

    递归体的代码逻辑没问题,但终止条件不太对。题目中是3到n的求和,可以假设n为1或2是非法输入。

*峰:

ifa==3elseretur 2n(n-1)+1

    讲师回复:

    不太对。我们需要用递归来实现。需要定一个调用自己的函数。

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

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

相关文章

paddle 训练模型的保持和载入

模型保持中的常见问题 ,查看官网 模型保存常见问题-使用文档-PaddlePaddle深度学习平台 一、保存载入体系简介 模型保存与载入 一、保存载入体系简介 1.1 基础API保存载入体系 飞桨框架2.1对模型与参数的保存与载入相关接口进行了梳理:对于训练调优场景…

2023秋招nlp笔试题-太初

单选题 浮点数的表示范围和精度取决于 浮点数的取值范围由阶码的位数决定,而浮点数的精度由尾数的位数决定 响应中断请求的条件是 A.外设提出中断; B.外设工作完成和系统允许时; C.外设工作完成和中断标记触发器为“1”时; D.CPU提出中断。 计算器浮点运算速度为85.0167PF…

Day30_路由的props配置

提出问题: Detail是如何得到别传给他的参数,以及如何简化写法。 第一种写法的props 对象式 使用的很少,传递的是死数据 1.index.js组件里面 2.在detail组件里面 3效果图: 第二种写法props 布尔值 仅限于params参数传递…

心理学考研难度 分析

全国招收心理学院校排名 高等教育评价专业机构软科发布了“2020软科中国最好学科排名”,心理学院校有55所上榜,排名前10%依次为北京师范大学、北京大学、华南师范大学、西南大学、华东师范大学、华中师范大学、上海交通大学、浙江大学、陕西师范大学、上…

内存对齐对性能的影响

计算字节数 在 Go 语言中,可以使用 unsafe.Sizeof 计算出一个数据类型实例需要占用的字节数。 package mainimport ("fmt""unsafe" )type Args struct {num1 intnum2 int }type Flag struct {num1 int16num2 int32 }func main() {fmt.Println…

浅析axios原理与使用(包括axios的优雅写法与res的解构赋值)

目录前言一,什么是axios二,axios的基本使用2.1 不含参的axios调用2.2 含参数的axios调用三,axios的原理(拦截器)3.1 客户端与服务器之间的交互原理(复习可略过)3.2 浅析axios原理四,…

西瓜书研读——第三章 线性模型: 线性判别分析 LDA

西瓜书研读系列: 西瓜书研读——第三章 线性模型:一元线性回归 西瓜书研读——第三章 线性模型:多元线性回归 西瓜书研读——第三章 线性模型:线性几率回归(逻辑回归) 主要教材为西瓜书,结合…

K8s存储与GlusterFS实战

一、存储概述 1、共享存储机制概述 Kubernetes对于有状态的容器应用或者对数据需要持久化的应用,不仅需要将容器内的目录挂载到宿主机的目录或者emptyDir临时存储卷,而且需要更加可靠的存储来保存应用产生的重要数据,以便容器应用在重建之后…

常见的垃圾回收机制

如何工作在某些 Java 虚拟机中,堆的实现截然不同:它更像一个传送带,每分配一个新对象,它就向前移动一格。 这意味着对象存储空间的分配速度特别快。Java 的"堆指针"只是简单地移动到尚未分配的区域,所以它的效率与 C++ 在栈上分配空间的效率相当垃圾回收器工作时…

将Springbooot项目部署到云服务器上运行

将Springbooot项目部署到云服务器上运行一、项目打包二、将结果jar文件上传服务器三、授予jar文件权限,并运行四、运行失败可能原因一、项目打包 File->Project Structure Project Settings -> Artifacts -> 点击 号 -> JAR -> From modules with d…

Linux-常见命令(三)

前言 作者:小蜗牛向前冲 名言:我可以接收失败,但我不能接收放弃 如果觉的博主的文章还不错的话,还请点赞,收藏,关注👀支持博主。如果发现有问题的地方欢迎❀大家在评论区指正。 目录 find指令&…

2022-2023-1 20221318 《计算机基础和程序设计》第五周学习总结

作业信息 这个作业属于那个班级 https://edu.cnblogs.com/campus/besti/2022-2023-1-CFAP 作业要求 https://www.cnblogs.com/rocedu/p/9577842.html#WEEK03 作业目标 学习《计算机科学概论》的第6章 《程序设计》第4章 作业正文 https://i.cnblogs.com/posts/edit;postId=1673…

JavaScript:DOM

目录 一、DOM介绍 1、节点和节点的关系 2、获取页面中的元素可以使用以下几种方式 3、鼠标点击事件 4、访问节点 5、获取元素的样式 一、DOM介绍 DOM:Document Object Model(文档对象模型) DOM是W3C组织推荐的处理可扩展标记语言的标…

数据结构线性表

目录 2.1 线性表的基本概念 2.2 线性表的顺序表示 2.3 线性表的链式表示 2.3.1 单链表 2.3.2 循环单链表 2.3.3 双向链表 2.3.4 双向循环链表 2.3.5 静态链表 牢固掌握线性表在两种存储结构下的各种基本操作,牢记各种算法思想 2.1 线性表的基本概念 Def&a…

Linux 学习 -- 容器技术

Linux 学习 – 容器技术 容器基础概述容器部署 一、容器基础概述 容器(Container) : 定义:指的是针对应用所需的运行环境资源(依赖库/目录/网络/用户……等)进行整体封装的技术。 特点:封装好的镜像相比虚拟机的粒度要更细&…

类与对象(十八)----super关键字

super关键字 super代表父类的引用,可以用于访问父类的属性,方法,构造器。 super使用 使用super访问父类的属性:权限内的才可以访问。使用语法----super.属性名 public class A {public int n1 12;protected int n2 12;int n…

程序员的数学课10 信息熵:事件的不确定性如何计算?

你好,欢迎来到第 10 课时——信息熵:事件的不确定性如何计算? 从加乘法则开始,我们基于事情的不确定性出发,尝试计算事情发生的可能性。然而,对于事件与事件之间的不确定性如何相比和衡量,单独…

iOS 16 中 SwiftUI 防止弹出的 sheet 视图被下滑关闭(dismiss)的新解决方案

功能需求 在SwiftUI中,我们往往需要只通过代码控制 sheet 弹出视图的关闭(dismiss),而禁止用户手动下滑关闭弹出的视图。 如上图所示,在 iOS 16 中 App 弹出的 sheet 视图只允许点击按钮关闭(dismiss),而用户无法通过下滑来关闭它。 我们之前在 SwiftUI禁止用户关闭s…

【图灵MySQL】Explain详解与索引最佳实践

Explain使用与详解 前期准备 在开始之前,我们先准备一下所需要的数据,创建3个表——actor、film、film_actor actor 表 只有一个主键 CREATE TABLE actor (id int(11) NOT NULL,name varchar(45) DEFAULT NULL,update_time datetime DEFAULT NULL,P…

PTA前三次作业总结分析——BLOG_1

PTA第一次大作业 前言:第一次的大作业比较的简单,没有涉及到类的设计,只要在主类中的主函数实现其相应功能。此次作业主要考察的还是我们对于输入输出的应用和基本判断,double 类型数据强制转换为float类型数据输出,以及循环语句的使用和string类的数据处理。虽然这些题目的…