10 | 递归:如何用三行代码找到“最终推荐人”?

Page content

[TOC]

2019-03-02-003

推荐注册返佣金这个功能一般来说不陌生吧?用户A推荐用户B来注册,用户B又推荐了用户C来注册。我们可以说C的“最终推荐人”为用户A,B的“最终推荐人”也为用户A,而用户A没有“最终推荐人”。

2019-03-02-001

一般来说,我们会通过数据库来记录这种推荐关系。在数据库表中,我们可以记录两行数据,其中actor_id,表示用户id,referrer_id表示推荐人。

基于这个场景,我们的问题是,给定一个用户ID,如何查找这个用户的“最终推荐人”?

如何理解“递归”?

数据结构和算法的知识点,其中最难理解的一个是动态规划,另一个就是递归。

**递归是一种应用非常广泛的算法(编程技巧)**很多数据结构和算法的编码实现都要用到递归,比如DFS深度优先搜索,前中后序二叉树遍历等等。

生活中的例子

陪女朋友去影院看电影,里面太黑,问前面的人在第几排,前面的人也不知道,他也问他前面的人。

递归需要满足三个条件

1、一个问题解可以分解成几个子问题的解

2、这个问题与分解后的子问题,处了数据规模不同,求解思路完全一样。

3、存在递归终止条件

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

如何编写递归代码

写递归代码的关键是写出递推公式,找到终止条件,剩下将递推公式转化为代码就很简单了。

假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?如果有 7 个台阶,你可以 2,2,2,1 这样子上去,也可以 1,2,1,1,2 这样子上去,总之走法有很多,那如何用编程求得总共有多少种走法呢?

f(n) = f(n-1)+f(n-2)

终止条件:f(1) = 1,f(2) = 2.

递推公式:f(n) = f(n-1) + f(n-2)

**我对台阶问题的理解是:到达n阶只可能来自n-1和n-2,所以f(n)=f(n-1)+f(n-2)**

代码实现:

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

**写递归代码的关键就是找到如何将大问题分解成小问题的规律,并且基于此写出递推公司,然后在推敲终止条件,最后将递推公司和终止条件翻译成代码。**

递归:**去的过程叫“递”,回来的过程叫“归”。**

人脑几乎没有办法将整个“递” “归”过程的一步一步想清楚。

计算机擅长做重复的事,所以递归正和他的胃口。而我们人脑更喜欢平铺直叙的思维方式。当我们看到递归时,我们总想把递归平铺展开,脑子里就会循环,一层一层往下调,然后再一层一层返回,试图想清楚计算机每一步是怎么执行的,这样很容易被绕进去。

对于递归代码,这种试图想清楚整个递和归过程的做法,实际上进入了一个思维误区。很多时候,我们理解起来比较吃力,主要原因是自己给自己制造了这种理解障碍。那正确的思维方式应该是怎么样的呢?

如果一个问题A可以分解成若干问题B、C、D,你可以假设问题B、C、D已经解决,在此基础上思考如何解决问题A。而且,只需要思考A与子问题B、C、D两层之间的关系即可,不需要一层一层思考子问题和子子问题,子子问题和子子子问题之间的关系。屏蔽掉递归细节,这样子理解起来就简单多了。

因此,**编写递归代码的关键是,只要遇到递推,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。**

递归警惕的问题

递归代码要警惕堆栈溢出

可以限制最大允许的递归深度。

递归代码要警惕重复计算

2019-03-02-002

可以使用散列表保存已经求解过的f(k)

其他问题

在时间效率上,递归代码里多了很多函数调用,当这些函数调用的数量较大时,就会积聚成一个可观的时间成本。在空间复杂度上,因为递归调用一次就会在内存栈中保存一次现场数据,所以在分析递归代码空间复杂度时,需要额外考虑这部分的开销,比如我们前面讲到的电影院递归代码,空间复杂度并不是 O(1),而是 O(n)。

怎么将递归代码改写成非递归代码?

递归有利有弊,利是递归代码的表达力很强,写起来非常简洁;弊是空间复杂度高,有堆栈溢出的分享、存在重复计算、过多的函数调用会耗时较多等问题。所以,在开发过程中,我们要根据实际情况来选择是否需要用递归的方式来实现。

将上面的例子改成非递归代码

int f(n){
	int ret = 1;
	for (int i = 2; i <=n ; ++i) {
		ret = ret + 1;
	}
	return ret;
}
int f(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;
}

那是不是多有的递归代码都可以改为这种迭代循环的非递归写法呢?

笼统的讲,是的。因为递归本身就是借助栈来实现的,只不过我们使用的栈是系统或者虚拟机本身提供的,我们没有感知罢了。如果我们自己在内存堆上实现栈,手动模拟入栈、出栈过程,这样任何递归代码都可以改写成看上去不是递归代码的样子。

但是这种思路实际上是将递归改为了“手动”递归,本质并没有变,而且也并没有解决前面讲到的某些问题,徒增了实现的复杂度。

解答开篇的问题

如何找到“最终推荐人”?

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,这样就会发生死循环。

第一个问题,我前面已经解答过了,可以用限制递归深度来解决。第二个问题,也可以用限制递归深度来解决。不过,还有一个更高级的处理方法,就是自动检测 A-B-C-A 这种“环”的存在。如何来检测环的存在呢?这个我暂时不细说,你可以自己思考下,后面的章节我们还会讲。

内容小节

递归是一种非常高效、简洁的编码技巧。只要是满足“三个条件”的问题就可以通过递归代码来解决。

递归代码有很多弊端,比如,堆栈溢出、重复计算、函数调用耗时多、空间复杂度高等。所以,在编写递归代码的时候,一定要控制好这些副作用。

课后思考

我们平时调试代码喜欢使用 IDE 的单步跟踪功能,像规模比较大、递归层次很深的递归代码,几乎无法使用这种调试方式。对于递归代码,你有什么好的调试方法呢?

调试递归:

1、 打印日志发现递归值。

2、 结合条件断点进行调试。

3、不太好的方法,打印递归深度

1:-10
2:--9
3:---8
4:----7
5:-----6
6:------5
7:-------4
8:--------3
9:---------2
10:----------1