递归其实一点不神秘,在日常生活中具有广泛的应用。
比如,你想打听小D同学的地址,但是你不认识小D,但认识小A,只能向小A打听小D同学的地址,但是小A也不认识小D,但认识小B,只能向小B打听小D同学的地址,同样悲剧的是,小B不认识小D,但认识小C,只能向小C打听小D同学的地址。正好小C认识小D,问到了小D的地址,就将小D的地址告诉了小B,小B又将地址告诉了小A,小A将地址告诉你了。我们把打听地址的动作叫做“递”,把反馈地址的动作叫做“归”。整个过程可以用下面的图来表示:
1. 递归可解决哪类问题
满足以下三个条件,就可以用递归来解决。
- 原始问题的解可以分解为几个子问题的解
- 原始问题和子问题,只有数据规模的不同,求解思路完全一样
- 存在递归终止条件
那我们看看上面你问小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。
写递归代码的关键就是找到如何将大问题分解为小问题的规律,然后按照下面套路即可实现:
- 写出递推公式,
- 推敲终止条件
最后将递推公式和终止条件翻译成代码。
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. 总结
递归有利有弊,利是递归代码的表达力很强,写起来非常简洁;而弊就是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题。所以,在开发过程中,我们要根据实际情况来选择是否需要用递归的方式来实现。
递归代码虽然简洁高效,但是,递归代码也有很多弊端。比如,堆栈溢出、重复计算、函数调用耗时多、空间复杂度高等,所以,在编写递归代码的时候,一定要控制好这些副作用。
参考文献
- https://www.cnblogs.com/schut/p/10625111.html
- https://juejin.im/post/5d85cda3f265da03b638e918
- https://time.geekbang.org/column/intro/126?code=vYLsf%2F9ydTb8LyFk-UikatPQjcI-4FecJoiTMxlIwSU%3D&utm_term=SPoster