当我开始学习口齿不清时,我遇到了“尾部递归”这个词。这到底是什么意思?
当前回答
考虑一个将前N个自然数相加的简单函数。(例如,和(5)=0+1+2+3+4+5=15)。
下面是一个使用递归的简单JavaScript实现:
function recsum(x) {
if (x === 0) {
return 0;
} else {
return x + recsum(x - 1);
}
}
如果调用recsum(5),JavaScript解释器将评估以下内容:
recsum(5)
5 + recsum(4)
5 + (4 + recsum(3))
5 + (4 + (3 + recsum(2)))
5 + (4 + (3 + (2 + recsum(1))))
5 + (4 + (3 + (2 + (1 + recsum(0)))))
5 + (4 + (3 + (2 + (1 + 0))))
5 + (4 + (3 + (2 + 1)))
5 + (4 + (3 + 3))
5 + (4 + 6)
5 + 10
15
请注意,在JavaScript解释器开始实际计算和之前,每个递归调用都必须完成。
下面是同一函数的尾部递归版本:
function tailrecsum(x, running_total = 0) {
if (x === 0) {
return running_total;
} else {
return tailrecsum(x - 1, running_total + x);
}
}
以下是调用tailrecsum(5)时发生的事件序列(由于默认的第二个参数,它实际上是tailrecsum(5,0))。
tailrecsum(5, 0)
tailrecsum(4, 5)
tailrecsum(3, 9)
tailrecsum(2, 12)
tailrecsum(1, 14)
tailrecsum(0, 15)
15
在尾部递归情况下,每次对递归调用求值时,running_total都会更新。
注:原始答案使用了Python中的示例。由于Python解释器不支持尾部调用优化,这些代码已更改为JavaScript。然而,虽然尾部调用优化是ECMAScript 2015规范的一部分,但大多数JavaScript解释器不支持它。
其他回答
这个问题有很多很好的答案。。。但我忍不住提出了另一种看法,即如何定义“尾部递归”,或者至少是“正确的尾部递归”。即:是否应该将其视为程序中特定表达式的属性?还是应该将其视为编程语言实现的属性?
关于后一种观点,Will Clinger的一篇经典论文“正确的尾部递归和空间效率”(PLDI 1998)将“正确的尾递归”定义为编程语言实现的属性。该定义被构造为允许忽略实现细节(例如调用堆栈实际上是通过运行时堆栈还是通过堆分配的帧链接列表表示的)。
为了实现这一点,它使用了渐近分析:不是人们通常看到的程序执行时间,而是程序空间使用情况。这样,堆分配的链接列表与运行时调用堆栈的空间使用最终是渐近等价的;因此,人们会忽略编程语言实现的细节(这一细节在实践中当然非常重要,但当试图确定给定的实现是否满足“属性尾部递归”的要求时,可能会让事情变得一团糟)
该论文值得仔细研究,原因如下:
它给出了程序尾部表达式和尾部调用的归纳定义。(这样的定义,以及为什么这样的电话很重要,似乎是这里给出的大多数其他答案的主题。)以下是这些定义,只是为了提供文本的味道:定义1以核心方案编写的程序的尾部表达式归纳如下。lambda表达式的主体是尾部表达式如果(如果E0 E1 E2)是尾部表达式,则E1和E2都是尾部表达式。其他的都不是尾部表达式。定义2尾部调用是作为过程调用的尾部表达式。
(尾部递归调用,或者正如论文所说,“self-tail调用”是尾部调用的一种特殊情况,其中过程本身被调用。)
它为评估核心方案的六个不同“机器”提供了正式定义,其中每个机器都具有相同的可观察行为,除了每个机器所处的渐近空间复杂性类。例如,在为分别为1。基于堆栈的内存管理,2。垃圾收集,但没有尾部调用。垃圾收集和尾部调用,本文继续介绍更高级的存储管理策略,如4。“evlis尾部递归”,在尾部调用的最后一个子表达式参数求值期间不需要保存环境,5。将闭包的环境减少到该闭包的自由变量,以及6。Appel和Shao定义的所谓“空间安全”语义。为了证明这些机器实际上属于六个不同的空间复杂性类,本文针对每对被比较的机器,提供了程序的具体示例,这些程序将揭示一台机器上的渐近空间爆炸,而不是另一台机器。
(现在仔细阅读我的答案,我不确定我是否真的抓住了克林格论文的关键点。但是,唉,我现在不能花更多的时间来研究这个答案。)
下面是一个使用尾部递归进行阶乘的常见Lisp示例。由于无堆栈的性质,人们可以进行疯狂的大型阶乘计算。。。
(defun ! (n &optional (product 1))
(if (zerop n) product
(! (1- n) (* product n))))
然后为了好玩,你可以尝试(格式化nil“~R”(!25))
许多人已经在这里解释了递归。我想引用Riccardo Terrell的《.NET中的并发性,并发和并行编程的现代模式》一书中关于递归的一些优点的一些想法:
“函数递归是FP中迭代的自然方式,因为它避免状态突变。在每次迭代期间,都会传递一个新值而不是被更新(变异)。在里面此外,可以编写递归函数,使您的程序更加模块化,并引入了开发机会并行化。"
以下是同一本书中关于尾部递归的一些有趣注释:
尾部调用递归是一种转换规则递归的技术函数转换为可处理大型输入的优化版本没有任何风险和副作用。注:尾部调用作为优化的主要原因是提高数据位置、内存使用率和缓存使用率。通过做尾巴调用时,被调用者使用与调用者相同的堆栈空间。这减少了记忆压力。它略微改善了缓存,因为存储器被后续调用方重用,并且可以留在缓存中,而不是驱逐旧的缓存线,为新的缓存腾出空间线
这本摘自《Lua编程》一书的摘录展示了如何进行正确的尾部递归(在Lua中,但也应适用于Lisp)以及为什么它更好。
尾部调用[尾部递归]是一种goto-dressed作为呼叫。当函数调用另一个作为其最后一个行动,所以它没有其他事情可做。例如在以下代码中,对g的调用是尾调用:函数f(x)返回g(x)终止在f调用g之后,它没有其他内容在这种情况下,程序不需要返回调用函数时调用的函数末端。因此在尾呼之后,程序不需要保留任何有关调用函数的信息在堆栈中。。。因为正确的尾呼使用no堆栈空间一个程序可以生成。例如,我们可以使用任意数字作为自变量;它永远不会溢出堆栈:函数foo(n)如果n>0,则返回foo(n-1)end终止…正如我前面所说的,尾呼是有点后顾之忧。因此,一个非常有用的正确的尾部调用在Lua用于编程状态机。此类应用程序可以代表功能状态;更改状态是去(或打电话)一个特定的作用例如,让我们考虑一个简单的迷宫游戏。迷宫有几个房间,每个房间最多四个门:北、南、东和西在每个步骤中,用户输入移动方向。如果有门在该方向上,用户将相应的房间;否则程序打印警告。目标是从最初的房间到最后的房间房间该游戏是典型的状态机,其中当前房间是状态。我们可以用一个每个房间的功能。我们用尾巴从一个房间移动到另一个有四个房间的小迷宫可能看起来像这样:功能室1()本地移动=io.read()如果移动==“南”,则返回房间3()elseif move==“east”然后返回room2()否则打印(“无效移动”)返回房间1()--呆在同一个房间终止终止功能室2()本地移动=io.read()如果move==“south”,则返回room4()elseif move==“west”然后返回房间1()否则打印(“无效移动”)返回室2()终止终止功能室3()本地移动=io.read()如果move==“north”,则返回room1()elseif move==“east”然后返回room4()否则打印(“无效移动”)返回室3()终止终止功能室4()打印(“恭喜!”)终止
因此,当您进行如下递归调用时:
function x(n)
if n==0 then return 0
n= n-2
return x(n) + 1
end
这不是尾部递归的,因为在进行递归调用之后,您仍然需要在该函数中做一些事情(添加1)。如果输入的数字很高,可能会导致堆栈溢出。
这里是前面提到的tailrecsum函数的Perl 5版本。
sub tail_rec_sum($;$){
my( $x,$running_total ) = (@_,0);
return $running_total unless $x;
@_ = ($x-1,$running_total+$x);
goto &tail_rec_sum; # throw away current stack frame
}