10.递归:如何用三行代码找到“最终推荐人”?
markdown文件已上传至github
推荐注册佣金这个功能大家应该都不陌生吧。用户A推荐用户B来注册,B推荐C注册。这里,用户B和用户C的最终“推荐人”都为用户A,用户A没有最终“最终推荐人”。
可以用数据库来记录这种推荐关系。在数据库表中,我们可以记录两行数据,actor_id表示用户ID,referrer_id表示推荐人ID。
**给定一个用户ID,如何查找这个用户的“最终推荐人”?
解决这个问题,可以用递归。
1.如何理解递归?
周末带着女朋友去看电影,女朋友问你咱们现在坐在第几排?电影院太黑,看不清,没法数。怎么办?
递归的思想:你可以问前面的人他在第几排,只要在它的数字上加一就知道自己在哪一排了。前一排的人也不知道自己在第几排,他也问他前一排的人。就这样一排一排往前问,直到问到第一排的人,说他在第一排,然后在这样一排一排再把数字传回来。于是你就知道自己在第几排了。
这就是一个递归分解问题求解的过程,去的过程叫做“递”,回的过程叫做“归’’。
所有的递归问题都可以用递归公式来表示。
表示你想知道自己在第几排, 表示前一排所在排数, 表示第一排的人知道自己在第一排。
有了递归公式就容易写出代码了:
int f(int n) {
if (n == 1) return 1;
return f(n-1) + 1;
}
2. 递归需要满足的条件。
只要满足下列三个条件,就可以用递归来解决。
1.一个问题的解可以分解为几个子问题的解。
子问题:数据规模更小的问题。
2.这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样。
3.存在递归终止条件。
把问题分解为子问题,子问题再分解为子问题,终止条件就是分解到某个子问题时,该子问题的解已经知道。
3.如何编写递归代码?
关键是写出递归公式,找到终止条件。
例:
假如有n个台阶,每次你可以跨1个台阶或者两个台阶,请问走完这n个台阶有多少种走法?
实际上,我们可以根据第一步的走法把所有走法分为两类:第一类是第一步走了一个台阶,第二类是第一步走了两个台阶。所以用公式表示为:
接下来看终止条件:当只剩一个台阶了,只有一种走法, 。这个终止条件够吗?
我们用n=2,n=3这样比较小的数试验一下。
n=2时, 。如果只有上面一个终止条件, 就无法求解了,还要有f(0)=1,表示0个台阶有一种走法,但是这样看起来就不符合正常的逻辑思维了。所以把 作为一种终止条件。
所得到的递归公式和递归条件如下:
所以可以写出代码:
int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
return f(n-1) + f(n-2);
}
写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤
3.1递归代码要警惕堆栈溢出。
堆栈溢出会造成系统性崩溃。
在“栈”这节讲过,函数调用会使用栈来保存临时变量,每调用一个函数都会将临时变量封装为栈帧压入内存栈,等函数执行完成时才出栈。系统栈或者虚拟机栈空间一般都不大,如果递归求解的数据规模很大,调用层次很深,一直压入栈就会有堆栈溢出的风险。
比如前面的讲到的电影院的例子,如果我们将系统栈或者 JVM 堆栈大小设置为 1KB,在求解 f(19999) 时便会出现如下堆栈报错:
Exception in thread "main" java.lang.StackOverflowError
如何避免出现堆栈溢出?
可以在代码中限制递归调用的最大深度来解决这个问题,递归调用超过这个深度后就不往下再递归了,直接返回报错。如下方代码:
// 全局变量,表示递归的深度。
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,就可以用这种方法,否则这种方法并不是很实用。规模大了,自己模拟一个栈,用非递归代码实现。
3.2递归代码要警惕重复计算
上台阶的例子把整个递归过程分解一下,如下:
从图中可以看到 被计算了很多次。
为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 。当递归调用到 时,先看下是否已经求解过了,如果是,直接从散列表中取值返回。代码如下:
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 hasSolvedList.get(n);
}
int ret = f(n-1) + f(n-2);
hasSolvedList.put(n, ret);
return ret;
}
3.3怎么将递归代码改写为非递归代码?
递归有利有弊,利是递归代码表达力很强,写起来很简洁;弊就是空间复杂度高,有堆栈溢出的风险,存在重复计算,过多的函数调用会耗时较多等问题。
将递归代码改写为非递归代码。
将 的递归代码改写为非递归代码如下:
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 (int i = 3; i <= n; ++i) {
ret = pre + prepre;
prepre = pre;
pre = ret;
}
return ret;
}
是不是所有的递归代码都可以改为这种迭代循环的非递归写法?
笼统地讲,是的。因为递归本身就是借助栈来实现的,只不过我们使用的栈是系统或者虚拟机本身提供的,我们没有感知罢了。如果我们自己在内存堆上实现栈,手动模拟入栈、出栈过程,这样任何递归代码都可以改写成看上去不是递归代码的样子。但是这种思路实际上是将递归改为了“手动”递归,本质并没有变,而且也并没有解决前面讲到的某些问题,徒增了实现的复杂度。
4.解答开篇
如何找到“最终推荐人”?
long findRootReferrerId(long actorId) {
Long referrerId = select referrer_id from [table] where actor_id = actorId;
if (referrerId == null) return actorId;
return findRootReferrerId(referrerId);
}
代码非常简洁,几行就搞定了。但是在实际项目中,这段代码并不能工作。因为:
1.如果递归很深,可能会有堆栈溢出的问题。(限制递归深度来解决)
2。如果数据库里存在脏数据,我们还需要处理由此产生的无限递归问题。(可以用限制递归深度来解决,还有更高级的做法,即检测"环"的存在。
递归代码虽然简洁高效,但是,递归代码也有很多弊端。比如,堆栈溢出、重复计算、函数调用耗时多、空间复杂度高等,所以,在编写递归代码的时候,一定要控制好这些副作用。
5.课后思考
我们平时调试代码喜欢使用 IDE 的单步跟踪功能,像规模比较大、递归层次很深的递归代码,几乎无法使用这种调试方式。对于递归代码,你有什么好的调试方法呢
方法:
1.打印日志发现,递归值。
2.结合条件断点进行调试。
6.参考
这个是我学习王争老师的《数据结构与算法之美》所做的笔记,王争老师是前谷歌工程师,该课程截止到目前已有87244人付费学习,质量不用多说。
截取了课程部分目录,课程结合实际应用场景,从概念开始层层剖析,由浅入深进行讲解。本人之前也学过许多数据结构与算法的课程,唯独王争老师的课给我一种茅塞顿开的感觉,强烈推荐大家购买学习。课程二维码我已放置在下方,大家想买的话可以扫码购买。
本人做的笔记并不全面,推荐大家扫码购买课程进行学习,而且课程非常便宜,学完后必有很大提高。