简单python递归算法详解 尾递归写给命令式编程程序员(1)

Python部落(python.freelycode.com)组织翻译,禁止转载,欢迎转发。

在Recurse Center期间,我通读了《计算机程序的构造和解释》。这是一本出版于1985年的MIT的编程入门教材,用LISP的一种方言Scheme语言来教授编程。Scheme是函数式语言,我也一直喜欢学习新的函数式编程概念。

本文目的是向没有函数式语言经验或概念的程序员解释“尾递归”。

在解释尾递归之前,我们先看看命令式语言Python中的递归。

递归的一个问题

简单python递归算法详解 尾递归写给命令式编程程序员(2)

上面这段代码定义了一个返回数字n的阶乘的函数:factorial(n) = n * n - 1 * ... * 2 * 1。如果n = 4, 可以获得期望的结果是4 * 3 * 2 * 1 = 24。

当我们运行一个递归函数时,在运行背后到底发生了什么?任一函数被调用,就会往进程堆栈中加入一个包含与该函数有关的数据的栈帧,通过Python中的inspect模块,我们可以观察到发生了什么:

简单python递归算法详解 尾递归写给命令式编程程序员(3)

运行这个脚本得到:

简单python递归算法详解 尾递归写给命令式编程程序员(4)

我们可以看到,当inspect.stack第一次被调用时,一个栈帧加到了堆栈中。再次调用,堆栈中就有两个栈帧了。

栈帧消耗内存,而一个Python进程只能分配到有限的内存。如果堆栈包含太多的栈帧,进程会耗尽所分配的内存,或者堆栈可能扩展到没有分配给该进程的内存空间而导致堆栈溢出。为了防止出现这样的问题,解释器会设置一个最大递归数的限制,可以通过调用sys.getrecursionlimit来查看这个限制。在我的计算机上,这个限制是1000(注)。

每一次调用factorial,一个新的栈帧就会加到堆栈中。如果加入了太多的栈帧,就会超过这个最大递归数的限制,解释器就会抛出如下异常:

简单python递归算法详解 尾递归写给命令式编程程序员(5)

由于栈帧产生系统开销,所以在命令式语言中,递归是内存密集型的操作。相比下面的使用迭代方式获取阶乘的函数:

简单python递归算法详解 尾递归写给命令式编程程序员(6)

这个函数只产生了一个栈帧,比递归方式能更轻易处理大于n十倍或者更大的数值。

迭代递归

思考当解释器执行factorial(4)时发生了什么:

简单python递归算法详解 尾递归写给命令式编程程序员(7)

我们能看到创建了一系列延迟的操作,直到遇到n = 1时才会计算出最终结果。这是因为解释器为了后面的执行必须要记住并跟踪前面的操作。

如果我们重新定义factorial函数为:

简单python递归算法详解 尾递归写给命令式编程程序员(8)

并重新思考解释器做了什么:

简单python递归算法详解 尾递归写给命令式编程程序员(9)

我们看到一系列扁平的factorial调用,状态保存在变量total中,而不是由解释器来保存。

尾递归

在“尾递归”语言中,以factorial_new这种方式定义的递归过程可以被解释器解释为迭代,从而不会带来递归的缺点。用这种优雅的递归过程,能使你获得和迭代一样的性能优势。解释器知道不需要在堆栈上创建更多的栈帧,从而抛弃创建栈帧的方式。

不幸的是,Python不是“尾递归”语言,所以factorial_new(1000, 1)仍然会抛出RuntimeError: maximum recursion depth exceeded。

更多信息,推荐参考《计算机程序的构造和解释》中的1.2.1小节。

注:在Python中可以调用sys.setrecursionlimit来设置最大递归数,但通常不建议这么做。递归调用太多的函数应该使用迭代的方式来重写。

英文原文:https://jamesroutley.co.uk/tech/2017/06/04/tail-recursion.html 译者:scala

,