10.如何写递归代码

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

markdown文件已上传至github

推荐注册佣金这个功能大家应该都不陌生吧。用户A推荐用户B来注册,B推荐C注册。这里,用户B和用户C的最终“推荐人”都为用户A,用户A没有最终“最终推荐人”。

可以用数据库来记录这种推荐关系。在数据库表中,我们可以记录两行数据,actor_id表示用户ID,referrer_id表示推荐人ID。

img

**给定一个用户ID,如何查找这个用户的“最终推荐人”?

解决这个问题,可以用递归。

1.如何理解递归?

周末带着女朋友去看电影,女朋友问你咱们现在坐在第几排?电影院太黑,看不清,没法数。怎么办?

递归的思想:你可以问前面的人他在第几排,只要在它的数字上加一就知道自己在哪一排了。前一排的人也不知道自己在第几排,他也问他前一排的人。就这样一排一排往前问,直到问到第一排的人,说他在第一排,然后在这样一排一排再把数字传回来。于是你就知道自己在第几排了。

这就是一个递归分解问题求解的过程,去的过程叫做“递”,回的过程叫做“归’’。

所有的递归问题都可以用递归公式来表示。
f ( n ) = f ( n 1 ) + 1   f ( 1 ) = 1 f(n) = f(n-1) +1 \space 其中,f(1)=1

f ( n ) f(n) 表示你想知道自己在第几排, f ( n 1 ) f(n-1) 表示前一排所在排数, f ( 1 ) = 1 f(1)=1 表示第一排的人知道自己在第一排。

有了递归公式就容易写出代码了:

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

2. 递归需要满足的条件。

只要满足下列三个条件,就可以用递归来解决。

1.一个问题的解可以分解为几个子问题的解。

​ 子问题:数据规模更小的问题。

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

3.存在递归终止条件。

把问题分解为子问题,子问题再分解为子问题,终止条件就是分解到某个子问题时,该子问题的解已经知道。

3.如何编写递归代码?

关键是写出递归公式,找到终止条件。

例:

假如有n个台阶,每次你可以跨1个台阶或者两个台阶,请问走完这n个台阶有多少种走法?

实际上,我们可以根据第一步的走法把所有走法分为两类:第一类是第一步走了一个台阶,第二类是第一步走了两个台阶。所以用公式表示为: f ( n ) = f ( n 1 ) + f ( n 2 ) f(n)=f(n-1)+f(n-2)

接下来看终止条件:当只剩一个台阶了,只有一种走法, f ( 1 ) = 1 f(1)=1 。这个终止条件够吗?

我们用n=2,n=3这样比较小的数试验一下。

n=2时, f ( 2 ) = f ( 1 ) + f ( 0 ) f(2)=f(1)+f(0) 。如果只有上面一个终止条件, f ( 2 ) f(2) 就无法求解了,还要有f(0)=1,表示0个台阶有一种走法,但是这样看起来就不符合正常的逻辑思维了。所以把 f ( 2 ) = 2 f(2)=2 作为一种终止条件。

所得到的递归公式和递归条件如下:
f ( 1 ) = 1 ; f ( 2 ) = 2 ; f ( n ) = f ( n 1 ) + f ( n 2 ) f(1)=1;\\ f(2)=2; \\ f(n)=f(n-1)+f(n-2)
所以可以写出代码:

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递归代码要警惕重复计算

上台阶的例子把整个递归过程分解一下,如下:

img

从图中可以看到 f ( 3 ) f(3) 被计算了很多次。

为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 f ( k ) f(k) 。当递归调用到 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 hasSolvedList.get(n);
  }
  
  int ret = f(n-1) + f(n-2);
  hasSolvedList.put(n, ret);
  return ret;
}

3.3怎么将递归代码改写为非递归代码?

递归有利有弊,利是递归代码表达力很强,写起来很简洁;弊就是空间复杂度高,有堆栈溢出的风险,存在重复计算,过多的函数调用会耗时较多等问题。

将递归代码改写为非递归代码。

f ( x ) = f ( x 1 ) + 1 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 (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人付费学习,质量不用多说。

在这里插入图片描述

截取了课程部分目录,课程结合实际应用场景,从概念开始层层剖析,由浅入深进行讲解。本人之前也学过许多数据结构与算法的课程,唯独王争老师的课给我一种茅塞顿开的感觉,强烈推荐大家购买学习。课程二维码我已放置在下方,大家想买的话可以扫码购买。

在这里插入图片描述

本人做的笔记并不全面,推荐大家扫码购买课程进行学习,而且课程非常便宜,学完后必有很大提高。

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/supreme_1/article/details/107713007