递归是一种编程技巧(算法),简单理解其实就是本身调用本身,有点像数学中的java
f(n) = f(n-1) + 1; f(n) = f(n-1) + f(n-2); f(n)=n*f(n-1);程序员
递归的优缺点算法
递归是一种应用很是普遍的算法(或者编程技巧)。以后咱们要讲的不少数据结构和算法的编码实现都要用到递归,好比 DFS 深度优先搜索、前中后序二叉树遍历等等。因此,搞懂递归很是重要,不然,后面复杂一些的数据结构和算法学起来就会比较吃力。编程
e.g:bash
周末你带着女友去电影院看电影,女友问你,我们如今坐在第几排啊?电影院里面太黑了,看不清,无法数,如今你怎么办?数据结构
别忘了你是程序员,这个可难不倒你,递归就开始排上用场了。因而你就问前面一排的人他是第几排,你想只要在他的数字上加一,就知道本身在哪一排了。可是,前面的人也看不清啊,因此他也问他前面的人。就这样一排一排往前问,直到问到第一排的人,说我在第一排,而后再这样一排一排再把数字传回来。直到你前面的人告诉你他在哪一排,因而你就知道答案了。数据结构和算法
这就是一个很是标准的递归求解问题的分解过程,去的过程叫“递”,回来的过程叫“归”。基本上,全部的递归问题均可以用递推公式来表示。刚刚这个生活中的例子,咱们用递推公式将它表示出来就是这样的:函数
f(n)=f(n-1)+1 其中,f(1)=1
复制代码
f(n) 表示你想知道本身在哪一排,f(n-1) 表示前面一排所在的排数,f(1)=1 表示第一排的人知道本身在第一排。有了这个递推公式,咱们就能够很轻松地将它改成递归代码,以下:编码
int f(int n) {
if (n == 1) return 1;
return f(n-1) + 1;
}
复制代码
那究竟什么样的问题能够用递归来解决呢?我总结了三个条件,只要同时知足如下三个条件,就能够用递归来解决。spa
何为子问题?子问题就是数据规模更小的问题。好比,前面讲的电影院的例子,你要知道,“本身在哪一排”的问题,能够分解为“前一排的人在哪一排”这样一个子问题。
好比电影院那个例子,你求解“本身在哪一排”的思路,和前面一排人求解“本身在哪一排”的思路,是如出一辙的。
把问题分解为子问题,把子问题再分解为子子问题,一层一层分解下去,不能存在无限循环,这就须要有终止条件。
仍是电影院的例子,第一排的人不须要再继续询问任何人,就知道本身在哪一排,也就是 f(1)=1,这就是递归的终止条件。
我我的以为,写递归代码最关键的是写出递推公式,找到终止条件,剩下将递推公式转化为代码就很简单了。
假如这里有 n 个台阶,每次你能够跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?若是有 7 个台阶,你能够 2,2,2,1 这样子上去,也能够 1,2,1,1,2 这样子上去,总之走法有不少,那如何用编程求得总共有多少种走法呢?
咱们仔细想下,实际上,能够根据第一步的走法把全部走法分为两类,第一类是第一步走了 1 个台阶,另外一类是第一步走了 2 个台阶。因此 n 个台阶的走法就等于先走 1 阶后,n-1 个台阶的走法 加上先走 2 阶后,n-2 个台阶的走法。用公式表示就是:
f(n) = f(n-1)+f(n-2)
复制代码
咱们再来看下终止条件。当有一个台阶时,咱们不须要再继续递归,就只有一种走法。因此** f(1)=1**。这个递归终止条件足够吗?咱们能够用 n=2,n=3 这样比较小的数试验一下。
n=2 时,f(2)=f(1)+f(0)。若是递归终止条件只有一个 f(1)=1,那 f(2) 就没法求解了。因此除了 f(1)=1 这一个递归终止条件外,还要有 f(0)=1,表示走 0 个台阶有一种走法,不过这样子看起来就不符合正常的逻辑思惟了。因此,咱们能够把f(2)=2 做为一种终止条件,表示走 2 个台阶,有两种走法,一步走完或者分两步来走。
因此,递归终止条件就是 f(1)=1,f(2)=2。这个时候,你能够再拿 n=3,n=4 来验证一下,这个终止条件是否足够而且正确。
咱们把递归终止条件和刚刚获得的递推公式放到一块儿就是这样的:
f(1) = 1;
f(2) = 2;
f(n) = f(n-1)+f(n-2)
复制代码
有了这个公式,咱们转化成递归代码就简单多了。最终的递归代码是这样的:
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 两层之间的关系便可,不须要一层一层往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归细节,这样子理解起来就简单多了。
所以,编写递归代码的关键是,只要遇到递归,咱们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每一个步骤。
在实际的软件开发中,编写递归代码时,咱们会遇到不少问题,好比堆栈溢出。而堆栈溢出会形成系统性崩溃,后果会很是严重。为何递归代码容易形成堆栈溢出呢?咱们又该如何预防堆栈溢出呢?
“栈”那一节讲过,函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间通常都不大。若是递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。
好比前面的讲到的电影院的例子,若是咱们将系统栈或者 JVM 堆栈大小设置为 1KB,在求解 f(19999) 时便会出现以下堆栈报错:
Exception in thread "main" java.lang.StackOverflowError
复制代码
咱们能够经过在代码中限制递归调用的最大深度的方式来解决这个问题。递归调用超过必定深度(好比 1000)以后,咱们就不继续往下再递归了,直接返回报错。仍是电影院那个例子,咱们能够改形成下面这样子,就能够避免堆栈溢出了。
// 全局变量,表示递归的深度。
int depth = 0;
int f(int n) {
++depth;
if (depth > 1000) throw exception;
if (n == 1) return 1;
return f(n-1) + 1;
}
复制代码
但这种作法并不能彻底解决问题,由于最大容许的递归深度跟当前线程剩余的栈空间大小有关,事先没法计算。若是实时计算,代码过于复杂,就会影响代码的可读性。因此,若是最大深度比较小,好比 十、50,就能够用这种方法,不然这种方法并非很实用。
除此以外,使用递归时还会出现重复计算的问题。刚才我讲的第二个递归代码的例子,若是咱们把整个递归过程分解一下的话,那就是这样的:
从图中,咱们能够直观地看到,想要计算 f(5),须要先计算 f(4) 和 f(3),而计算 f(4) 还须要计算 f(3),所以,f(3) 就被计算了不少次,这就是重复计算问题。
为了不重复计算,咱们能够**经过一个数据结构(好比散列表)来保存已经求解过的 f(k)。**当递归调用到 f(k) 时,先看下是否已经求解过了。若是是,则直接从散列表中取值返回,不须要重复计算,这样就能避免刚讲的问题了。
按照上面的思路,咱们来改造一下刚才的代码:
public int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
// hasSolvedList 能够理解成一个 Map,key 是 n,value 是 f(n)
if (hasSolvedList.containsKey(n)) {
return hasSovledList.get(n);
}
int ret = f(n-1) + f(n-2);
hasSovledList.put(n, ret);
return ret;
}
复制代码
咱们刚说了,递归有利有弊,
那咱们是否能够把递归代码改写为非递归代码呢?好比刚才那个电影院的例子,咱们抛开场景,只看 f(x) =f(x-1)+1 这个递推公式。咱们这样改写看看:
int f(int n) {
int ret = 1;
for (int i = 2; i <= n; ++i) {
ret = ret + 1;
}
return ret;
}
复制代码
一样,第二个例子也能够改成非递归的实现方式。
int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
int ret = 0;
int pre = 2;
int prepre = 1;
for (int i = 3; i <= n; ++i) {
ret = pre + prepre;
prepre = pre;
pre = ret;
}
return ret;
}
复制代码