递归的方式和时间

当函数调用导致在原始函数调用终止之前再次调用相同的函数时,会发生递归。例如,考虑众所周知的数学表达式 x!(即因子运算)。为所有非负整数定义了阶乘操作,如下所示:

  • 如果数字为 0,则答案为 1。
  • 否则,答案是该数字乘以比该数字小 1 的阶乘。

在 Python 中,可以将因子操作的简单实现定义为如下函数:

def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n - 1)

递归函数有时很难掌握,所以让我们一步一步地逐步完成。考虑表达 factorial(3)。此和所有函数调用都会创建一个新环境。环境基本上只是将标识符(例如 nfactorialprint 等)映射到其对应值的表。在任何时间点,你都可以使用 locals() 访问当前环境。在第一个函数调用中,唯一定义的局部变量是 n = 3。因此,打印 locals() 会显示 {'n': 3}。从 n == 3 开始,返回值变为 n * factorial(n - 1)

在接下来的步骤中,事情可能会变得有些混乱。看看我们的新表达,我们已经知道什么是 n。但是,我们还不知道 factorial(n - 1) 是什么。首先,n - 1 评估为 2。然后,2 作为 n 的值传递给 factorial。由于这是一个新的函数调用,因此创建了第二个环境来存储这个新的 n。设 A 为第一环境, B 为第二环境。 A 仍然存在且等于 {'n': 3},然而, B (等于 {'n': 2})是当前环境。查看函数体,返回值又是 n * factorial(n - 1)。在不评估此表达式的情况下,让我们将其替换为原始的返回表达式。通过这样做,我们在精神上丢弃B,所以记得要相应地替代 n(即引用Bnn - 1 其使用取代A小号 n’)。现在,原始的返回表达式变为 n * ((n - 1) * factorial((n - 1) - 1))。花一点时间确保你明白为什么会这样。

现在,让我们评估一下这个部分。从 A ’s n == 3 开始,我们将 1 传递给 factorial。因此,我们正在创造一个新的环境 C ,它等于 {'n': 1}。同样,返回值是 n * factorial(n - 1)。因此,让我们替换原始返回表达式的 factorial((n - 1) - 1)),类似于我们之前调整原始返回表达式的方式。 原始表达现在是 n * ((n - 1) * ((n - 2) * factorial((n - 2) - 1)))

几乎完成了。现在,我们需要评估 factorial((n - 2) - 1)。这一次,我们正在传递 0。因此,这评估为 1。现在,让我们进行最后一次替换。 原始的回归表达现在是 n * ((n - 1) * ((n - 2) * 1))。回想一下原始返回表达式在 A 下计算,表达式变为 3 * ((3 - 1) * ((3 - 2) * 1))。当然,这评估为 6.要确认这是正确答案,请回忆一下 3! == 3 * 2 * 1 == 6。在进一步阅读之前,请确保你完全理解环境的概念以及它们如何应用于递归。

声明 if n == 0: return 1 被称为基本案例。这是因为它没有递归。绝对需要一个基本案例。没有一个,你将遇到无限递归。话虽如此,只要你至少有一个基本案例,你就可以拥有任意数量的案例。例如,我们可以按如下方式编写 factorial

def factorial(n):
    if n == 0:
        return 1
    elif n == 1:
        return 1
    else:
        return n * factorial(n - 1)

你可能还有多个递归案例,但我们不会涉及到这种情况,因为它相对不常见并且通常很难进行精神处理。

你还可以进行并行递归函数调用。例如,考虑 Fibonacci 序列 ,其定义如下:

  • 如果数字为 0,则答案为 0。
  • 如果数字是 1,那么答案是 1。
  • 否则,答案是前两个斐波纳契数的总和。

我们可以定义如下:

def fib(n):
    if n == 0 or n == 1:
        return n
    else:
        return fib(n - 2) + fib(n - 1)

我不会像使用 factorial(3) 那样彻底地完成这个功能,但是 fib(5) 的最终返回值等同于以下( 语法无效)表达式:

(
  fib((n - 2) - 2)
  +
  (
    fib(((n - 2) - 1) - 2)
    +
    fib(((n - 2) - 1) - 1)
  )
)
+
(
  (
    fib(((n - 1) - 2) - 2)
    +
    fib(((n - 1) - 2) - 1)
  )
  +
  (
    fib(((n - 1) - 1) - 2)
    +
    (
      fib((((n - 1) - 1) - 1) - 2)
      +
      fib((((n - 1) - 1) - 1) - 1)
    )
  )
)

这变成了 (1 + (0 + 1)) + ((0 + 1) + (1 + (0 + 1))),当然评估为 5

现在,我们将介绍一些词汇术语:

  • 一个尾呼叫是一个简单的递归函数调用,它是返回值之前要执行的最后一个操作。要清楚,return foo(n - 1) 是一个尾调用,但 return foo(n - 1) + 1 不是(因为添加是最后一个操作)。
  • 尾调用优化 (TCO)是一种自动减少递归函数递归的方法。
  • 尾调用消除 (TCE)是对一个表达式的尾调用的减少,该表达式可以在没有递归的情况下进行求值。TCE 是一种 TCO。

尾调用优化有很多原因:

  • 解释器可以最小化环境占用的内存量。由于没有计算机具有无限的内存,过多的递归函数调用会导致堆栈溢出
  • 解释器可以减少堆栈帧交换机的数量。

由于多种原因, Python 没有实现任何形式的 TCO。因此,需要其他技术来限制这种限制。选择方法取决于用例。通过一些直觉,factorialfib 的定义可以相对容易地转换为迭代代码,如下所示:

def factorial(n):
    product = 1
    while n > 1:
        product *= n
        n -= 1
    return product

def fib(n):
    a, b = 0, 1
    while n > 0:
        a, b = b, a + b
        n -= 1
    return a

这通常是手动消除递归的最有效方法,但对于更复杂的函数来说,它可能变得相当困难。

另一个有用的工具是 Python 的 lru_cache 装饰器,它可用于减少冗余计算的次数。

你现在已经知道如何避免 Python 中的递归,但何时应该使用递归?答案是不经常。所有递归函数都可以迭代实现。这只是一个弄清楚如何做到这一点的问题。但是,极少数情况下递归是可以的。当预期的输入不会导致大量的递归函数调用时,递归在 Python 中很常见。

如果递归是你感兴趣的主题,我恳请你学习函数语言,如 Scheme 或 Haskell。在这种语言中,递归更有用。

请注意,Fibonacci 序列的上述示例虽然很好地展示了如何在 python 中应用定义以及稍后使用 lru 缓存,但由于它为每个非基本情况进行 2 次递归调用,因此运行时间效率低。对函数的调用次数呈指数增长至 n
相反,非直观地,更有效的实现将使用线性递归:

def fib(n):
    if n <= 1:
        return (n,0)
    else:
        (a, b) = fib(n - 1)
        return (a + b, a)

但那个问题就是返回一数字。这强调了一些函数实际上并没有从递归中获得太多收益。