6.5 Python递归函数详解

深入学习Python递归函数的使用方法,包括递归函数的基本概念、阶乘计算示例、尾递归优化原理,以及汉诺塔问题的递归实现,掌握Python递归编程的核心知识。

函数内部可以调用其他函数。如果一个函数调用自己,那就是递归函数了。

拿计算阶乘来说,n! = 1 × 2 × 3 × ... × n。写个 fact(n) 函数,你会发现一个规律:

fact(n)=n!=1×2×3×⋅⋅⋅×(n−1)×n=(n−1)!×n=fact(n−1)×n

也就是 fact(n) 可以表示成 n × fact(n-1),只有 n=1 时需要单独处理。

递归写法很直接:

def fact(n):
    if n==1:
        return 1
    return n * fact(n - 1)

跑一下试试:

>>> fact(1)
1
>>> fact(5)
120
>>> fact(100)
93326215443944152681699238856266700490715968264381621468592963895217599993229915608941463976156518286253697920827223758251185210916864000000000000000000000000

fact(5) 的时候,函数调用过程是这样的:

=> fact(5)
=> 5 * fact(4)
=> 5 * (4 * fact(3))
=> 5 * (4 * (3 * fact(2)))
=> 5 * (4 * (3 * (2 * fact(1))))
=> 5 * (4 * (3 * (2 * 1)))
=> 5 * (4 * (3 * 2))
=> 5 * (4 * 6)
=> 5 * 24
=> 120

递归的好处是定义简单、逻辑清晰。所有递归函数都能改成循环写法,但循环的逻辑往往不如递归清楚。

用递归要小心栈溢出。计算机里函数调用靠栈(stack)这种数据结构实现,每进入一个函数调用就加一层栈帧,函数返回就减一层。栈的大小有限,递归调用太多次就会把栈撑爆。试试 fact(1000)

>>> fact(1000)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in fact
  ...
  File "<stdin>", line 4, in fact
RuntimeError: maximum recursion depth exceeded in comparison

怎么解决栈溢出?用尾递归优化。尾递归和循环效果一样,可以把循环看成是特殊的尾递归。

尾递归指的是函数返回时调用自己,而且 return 语句不能包含表达式。这样编译器或解释器就能优化,让递归无论调多少次都只占一个栈帧,不会溢出。

上面的 fact(n) 因为 return n * fact(n - 1) 有乘法表达式,不是尾递归。要改成尾递归,得多写点代码,把每一步的乘积传进递归函数:

def fact(n):
    return fact_iter(n, 1)

def fact_iter(num, product):
    if num == 1:
        return product
    return fact_iter(num - 1, num * product)

看到没?return fact_iter(num - 1, num * product) 只返回递归函数本身,num - 1num * product 在调用前就算好了,不影响函数调用。

fact(5) 对应的 fact_iter(5, 1) 调用过程:

=> fact_iter(5, 1)
=> fact_iter(4, 5)
=> fact_iter(3, 20)
=> fact_iter(2, 60)
=> fact_iter(1, 120)
=> 120

尾递归调用时如果做了优化,栈不会增长,调多少次都不会溢出。

但问题来了——大多数编程语言没做尾递归优化,Python解释器也没做。所以即使把 fact(n) 改成尾递归,照样会栈溢出。

练习

汉诺塔的移动用递归函数特别好实现。

写个 move(n, a, b, c) 函数,参数 n 表示第一个柱子A上的盘子数量,然后打印出把所有盘子从A借助B移到C的方法:

def move(n, a, b, c):
    if n == 1:
        print(a, '-->', c)

# 期待输出:
# A --> C
# A --> B
# C --> B
# A --> C
# B --> A
# B --> C
# A --> C
move(3, 'A', 'B', 'C')

小结

递归函数的优点是逻辑简单清晰,缺点是调用太深会导致栈溢出。

支持尾递归优化的语言可以通过尾递归防止栈溢出。尾递归和循环其实等价,没有循环语句的编程语言只能靠尾递归实现循环。

Python标准解释器没做尾递归优化,任何递归函数都有栈溢出的风险。