- 算法精粹:经典计算机科学问题的Python实现
- (美) 大卫·科帕克(David Kopec)
- 2016字
- 2020-08-27 03:47:36
1.1 斐波那契序列
斐波那契序列(Fibonacci sequence)是一系列数字,其中除第1个和第2个数字之外,其他数字都是前两个数字之和:
0, 1, 1, 2, 3, 5, 8, 13, 21, …
在此序列中,第1个斐波那契数是0。第4个斐波那契数是2。后续任一斐波那契数n的值可用以下公式求得:
fib(n) = fib(n − 1) + fib(n − 2)
1.1.1 尝试第一次递归
上述计算斐波那契序列数(如图1-1所示)的公式是一种伪代码形式,可将其简单地转换为一个Python递归函数,如代码清单1-1和代码清单1-2所示。所谓递归函数是一种调用自己的函数。这次机械的转换将作为你编写函数的首次尝试,返回的是斐波那契序列中的给定数。
图1-1 每个火柴人的身高都是前两个火柴人身高之和
代码清单1-1 fib1.py
def fib1(n: int) -> int: return fib1(n - 1) + fib1(n - 2)
下面试着带上参数值来调用这个函数。
代码清单1-2 fib1.py(续)
if __name__ == "__main__": print(fib1(5))
若我们运行fib1.py,系统就会生成一条错误消息:
RecursionError: maximum recursion depth exceeded
这里有一个问题,fib1()将一直运行下去,而不会返回最终结果。每次调用fib1()都会再多调用两次fib1(),如此反复永无止境。这种情况被称为无限递归(如图1-2所示),类似于无限循环(infinite loop)。
图1-2 递归函数fib(n)带上参数n−2和n−1调用自己
1.1.2 基线条件的运用
请注意,在运行fib1()之前,Python运行环境不会有任何提示有错误存在。避免无限递归由程序员负责,而不由编译器或解释器负责。出现无限递归的原因是尚未指定基线条件(base case)。在递归函数中,基线条件即函数终止运行的时点。
就斐波那契函数而言,天然存在两个基线条件,其形式就是序列最开始的两个特殊数字0和1。0和1都不是由序列的前两个数求和得来的,而是序列最开始的两个特殊数字。那就试着将其设为基线条件吧,具体代码如代码清单1-3所示。
图1-3 每次非基线条件下的fib2()调用都会再生成两次fib2()调用
代码清单1-3 fib2.py
def fib2(n: int) -> int: if n < 2:# base case return n return fib2(n - 2) + fib2(n - 1)# recursive case
注意 斐波那契函数的fib2()版本将返回0作为第0个数(fib2(0)),而不是第一个数,这正符合我们的本意。这在编程时很有意义,因为大家已经习惯了序列从第0个元素开始。
fib2()能被调用成功并将返回正确的结果。可以用几个较小的数试着调用一下,具体代码如代码清单1-4所示。
代码清单1-4 fib2.py(续)
if __name__ == "__main__": print(fib2(5)) print(fib2(10))
请勿尝试调用fib2(50),因为它永远不会终止运行!每次调用fib2()都会再调用两次fib2(),方式就是递归调用fib2(n - 1)和fib2(n - 2)(如图1-3所示)。换句话说,这种树状调用结构将呈指数级增长。例如,调用fib2(4)将产生如下一整套调用:
fib2(4) -> fib2(3), fib2(2) fib2(3) -> fib2(2), fib2(1) fib2(2) -> fib2(1), fib2(0) fib2(2) -> fib2(1), fib2(0) fib2(1) -> 1 fib2(1) -> 1 fib2(1) -> 1 fib2(0) -> 0 fib2(0) -> 0
不妨来数一下(如果加入几次打印函数调用即可看明白),仅为了计算第4个元素就需要调用9次fib2()!情况会越来越糟糕,计算第5个元素需要调用15次,计算第10个元素需要调用117次,计算第20个元素需要调用21891次。我们应该能改善这种情况。
1.1.3 用结果缓存来救场
结果缓存(memoization)是一种缓存技术,即在每次计算任务完成后就把结果保存起来,这样在下次需要时即可直接检索出结果,而不需要一而再再而三地重复计算(如图1-4所示)。
图1-4 人类的记忆缓存
下面创建一个新版的斐波那契函数,利用Python的字典对象作为结果缓存,如代码清单1-5所示。
代码清单1-5 fib3.py
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]
现在就可以放心地调用fib3(50)了,如代码清单1-6所示。
代码清单1-6 fib3.py(续)
if __name__ == "__main__": print(fib3(5)) print(fib3(50))
现在一次调用fib3(20)只会产生39次fib3()调用,而不会像调用fib2(20)那样产生21891次fib2()调用。memo中预填了之前的基线条件0和1,并加了一条if语句大幅降低了fib3()的计算复杂度。
1.1.4 自动化的结果缓存
还可以对fib3()做进一步的简化。Python自带了一个内置的装饰器(decorator),可以自动为任何函数缓存结果。如代码清单1-7所示,在fib4()中,装饰器@functools.lru_cache()所用的代码与fib2()中所用的代码完全相同。每次用新的参数执行fib4()时,该装饰器就会把返回值缓存起来。以后再用相同的参数调用fib4()时,都会从缓存中读取该参数对应的fib4()之前的返回值并返回。
代码清单1-7 fib4.py
from functools import lru_cache @lru_cache(maxsize=None) def fib4(n: int) -> int:# same definition as fib2() if n < 2:# base case return n return fib4(n - 2) + fib4(n - 1)# recursive case if __name__ == "__main__": print(fib4(5)) print(fib4(50))
注意,虽然以上斐波那契函数体部分与fib2()中的函数体部分相同,但能立刻计算出fib4(50)的结果。@lru_cache的maxsize属性表示对所装饰的函数最多应该缓存多少次最近的调用结果,如果将其设置为None就表示没有限制。
1.1.5 简洁至上的斐波那契
还有一种性能更好的做法,即可以用老式的迭代法来解决斐波那契问题,如代码清单1-8所示。
代码清单1-8 fib5.py
def fib5(n: int) -> int: if n == 0: return n# special case last: int = 0# initially set to fib(0) next: int = 1# initially set to fib(1) for _ in range(1, n): last, next = next, last + next return next if __name__ == "__main__": print(fib5(5)) print(fib5(50))
警告 fib5()中的for循环体用到了元组(tuple)解包操作,或许这有点儿过于卖弄了。有些人可能会觉得这是为了简洁而牺牲了可读性,还有些人可能会发现简洁本身就更具可读性,这里的要领就是last被设置为next的上一个值,next被设置为last的上一个值加上next的上一个值。这样在last已更新而next未更新时,就不用创建临时变量以存储next的上一个值了。以这种形式使用元组解包来实现某种变量交换的做法在Python中十分常见。
以上方案中,for循环体最多会运行n-1次。换句话说,这是效率最高的版本。为了计算第20个斐波那契数,这里的for循环体只运行了19次,而fib2()则需要21891次递归调用。对现实世界中的应用程序而言,这种强烈的反差将会造成巨大的差异!
递归解决方案是反向求解,而迭代解决方案则是正向求解。有时递归是最直观的问题解决方案。例如,fib1()和fib2()的函数体几乎就是原始斐波那契公式的机械式转换。然而直观的递归解决方案也可能伴随着巨大的性能损耗。请记住,能用递归方式求解的问题也都能用迭代方式来求解。
1.1.6 用生成器生成斐波那契数
到目前为止,已完成的这些函数都只能输出斐波那契序列中的单个值。如果要将到某个值之前的整个序列输出,又该怎么做呢?用yield语句很容易就能把fib5()转换为Python生成器。在对生成器进行迭代时,每轮迭代都会用yield语句从斐波那契序列中吐出一个值,如代码清单1-9所示。
代码清单1-9 fib6.py
from typing import Generator def fib6(n: int) -> Generator[int, None, None]: yield 0# special case if n > 0: yield 1# special case last: int = 0# initially set to fib(0) next: int = 1# initially set to fib(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语句,循环就会结束迭代。