递归需要满足的三个条件
什么样的问题可以用递归来解决呢?
1. 一个问题的解可以分解为几个子问题的解
子问题就是数据规模更小的问题。
2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
3. 递归存在终止条件
如何编写递归代码
写递归代码的关键就是写出递归公式,找到终止条件,剩下的递归代码就很简单了。下面看一个例子:
加入有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?
其实,可以根据第一步的走法把所有的走法分为两类,第一类是第一步走了 1 个台阶,另一类是第一步走了 2 个台阶。所以 n 个台阶的走法就等于先走 1 阶后,n-1 台阶的走法,再加上先走 2 阶后的走法,用公式表示:
f(n) = f(n-1) + f(n-2)
上面就是「递归公式」了,接下来就是找到终止条件,当只有一个台阶时走法只有一种,终止条件就是 f(1) = 1。我们可以用 n=2,n=3 这样的小数试验一下。f(2) = f(1) + f(0),如果递归终止条件只有 f(1),明显 f(2) 不能求解。所以我们还需要有一个 f(0) = 1 的终止条件,不过这样不太符合正常的逻辑思维。所以我们直接规定 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 fun(int n){
if(n == 1) return 1;
if(n == 2) return 2;
return fun(n - 1) + fun(n - 2);
}
编写递归的关键是,只要遇到递归,我们就把它抽象成一个递归公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。
递归的弊端
递归代码要警惕堆栈溢出
为什么递归代码容易造成堆栈溢出呢?
函数调用会使用栈来保存临时变量,每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。
应该如何预防避免堆栈溢出呢?
我们可以通过在代码中限制递归调用的「最大深度」的方式来解决这个问题。递归调用超过一定的深度(比如 1000)之后,就不再继续往下递归了,直接返回报错。
// 全局变量,表示递归的深度。
int depth = 0;
int f(int n) {
++depth;
if (depth > 1000) throw exception;
if (n == 1) return 1;
return f(n-1) + 1;
}
但是这种做法不能完成解决问题,因为最大允许的递归深度跟当前的线程剩余的栈空间大小有关,事先无法计算。如果进行实时计算代码就会过于复杂,影响代码的可读性。所以如果最大深度比较小,比如 10、50 这种,就可以用,否则这种方法并不是很实用。
递归代码需要警惕重复计算
上面的爬楼梯例子,我们就进行了很多重复计算:
为了避免重复计算,我们可以通过一个数据结(比如散列表)来保存求解过的 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;
}
函数调用耗时多
空间复杂度高
怎么将递归代码改成非递归代码
对于上面的爬楼梯的例子可以改成如下:
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;
}
所有的递归代码都可以改成这种迭代循环的非递归写法。
但是这种思路实际上是将递归改成手动递归,本质没有变,而且没有解决一些递归代码的弊端,徒增了实现的难度。
课后思考
给定一个用户 ID,如何查找这个用户的最终推荐人?
long findRootReferrerId(long actorId) {
Long referrerId = select referrer_id from [table] where actor_id = actorId;
if (referrerId == null) return actorId;
return findRootReferrerId(referrerId);
}
三行代码就能搞定,但是在实际项目中不能直接使用,这里面还存在两个问题:
第一,如果递归很深,可能会有堆栈溢出的风险。
第二,如果数据库中存在一个脏数据,我们还要处理由此产生的无限递归的问题。
对于这两个问题,第一个问题可以用「限制递归深度」来解决,第二个也可以用「限制递归深度」来解决,但是更高级的处理方法,是自动检测 A-B-C-A 这种环的存在。
我们平时调试代码喜欢使用 IDE 的单步跟踪功能,像规模较大、递归层级深的递归代码,几乎无法使用这种方式。对于递归代码有什么好的调试方式呢?
第一,打印日志,打印对应的递归值。 第二,结合条件进行断点调试。