文章目录
前言
递归的应用非常广泛,之后要讲的很多数据结构和算法的编码实现都要用到递归,搞懂递归非常重要,否则,后面复杂一些的数据结构和算法学起来就会比较吃力。
本课要解决的问题问题
给定一个用户 ID,如何查找这个用户的“最终推荐人”?
一、如何理解“递归”?
递归公式:
f (n) = f (n - 1) + 1 其中 f(1) = 1、
基本上所有的递归问题都能用这个公式表示。
int f(n) {
if (n == 1) {
return 1;
}
return f(n-1) + 1;
}
递归需要满足三个条件
1. 一个问题的解可以分解为几个子问题的解
子问题就是数据规模更小的问题。比如,你要知道自己要做哪一排看电影,可以分解为:前面的人要坐哪一排。前面的人也不知道,于是又问排在他前面的人。但是越往前问,需要问的人越少(数据规模越小)。
2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
排在你前面的人要解决的问题和你一样。都是想知道前面的人坐第几排。
3. 存在递归终止条件
递归得有终止条件。
二、如何编写递归代码?
写递归代码最关键的是写出递推公式,找到终止条件,剩下将递推公式转化为代码就很简单了。
但是有些问题要分解成多个子问题时,递归代码就不好理解了。对于递归代码,这种试图想清楚整个递和归过程的做法,实际上是进入了一个思维误区。
因此,编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。
避免堆栈溢出
写递归还要避免堆栈溢出。那么,如何避免出现堆栈溢出呢?
我们可以通过在代码中限制递归调用的最大深度的方式来解决这个问题。递归调用超过一定深度(比如 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,就可以用这种方法,否则这种方法并不是很实用。
递归代码要警惕重复计算
举个例子:假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?如果有 7 个台阶,你可以 2,2,2,1 这样子上去,也可以 1,2,1,1,2 这样子上去,总之走法有很多,那如何用编程求得总共有多少种走法呢?
int f(int i) {
if (n == 1) {
return 1;
}
if (n == 2) {
return 2;
}
return f(n-1) + f(n-2);
}
但是这样就会有重复计算的情况。
由图可知,f(3)重复执行多次。
可以使用一个数据结构(比如散列表)来保存已经求解过的f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。
改造代码如下:
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 hasSolvedList.get(n);
}
int ret = f(n-1) + f(n-2);
hasSolvedList.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 (itn i=3; i <= n; i++) {
ret = pre + prepre;
prepre = pre;
pre = ret;
}
return ret;
}
那是不是所有的递归代码都可以改为这种迭代循环的非递归写法呢?
笼统地讲,是的。因为递归本身就是借助栈来实现的,只不过我们使用的栈是系统或者虚拟机本身提供的,我们没有感知罢了。如果我们自己在内存堆上实现栈,手动模拟入栈、出栈过程,这样任何递归代码都可以改写成看上去不是递归代码的样子。
但是这种思路实际上是将递归改为了“手动”递归,本质并没有变,而且也并没有解决前面讲到的某些问题,徒增了实现的复杂度。
解答开篇
long findRootReferrerId(long actorId) {
Long referrerId = select referrer_id from [table] where actor_id = actorId;
if (referrerId == null) return actorId;
return findRootReferrerId(referrerId);
}
但在实际开发中,这种代码并不能工作。因为:
第一,如果递归很深,可能会有堆栈溢出的问题。
第二,如果数据库里存在脏数据,我们还需要处理由此产生的无限递归问题。比如 demo 环境下数据库中,测试工程师为了方便测试,会人为地插入一些数据,就会出现脏数据。如果 A 的推荐人是 B,B 的推荐人是 C,C 的推荐人是 A,这样就会发生死循环。
所以上面的代码还需要优化。以后会讲。
总结
递归的优点是代码简洁。
缺点是难理解、难写,函数调用耗时多、空间复杂度高。同时要避免出现堆栈溢出、重复计算等问题。
思考
使用 IDE 的单步跟踪功能,像规模比较大、递归层次很深的递归代码,几乎无法使用这种调试方式。对于递归代码,有什么好的调试方法呢?
回答:
1.打印日志发现,递归值。(拿到递归值,输入条件断点)
2.结合条件断点进行调试