递归
递归是一种常见的算法,指函数直接或间接地调用自身。递归可以解决的问题通常可以被分解为相似的子问题。这些子问题的结构与原始问题相似,但规模较小。 在数学学习中,归纳法是一种常见的方法,递归可以看作是归纳法的程序化实现。递归的策略是把一个大的复杂的问题转换为一个小的和原问题相似的问题,再进一步把问题拆成更小的问题,最终解决整个问题。理论上,所有递归调用都可以用循环结构替代。但在某些情况下,递归调用确实能显著降低代码的复杂性,有助于缩短编程时间、提高程序可读性。所以,学会如何实现递归还是很有用的。
计算阶乘
下面看一个最简单的例子:计算阶乘。计算某个正整数的阶乘就是用那个数去乘所有比它小的正整数。比如 3 的阶乘写作 3! = 3*2*1
;类似的 6! = 6*5*4*3*2*1
。如果用循环来计算 n 的阶乘,就是把所有小于 n 的正整数乘起来就行了:
def factorial(n):
result = 1
for i in range(1, n+1):
result *= i
return result
print(factorial(5)) # 输出:120
但是我们还可以换一种方法来考虑如何计算阶乘:我们不是直接从原始的数据开始计算,而是用归纳的方法,一步一步地来简化问题,比如 6 的阶乘,它可以通过先计算 5 的 阶乘,再把结果乘以 6。用公式来描述就是 ,或者写成函数的形式为:。数学归纳法中一定要有一个基本情况,对于阶乘的问题,基本情况是 0,0 的阶乘等于 1,这是人为定义出来的。有的读者可能已经注意到了,用归纳法表示的阶乘与直接计算阶乘不是完全一模一样,归纳法把阶乘计算推广到了所有非负整数,而直接计算,只能计算正整数的阶乘。这算是归纳法的一个小优势吧。
以下是使用递归实现的阶乘函数:
def factorial(n):
# 基线条件
if n == 0:
return 1
# 递归条件
else:
return n * factorial(n-1)
print(factorial(5)) # 输出:120
设计递归算法的时候,一定要明确两个条件:
- 基线条件(Base Case):每个递归函数都需要有一个或多个基线条件。当满足基线条件时,函数将直接返回一个值而不再递归调用自身。
- 递归条件(Recursive Case):这是函数将继续调用自己的部分,通常在处理规模减小或更接近基线条件的问题时。
在上面例子中,当 n 为 0 时,函数直接返回 1 (基线条件)。否则,函数会递归地调用自己来计算n-1的阶乘,并将结果与n相乘。
计算斐波纳契数列
阶乘这个问题过于简单,还不能完全体现递归的优势。我们可以再考虑一个稍微更复杂一点的问题:斐波纳契数列。 这个问题是意大利人斐波纳契在描述兔子繁殖的数量时用到的:
- 第一个月初有一对刚诞生的兔子
- 第二个月之后(第三个月初)它们可以生育
- 每月每对可生育的兔子会诞生下一对新兔子
- 兔子永不死去
每个月兔子的总数量用数学归纳法来描述就是:
这个问题的数学归纳法公式简洁明了,非常适合用递归算法来解决。首先,还先编写归纳法的基本情况,也就是递归的结束条件,当输入为 0 和 1 时,分别输出 0 和 1:
def fibonacci(n):
if n <= 1:
return n
else:
return fibonacci(n - 1) + fibonacci(n - 2)
# 测试
n = 10
for i in range(n):
print(fibonacci(i), end=" ")
运行上面的代码将打印斐波纳契数列的前10个数字:0, 1, 1, 2, 3, 5, 8, 13, 21, 34。 读者可以用一个不大于 20 的输入数据试验一下,看结果是否正确。但是千万不要尝试太大的输入数据(大于 30 ),因为上面这个算法,它的功能上虽然正确,效率上却存在巨大缺陷,当输入数据大于 30 的时候,它的运算会非常非常耗时。我们分析一下上图程序的运行过程,假如输入数值为 20,程序会递归调用两次,分别计算两个子问题:
- 首先计算 19 的斐波纳契数,在其内部,程序又会进行两次递归调用,一次去计算 18 的斐波纳契数……;
- 然后计算 18 的斐波纳契数,可是 18 的斐波纳契数已经在上面那个递归调用的分支里计算过了啊,现在又要重复计算一遍!
我们可以把上述的调用关系用一棵二叉树来表示:
这样的算法,输入值每增加 1,程序的计算量就要翻倍。也就是程序的运算量与输入数据的大小呈指数关系,时间复杂度: