递归
递推公式:
f
(
n
)
=
f
(
n
−
1
)
+
1
,
其
中
f
(
1
)
=
1
f(n)=f(n-1)+1,其中f(1)=1
f(n)=f(n−1)+1,其中f(1)=1
针对上面的递推公式,很容易写出递归代码:
int f(int n){
if(n==1) return 1;
return f(n-1)+1;
}
递归需要满足的三个条件
- 一个问题的解可以分解为几个子问题的解
子问题就是数据规模更小的问题 - 这个问题与分解后的子问题,除了数据规模不同,求解思路完全一样
- 存在递归终止条件
如果编写递归代码
小例子:
假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?如果有 7 个台阶,你可以 2,2,2,1 这样子上去,也可以 1,2,1,1,2 这样子上去,总之走法有很多,那如何用编程求得总共有多少种走法呢?
用公式表示:
f
(
n
)
=
f
(
n
−
1
)
+
f
(
n
−
2
)
f(n)=f(n-1)+f(n-2)
f(n)=f(n−1)+f(n−2)
思路:第一类是第一步走了 1 个台阶,另一类是第一步走了 2 个台阶。所以 n 个台阶的走法就等于先走 1 阶后,n-1 个台阶的走法 加上先走 2 阶后,n-2 个台阶的走法。
寻找终止条件:
当有一个台阶时,我们不要再继续递归,就只有一种走法。所以 f(1)=1;n=2 时,f(2)=f(1)+f(0)。如果递归终止条件只有一个 f(1)=1,那 f(2) 就无法求解了。所以除了 f(1)=1
这一个递归终止条件外,所以,我们可以把 f(2)=2 作为一种终止条件,表示走 2 个台阶,有两种走法,
一步走完或者分两步来走。
得出完整的公式:
f
(
n
)
=
f
(
n
−
1
)
+
f
(
n
−
2
)
f(n)=f(n-1)+f(n-2)
f(n)=f(n−1)+f(n−2)
f
(
1
)
=
1
f(1)=1
f(1)=1
f
(
2
)
=
2
f(2)=2
f(2)=2
写出最终的递归代码:
int f(int n){
if(n==1)
return 1;
if(n==2)
return 2;
f(n)=f(n-1)+f(n-2);
}
总结:写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再敲终止条件,最后将递推公式和终止条件翻译成代码。
递归要警惕堆栈溢出
每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。
因为最大允许的递归深度跟当前线程剩余的栈空间大小有关,事
先无法计算。如果实时计算,代码过于复杂,就会影响代码的可读性。所以,如果最大深度比较
小,比如 10、50,就可以用这种方法,否则这种方法并不是很实用。
递归要警惕重复计算
为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 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;
}
过多的函数调用比较耗时
改为非递归的方式
将例子2改为非递归的方式,仍然使用刚才的公式:
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;
rep=ret;
return ret;
}
}
递归要警惕环的出现
比如:如果 A 的推荐人是B,B 的推荐人是 C,C 的推荐人是 A,这样就会发生死循环。
总结
递归代码虽然简洁高效,但是,递归代码也有很多弊端。比如,堆栈溢出、重复计算、函数调用耗时多、空间复杂度高等,所以,在编写递归代码的时候,一定要控制好这些副作用。
课后思考
递归的debug方法?
- 打印日志发现,递归值
- 结合条件断点进行调试
待补充
递归的时间和空间复杂度的计算