斐波那契序列(Fibonacci sequence)是一系列数字,其中除第 1 个和第 2 个数字之外,其他数字都是前两个数字之和: 0, 1, 1, 2, 3, 5, 8, 13, 21, …
这是一个典型的递归问题,递归问题有两个关键,一是递归式,二是基线条件。
就斐波那契函数而言,天然存在两个基线条件,第 1 个斐波那契数是 0。第 2 个斐波那契数是 1。后续任一斐波那契数 n 的值可用以下公式求得:fib(n) = fib(n − 1) + fib(n − 2)
def fib2(n: int) -> int:
if n < 2: # base case
return n
return fib2(n - 2) + fib2(n - 1) # recursive case
if __name__ == "__main__":
print(fib2(5))
print(fib2(10))
不妨来数一下(如果加入几次打印函数调用即可看明白),仅为了计算第 4 个元素就需要调用 9 次 fib2()
!情况会越来越糟糕,计算第 5 个元素需要调用 15 次,计算第 10 个元素需要调用 117 次,计算第 20 个元素需要调用 21891 次。我们应该能改善这种情况。
结果缓存(memoization)是一种缓存技术,即在每次计算任务完成后就把结果保存起来,这样在下次需要时即可直接检索出结果,而不需要一而再再而三地重复计算。
from typing import Dict
memo: Dict[int, int] = {0: 0, 1: 1} # our base cases
def fib3(n: int) -> int:
if n not in memo:
memo[n] = fib3(n - 1) + fib3(n - 2) # memoization
return memo[n]
if __name__ == "__main__":
print(fib3(5))
print(fib3(50))
现在一次调用 fib3(20)
只会产生 39 次 fib3()
调用,而不会像调用 fib2(20)
那样产生 21891 次 fib2()
调用。memo 中预填了之前的基线条件 0 和 1,并加了一条 if
语句大幅降低了 fib3()
的计算复杂度。
还可以对 fib3()
做进一步的简化。Python 自带了一个内置的装饰器(decorator),可以自动为任何函数缓存结果。在 fib4()
中,装饰器 @functools.lru_cache()
所用的代码与 fib2()
中所用的代码完全相同。每次用新的参数执行 fib4()
时,该装饰器就会把返回值缓存起来。以后再用相同的参数调用 fib4()
时,都会从缓存中读取该参数对应的 fib4()
之前的返回值并返回。
from functools import lru_cache
@lru_cache(maxsize=None)
def fib4(n: int) -> int:
if n < 2:
return n
return fib4(n - 2) + fib4(n - 1)
if __name__ == "__main__":
print(fib4(5))
print(fib4(50))
还有一种性能更好的做法,即可以用老式的迭代法来解决斐波那契问题,如代码所示。
def fib5(n: int) -> int:
if n == 0: return n
last: int = 0
next: int = 1
for _ in range(1, n):
last, next = next, last + next
return next
if __name__ == "__main__":
print(fib5(5))
print(fib5(50))
以上方案中,for
循环体最多会运行 n-1 次。换句话说,这是效率最高的版本。为了计算第 20 个斐波那契数,这里的 for
循环体只运行了 19 次,而 fib2()
则需要 21891 次递归调用。对现实世界中的应用程序而言,这种强烈的反差将会造成巨大的差异!
递归解决方案是反向求解,而迭代解决方案则是正向求解。有时递归是最直观的问题解决方案。例如,fib1()
和 fib2()
的函数体几乎就是原始斐波那契公式的机械式转换。然而直观的递归解决方案也可能伴随着巨大的性能损耗。请记住,能用递归方式求解的问题也都能用迭代方式来求解。
到目前为止,已完成的这些函数都只能输出斐波那契序列中的单个值。如果要将到某个值之前的整个序列输出,又该怎么做呢?用 yield
语句很容易就能把 fib5()
转换为 Python 生成器。在对生成器进行迭代时,每轮迭代都会用 yield
语句从斐波那契序列中吐出一个值:
from typing import Generator
def fib6(n: int) -> Generator[int, None, None]:
yield 0
if n > 0: yield 1
last: int = 0
next: int = 1
for _ in range(1, n):
last, next = next, last + next
yield next # main generation step
if __name__ == "__main__":
for i in fib6(50):
print(i)
运行 fib6.py
将会打印出斐波那契序列的前 51 个数。for
循环 for i in fib6(50):
每一次迭代时,fib6()
都会一路运行至某条 yield
语句。如果直到函数的末尾也没遇到 yield
语句,循环就会结束迭代。