写递归函数的正确思维方法

递归是编程中一个相对难以理解但是却又很重要的概念. 对于从命令式语言开始学习编程的程序员天生对此有理解缺陷,
而对于从类似C++这种对函数式编程范式不友好的语言开始学习编程的程序员就更加如此了.(比如我自己) 碰巧(其实不巧)最近在读这本书(这本书国内没有引进,
网上只有巨贵的亚马逊卖的原版,
我读的是网上的中文版), Paul
Graham在书中讲述的如何写递归函数的部分, 让我印象深刻. 因为原书是讲Lisp的, 当然这个部分也是用Lisp作为例子描述的,
考虑到国内会看这本书的人太少, 能看懂Lisp的就更不多了, 我这里根据自己的理解, 重新整理一下. 最重要的是, 书中原来的例子太少, 太简单,
我自己提供了一些额外的, 并且更加复杂的例子. 以期对问题能有更好的理解.

什么是递归


迭代的是人,递归的是神
–L. Peter Deutsch

简单的定义: “当函数直接或者间接调用自己时,则发生了递归.” 说起来简单, 但是理解起来复杂, 因为递归并不直观, 也不符合我们的思维习惯,
相对于递归, 我们更加容易理解迭代. 因为我们日常生活中的思维方式就是一步接一步的, 并且能够理解一件事情做了N遍这个概念.
而我们日常生活中几乎不会有递归思维的出现.
举个简单的例子, 即在C/C++中计算一个字符串的长度. 下面是传统的方式,
我们一般都这样通过迭代来计算长度, 也很好理解.

size_t length(const char *str) {
size_t length = 0;
while (*str != 0) {
++length;
++str;
}

return length;
}

而事实上, 我们也可以通过递归来完成这样的任务.

size_t length(const char *str) {
if (*str == 0) {
return 0;
}
return length(++str) + 1;
}

只不过, 我们都不这么做罢了, 虽然这样的实现有的时候可能代码更短, 但是很明显, 从思维上来说更加难以理解一些. 当然,
我是说假如你不是习惯于函数式语言的话. 这个例子相对简单, 稍微看一下还是能明白吧.
迭代的算法可以这样描述: 从第一个字符开始判断字符串的每一个字符,
当该字符不为0的时候, 该字符串的长度加一.
递归的算法可以这样描述: 当前字符串的长度等于当前字符串除了首字符后,
剩下的字符串长度+1.
作为这么简单的例子, 两种算法其实大同小异, 虽然我们习惯迭代, 但是, 也能看到, 递归的算法无论是从描述上还是实际实现上,
并不比迭代要麻烦.

理解递归

在初学递归的时候, 看到一个递归实现, 我们总是难免陷入不停的回溯验证之中, 因为回溯就像反过来思考迭代, 这是我们习惯的思维方式,
但是实际上递归不需要这样来验证. 比如, 另外一个常见的例子是阶乘的计算.
阶乘的定义: “一个正整数的阶乘(英语:factorial)是所有小于或等于该数的正整数的积,并且0的阶乘为1。” 以下是Ruby的实现:

def factorial(n)
if n <= 1 then
return 1
else
return n * factorial(n - 1)
end
end

我们怎么判断这个阶乘的递归计算是否是正确的呢? 先别说测试, 我说我们读代码的时候怎么判断呢?
回溯的思考方式是这么验证的, 比如当n = 4时,
那么factoria(4)等于4 * factoria(3),
factoria(3)等于3 *
factoria(2)
factoria(2)等于2 * factoria(1),
等于2 * 1, 所以factoria(4)等于4 * 3 * 2 * 1.
这个结果正好等于阶乘4的迭代定义.
用回溯的方式思考虽然可以验证当n = 某个较小数值是否正确, 但是其实无益于理解.
Paul
Graham提到一种方法, 给我很大启发, 该方法如下:

  1. 当n=0, 1的时候, 结果正确.

  2. 假设函数对于n是正确的,
    函数对n+1结果也正确.
    如果这两点是成立的,我们知道这个函数对于所有可能的n都是正确的。

这种方法很像数学归纳法,
也是递归正确的思考方式, 事实上, 阶乘的递归表达方式就是1!=1,n!=(n-1)!×n(见wiki).
当程序实现符合算法描述的时候, 程序自然对了, 假如还不对, 那是算法本身错了…… 相对来说, n,n+1的情况为通用情况, 虽然比较复杂, 但是还能理解,
最重要的, 也是最容易被新手忽略的问题在于第1点, 也就是基本用例(base case)要对. 比如, 上例中, 我们去掉if n <=
1
的判断后, 代码会进入死循环, 永远不会结束.

使用递归

