数据结构与算法——递归篇

35 阅读4分钟

一、🐶 递归的定义

  • 1、一个问题可以分解为几个子问题的解
  • 2、这个问题的求解思路与子问题的求解思路一致
  • 3、存在递归终止条件

二、🐶 递归的编写方法

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

1、写出递推公式

可以根据第一步的走法把所有走法分为两类,

  • 第一类是第一步走了 1 个台阶,
  • 另一类是第一步走了 2 个台阶。
  • 所以 n 个台阶的走法就等于先走 1 阶后,n-1 个台阶的走法 加上先走 2 阶后,n-2 个台阶的走法。用公式表示就是:
f(n) = f(n-1)+f(n-2)

这个就是我们的递推公式。

2、寻找终止条件

  • 当有一个台阶时,我们不需要再继续递归,就只有一种走法。所以 f(1)=1。
  • 当有2个台阶时,有2种走法,2个1步或者1个2步,所以 f(2)=2。 递归终止条件就是 f(1)=1,f(2)=2。

3、将递推公式和终止条件翻译成代码

我们把递归终止条件和刚刚得到的递推公式放到一起就是这样的:

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

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

三、🐶 递归注意事项

1、堆栈溢出问题

堆栈溢出会造成系统崩溃,为什么递归容易造成对战堆栈问题呢,我们又该如何避免?

  • 函数调用会使用栈来保存临时变量,每调用一个函数,都会将临时变量封装为一个栈帧压入栈中,等函数执行完返回时再出栈,系统栈或者虚拟机空间一般不大,如果递归求解的数据规模很大,调用层次很深,一直压入栈就会有堆栈溢出的风险。
  • 我们可以通过在代码中限制递归调用最大深度来解决这个问题,递归调用超过一定深度时我们就不往下递归了,直接返回报错,代码如下
// 全局变量,表示递归的深度。
int depth = 0;

function f(n) {
    ++depth; 
    if (depth > 1000) throw exception;
    
    if (n == 1) return 1; 
    if (n == 2) return 2; 
    return f(n-1) + f(n-2);
}

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

2、重复计算问题

为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。代码如下:

function f(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); 
    }
    let ret = f(n-1) + f(n-2);
    hasSolvedList.put(n, ret);
    return ret;
}

3、时间和空间复杂度

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

四、🐶 总结

  • 递归是一种简洁高效的编码编码技巧。
  • 编写递归代码的关键是找到递推公式和终止条件。
  • 递归代码存在堆栈溢出、重复计算、时间和空间复杂度等问题。
  • 可以通过限制递归深度、使用数据结构、改写为迭代循环等方式解决问题。