数据结构与算法之美n---递归

递归

递推公式:

f(n)=f(n1)+1f(1)=1f(n)=f(n-1)+1,其中f(1)=1
针对上面的递推公式,很容易写出递归代码:

int f(int n){
	if(n==1) return 1;
	return f(n-1)+1;
}

递归需要满足的三个条件

  1. 一个问题的解可以分解为几个子问题的解
    子问题就是数据规模更小的问题
  2. 这个问题与分解后的子问题,除了数据规模不同,求解思路完全一样
  3. 存在递归终止条件

如果编写递归代码

小例子:
假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?如果有 7 个台阶,你可以 2,2,2,1 这样子上去,也可以 1,2,1,1,2 这样子上去,总之走法有很多,那如何用编程求得总共有多少种走法呢?
用公式表示:
f(n)=f(n1)+f(n2)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(n1)+f(n2)f(n)=f(n-1)+f(n-2)
f(1)=1f(1)=1
f(2)=2f(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,就可以用这种方法,否则这种方法并不是很实用。

递归要警惕重复计算

数据结构与算法之美n---递归
为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 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方法?

  1. 打印日志发现,递归值
  2. 结合条件断点进行调试

待补充

递归的时间和空间复杂度的计算