既然递归比迭代要难以理解, 为啥我们还需要递归呢? 从上面的例子来看, 自然意义不大, 但是很多东西的确用递归思维会更加简单……
经典的例子就是斐波那契数列,
在数学上, 斐波那契数列就是用递归来定义的:

·F0 = 0
·F1 = 1 
·Fn = Fn – 1 + Fn – 2

有了递归的算法, 用程序实现实在再简单不过了:

def fibonacci(n)
if n == 0 then
return 0
elsif n == 1 then
return 1
else
return fibonacci(n - 1) + fibonacci(n - 2)
end
end

改为用迭代实现呢? 你可以试试.
上面讲了怎么理解递归是正确的, 同时可以看到在有递归算法描述后, 其实程序很容易写, 那么最关键的问题就是,
我们怎么找到一个问题的递归算法呢?
Paul Graham提到, 你只需要做两件事情:

  1. 你必须要示范如何解决问题的一般情况, 通过将问题切分成有限小并更小的子问题.

  2. 你必须要示范如何通过有限的步骤, 来解决最小的问题(基本用例).
    如果这两件事完成了, 那问题就解决了. 因为递归每次都将问题变得更小,
    而一个有限的问题终究会被解决的, 而最小的问题仅需几个有限的步骤就能解决.

这个过程还是数学归纳法的方法, 只不过和上面提到的一个是验证, 一个是证明.
现在我们用这个方法来寻找汉诺塔这个游戏的解决方法.(这其实是数学家发明的游戏)

有三根杆子A,B,C。A杆上有N个(N>1)穿孔圆盘,盘的尺寸由下到上依次变小。要求按下列规则将所有圆盘移至C杆:
1.每次只能移动一个圆盘.
2.大盘不能叠在小盘上面.


这个游戏在只有3个盘的时候玩起来较为简单,
盘越多, 就越难, 玩进去后, 你就会进入一种不停的通过回溯来推导下一步该干什么的状态, 这是比较难的.
我记得第一次碰到这个游戏好像是在大航海时代某一代游戏里面, 当时就觉得挺有意思的. 推荐大家都实际的玩一下这个游戏,
试试你脑袋能想清楚几个盘的情况.
现在我们来应用Paul Graham的方法思考这个游戏.

一般情况:
当有N个圆盘在A上, 我们已经找到办法将其移到C杠上了, 我们怎么移动N+1个圆盘到C杠上呢? 很简单,
我们首先用将N个圆盘移动到C上的方法将N个圆盘都移动到B上, 然后再把第N+1个圆盘(最后一个)移动到C上, 再用同样的方法将在B杠上的N个圆盘移动到C上.
问题解决.

基本用例:
当有1个圆盘在A上, 我们直接把圆盘移动到C上即可.

算法描述大概就是上面这样了, 其实也可以看作思维的过程, 相对来说还是比较自然的. 下面是Ruby解:

def hanoi(n, from, to, other)
if n == 1 then
puts from + ‘ -> ‘ + to
else
hanoi(n-1, from, other, to)
hanoi(1, from, to, other)
hanoi(n-1, other, to, from)
end
end

当n=3时的输出:

A -> C
A -> B
C -> B
A -> C
B -> A
B ->
C
A -> C

上述代码中, from, to, other的作用其实也就是提供一个杆子的替代符, 在n=1时, 其实也就相当于直接移动. 看起来这么复杂的问题,
其实用递归这么容易, 没有想到吧. 要是想用迭代来解决这个问题呢? 还是你自己试试吧, 你试的越多, 就能越体会到递归的好处.

递归的问题

当然, 这个世界上没有啥时万能的, 递归也不例外, 首先递归并不一定适用所有情况, 很多情况用迭代远远比用递归好了解, 其次, 相对来说,
递归的效率往往要低于迭代的实现, 同时, 内存好用也会更大, 虽然这个时候可以用尾递归来优化,
但是尾递归并不是一定能简单做到.

参考


    1. Ansi Common
      Lisp

    2. 精通递归程序设计

时间: 2024-08-18 14:19:43

写递归函数的正确思维方法的相关文章

thinkphp3.2 cli模式的正确使用方法

最近要使用thinkphp3.2版本的cli模式,手动执的话没有问题,比如php /www/index.php home/article/get 这样没有问题,但是一般用cli模式都是定时任务比较多,这个时候写到定时任务的时候,比如 0 * * * * * php /www/index.php home/article/get 这样就会出现引入ThinkPHP.php失败的问题,这个问题算是比较容易解决的,但是后来还有其他各种报错,经过查看相关核心代码,终于找到无需修改代码,就可以轻易使用cli

阿里P8架构专家的晋升法则(思维方法)

