测试开发基础之算法(7): 如何编写递归代码

递归其实一点不神秘,在日常生活中具有广泛的应用。
比如,你想打听小D同学的地址,但是你不认识小D,但认识小A,只能向小A打听小D同学的地址,但是小A也不认识小D,但认识小B,只能向小B打听小D同学的地址,同样悲剧的是,小B不认识小D,但认识小C,只能向小C打听小D同学的地址。正好小C认识小D,问到了小D的地址,就将小D的地址告诉了小B,小B又将地址告诉了小A,小A将地址告诉你了。我们把打听地址的动作叫做“递”,把反馈地址的动作叫做“归”。整个过程可以用下面的图来表示:
在这里插入图片描述

1. 递归可解决哪类问题

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

  1. 原始问题的解可以分解为几个子问题的解
  2. 原始问题和子问题,只有数据规模的不同,求解思路完全一样
  3. 存在递归终止条件

那我们看看上面你问小D地址的问题,是否可以用递归来解决,也就是是否符合上面3个条件呢?

首先,你问小D的地址,可以分解为你问小A,小A问小B,小B问小C,小C问小D。

其次,你想得到小D的地址,需要经过3个人,而你问小A,小A问小B,小B问小C,小C问小D,只需要问一个人,数据规模不同

最后,终止条件是找到了小D,小D将地址告诉了小C。

2.写递归代码的套路

以斐波那契数列为例,菲波那切数列是后一个元素是前两个相邻元素的和。比如:# 1,1,2,3,5,8,13,21,34,55,…。那么我们如何得到第n个数是多少?

求第n个元素,可以先求出n-1和n-2个元素的值,然后再将这两个求和。最终终止条件是第1个元素和第2个元素都是1。

写递归代码的关键就是找到如何将大问题分解为小问题的规律,然后按照下面套路即可实现:

  1. 写出递推公式,
  2. 推敲终止条件

最后将递推公式和终止条件翻译成代码。

def fibonacci(n):
    if n < 1:  # 递归终止条件
        return 0
    if n in [1, 2]:  # 递归终止条件
        val = 1
        return val 
    return fibonacci(n - 1) + fibonacci(n - 2)  # 递归公式

不过递归的算法时间复杂度是非常高的。后面我们需要对其进行优化。这里重点理解递归公式和终止条件的写法。

3.递归方法求阶乘

再来看一个适合用递归方法解决的问题,求阶乘。中学时学过一个正整数的阶乘(factorial)是所有小于及等于该数的正整数的积,比如3的阶乘是3!= 123,4的阶乘是4!=1234,5的阶乘是5!=1234*5 。

所以,递归求解阶乘的套路,递归公式是,n的阶乘可以表示成n!=n*(n-1)!。终止条件是1!等于1。

翻译成代码就是:


def factorial(n):
    if n == 1:  # 终止条件
        return 1 
    return n * factorial(n - 1)  # 递归公式

通过前面两个例子,发现终止条件可能有1个或者多个。在求斐波那契数列时,终止条件包含三个:n0时,f(n)=0;n1时,f(n)=1;n2时,f(n)=1;而在求阶乘时,终止条件就一个n1时,f(n)=1。

4.递归方法求n个台阶的走法

再巩固一下递归代码编写的套路。

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

分析:

我们从第一步开始想,如果第一步跨1个台阶,问题就变成了n-1个台架有多少种走法。如果第一步跨2个台阶,问题就变成n-2个台阶有多少种走法。 我们把n-1个台阶的走法和n-2个台阶的走法求和,就是n个台阶的走法。用公式表示就是f(n)=f(n-1)+f(n-2)。这就是递归公式了。

再来看看终止条件,最后1个台阶就不需要再继续递归了,只有一种走法,就是f(1)=1。我们把这个放到递归公式里面看下,通过这个终止条件能否求出f(2),发现f(2)=f(1)+f(0),也就是仅知道f(1)是不能求出f(2)的,因此要么知道f(0)的值,或者直接将f(2)作为一个递归终止条件。f(0)表示0个台阶有几种走法,f(2)表示2个台阶有几种走法。明显,f(2)更容易理解一些。所以定为f(2)=2也是一个终止条件,表示最后2个台阶有两种走法,即一次跨1个台阶和一次跨2个台阶。

有了f(1)和f(2),就能求出f(3),进而求出f(n)了。转化成代码即是:

def walk(n):
    if n == 1:  # 递归终止条件
        return 1
    if n == 2:  # 递归终止条件
        return 2
    return walk(n - 1) + walk(n - 2)  # 递归公式

通过上面三个例子,基本上掌握了编写递归代码的套路。这里提个建议,对于阅读递归代码时,千万不要试图想清楚整个递和归的过程,一旦你的思路陷入递和归的过程,就会发现我们的脑容量就不够用了。

不管是编写递归还是阅读递归代码,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑搞清楚计算机每一步都是怎么执行的。

5.避免堆栈溢出和重复计算

编写递归代码时,我们会遇到很多问题,比较常见的一个就是堆栈溢出,而堆栈溢出会造成系统性崩溃,后果会非常严重。什么是堆栈溢出呢?

函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。

那么,如何避免出现堆栈溢出呢?

通常可以在代码中限制递归调用的最大深度的方式来解决这个问题。比如Python语言,限制了递归深度,当递归深度过高,则会抛出:
RecursionError: maximum recursion depth exceeded in comparison异常,防止系统性崩溃。

我们在代码中也可以自己设置递归的深度,比如限制n最大不能超过100,代码如下

def walk(n):
    if n == 1:
        return 1
    if n == 2:
        return 2
    if n > 100:
        raise RecursionError("recursion depth exceede 100")
    return walk(n - 1) + walk(n - 2)

除此之外,使用递归时还会出现重复计算的问题。什么意思?拿走台阶那个例子来说明。
比如计算6个台阶的走法f(6),过程如下图:
在这里插入图片描述
从图中,我们可以直观地看到,想要计算 f(5),需要先计算 f(4) 和 f(3),而计算 f(4) 还需要计算 f(3),因此,f(3) 就被计算了很多次,这就是重复计算问题。

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

修改下计算台阶走法的代码,解决重复计算的问题:

data = dict()  # 保存中间结果


def walk(n):
    if n == 1:
        return 1
    if n == 2:
        return 2
    if n > 100:
        raise RecursionError("recursion depth exceede 100")
    if n in data:  # 如果在中间结果中,则直接返回,不用进入递推公式再次计算
        print(n, data)
        return data[n]
    result = walk(n - 1) + walk(n - 2) 
    data[n] = result
    return result


print(walk(6))

6. 迭代循环代替递归

递归代码都可以改为迭代循环的非递归写法。比如上面台阶走法的递归代码可以改造成下面这种循环迭代的写法。

def walk_by_iteration(n):
    if n == 1:
        return 1
    if n == 2:
        return 2
    result = 0
    pre = 2
    prepre = 1
    for i in range(3, n + 1):
        result = pre + prepre
        prepre = pre
        pre = result
    return result


print(walk_by_iteration(6))

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

7.leetcode练习题:各位相加

给定一个非负整数 num,反复将各个位上的数字相加,直到结果为一位数。
示例:
输入: 38
输出: 2
解释: 各位相加的过程为:3 + 8 = 11, 1 + 1 = 2。 由于 2 是一位数,所以返回 2。
进阶:
你可以不使用循环或者递归,且在 O(1) 时间复杂度内解决这个问题吗?
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/add-digits

根据要求,求一个数各个位之和,直到和为个位数,即 n=a* 100+b* 10+c,求f(n)=a+b+c,
如果f(n)<10,则return f(n),否则return f(f(n));以此类推。递归终止条件是f(n)<10,递归公式是f(f(n))。递归代码是:

def get_sum(num: int) -> int:
    if num < 10:  # 递归终止条件
        return num
    return get_sum(get_sum(num // 10) + (num % 10))  # 递归公式

不过这道题,利用递归公式,算法的时间复杂度比较高,是O(n),这个问题还可以归纳法,得到一个复杂度为O(1)的算法。
当数字为0-9时,结果为它本身,
当数字大于9,且为9的倍数时,结果为9,
当数字大于9,且不为9的倍数时,结果为该数mod 9 的余数。

def get_sum(num: int) -> int:
    if num > 9:
        num = num % 9
        if num == 0:
            return 9
    return num

8. 总结

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

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

参考文献

  1. https://www.cnblogs.com/schut/p/10625111.html
  2. https://juejin.im/post/5d85cda3f265da03b638e918
  3. https://time.geekbang.org/column/intro/126?code=vYLsf%2F9ydTb8LyFk-UikatPQjcI-4FecJoiTMxlIwSU%3D&utm_term=SPoster
发布了187 篇原创文章 · 获赞 270 · 访问量 172万+

猜你喜欢

转载自blog.csdn.net/liuchunming033/article/details/103392950