普通递归

普通递归的阶乘计算方式是每次递归调用后做一些额外的乘法操作,直到递归达到基准情况:

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

在这个函数中,每次递归调用后,返回值都要与当前的 n 相乘。这意味着每个递归调用都需要等待其子调用返回结果后再继续操作。每次递归调用都会把当前函数的状态(即 n 和栈信息)保存在栈帧中,直到递归最深的一层完成后,结果才开始从下往上返回。

缺点

  • 每次递归调用都会创建一个新的栈帧,栈空间会随着递归深度的增加而增加。如果 n 很大,这可能会导致栈溢出。
  • 在递归过程中,程序还需要等待每次递归返回后才能继续计算。

尾递归

[!NOTE] 核心是如何通过优化递归的栈使用,避免过多的栈空间消耗,特别是在递归深度较大的情况下。

尾递归的阶乘计算方式确保递归调用是函数的最后一步操作,没有额外的乘法计算,这样编译器就可以优化掉不必要的栈帧,从而节省内存。

def factorial_tail(n, accumulator=1):
    if n == 0:
        return accumulator
    else:
        return factorial_tail(n - 1, n * accumulator)

在这个尾递归的实现中,递归调用是最后一步操作,并且没有等待其他操作。我们用一个额外的参数 accumulator 来保持当前计算的积,每次递归调用时,n 减小,accumulator 更新为 n * accumulator,直到递归终止。

优化

  • 由于尾递归的特性,编译器可以重用当前栈帧,不需要为每次递归创建新的栈帧。这使得栈空间的使用量保持常数,因此可以处理非常大的 n 值,而不会发生栈溢出。
  • 递归调用是最后一步,编译器可以将其优化为迭代,避免递归的开销。

为什么尾递归更高效?

假设我们计算 factorial(1000)

  • 在普通递归中,factorial(1000) 会递归调用 factorial(999),直到递归到 factorial(0),每一层都要保存栈帧,直到最后一步返回。这可能导致深度递归时的栈溢出。
  • 在尾递归版本中,递归调用时并没有需要回溯的操作,因此栈帧会被复用,避免了栈空间的消耗,甚至可以通过迭代优化来避免递归带来的开销。

尾递归和普通递归的比较

特性 普通递归 尾递归
栈空间消耗 每次递归调用都需要额外的栈帧 递归调用是最后一步,可以复用栈帧
计算顺序 需要等到子递归返回结果后再进行计算 不需要等待,直接传递计算结果
语言支持 不支持尾调用优化的语言容易栈溢出 语言支持尾递归优化时更高效
适用场景 小规模递归或递归深度不大时能正常工作 大规模递归或需要避免栈溢出时,尾递归更适合