一、🐶 递归的定义
- 1、一个问题可以分解为几个子问题的解
- 2、这个问题的求解思路与子问题的求解思路一致
- 3、存在递归终止条件
二、🐶 递归的编写方法
假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?
1、写出递推公式
可以根据第一步的走法把所有走法分为两类,
- 第一类是第一步走了 1 个台阶,
- 另一类是第一步走了 2 个台阶。
- 所以 n 个台阶的走法就等于先走 1 阶后,n-1 个台阶的走法 加上先走 2 阶后,n-2 个台阶的走法。用公式表示就是:
f(n) = f(n-1)+f(n-2)
这个就是我们的递推公式。
2、寻找终止条件
- 当有一个台阶时,我们不需要再继续递归,就只有一种走法。所以 f(1)=1。
- 当有2个台阶时,有2种走法,2个1步或者1个2步,所以 f(2)=2。 递归终止条件就是 f(1)=1,f(2)=2。
3、将递推公式和终止条件翻译成代码
我们把递归终止条件和刚刚得到的递推公式放到一起就是这样的:
function f(n) {
if (n == 1) return 1;
if (n == 2) return 2;
return f(n-1) + f(n-2);
}
写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。
三、🐶 递归注意事项
1、堆栈溢出问题
堆栈溢出会造成系统崩溃,为什么递归容易造成对战堆栈问题呢,我们又该如何避免?
- 函数调用会使用栈来保存临时变量,每调用一个函数,都会将临时变量封装为一个栈帧压入栈中,等函数执行完返回时再出栈,系统栈或者虚拟机空间一般不大,如果递归求解的数据规模很大,调用层次很深,一直压入栈就会有堆栈溢出的风险。
- 我们可以通过在代码中限制递归调用最大深度来解决这个问题,递归调用超过一定深度时我们就不往下递归了,直接返回报错,代码如下
// 全局变量,表示递归的深度。
int depth = 0;
function f(n) {
++depth;
if (depth > 1000) throw exception;
if (n == 1) return 1;
if (n == 2) return 2;
return f(n-1) + f(n-2);
}
但是这种做法并不能完全解决问题,递归深度和当前线程剩余的栈空间大小有关,事先无法计算,如果实时计算代码过于复杂且可读性差,所以,如果最大深度比较小,就可以用这种方法,否则这种方法并不是很实用。
2、重复计算问题
为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。代码如下:
function f(n) {
if (n == 1) return 1;
if (n == 2) return 2;
// hasSolvedList可以理解成一个Map,key是n,value是f(n)
if (hasSolvedList.containsKey(n)) {
return hasSolvedList.get(n);
}
let ret = f(n-1) + f(n-2);
hasSolvedList.put(n, ret);
return ret;
}
3、时间和空间复杂度
- 在时间效率上,递归代码里多了很多函数调用,当这些函数调用的数量较大时,就会积聚成一个可观的时间成本。
- 在空间复杂度上,因为递归调用一次就会在内存栈中保存一次现场数据,所以在分析递归代码空间复杂度时,需要额外考虑这部分的开销。
四、🐶 总结
- 递归是一种简洁高效的编码编码技巧。
- 编写递归代码的关键是找到递推公式和终止条件。
- 递归代码存在堆栈溢出、重复计算、时间和空间复杂度等问题。
- 可以通过限制递归深度、使用数据结构、改写为迭代循环等方式解决问题。