今天我们不谈技术我们谈思维方法,如何让你用同样的时间完成其他程序猿2倍的工作量 时间管理 大多数人看起来都很忙,但其实很多时候都是瞎忙,本质就是时间利用率太低. 时间管理的第一步,是找出一天中自己的高效时段,具体就是用时间日志法,忠实记录每一个小时自己的预期目标.实际完成情况.工作状态,一天结束之后统计.一天工作八小时(当然IT从业者可能是12小时或者更多),高效的时间并不多.书中对高效.低效.休息三种状态的定义是这样的. 1)集中精力工作:心无杂念:进入忘我状态:效率很高.2) 无意义浪费时间

SQL Server附加数据库出现错误5123的正确解决方法

SQL Server附加数据库出现错误5123的正确解决方法 因为自己有一本基于SQL Server 2005的数据库教程,里边使用的示例数据库是AdventureWorks for SQL Server 2005,而我的机子上装的是SQL Server 2008,示例数据库是AdventureWorks for SQL Server 2008.起初我以为示例数据库AdventureWorks for SQL Server 2005 与AdventureWorks for SQL Server

递归函数的概念使用方法与实例

一.栈     在说函数递归的时候,顺便说一下栈的概念.     栈是一个后进先出的压入(push)和弹出(pop)式数据结构.在程序运行时,系统每次向栈中压入一个对象,然后栈指针向下移动一个位置.当系统从栈中弹出一个对象时,近期进栈的对象将被弹出.然后栈指针向上移动一个位置.程序猿常常利用栈这种数据结构来处理那些最适合用后进先出逻辑来描写叙述的编程问题.这里讨论的程序中的栈在每一个程序中都是存在的,它不须要程序猿编写代码去维护,而是由运行是系统自己主动处理.所谓的系统自己主动维护,实际上就是编

当写listview的onItemClick的方法时写Toast的参数context写成this出现can&#39;t resolve method ’make text(OnClickListener,java.lang.String,int)&#39;的错误,原因

listView.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { Fruit fruit = fruitList.get(position); //Toast.makeText(this,fruit.getName(),Toast.L

Flexbox 布局的正确使用方法

Flexbox 布局的正确使用方法 在项目中,我们还会大量使用到flexbox的新旧属性,但大多数人一般只会写新属性,旧属性交由autoprefixer处理,但其实完成同样功能的新旧属性表现形式却不尽相同.还有部分人只使用"万能"的flex:number属性为伸缩项目分配空间,但有些特殊情景却无法满足,此文为此梳理了flexbox的新旧属性区别和分配空间的原理,为大家用flexbox布局的项目通通渠. Flexbox兼容性 PC端的兼容性 移动端的兼容性 如上图,为了兼容IE10-11

面向对象的思维方法

我是从学习Java编程开始接触OOP(面向对象编程),刚开始使用Java编写程序的时候感觉很别扭,因为我早以习惯用C来编写程序,很欣赏C的简洁性和高效性,喜欢C简练而表达能力丰富的风格,特别忍受不了Java运行起来慢吞吞的速度,相对冗长的代码,而且一个很简单的事情,要写好多类,一个类调用一个类,心里的抵触情绪很强. 我对Java的面向对象的特性琢磨良久,自认为有所领悟,也开始有意识的运用OOP风格来写程序,然而还是经常会觉得不知道应该怎样提炼类,面对一个具体的问题的时候,会觉得脑子里千头万绪的,

新随笔--读书笔记《学会提问》:学习提出好问题的思维方法

新随笔--读书笔记<学会提问>:学习提出好问题的思维方法 书中要点摘录: "对自己的所见所闻如何回应,你必须做出选择: 1.你把别人的观点当成自己的观点. 2. 对对方的观点进行批判后,再接收." 批注: 我平时也有这个问题,常常会不加思考的接受别人的观点,通常是比我资深或更专业的人的观点,大多数时候确实没有经过分析. 日后需要注意: 1. 或许他的论点是片面的,或已过时的,我没有分析,简单接受,时间长了会影响我的思维习惯. 2. 如果对观点进行批判后再接受,更能将谈话深入

AspectJ学习笔记2-Eclipse中AspectJ插件AJDT的正确安装方法

接着之前一篇日志.这个事情也挺无语的,简单记录一下. 在这里:http://www.eclipse.org/ajdt/ 可以下载最新的Eclipse Plugin,下载解压之后,一般来说,直接把解压后文件夹下的features和plugins放到Eclipse的文件夹下就行了.不过我这样做以后,启动Eclipse,发现没什么作用.才参考网上有人介绍的第二种方法,也就是Help--Install New Software--Add--Local这种方式选择刚才的解压文件夹,但是这样操作以后会报像下