尾递归是函数式语言中一个重要的性能优化策略,因为它允许递归调用消耗常量堆栈(而不是O(n))。
是否有任何问题根本无法以尾递归方式编写,或者总是可以将天真递归函数转换为尾递归函数?
如果是这样,有一天功能编译器和口译员可能足够聪明,可以自动执行转换吗?
答案 0 :(得分:47)
是的,实际上你可以接受一些代码并转换每个函数调用 - 并且每次返回尾部调用。你最终得到的是持续传递风格,或CPS。
例如,这是一个包含两个递归调用的函数:
(define (count-tree t)
(if (pair? t)
(+ (count-tree (car t)) (count-tree (cdr t)))
1))
如果你将这个函数转换为延续传递样式,那么这就是它的样子:
(define (count-tree-cps t ctn)
(if (pair? t)
(count-tree-cps (car t)
(lambda (L) (count-tree-cps (cdr t)
(lambda (R) (ctn (+ L R))))))
(ctn 1)))
额外参数ctn
是count-tree-cps
尾调用而不是返回的过程。 (sdcvvc的回答说你不能在O(1)空间中做所有事情,这是正确的;这里每个延续都是一个占用一些记忆的闭包。)
我没有将对car
或cdr
或+
的调用转换为尾调用。也可以这样做,但我认为那些叶调用实际上是内联的。
现在为有趣的部分。 Chicken Scheme实际上对它编译的所有代码进行了此转换。由Chicken 编译的程序永远不会返回。有一篇经典论文解释了为什么Chicken Scheme会这样做,在1994年实施鸡之前写成:CONS should not cons its arguments, Part II: Cheney on the M.T.A.
令人惊讶的是,继续传递风格在JavaScript中相当普遍。您可以使用它to do long-running computation,避免浏览器的“慢速脚本”弹出窗口。它对异步API很有吸引力。 jQuery.get
(XMLHttpRequest的一个简单包装)显然是继续传递的风格;最后一个参数是一个函数。
答案 1 :(得分:26)
观察到任何相互递归函数的集合都可以转换为尾递归函数,这是正确但没有用的。这个观察结果与20世纪60年代的旧栗子相当,控制流构造可以被消除,因为每个程序都可以写成一个循环,其中嵌套了一个case语句。
有用的是,许多明显不是尾递归的函数可以通过添加累积参数转换为尾递归形式。 (这种转换的一个极端版本是转换为延续传递方式(CPS),但大多数程序员发现CPS转换的输出很难阅读。)
这是一个“递归”函数的例子(实际上它只是迭代)但不是尾递归的:
factorial n = if n == 0 then 1 else n * factorial (n-1)
在这种情况下,在递归调用之后,乘法发生。 我们可以通过将产品放在累积参数中来创建尾递归的版本:
factorial n = f n 1
where f n product = if n == 0 then product else f (n-1) (n * product)
内部函数f
是尾递归的,并编译成一个紧密的循环。
我发现以下区别很有用:
在迭代或递归程序中,您可以解决大小为n
的问题
首先解决大小为n-1
的一个子问题。计算阶乘函数
属于这一类,它可以迭代地完成或
递归。 (这个想法概括为例如Fibonacci函数,其中
您需要n-1
和n-2
来解决n
。)
在递归程序中,首先解决 2 ,解决大小为n
的问题
大小为n/2
的子问题。或者,更一般地说,您解决了n
大小的问题
首先解决大小为k
的子问题和大小为n-k
的子问题,其中1 < k <
n
。 Quicksort和mergesort就是这类问题的两个例子
可以很容易地递归编程,但编程并不容易
迭代地或仅使用尾递归。 (您基本上必须使用显式模拟递归
叠加。)
在动态编程中,首先解决所有,解决大小为n
的问题
各种规模的子问题k
,其中k<n
。从一个找到最短的路线
伦敦地铁指向另一个就是这种情况的一个例子
问题。 (伦敦地铁是一个多重连接图,你
通过首先找到最短路径的所有点来解决问题
是1站,然后最短的路径是2站等等。)
只有第一种程序将简单转换为尾递归形式。
答案 2 :(得分:11)
任何递归算法都可以重写为迭代算法(可能需要堆栈或列表),并且迭代算法总是可以重写为尾递归算法,因此我认为任何递归解决方案都可以以某种方式转换为尾部 - 递归解决方案。
(在评论中,Pascal Cuoq指出任何算法都可以转换为continuation-passing style。)
请注意,仅仅因为某些东西是尾递归并不意味着它的内存使用是不变的。它只是意味着调用返回堆栈不会增长。
答案 3 :(得分:9)
你不能在O(1)空间(空间层次定理)中做所有事情。如果你坚持使用尾递归,那么你可以将调用堆栈存储为参数之一。显然,这并没有改变任何事情;在内部的某个地方,有一个调用堆栈,你只是让它明确可见。
如果是这样,有一天功能编译器和口译员可能足够聪明,可以自动执行转换吗?
这种转换不会降低空间复杂性。
正如Pascal Cuoq评论的那样,另一种方法是使用CPS;那么所有的调用都是尾递归的。
答案 4 :(得分:1)
我认为tak之类的东西不能仅使用尾调用来实现。 (不允许继续)