What is tail recursion?
当开始学习Lisp时,我遇到了术语tail recursive。这到底是什么意思?
考虑将前n个整数相加的简单函数。(如
下面是一个使用递归的简单JavaScript实现:
1 2 3 4 5 6 7 | function recsum(x) { if (x===1) { return x; } else { return x + recsum(x-1); } } |
如果您调用
1 2 3 4 5 6 7 | 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解释器开始实际计算和之前,每个递归调用都必须完成。
下面是相同函数的尾部递归版本:
1 2 3 4 5 6 7 | function tailrecsum(x, running_total=0) { if (x===0) { return running_total; } else { return tailrecsum(x-1, running_total+x); } } |
下面是如果您调用
1 2 3 4 5 6 7 | tailrecsum(5, 0) tailrecsum(4, 5) tailrecsum(3, 9) tailrecsum(2, 12) tailrecsum(1, 14) tailrecsum(0, 15) 15 |
在尾部递归的情况下,每次对递归调用进行评估时,都会更新
注意:最初的答案使用了Python中的示例。由于现代的javascript解释器支持尾调用优化,但python解释器不支持,所以这些都改为javascript。
在传统的递归中,典型的模型是先执行递归调用,然后获取递归调用的返回值并计算结果。以这种方式,在每次递归调用返回之前,不会得到计算结果。
在尾部递归中,首先执行计算,然后执行递归调用,将当前步骤的结果传递到下一个递归步骤。这导致最后一个语句的形式为
这样做的结果是,一旦您准备好执行下一个递归步骤,就不再需要当前的堆栈帧了。这样可以进行一些优化。事实上,对于一个适当编写的编译器,您不应该有一个带有尾部递归调用的堆栈溢出snicker。只需在下一个递归步骤中重用当前堆栈帧。我很肯定Lisp会这么做。
重要的一点是尾部递归本质上等同于循环。这不仅仅是编译器优化的问题,也是表达能力的一个基本事实。这是双向的:您可以采用表单的任何循环
1 | while(E) { S }; return Q |
其中
1 | f() = if E then { S; return f() } else { return Q } |
当然,必须定义
1 2 3 4 5 6 7 8 | sum(n) { int i = 1, k = 0; while( i <= n ) { k += i; ++i; } return k; } |
相当于尾部递归函数
1 2 3 4 5 6 7 8 9 10 11 | 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); } |
(这种用参数较少的函数"包装"尾部递归函数是一种常见的函数习惯用法。)
本文摘录自Lua中的编程一书,展示了如何进行适当的尾部递归(在Lua中,但是也应该应用于Lisp)以及为什么它更好。
A tail call [tail recursion] is a kind of goto dressed
as a call. A tail call happens when a
function calls another as its last
action, so it has nothing else to do.
For instance, in the following code,
the call tog is a tail call:
1
2
3 function f (x)
return g(x)
endAfter
f callsg , it has nothing else
to do. In such situations, the program
does not need to return to the calling
function when the called function
ends. Therefore, after the tail call,
the program does not need to keep any
information about the calling function
in the stack. ...Because a proper tail call uses no
stack space, there is no limit on the
number of"nested" tail calls that a
program can make. For instance, we can
call the following function with any
number as argument; it will never
overflow the stack:
1
2
3 function foo (n)
if n > 0 then return foo(n - 1) end
end... As I said earlier, a tail call is a
kind of goto. As such, a quite useful
application of proper tail calls in
Lua is for programming state machines.
Such applications can represent each
state by a function; to change state
is to go to (or to call) a specific
function. As an example, let us
consider a simple maze game. The maze
has several rooms, each with up to
four doors: north, south, east, and
west. At each step, the user enters a
movement direction. If there is a door
in that direction, the user goes to
the corresponding room; otherwise, the
program prints a warning. The goal is
to go from an initial room to a final
room.This game is a typical state machine,
where the current room is the state.
We can implement such maze with one
function for each room. We use tail
calls to move from one room to
another. A small maze with four rooms
could look like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 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
所以,当您进行如下递归调用时:
1 2 3 4 5 | function x(n) if n==0 then return 0 n= n-2 return x(n) + 1 end |
这不是尾递归,因为在进行递归调用后,您仍然需要在该函数中执行(添加1)操作。如果输入一个非常高的数字,它可能会导致堆栈溢出。
使用常规递归,每个递归调用将另一个条目推送到调用堆栈中。当递归完成后,应用程序必须将每个条目全部弹出。
对于尾部递归,编译器可能能够将堆栈折叠到一个条目,这取决于语言,因此可以节省堆栈空间…大型递归查询实际上会导致堆栈溢出。
基本上尾部递归可以优化为迭代。
这里是一个例子,而不是用文字来解释。这是factorial函数的方案版本:
1 2 3 | (define (factorial x) (if (= x 0) 1 (* x (factorial (- x 1))))) |
这里是factorial的一个版本,它是tail递归的:
1 2 3 4 5 6 | (define factorial (letrec ((fact (lambda (x accum) (if (= x 0) accum (fact (- x 1) (* accum x)))))) (lambda (x) (fact x 1)))) |
在第一个版本中,您会注意到对fact的递归调用被送入乘法表达式,因此在进行递归调用时,状态必须保存在堆栈上。在尾部递归版本中,没有其他s-expression等待递归调用的值,而且由于没有进一步的工作要做,因此状态不必保存在堆栈上。通常,模式尾递归函数使用恒定的堆栈空间。
行话文件对尾部递归的定义有这样的说明:
尾部递归/n.。/
如果您还不厌倦它,请参见tail递归。
尾递归是指递归算法中最后一个逻辑指令中的最后一个递归调用。
通常在递归中,您有一个基本情况,即停止递归调用并开始弹出调用堆栈。为了使用一个经典的例子,虽然比lisp更C-ish,但是factorial函数说明了尾部递归。递归调用在检查基本情况条件后发生。
1 2 3 4 5 6 | factorial(x, fac) { if (x == 1) return fac; else return factorial(x-1, x*fac); } |
注意,对factorial的初始调用必须是factorial(n,1),其中n是要计算factorial的数字。
这意味着,不需要将指令指针推到堆栈上,只需跳到递归函数的顶部并继续执行即可。这允许函数无限期地循环,而不会溢出堆栈。
我写了一篇关于这个主题的博客文章,其中有一些关于堆栈框架的图形示例。
下面是比较两个函数的快速代码段。第一种是传统的递归,用于查找给定数字的阶乘。第二个使用尾递归。
非常简单和直观的理解。
判断递归函数是否为尾部递归的一个简单方法是,它是否返回基本情况下的具体值。这意味着它不会返回1或true或类似的内容。它很可能返回某个方法参数的一些变量。
另一种方法是判断递归调用是否没有任何加法、算术、修改等。这意味着它只是一个纯粹的递归调用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | 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); } } |
我理解
比较python中提供的示例:
1 2 3 4 5 | def recsum(x): if x == 1: return x else: return x + recsum(x - 1) |
递归
1 2 3 4 5 | def tailrecsum(x, running_total=0): if x == 0: return running_total else: return tailrecsum(x - 1, running_total + x) |
尾递归
正如您在一般递归版本中看到的,代码块中的最后一个调用是
但是,在tail递归版本中,代码块中的最后一个调用(或tail调用)是
这一点很重要,因为这里看到的tail递归不会使内存增长,因为当底层VM看到一个函数在tail位置调用自己(函数中要计算的最后一个表达式)时,它会消除当前的堆栈帧,即tail call optimization(TCO)。
编辑铌。请记住,上面的示例是用运行时不支持TCO的python编写的。这只是一个解释这一点的例子。TCO支持Scheme、Haskell等语言。
在爪哇,这里有一个可能的Fibonacci函数的尾部递归实现:
1 2 3 4 5 6 7 8 9 10 11 | 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); } |
与标准递归实现相比:
1 2 3 4 5 | public int recursive(final int n) { if (n <= 2) return 1; return recursive(n - 1) + recursive(n - 2); } |
下面是一个使用尾部递归进行阶乘的常见Lisp示例。由于无栈性,人们可以执行疯狂的大阶乘计算…
1 2 3 | (defun ! (n &optional (product 1)) (if (zerop n) product (! (1- n) (* product n)))) |
然后为了好玩,你可以试试
我不是Lisp程序员,但我认为这会有所帮助。
基本上,它是一种编程风格,这样递归调用就是最后一件事。
简而言之,尾部递归将递归调用作为函数中的最后一条语句,这样就不必等待递归调用。
所以这是一个尾部递归,即n(x-1,p*x)是函数中的最后一个语句,编译器很聪明地发现它可以优化为for循环(factorial)。第二个参数p表示中间产品值。
1 2 3 | function N(x, p) { return x == 1 ? p : N(x - 1, p * x); } |
这是编写上述阶乘函数的非尾递归方法(尽管某些C++编译器可能能够优化它)。
1 2 3 | function N(x) { return x == 1 ? 1 : x * N(x - 1); } |
但这不是:
1 2 3 4 5 | function F(x) { if (x == 1) return 0; if (x == 2) return 1; return F(x - 1) + F(x - 2); } |
我写了一篇长文章,标题是"理解尾部递归- VisualStudioC++ +汇编视图"
这里是前面提到的
1 2 3 4 5 6 7 8 | 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 } |
这是从计算机程序的结构和解释中摘录的关于尾递归的内容。
In contrasting iteration and recursion, we must be careful not to
confuse the notion of a recursive process with the notion of a
recursive procedure. When we describe a procedure as recursive, we are
referring to the syntactic fact that the procedure definition refers
(either directly or indirectly) to the procedure itself. But when we
describe a process as following a pattern that is, say, linearly
recursive, we are speaking about how the process evolves, not about
the syntax of how a procedure is written. It may seem disturbing that
we refer to a recursive procedure such as fact-iter as generating an
iterative process. However, the process really is iterative: Its state
is captured completely by its three state variables, and an
interpreter need keep track of only three variables in order to
execute the process.One reason that the distinction between process and procedure may be
confusing is that most implementations of common languages (including Ada, Pascal, and
C) are designed in such a way that the interpretation of any recursive
procedure consumes an amount of memory that grows with the number of
procedure calls, even when the process described is, in principle,
iterative. As a consequence, these languages can describe iterative
processes only by resorting to special-purpose"looping constructs"
such as do, repeat, until, for, and while. The implementation of
Scheme does not share this defect. It
will execute an iterative process in constant space, even if the
iterative process is described by a recursive procedure. An
implementation with this property is called tail-recursive. With a
tail-recursive implementation, iteration can be expressed using the
ordinary procedure call mechanism, so that special iteration
constructs are useful only as syntactic sugar.
尾递归就是你现在的生活。您不断地循环使用同一个堆栈帧,一次又一次,因为没有理由或方法返回到"上一个"帧。过去的事已经过去了,可以抛弃了。你得到一个框架,永远进入未来,直到你的过程不可避免地消亡。
当您考虑某些进程可能使用额外的帧时,类比会失效,但如果堆栈不无限增长,则仍被视为尾部递归。
A tail recursion is a recursive function where the function calls
itself at the end ("tail") of the function in which no computation is
done after the return of recursive call. Many compilers optimize to
change a recursive call to a tail recursive or an iterative call.
考虑一个数的阶乘的计算问题。
一个简单的方法是:
1 2 3 4 5 | factorial(n): if n==0 then 1 else n*factorial(n-1) |
假设您称为阶乘(4)。递归树是:
1 2 3 4 5 6 7 8 9 10 11 | factorial(4) / \ 4 factorial(3) / \ 3 factorial(2) / \ 2 factorial(1) / \ 1 factorial(0) \ 1 |
上述情况下的最大递归深度为O(n)。
但是,请考虑以下示例:
1 2 3 4 5 6 | factAux(m,n): if n==0 then m; else factAux(m*n,n-1); factTail(n): return factAux(1,n); |
facttail(4)的递归树为:
1 2 3 4 5 6 7 8 9 10 11 12 13 | factTail(4) | factAux(1,4) | factAux(4,3) | factAux(12,2) | factAux(24,1) | factAux(24,0) | 24 |
在这里,最大递归深度是O(N),但是没有任何调用向堆栈添加任何额外的变量。因此编译器可以去掉堆栈。
为了理解尾调用递归和非尾调用递归之间的一些核心区别,我们可以探索这些技术的.NET实现。
这里有一篇文章,其中有C,f,c++CLI中的一些例子:C,f,c++CLI中尾递归的冒险。
C不优化尾调用递归,而F优化尾调用递归。
原理上的差异涉及循环与lambda演算。C设计时考虑到了循环,而F是根据lambda微积分原理构建的。关于lambda微积分原理的一本非常好(免费)的书,请参阅Abelson、Sussman和Sussman的《计算机程序的结构和解释》。
关于f_中的尾部调用,有关非常好的介绍性文章,请参见f_中的尾部调用的详细介绍。最后,这里有一篇文章介绍了非尾递归和尾调用递归(在f)之间的区别:尾递归和f sharp中的非尾递归。
如果您想了解C和F之间尾调用递归的一些设计差异,请参见在C和F中生成尾调用操作码。
如果您非常关心什么条件阻止C编译器执行尾调用优化,请参阅本文:jit clr尾调用条件。
递归是指调用自身的函数。例如:
1 2 3 | (define (un-ended name) (un-ended 'me) (print"How can I get here?")) |
尾递归是指结束函数的递归:
1 2 3 | (define (un-ended name) (print"hello") (un-ended 'me)) |
看,un-ended函数(过程,在方案行话中)做的最后一件事就是调用它自己。另一个(更有用的)例子是:
1 2 3 4 5 6 7 8 | (define (map lst op) (define (helper done left) (if (nil? left) done (helper (cons (op (car left)) done) (cdr left)))) (reverse (helper '() lst))) |
在助手过程中,如果左边不是零,它所做的最后一件事就是调用自己(在cons-something和cdr-something之后)。基本上就是这样映射列表的。
尾部递归有一个很大的优势,即解释器(或编译器,依赖于语言和供应商)可以优化它,并将其转换为相当于while循环的东西。实际上,在方案传统中,大多数"for"和"while"循环都是以尾部递归方式完成的(据我所知,没有for和while)。
有两种基本的递归:头递归和尾递归。
In head recursion, a function makes its recursive call and then
performs some more calculations, maybe using the result of the
recursive call, for example.In a tail recursive function, all calculations happen first and
the recursive call is the last thing that happens.
从这个超级棒的职位上。请考虑读一下。
这个问题有很多很好的答案…但是我不得不同意另一种说法,那就是如何定义"尾部递归",或者至少定义"适当的尾部递归"。也就是说:应该把它看作程序中特定表达式的属性吗?或者应该把它看作编程语言实现的一个属性吗?
对于后一种观点,有一篇经典的will-clinger论文,"适当的尾部递归和空间效率"(PLDI 1998),将"适当的尾部递归"定义为编程语言实现的一个属性。定义的构造允许忽略实现细节(例如调用堆栈实际上是通过运行时堆栈还是通过堆栈分配的帧链接列表表示的)。
为了实现这一点,它使用渐进分析:不像人们通常看到的那样,是程序执行时间,而是程序空间的使用。这样,分配给堆的链接列表与运行时调用堆栈的空间使用量最终会渐进地相等;因此,我们可以忽略编程语言实现细节(在实践中,这一细节确实很重要,但在试图确定给定实现是否n满足"属性尾递归"的要求)
本文值得仔细研究的原因有很多:
它给出了程序尾部表达式和尾部调用的归纳定义。(这种定义,以及为什么这种呼叫很重要,似乎是这里给出的大多数其他答案的主题。)
以下是这些定义,只是为了提供文本的风格:
Definition 1 The tail expressions of a program written in Core Scheme are defined inductively as follows.
- The body of a lambda expression is a tail expression
- If
(if E0 E1 E2) is a tail expression, then bothE1 andE2 are tail expressions.- Nothing else is a tail expression.
Definition 2 A tail call is a tail expression that is a procedure call.
(尾部递归调用,或者如本文所说,"self tail call"是尾部调用的一种特殊情况,在这种情况下,过程本身被调用。)
它为六个不同的"机器"提供了评估核心方案的形式定义,其中每台机器都有相同的可观测行为,除了每个机器所处的渐进空间复杂性类。
例如,在分别给出机器的定义之后,1。基于堆栈的内存管理,2.垃圾收集,但没有尾调用,3.垃圾收集和尾叫,论文继续采用更先进的存储管理策略,如4。"evlis tail recursion",其中不需要在tail调用中最后一个子表达式参数的计算过程中保留环境,5。将一个闭包的环境简化为该闭包的自由变量,以及6。所谓的"空间安全"语义学由阿佩尔和邵定义。
为了证明这些机器实际上属于六个不同的空间复杂度类,本文对每对被比较的机器提供了具体的程序示例,这些程序将揭示一台机器而不是另一台机器上的渐进空间膨胀。
(现在仔细阅读我的答案,我不确定我是否真的能够捕捉到粘纸的关键点。但是,唉,我现在不能花更多的时间来研究这个答案。)
递归函数是一个本身调用的函数
它允许程序员使用最少的代码编写高效的程序。
缺点是,如果编写不当,它们可能导致无限循环和其他意外结果。
我将解释简单递归函数和尾部递归函数
为了写一个简单的递归函数
从给出的示例中:
1 2 3 4 5 6 | public static int fact(int n){ if(n <=1) return 1; else return n * fact(n-1); } |
从上面的例子中
1 2 | if(n <=1) return 1; |
是退出循环的决定因素
1 2 | else return n * fact(n-1); |
实际处理要完成吗
为了便于理解,让我一个接一个地完成任务。
让我们看看如果我运行
1 2 3 4 5 6 | public static int fact(4){ if(4 <=1) return 1; else return 4 * fact(4-1); } |
在堆栈内存中,我们有
代入n=3
1 2 3 4 5 6 | public static int fact(3){ if(3 <=1) return 1; else return 3 * fact(3-1); } |
所以它返回
记住我们称之为"4"事实(3)``
到目前为止,这个堆栈有
在堆栈内存中,我们有
代入n=2
1 2 3 4 5 6 | public static int fact(2){ if(2 <=1) return 1; else return 2 * fact(2-1); } |
所以它返回
记住,我们叫
到目前为止,这个堆栈有
在堆栈内存中,我们有
代入n=1
1 2 3 4 5 6 | public static int fact(1){ if(1 <=1) return 1; else return 1 * fact(1-1); } |
所以它返回
记住,我们叫
到目前为止,这个堆栈有
最后,事实(4)=4*3*2*1=24
尾部递归将是
1 2 3 4 5 6 7 | public static int fact(x, running_total=1) { if (x==1) { return running_total; } else { return fact(x-1, running_total*x); } } |
1 2 3 4 5 6 7 | public static int fact(4, running_total=1) { if (x==1) { return running_total; } else { return fact(4-1, running_total*4); } } |
在堆栈内存中,我们有
代入n=3
1 2 3 4 5 6 7 | public static int fact(3, running_total=4) { if (x==1) { return running_total; } else { return fact(3-1, 4*3); } } |
所以它返回
在堆栈内存中,我们有
代入n=2
1 2 3 4 5 6 7 | public static int fact(2, running_total=12) { if (x==1) { return running_total; } else { return fact(2-1, 12*2); } } |
所以它返回
在堆栈内存中,我们有
代入n=1
1 2 3 4 5 6 7 | public static int fact(1, running_total=24) { if (x==1) { return running_total; } else { return fact(1-1, 24*1); } } |
所以它返回
最后,事实结果(4,1)=24
尾部递归函数是一个递归函数,返回前的最后一个操作是进行递归函数调用。即,立即返回递归函数调用的返回值。例如,您的代码如下所示:
1 2 3 4 | def recursiveFunction(some_params): # some code here return recursiveFunction(some_args) # no code after the return statement |
实现尾调用优化或尾调用消除的编译器和解释程序可以优化递归代码以防止堆栈溢出。如果编译器或解释器没有实现尾调用优化(如cpython解释器),那么用这种方式编写代码没有额外的好处。
例如,这是Python中的标准递归阶乘函数:
1 2 3 4 5 6 7 8 9 | 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) |
这是阶乘函数的尾调用递归版本:
1 2 3 4 5 6 7 8 9 10 | 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解释器也不会进行尾调用优化,因此像这样安排代码不会带来任何运行时好处。)
为了利用尾调用优化,您可能需要使代码更加不可读,如factorial示例所示。(例如,基本情况现在有点不直观,并且
但是尾调用优化的好处是它可以防止堆栈溢出错误。(我将注意到,通过使用迭代算法而不是递归算法,您可以获得同样的好处。)
当调用堆栈上推送了太多的帧对象时,会导致堆栈溢出。当调用函数时,一个帧对象被推到调用堆栈上,当函数返回时从调用堆栈中弹出。frame对象包含一些信息,比如局部变量和函数返回时返回的代码行。
如果递归函数在不返回的情况下进行过多的递归调用,则调用堆栈可能会超出其帧对象限制。(数字因平台而异;在Python中,默认为1000个帧对象。)这会导致堆栈溢出错误。(嘿,这就是这个网站的名字来源!)
但是,如果递归函数所做的最后一件事是进行递归调用并返回其返回值,那么它不需要将当前帧对象保留在调用堆栈上。毕竟,如果递归函数调用后没有代码,就没有理由挂起当前帧对象的局部变量。因此,我们可以立即除去当前帧对象,而不是将其保留在调用堆栈中。这样做的最终结果是,调用堆栈的大小不会增加,因此无法堆栈溢出。
编译器或解释器必须具有尾调用优化功能,以便能够识别何时可以应用尾调用优化。即使这样,您可能已经重新安排了递归函数中的代码,以利用尾调用优化,如果可读性的潜在降低值得优化,则由您决定。
许多人已经在这里解释了递归。我想引用几点关于递归从Riccardo Terrell的书".NET中的并发性,并发和并行编程的现代模式"中得到的一些好处的想法:
"Functional recursion is the natural way to iterate in FP because it
avoids mutation of state. During each iteration, a new value is passed
into the loop constructor instead to be updated (mutated). In
addition, a recursive function can be composed, making your program
more modular, as well as introducing opportunities to exploit
parallelization."
以下是同一本书中关于尾部递归的一些有趣的注释:
Tail-call recursion is a technique that converts a regular recursive
function into an optimized version that can handle large inputs
without any risks and side effects.NOTE The primary reason for a tail call as an optimization is to
improve data locality, memory usage, and cache usage. By doing a tail
call, the callee uses the same stack space as the caller. This reduces
memory pressure. It marginally improves the cache because the same
memory is reused for subsequent callers and can stay in the cache,
rather than evicting an older cache line to make room for a new cache
line.