4. 数据结构与算法 —— 递归

380 阅读5分钟

递归需要满足的三个条件

什么样的问题可以用递归来解决呢?

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 的单步跟踪功能,像规模较大、递归层级深的递归代码,几乎无法使用这种方式。对于递归代码有什么好的调试方式呢?

第一,打印日志,打印对应的递归值。 第二,结合条件进行断点调试。