递归概念

去的过程(求解过程)叫“递”,回来的过程(返回结果)叫“归”
递归需要满足的三个条件

  1. 一个问题的解可以分解为几个子问题的解
  2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
  3. 存在递归终止条件

编写递归代码关键是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。如果一个问题 A 可以分解为若干子问题 B、C、D,可以假设子问题 B、C、D 已经解决,在此基础上思考如何解决问题 A。而且只需要思考问题 A 与子问题 B、C、D 两层之间的关系即可,不需要一层一层往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归细节,理解起来就简单多了。

递归问题

警惕堆栈溢出

在代码中限制递归调用的最大深度的方式来解决这个问题

  1. // 全局变量,表示递归的深度。
  2. int depth = 0;
  3. int f(int n) {
  4. ++depth
  5. if (depth > 1000) throw exception; // 递归调用超过一定深度(比如 1000)之后,不继续往下递归了,直接返回报错。
  6. if (n == 1) return 1;
  7. return f(n-1) + 1;
  8. }

但这种做法并不能完全解决问题,因为最大允许的递归深度跟当前线程剩余的栈空间大小有关,事先无法计算。如果实时计算,代码过于复杂,就会影响代码的可读性。所以,如果最大深度比较小,比如 10、50,就可以用这种方法,否则这种方法并不是很实用。

警惕重复计算

image.png
为了避免重复计算,可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算。

  1. public int f(int n) {
  2. if (n == 1) return 1;
  3. if (n == 2) return 2;
  4. // hasSolvedList 可以理解成一个 Map,key 是 n,value 是 f(n)
  5. if (hasSolvedList.containsKey(n)) {
  6. return hasSovledList.get(n);
  7. }
  8. int ret = f(n-1) + f(n-2);
  9. hasSovledList.put(n, ret);
  10. return ret;
  11. }

复杂度高

在时间复杂度上,递归代码里多了很多函数调用,当这些函数调用的数量较大时,就会积聚成一个可观的时间成本。在空间复杂度上,因为递归调用一次就会在内存栈中保存一次现场数据,所以在分析递归代码空间复杂度时,需要额外考虑这部分的开销。

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

笼统地讲,所有的递归代码都可以改为迭代循环的非递归写法。递归本身就是借助栈来实现的,只不过使用的栈是系统或者虚拟机本身提供的,无法感知罢了。如果自己在内存堆上实现栈,手动模拟入栈、出栈过程,这样任何递归代码都可以改写成看上去不是递归代码的样子。但是这种思路实际上是将递归改为了“手动”递归,本质并没有变,而且也并没有解决递归的问题,徒增了实现的复杂度

递归应用

DFS 深度优先搜索

前中后序二叉树遍历