在开始学习lisp的同时,我遇到了 tail-recursive 这个术语。它究竟意味着什么?
答案 0 :(得分:1490)
考虑一个添加前N个整数的简单函数。 (例如sum(5) = 1 + 2 + 3 + 4 + 5 = 15
)。
这是一个使用递归的简单JavaScript实现:
function recsum(x) {
if (x===1) {
return x;
} 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)))
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中的示例。这些已经改为JavaScript,因为现代JavaScript解释器支持tail call optimization但Python解释器不支持。
答案 1 :(得分:630)
在传统递归中,典型的模型是首先执行递归调用,然后获取递归调用的返回值并计算结果。通过这种方式,在每次递归调用返回之前,都不会得到计算结果。
在尾递归中,首先执行计算,然后执行递归调用,将当前步骤的结果传递给下一个递归步骤。这导致最后一个语句采用(return (recursive-function params))
的形式。 基本上,任何给定递归步骤的返回值都与下一次递归调用的返回值相同。
这样做的结果是,一旦准备好执行下一个递归步骤,就不再需要当前的堆栈帧了。这允许一些优化。事实上,使用适当编写的编译器,您应该永远不会有带尾递归调用的堆栈溢出 snicker 。只需重复使用当前堆栈帧进行下一个递归步骤。我很确定Lisp会这样做。
答案 2 :(得分:183)
重要的一点是尾递归基本上等于循环。这不仅仅是编译器优化的问题,而是表达性的基本事实。这有两种方式:你可以采取任何形式的循环
while(E) { S }; return Q
其中E
和Q
是表达式,S
是一系列语句,并将其转换为尾递归函数
f() = if E then { S; return f() } else { return Q }
当然,必须定义E
,S
和Q
来计算某些变量的一些有趣值。例如,循环函数
sum(n) {
int i = 1, k = 0;
while( i <= n ) {
k += i;
++i;
}
return k;
}
等同于尾递归函数
sum_aux(n,i,k) {
if( i <= n ) {
return sum_aux(n,i+1,k+i);
} else {
return k;
}
}
sum(n) {
return sum_aux(n,1,0);
}
(使用具有较少参数的函数对尾递归函数进行“包装”是一种常见的功能习惯。)
答案 3 :(得分:127)
本书
尾部调用 [tail recursion]是一种goto打扮 作为电话。当a时发生尾调用 函数将另一个调用为最后一个 行动,所以它没有别的办法。 例如,在以下代码中, 对
g
的调用是尾调用:function f (x) return g(x) end
在
f
调用g
后,它没有其他内容 去做。在这种情况下,该计划 不需要返回调用 函数调用时的函数 结束。因此,尾调用后, 该计划不需要保留任何 有关呼叫功能的信息 在堆栈中。 ...因为正确的尾调用不会 堆栈空间,没有限制 “嵌套”尾部调用的数量a 程序可以做。例如,我们可以 用any调用以下函数 数字作为参数;永远不会 溢出堆栈:
function foo (n) if n > 0 then return foo(n - 1) end end
......正如我之前所说,尾部呼叫是一个 转发。因此,一个非常有用 应用适当的尾调用 Lua用于编程状态机。 这样的应用可以代表每个 以功能陈述;改变国家 是去(或打电话)具体的 功能。举个例子,让我们来吧 考虑一个简单的迷宫游戏。迷宫 有几个房间,每个房间最多 四扇门:北,南,东,和 西方。在每一步,用户输入一个 运动方向。如果有门 在那个方向,用户去 相应的房间;否则, 程序打印警告。目标是 从最初的房间到决赛 室。
这个游戏是一个典型的状态机, 当前房间是哪个州。 我们可以用一个这样的迷宫来实现 每个房间的功能。我们用尾巴 打电话从一个房间搬到 另一个。一个有四个房间的小迷宫 可能看起来像这样:
function room1 () local move = io.read() if move == "south" then return room3() elseif move == "east" then return room2() else print("invalid move") return room1() -- stay in the same room end end function room2 () local move = io.read() if move == "south" then return room4() elseif move == "west" then return room1() else print("invalid move") return room2() end end function room3 () local move = io.read() if move == "north" then return room1() elseif move == "east" then return room4() else print("invalid move") return room3() end end function room4 () print("congratulations!") end
所以你看,当你进行递归调用时:
function x(n)
if n==0 then return 0
n= n-2
return x(n) + 1
end
这不是尾递归,因为在进行递归调用之后,您仍然需要在该函数中执行操作(添加1)。如果输入一个非常高的数字,可能会导致堆栈溢出。
答案 4 :(得分:65)
使用常规递归,每次递归调用都会将另一个条目推送到调用堆栈。递归完成后,应用程序必须将每个条目一直弹回。
使用尾递归,根据语言,编译器可能能够将堆栈折叠为一个条目,因此您可以节省堆栈空间......大型递归查询实际上可能导致堆栈溢出。
基本上Tail递归可以优化为迭代。
答案 5 :(得分:63)
这不是用文字解释,而是一个例子。这是阶乘函数的Scheme版本:
(define (factorial x)
(if (= x 0) 1
(* x (factorial (- x 1)))))
这是一个尾递归的阶乘版本:
(define factorial
(letrec ((fact (lambda (x accum)
(if (= x 0) accum
(fact (- x 1) (* accum x))))))
(lambda (x)
(fact x 1))))
您将在第一个版本中注意到对事实的递归调用被送入乘法表达式,因此在进行递归调用时必须将状态保存在堆栈中。在尾递归版本中,没有其他S表达式等待递归调用的值,并且由于没有其他工作要做,因此不必将状态保存在堆栈中。通常,Scheme尾递归函数使用常量堆栈空间。
答案 6 :(得分:62)
行话文件可以说明尾递归的定义:
尾递归 / n ./
如果您已经厌倦了,请参阅尾递归。
答案 7 :(得分:29)
尾递归是指递归调用在递归算法的最后一条逻辑指令中的最后一次。
通常在递归中,你有一个基本情况,它会停止递归调用并开始弹出调用堆栈。使用经典示例,尽管比Lisp更多C-ish,但是阶乘函数说明了尾递归。在检查基本情况后,发生递归调用。
factorial(x, fac=1) {
if (x == 1)
return fac;
else
return factorial(x-1, x*fac);
}
对factorial的初始调用将是factorial(n)
,其中fac=1
(默认值),n是要计算析因的数字。
答案 8 :(得分:26)
这意味着您不必将指令指针推到堆栈上,只需跳转到递归函数的顶部并继续执行即可。这允许函数无限递归,而不会溢出堆栈。
我在这个主题上写了一篇blog帖子,其中有关于堆栈帧的图形示例。
答案 9 :(得分:19)
这是一个比较两个函数的快速代码段。第一种是用于查找给定数字的阶乘的传统递归。第二个使用尾递归。
非常简单直观易懂。
判断递归函数是否为尾递归的简单方法是它是否在基本情况下返回具体值。意味着它不会返回1或真或类似的东西。它很可能会返回一个方法参数的变体。
另一种方法是判断递归调用是否没有任何加法,算术,修改等......意味着它只是一个纯粹的递归调用。
public static int factorial(int mynumber) {
if (mynumber == 1) {
return 1;
} else {
return mynumber * factorial(--mynumber);
}
}
public static int tail_factorial(int mynumber, int sofar) {
if (mynumber == 1) {
return sofar;
} else {
return tail_factorial(--mynumber, sofar * mynumber);
}
}
答案 10 :(得分:16)
我了解tail call recursion
的最佳方式是递归的特殊情况,其中最后一次调用(或尾调用)是函数本身。
比较Python中提供的示例:
def recsum(x):
if x == 1:
return x
else:
return x + recsum(x - 1)
^递推
def tailrecsum(x, running_total=0):
if x == 0:
return running_total
else:
return tailrecsum(x - 1, running_total + x)
^ TAIL RECURSION
正如您在常规递归版本中所看到的,代码块中的最终调用是x + recsum(x - 1)
。因此,在调用recsum
方法之后,还有另一个操作x + ..
。
但是,在尾递归版本中,代码块中的最终调用(或尾调用)是tailrecsum(x - 1, running_total + x)
,这意味着最后一次调用方法本身并且之后没有操作。
这一点很重要,因为这里看到的尾递归并没有使内存增长,因为当底层VM看到一个函数调用自身处于尾部位置(在函数中要计算的最后一个表达式)时,它会消除当前堆栈帧,称为尾调用优化(TCO)。
NB。请记住,上面的示例是用Python编写的,其运行时不支持TCO。这只是一个例子来解释这一点。 Scheme,Haskell等语言支持TCO
答案 11 :(得分:11)
在Java中,这里有一个可能的Fibonacci函数的尾递归实现:
public int tailRecursive(final int n) {
if (n <= 2)
return 1;
return tailRecursiveAux(n, 1, 1);
}
private int tailRecursiveAux(int n, int iter, int acc) {
if (iter == n)
return acc;
return tailRecursiveAux(n, ++iter, acc + iter);
}
将此与标准递归实现进行对比:
public int recursive(final int n) {
if (n <= 2)
return 1;
return recursive(n - 1) + recursive(n - 2);
}
答案 12 :(得分:10)
这是一个Common Lisp示例,它使用尾递归来执行阶乘。由于无堆栈特性,人们可以执行疯狂的大因子计算......
(defun ! (n &optional (product 1))
(if (zerop n) product
(! (1- n) (* product n))))
然后为了好玩,你可以尝试(format nil "~R" (! 25))
答案 13 :(得分:9)
我不是Lisp程序员,但我认为this会有所帮助。
基本上它是一种编程风格,使得递归调用是你做的最后一件事。
答案 14 :(得分:9)
简而言之,尾递归将递归调用作为函数中的 last 语句,这样就不必等待递归调用。
所以这是一个尾递归,即N(x - 1,p * x)是函数中的最后一个语句,编译器很聪明地知道它可以被优化为for循环(factorial)。第二个参数p带有中间产品值。
function N(x, p) {
return x == 1 ? p : N(x - 1, p * x);
}
这是编写上述阶乘函数的非尾递归方式(尽管有些C ++编译器可能无论如何都能优化它。)
function N(x) {
return x == 1 ? 1 : x * N(x - 1);
}
但这不是:
function F(x) {
if (x == 1) return 0;
if (x == 2) return 1;
return F(x - 1) + F(x - 2);
}
我写过一篇名为“Understanding Tail Recursion – Visual Studio C++ – Assembly View”的长篇文章
答案 15 :(得分:8)
这是前面提到的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
}
答案 16 :(得分:7)
这是Structure and Interpretation of Computer Programs关于尾递归的摘录。
对比迭代和递归,我们必须小心不要 将递归过程的概念与a的概念混淆 递归程序。当我们将过程描述为递归时,我们就是 指的是程序定义所指的句法事实 (直接或间接)程序本身。但是当我们 将流程描述为遵循线性模式 递归,我们正在谈论过程如何演变,而不是 如何编写过程的语法。这似乎令人不安 我们将一个递归过程称为fact-iter,因为它生成一个 迭代过程。然而,这个过程确实是迭代的:它的状态 被它的三个状态变量完全捕获,并且a 解释器需要只跟踪三个变量 执行过程。
过程和程序之间的区别可能是一个原因 令人困惑的是大多数常见语言的实现(包括Ada,Pascal和 C)的设计方式是任何递归的解释 过程消耗的内存量随着数量的增长而增长 程序调用,即使所描述的过程原则上是 迭代。因此,这些语言可以描述迭代 仅通过使用特殊用途的“循环结构”来处理 例如do,repeat,until,for和while。 执行 Scheme不会分享这个缺陷。它 将在恒定空间中执行迭代过程,即使是 迭代过程由递归过程描述。一个 使用此属性的实现称为tail-recursive。使用 尾递归实现,迭代可以用表达式表示 普通的过程调用机制,使特殊的迭代 构建体仅用作语法糖。
答案 17 :(得分:6)
尾递归是你现在的生活。你不断地循环使用相同的堆栈帧,因为没有理由或方法可以返回到“之前的”帧。过去已经完成,所以它可以被丢弃。你得到一个框架,永远移动到未来,直到你的过程不可避免地死亡。
如果你认为某些进程可能会使用额外的帧,但如果堆栈没有无限增长,那么这种类比会被认为是尾递归的。
答案 18 :(得分:6)
尾递归是函数调用的递归函数 本身在函数的末尾(“尾部”),其中没有计算 在递归调用返回后完成。许多编译器优化到 将递归调用更改为尾递归或迭代调用。
考虑计算数字因子的问题。
一种直截了当的方法是:
factorial(n):
if n==0 then 1
else n*factorial(n-1)
假设您调用factorial(4)。递归树将是:
factorial(4)
/ \
4 factorial(3)
/ \
3 factorial(2)
/ \
2 factorial(1)
/ \
1 factorial(0)
\
1
上述情况下的最大递归深度为O(n)。
但是,请考虑以下示例:
factAux(m,n):
if n==0 then m;
else factAux(m*n,n-1);
factTail(n):
return factAux(1,n);
factTail(4)的递归树将是:
factTail(4)
|
factAux(1,4)
|
factAux(4,3)
|
factAux(12,2)
|
factAux(24,1)
|
factAux(24,0)
|
24
此处,最大递归深度为O(n),但没有一个调用将任何额外变量添加到堆栈。因此编译器可以取消堆栈。
答案 19 :(得分:5)
为了理解尾调用递归和非尾调用递归之间的一些核心差异,我们可以探索这些技术的.NET实现。
这篇文章包含C#,F#和C ++ \ CLI中的一些示例:Adventures in Tail Recursion in C#, F#, and C++\CLI。
C#不优化尾调用递归,而F#则优化。
原则上的差异涉及循环与Lambda演算。 C#的设计考虑了循环,而F#是根据Lambda演算的原理构建的。有关Lambda演算原理的非常好(且免费)的书,请参阅Structure and Interpretation of Computer Programs, by Abelson, Sussman, and Sussman。
关于F#中的尾调用,有关非常好的介绍性文章,请参阅Detailed Introduction to Tail Calls in F#。最后,这篇文章介绍了非尾递归和尾调用递归(在F#中)之间的区别:Tail-recursion vs. non-tail recursion in F sharp。
如果您想了解C#和F#之间尾调用递归的一些设计差异,请参阅Generating Tail-Call Opcode in C# and F#。
如果您非常关心哪些条件阻止C#编译器执行尾部调用优化,请参阅此文章:JIT CLR tail-call conditions。
答案 20 :(得分:4)
递归意味着一个函数调用自身。例如:
(define (un-ended name)
(un-ended 'me)
(print "How can I get here?"))
Tail-Recursion表示结束函数的递归:
(define (un-ended name)
(print "hello")
(un-ended 'me))
请参阅,最后一个未结束的函数(过程,在方案术语中)的作用是调用自身。另一个(更有用)的例子是:
(define (map lst op)
(define (helper done left)
(if (nil? left)
done
(helper (cons (op (car left))
done)
(cdr left))))
(reverse (helper '() lst)))
在辅助程序中,如果左边不是nil,它会做的最后一件事就是自己调用(事后有点和cdr的事情)。这基本上就是您映射列表的方式。
尾部递归具有很大的优势,解释器(或编译器,依赖于语言和供应商)可以优化它,并将其转换为等同于while循环的东西。事实上,在Scheme传统中,大多数“for”和“while”循环是以尾递归方式完成的(据我所知,目前还没有。)
答案 21 :(得分:4)
有两种基本类型的递归:头部递归和尾部递归。
在头部递归中,函数进行递归调用,然后执行 执行一些更多的计算,也许使用结果 例如,递归调用。
在尾递归函数中,所有计算首先发生 递归调用是最后发生的事情。
取自this超级精彩的帖子。 请考虑阅读。
答案 22 :(得分:3)
这个问题有很多很好的答案......但是我不得不提出如何定义&#34;尾递归&#34;或者至少&#34;正确的尾递归的替代方法。 &#34;即:是否应该将其视为程序中特定表达式的属性?或者应该将其视为编程语言的实现的属性?
关于后一种观点的更多信息,Will Clinger有一个经典的paper,&#34;正确的尾部递归和空间效率&#34; (PLDI 1998),定义了正确的尾递归&#34;作为编程语言实现的属性。构造定义是为了允许忽略实现细节(例如调用堆栈是通过运行时堆栈实际表示还是通过堆分配的链接列表来表示)。
要实现这一点,它使用渐近分析:不是通常看到的程序执行时间,而是程序空间使用。这样,堆分配的链表与运行时调用堆栈的空间使用最终是渐近等价的;所以人们可以忽略这种编程语言的实现细节(这个细节在实践中确实很重要,但是当人们试图确定给定的实现是否满足要求时,可能会使水域变得非常混乱。尾递归&#34;)
由于以下几个原因,本文值得仔细研究:
它给出了程序的尾部表达式和尾部调用的归纳定义。 (这样的定义,以及为什么这些呼叫很重要,似乎是这里给出的大多数其他答案的主题。)
以下是这些定义,只是为了提供文本的味道:
定义1 以核心方案编写的程序的尾部表达式的归纳定义如下。
- lambda表达式的主体是尾部表达式
- 如果
(if E0 E1 E2)
是尾部表达式,则E1
和E2
都是尾部表达式。- 没有别的东西是尾巴表达。
醇>定义2 尾调用是一个尾部表达式,是一个过程调用。
(尾递归调用,或者正如文章所说,&#34;自尾调用&#34;是一个尾调用的特例,其中自己调用该过程。)
它为六种不同的&#34;机器提供了正式的定义。用于评估核心方案,其中每台机器具有相同的可观察行为除之外的渐近空间复杂度类。
例如,在为机器定义后,分别为:1。基于堆栈的内存管理,2。垃圾收集但没有尾调用,3。垃圾收集和尾调用,本文继续介绍更高级的存储管理策略,例如4.&#34; evlis tail recursion&#34;,其中不需要在尾部调用中的最后一个子表达式参数的评估中保留环境,5。将闭包的环境减少到< em>只是该闭包的自由变量,以及6.所谓的&#34; safe-for-space&#34;由Appel and Shao定义的语义。
为了证明这些机器实际上属于六个不同的空间复杂性类别,对于每对比较的机器,本文提供了一些程序的具体示例,这些程序将在一台机器上暴露渐近空间爆炸而不是其他
(现在阅读我的回答,我不确定我是否真的能够抓住Clinger paper的关键点。但是,唉,我不能把更多的时间用于开发这个现在回答。)
答案 23 :(得分:2)
递归函数是自行调用
的函数它允许程序员使用最少的代码来编写高效的程序。
缺点是,如果写得不正确,它们会导致无限循环和其他意外结果。
我将解释简单递归函数和尾递归函数
为了编写简单的递归函数
从给定的示例中:
public static int fact(int n){
if(n <=1)
return 1;
else
return n * fact(n-1);
}
从上面的示例
if(n <=1)
return 1;
是退出循环的决定因素
else
return n * fact(n-1);
是要完成的实际处理吗
为了便于理解,让我一步一步地完成任务。
让我们看看如果我运行fact(4)
public static int fact(4){
if(4 <=1)
return 1;
else
return 4 * fact(4-1);
}
If
循环失败,因此进入else
循环
因此它返回4 * fact(3)
在堆栈内存中,我们有4 * fact(3)
替换n = 3
public static int fact(3){
if(3 <=1)
return 1;
else
return 3 * fact(3-1);
}
If
循环失败,因此进入else
循环
因此它返回3 * fact(2)
请记住,我们将其称为“''4 * fact(3)``
fact(3) = 3 * fact(2)
到目前为止,堆栈中有4 * fact(3) = 4 * 3 * fact(2)
在堆栈内存中,我们有4 * 3 * fact(2)
替换n = 2
public static int fact(2){
if(2 <=1)
return 1;
else
return 2 * fact(2-1);
}
If
循环失败,因此进入else
循环
因此它返回2 * fact(1)
请记住我们叫4 * 3 * fact(2)
fact(2) = 2 * fact(1)
到目前为止,堆栈中有4 * 3 * fact(2) = 4 * 3 * 2 * fact(1)
在堆栈内存中,我们有4 * 3 * 2 * fact(1)
替换n = 1
public static int fact(1){
if(1 <=1)
return 1;
else
return 1 * fact(1-1);
}
If
循环为真
因此它返回1
请记住我们叫4 * 3 * 2 * fact(1)
fact(1) = 1
到目前为止,堆栈中有4 * 3 * 2 * fact(1) = 4 * 3 * 2 * 1
最后, fact(4)= 4 * 3 * 2 * 1 = 24 的结果
尾部递归为
public static int fact(x, running_total=1) {
if (x==1) {
return running_total;
} else {
return fact(x-1, running_total*x);
}
}
public static int fact(4, running_total=1) {
if (x==1) {
return running_total;
} else {
return fact(4-1, running_total*4);
}
}
If
循环失败,因此进入else
循环
因此它返回fact(3, 4)
在堆栈内存中,我们有fact(3, 4)
替换n = 3
public static int fact(3, running_total=4) {
if (x==1) {
return running_total;
} else {
return fact(3-1, 4*3);
}
}
If
循环失败,因此进入else
循环
因此它返回fact(2, 12)
在堆栈内存中,我们有fact(2, 12)
替换n = 2
public static int fact(2, running_total=12) {
if (x==1) {
return running_total;
} else {
return fact(2-1, 12*2);
}
}
If
循环失败,因此进入else
循环
因此它返回fact(1, 24)
在堆栈内存中,我们有fact(1, 24)
替换n = 1
public static int fact(1, running_total=24) {
if (x==1) {
return running_total;
} else {
return fact(1-1, 24*1);
}
}
If
循环为真
因此它返回running_total
running_total = 24
最后, fact(4,1)= 24
的结果答案 24 :(得分:1)
许多人已经在这里解释了递归。我想引用一些关于递归从Riccardo Terrell的书“ .NET中的并发,并发和并行编程的现代模式”中获得的一些优点的想法:
“函数递归是在FP中进行迭代的自然方法,因为它 避免状态突变。在每次迭代期间,都会传递一个新值 进入循环构造函数,而不是进行更新(变异)。在 另外,可以组成一个递归函数,使您的程序 更加模块化,并引入利用机会 并行化。”
以下是同一本书中有关尾递归的一些有趣的注释:
尾调用递归是一种转换常规递归的技术 成为可以处理大量输入的优化版本 没有任何风险和副作用。
注意尾部调用作为优化的主要原因是 提高数据局部性,内存使用率和缓存使用率。通过做一条尾巴 调用时,被调用方使用与调用方相同的堆栈空间。这减少了 记忆压力。它可以略微改善缓存,因为 内存被后续调用者重用,并且可以保留在缓存中, 而不是驱逐较旧的缓存行为新缓存腾出空间 线。
答案 25 :(得分:1)
tail递归函数是一个递归函数,在返回之前,它执行的最后一个操作是调用递归函数。即,立即返回递归函数调用的返回值。例如,您的代码如下所示:
def recursiveFunction(some_params):
# some code here
return recursiveFunction(some_args)
# no code after the return statement
实现尾调用优化或尾调用消除的编译器和解释器可以优化递归代码以防止堆栈溢出。如果您的编译器或解释器未实现尾部调用优化(例如CPython解释器),则以这种方式编写代码没有任何其他好处。
例如,这是Python中的标准递归阶乘函数:
def factorial(number):
if number == 1:
# BASE CASE
return 1
else:
# RECURSIVE CASE
# Note that `number *` happens *after* the recursive call.
# This means that this is *not* tail call recursion.
return number * factorial(number - 1)
这是阶乘函数的尾调用递归版本:
def factorial(number, accumulator=1):
if number == 0:
# BASE CASE
return accumulator
else:
# RECURSIVE CASE
# There's no code after the recursive call.
# This is tail call recursion:
return factorial(number - 1, number * accumulator)
print(factorial(5))
(请注意,即使这是Python代码,CPython解释器也不会进行尾部调用优化,因此像这样安排代码不会给运行时带来任何好处。)
如阶乘示例所示,您可能必须使代码更加不可读才能利用尾部调用优化。 (例如,基本情况现在有点不直观了,accumulator
参数实际上被用作一种全局变量。)
但是尾部调用优化的好处是可以防止堆栈溢出错误。 (我会注意到,通过使用迭代算法而不是递归算法,您可以获得相同的好处。)
堆栈溢出是由于调用堆栈中放入过多帧对象而引起的。调用函数时,将框架对象压入调用堆栈,并在函数返回时从调用堆栈弹出。框架对象包含信息,例如局部变量以及函数返回时返回的代码行。
如果您的递归函数进行太多的递归调用而没有返回,则调用堆栈可能超出其框架对象限制。 (该数字因平台而异;在Python中,默认为1000个框架对象。)这会导致堆栈溢出错误。 (嘿,这就是这个网站的名称!)
但是,如果递归函数做的最后一件事是进行递归调用并返回其返回值,则没有理由需要保持当前帧对象保留在调用堆栈中。毕竟,如果在递归函数调用之后没有任何代码,则没有理由继续使用当前帧对象的局部变量。因此,我们可以立即摆脱当前框架对象,而不必将其保留在调用堆栈中。最终结果是您的调用堆栈不会增加大小,因此不会导致堆栈溢出。
编译器或解释器必须具有尾调用优化功能,才能识别何时可以应用尾调用优化。即使这样,您也可能已经在递归函数中重新安排了代码以利用尾部调用优化,如果这种可读性的降低值得进行优化,则取决于您。
答案 26 :(得分:1)
这是一种特殊的递归形式,其中函数的最后一个操作是递归调用。通过在当前堆栈帧中执行调用并返回其结果而不是创建新的堆栈帧,可以优化递归。
当递归调用是该函数执行的最后一件事时,递归函数就是尾部递归。例如,以下C ++函数print()是尾递归的。
尾部递归函数的示例
void print(int n)
{
if (n < 0) return;
cout << " " << n;
print(n-1);}
// The last executed statement is recursive call
被认为比非尾递归函数更好的尾递归函数,因为尾递归可以由编译器进行优化。编译器用来优化尾递归函数的想法很简单,因为递归调用是最后一条语句,所以当前函数没有任何事情要做,因此保存当前函数的堆栈框架毫无用处。
答案 27 :(得分:0)
与普通递归相比,尾递归非常快。 之所以快,是因为祖先调用的输出不会写在堆栈中以保持跟踪。 但是在正常的递归中,所有祖先都会调用以堆栈形式编写的输出来保持跟踪。
答案 28 :(得分:0)
如果每个递归案例仅包含对函数本身的调用,并且可能使用不同的参数,则该函数为尾递归。或者,尾递归是没有待处理工作的递归。请注意,这是一个与编程语言无关的概念。
将函数定义为:
g(a, b, n) = a * b^n
可能的尾递归公式为:
g(a, b, n) | n is zero = a
| n is odd = g(a*b, b, n-1)
| otherwise = g(a, b*b, n/2)
如果检查涉及递归案例的g(...)
的每个RHS,您会发现RHS的整个主体是对g(...)
的调用,并且仅 那。这个定义是 tail recursive 。
为进行比较,非尾递归公式可能是:
g'(a, b, n) = a * f(b, n)
f(b, n) | n is zero = 1
| n is odd = f(b, n-1) * b
| otherwise = f(b, n/2) ^ 2
f(...)
中的每个递归案例都有一些待处理的工作需要在递归调用之后发生。
请注意,当我们从g'
转到g
时,我们就充分利用了关联性
(和可交换性)的乘法。这不是偶然的,大多数情况下,您需要将递归转换为尾递归将利用这些属性:如果我们要热切地做一些工作而不是搁置它,我们必须使用类似关联性的方法来证明答案将是相同的。
尾部递归调用可以通过向后跳转来实现,而不是将堆栈用于普通递归调用。请注意,检测尾部呼叫或发出向后跳转通常很简单。但是,通常很难重新排列参数以使向后跳转成为可能。由于此优化并非免费的,因此语言实现可以选择不实施此优化,也可以通过使用“ tailcall”指令标记递归调用和/或选择更高的优化设置来要求加入。
但是,某些语言(例如Scheme)确实需要 all 实现来优化尾部递归函数,甚至可能是所有位于尾部位置的调用。
在大多数命令式语言中,向后跳转通常抽象为(while)循环,而尾递归在优化为向后跳转时与循环是同构的。