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);
}
在 traditional recursion 中,典型的模型是首先执行递归调用,然后获取递归调用的返回值并计算结果 . 以这种方式,在每次递归调用返回之前,您不会得到计算结果 .
在 tail recursion 中,首先执行计算,然后执行递归调用,将当前步骤的结果传递给下一个递归步骤 . 这导致最后一个语句采用 (return (recursive-function params)) 的形式 . Basically, the return value of any given recursive step is the same as the return value of the next recursive call .
23 回答
在Java中,这里有一个可能的Fibonacci函数的尾递归实现:
将此与标准递归实现进行对比:
考虑一个添加前N个整数的简单函数 . (例如
sum(5) = 1 + 2 + 3 + 4 + 5 = 15
) .这是一个使用递归的简单JavaScript实现:
如果你调用了
recsum(5)
,这就是JavaScript解释器会评估的内容:请注意每个递归调用必须在JavaScript解释器开始实际执行计算总和之前完成 .
这是同一函数的尾递归版本:
这是您调用
tailrecsum(5)
时会发生的事件序列(由于默认的第二个参数,它实际上是tailrecsum(5, 0)
) .在尾递归的情况下,每次递归调用的评估都会更新
running_total
.注意:原始答案使用了Python中的示例 . 这些已经改为JavaScript,因为现代JavaScript解释器支持tail call optimization但Python解释器不支持 .
“Lua编程”一书的摘录显示how to make a proper tail recursion(在Lua中,但也应该适用于Lisp)以及为什么它更好 .
功能室2()
local move = io.read()
如果移动==“南”然后返回room4()
elseif move ==“west”然后返回room1()
否则打印(“无效移动”)
返回room2()
结束
结束
功能室3()
local move = io.read()
如果move ==“north”则返回room1()
elseif move ==“east”然后返回room4()
否则打印(“无效移动”)
返回room3()
结束
结束
功能室4()
打印( “祝贺你!”)
结束
所以你看,当你做一个递归调用,如:
这不是尾递归,因为在进行递归调用之后,您仍然可以在该函数中执行操作(添加1) . 如果输入一个非常高的数字,它可能会导致堆栈溢出 .
为了理解尾调用递归和非尾调用递归之间的一些核心差异,我们可以探索这些技术的.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 .
使用常规递归,每个递归调用将另一个条目推送到调用堆栈 . 递归完成后,应用程序必须将每个条目一直弹回 .
使用尾递归,根据语言,编译器可能能够将堆栈折叠为一个条目,因此您可以节省堆栈空间......大型递归查询实际上可能导致堆栈溢出 .
基本上Tail递归可以优化为迭代 .
我不是Lisp程序员,但我认为this会有所帮助 .
基本上它是一种编程风格,使得递归调用是你做的最后一件事 .
尾递归是你现在的生活 . 你不断地循环使用相同的堆栈帧,因为没有理由或方法可以返回到“之前的”帧 . 过去已经完成,所以它可以被丢弃 . 你得到一个框架,永远移动到未来,直到你的过程不可避免地死亡 .
当你考虑某些进程可能会使用额外的帧但是如果堆栈没有无限增长时仍然被认为是尾递归的话,这个类比就会崩溃 .
这是一个比较两个函数的快速代码片段 . 第一种是传统递归,用于查找给定数字的阶乘 . 第二个使用尾递归 .
理解起来非常简单直观 .
判断递归函数是否为尾递归的简单方法是它是否在基本情况下返回具体值 . 意味着它不会返回1或true或类似的东西 . 它很可能会返回一个方法参数的变体 .
另一种方法是判断递归调用是否没有任何添加,算术,修改等等...意味着它只是一个纯粹的递归调用 .
这是关于尾递归的Structure and Interpretation of Computer Programs的摘录 .
这是一个Common Lisp示例,它使用尾递归来执行阶乘 . 由于无堆栈特性,人们可以执行疯狂的大因子计算......
然后为了好玩你可以尝试
(format nil "~R" (! 25))
这个问题有很多很好的答案......但是我不得不提出如何定义"tail recursion",或者至少"proper tail recursion."的另一种看法 . 即:应该把它看作程序中特定表达式的属性吗?或者应该将其视为编程语言实现的属性?
有关后一种观点的更多信息,Will Clinger,"Proper Tail Recursion and Space Efficiency"(PLDI 1998)的经典paper将"proper tail recursion"定义为编程语言实现的属性 . 构造定义是为了允许忽略实现细节(例如调用堆栈是否实际通过运行时堆栈或通过堆分配的链接帧列表来表示) .
为了实现这一点,它使用渐近分析:不是通常看到的程序执行时间,而是程序空间使用 . 这样,堆分配的链表与运行时调用栈的空间使用最终是渐近等价的;所以人们可以忽略那种编程语言的实现细节(这个细节在实践中确实很重要,但是当人们尝试确定给定的实现是否满足要求"property tail recursive")
该论文值得仔细研究,原因如下:
以下是这些定义,只是为了提供文本的味道:
(尾递归调用,或者正如文章所说,“自尾调用”是尾调用的特例,其中自己调用过程 . )
例如,在为机器定义后,分别为:1 . 基于堆栈的内存管理,2 . 垃圾收集,但没有尾调用,3 . 垃圾收集和尾调用,本文继续介绍更高级的存储管理策略,如4. "evlis tail recursion",其中不需要在尾部调用中的最后一个子表达式参数的评估中保留环境,5 . 将闭包的环境简化为该闭包的自由变量,并且6. so-由Appel and Shao定义的"safe-for-space"语义 .
(现在阅读我的答案,我'm not sure if I' m设法实际捕获了Clinger paper的关键点 . 但是,唉,我现在不能把更多的时间用于开发这个答案 . )
简而言之,尾递归具有递归调用作为函数中的 last 语句,因此它不必等待递归调用 .
所以这是一个尾递归,即N(x - 1,p * x)是函数中的最后一个语句,编译器很聪明地知道它可以被优化为for循环(factorial) . 第二个参数p带有中间产品值 .
这是编写上述阶乘函数的非尾递归方式(尽管有些C编译器可能无论如何都能优化它) .
但这不是:
我写了一篇名为“Understanding Tail Recursion – Visual Studio C++ – Assembly View”的长篇文章
递归意味着一个函数调用自身 . 例如:
Tail-Recursion表示结束函数的递归:
看,最后一个未结束的函数(过程,在方案术语中)做的是调用自身 . 另一个(更有用)的例子是:
在辅助程序中,如果左边不是nil,它会做的最后一件事就是自己调用(事后有些东西和cdr的东西) . 这基本上是您映射列表的方式 .
尾递归具有很大的优势,解释器(或编译器,依赖于语言和供应商)可以优化它,并将其转换为等同于while循环的东西 . 事实上,在Scheme传统中,大多数“for”和“while”循环是以尾递归的方式完成的(据我所知,没有for和while) .
行话文件有关于尾递归定义的说法:
tail recursion /n./
如果你还没有厌倦它,请参阅尾递归 .
我理解
tail call recursion
的最好方法是递归的特例,其中 last call (或尾调用)就是函数本身 .比较Python中提供的示例:
^递推
^ TAIL RECURSION
正如您在常规递归版本中所看到的,代码块中的最终调用是
x + recsum(x - 1)
. 因此在调用recsum
方法之后,还有另一个操作x + ..
.但是,在尾递归版本中,代码块中的最终调用(或尾调用)是
tailrecsum(x - 1, running_total + x)
,这意味着最后一次调用方法本身并且之后没有操作 .这一点很重要,因为这里看到的尾递归并没有使内存增长,因为当底层VM看到一个函数调用自身处于尾部位置(在函数中要计算的最后一个表达式)时,它会消除当前的堆栈帧,被称为尾部呼叫优化(TCO) .
编辑
NB . 请记住,上面的示例是用Python编写的,其运行时不支持TCO . 这只是一个例子来解释这一点 . Scheme,Haskell等语言支持TCO
重要的一点是尾递归本质上相当于循环 . 这不仅仅是编译器优化的问题,而是表达性的基本事实 . 这有两种方式:你可以采取任何形式的循环
其中
E
和Q
是表达式,S
是一个语句序列,并将其转换为尾递归函数当然,必须定义
E
,S
和Q
来计算某些变量的一些有趣值 . 例如,循环功能相当于尾递归函数
(使用具有较少参数的函数对尾递归函数进行“包装”是一种常见的功能习惯 . )
递归有两种基本类型: head recursion 和 tail recursion.
取自this超级棒的帖子 . 请考虑阅读 .
在 traditional recursion 中,典型的模型是首先执行递归调用,然后获取递归调用的返回值并计算结果 . 以这种方式,在每次递归调用返回之前,您不会得到计算结果 .
在 tail recursion 中,首先执行计算,然后执行递归调用,将当前步骤的结果传递给下一个递归步骤 . 这导致最后一个语句采用
(return (recursive-function params))
的形式 . Basically, the return value of any given recursive step is the same as the return value of the next recursive call .这样做的结果是,一旦准备好执行下一个递归步骤,就不再需要当前的堆栈帧了 . 这允许一些优化 . 实际上,使用适当编写的编译器,您应该永远不会有带尾递归调用的堆栈溢出窃听器 . 只需重复使用当前堆栈帧进行下一个递归步骤 . 我很确定Lisp会这样做 .
这是前面提到的
tailrecsum
函数的Perl 5版本 .尾递归是指递归调用在递归算法的最后一条逻辑指令中的最后一次 .
通常在递归中你有一个基本情况,它停止递归调用并开始弹出调用堆栈 . 使用一个经典的例子,虽然比Lisp更多的C-ish,但是阶乘函数说明了尾递归 . 检查基本情况后发生递归调用 .
注意,对factorial的初始调用必须是阶乘(n,1),其中n是要计算阶乘的数字 .
这意味着您不必将指令指针推到堆栈上,只需跳转到递归函数的顶部并继续执行即可 . 这允许函数无限递归,而不会溢出堆栈 .
我在这个主题上写了一篇blog帖子,里面有堆栈帧的图形示例 .
考虑计算数字因子的问题 .
一个简单的方法是:
假设你调用factorial(4) . 递归树将是:
上述情况下的最大递归深度为O(n) .
但是,请考虑以下示例:
factTail(4)的递归树将是:
这里,最大递归深度是O(n),但没有一个调用将任何额外的变量添加到堆栈 . 因此编译器可以取消堆栈 .
这是一个例子,而不是用文字解释 . 这是阶乘函数的Scheme版本:
这是一个尾递归的阶乘版本:
您将在第一个版本中注意到对事实的递归调用被送入乘法表达式,因此在进行递归调用时必须将状态保存在堆栈中 . 在尾递归版本中,没有其他S表达式等待递归调用的值,并且由于没有进一步的工作要做,因此不必将状态保存在堆栈中 . 通常,Scheme尾递归函数使用常量堆栈空间 .