零,递归的典例
递归的例子。 周末你带着女朋友去电影院看电影,女朋友问你,咱们现在坐在第几排啊?电影院里面太黑了,看不清,没法数,现在你怎么办? 别忘了你是程序员,这个可难不倒你,递归就开始排上用场了。于是你就问前面一排的人他是第几排,你想只要在他的数字上加一,就知道自己在哪一排了。但 是,前面的人也看不清啊,所以他也问他前面的人。就这样一排一排往前问,直到问到第一排的人,说我在第一排,然后再这样一排一排再把数字传回来。直到你前面的人告诉你他在哪一排,于是你就知道答案了。
一,如何理解“递归”
数据结构与算法中,两个最难理解的知识点:动态规划和递归。
递归是一种应用非常广泛的算法(或者编程技巧)。很多数据结构和算法的编码实现都要用到递归,比如DFS深度优先搜索、前中后序二叉树遍历等等。所以,搞懂递归非常重要。
递归求解问题,分解过程有两个,去的过程叫“递”,回的过程叫做“归”。基本上,所有的递归问题都可以用递推公式来表示。
二,递归需要满足的三个条件
1,一个问题的解可以分解成几个子问题的解。
何为子问题?子问题就是数据规模更小的问题,就像“自己在哪一排”的问题,可以分解为“前一排人在哪一排”的子问题。
2,这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样。
就像电影院里“自己在第几排”和“前一排人在第几排”的问题处理方式完全一样。
3,存在递归终止条件
递归,一层层的“递”下去,分解成子问题,不能无限循环下去,需要有终止条件。
4,举例
问题:假设有n个台阶,每次你可以跨1个台阶或者2个台阶,请问走这个n个台阶有多少种走法?
思考:是否满足递归的条件,1)“走这n个台阶有多少种走法”问题与子问题“走n-1个台阶和n-2个台阶一共有多少种走法”是解决听一个问题。2)这个问题和子问题的解法都是一样,除数据量不同,分为走1或者2个台阶。3)存在终止条件,最后只剩1个或者0个台阶时终止。
求解:1)找出递推公式:可以根据第一步的走法把所有走法分为两类,第一类是第一步走了1个台阶,另一类是第一步走了2个台阶。所以n个台阶的走法就等于先走1阶后,n-1个台阶的走法加上先走2阶后,n-2个台阶的走法。用公式表示就是:
2) 找出终止条件:当只有一个台阶时:只有一种走法,所以。当只有2个台阶时:有2种走法,即
,其中
,表示0阶梯时有一种走法,不符合逻辑,所以另一个截至条件是
。 再考虑当有3,4个台阶时验证一下。综上所述,终止条件有两:
和
。
3)将终止条件与递推公式放一起,写出代码,如下
;
;
;
int f(int n){
if(n==1) return 1;
if(n==2) return 2;
return f(n-1)+f(n-2);
}
三,如何编写递归代码
写递归代码最关键的是找到如何将大问题分解成小问题的规律,并基于此写出递推公式,然后再推敲终止条件,最后将终止条件和递推公式翻译成代码。
正确理解递归的思维方式是:如果一个问题A可以分解为若干子问题B,C,D,可以假设B,C,D已经解决,在此基础上思考如何解决问题A。而且只需要思考问题A与子问题B,C,D两层之间的关系即可,不需要一层一层往下思考子问题与子子问题之间的关系。屏蔽掉递归细节,因此,编写递归代码的关键就是,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。
四,递归代码要警惕堆栈溢出
编写递归代码时,会遇到很多问题,比如堆栈溢出。而堆栈溢出会造成系统性崩溃,后果会很严重。递归是如何导致堆栈溢出呢?又该如何预防溢出呢?
在理解数据结构“栈”时说过,函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈 或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。
如何避免呢?可以通过代码限制递归调用的最大深度方式来解决,但这种做法并不能完全解决问题,因为最大允许的递归深度跟当前线程剩余的栈空间大小有关,事先无法计算。
五,递归代码要警惕重复计算
除要警惕堆栈溢出问题,使用递归时还会出现重复计算的问题,如之前台阶的问题,如图:
从图中可以直观地看到,想要计算f(5),需要先计算f(4)和f(3),而计算f(4)还需要计算f(3),因此,f(3)就被计算了很多次,这就是重复计算问题。
为了避免重复计算,可以通过一个数据结构(比如散列表)来保存已经求解过的f(k)。当递归调用到f(k)时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。
在时间效率上,递归会产生很多函数调用,当函数调用的数据较大时,就会汇聚成一个可观的时间成本。同时,空间复杂度上,递归调用一次会在内存栈种保存现场数据,所以在分析递归代码空间复杂度时,需要额外考虑这部分的开销。所以递归空间复杂度并不是O(1),而是O(n)。
六,将递归代码改写成非递归代码
递归有利有弊,利是递归代码的表达力很强,写起来非常简洁;而弊就是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗 时较多等问题。所以,在开发过程中,要根据实际情况来选择是否需要用递归的方式来实现。
笼统地讲,所有的递归代码都可以改为迭代循环的非递归写法。因为递归本身就是借助栈来实现的,只不过使用的栈是系统或者虚拟机本身提供的,我们没有感知罢了。如果自己在内存堆上实现栈,手动模拟入栈、出栈过程,这样任何递归代码都可以改写成看上去不是递归代码的样子。 但是这种思路实际上是将递归改为了“手动”递归,本质并没有变,而且也并没有解决前面讲到的某些问题,徒增了实现的复杂度。