什么是尾递归?


1692

在开始学习Lisp的同时,我遇到了术语“ 尾递归”。到底是什么意思?


153
出于好奇:使用该语言已有很长时间了。在使用古英语时;while是while的中古英语发展。作为连接词,它们在含义上可以互换,但是在标准的美式英语中却没有幸免。
Filip Bartuzi 2014年

14
也许已经晚了,但这是一篇关于尾递归的很好的文章:programmerinterview.com/index.php/recursion/tail-recursion
Sam003

5
识别尾部递归函数的一大好处是可以将其转换为迭代形式,从而使该算法摆脱了方法栈的开销。可能想访问@Kyle Cronin和下面的其他一些人的回复
KGhatak

@yesudeep的此链接是我找到的最好,最详细的描述-lua.org/pil/6.3.html
Jeff Fischer

1
有人可以告诉我,合并排序和快速排序是否使用尾递归(TRO)?
majurageerthan

Answers:


1717

考虑一个简单的函数,该函数将前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,因为Python解释器不支持尾部调用优化。但是,尽管尾部调用优化是ECMAScript 2015规范的一部分,但大多数JavaScript解释器都不支持它


32
我是否可以说,使用尾部递归,最终答案仅由方法的LAST调用来计算?如果不是尾递归,则需要所有方法的所有结果来计算答案。
chrisapotek

2
这是一个附录,在Lua中提供了一些示例:lua.org/pil/6.3.html 可能也很有用!:)
yesudeep

2
有人可以解决chrisapotek的问题吗?我很困惑如何tail recursion用一种不会优化掉尾声的语言来实现。
凯文·梅雷迪斯

3
@KevinMeredith“ tail recursion”表示函数中的最后一条语句是对该函数的递归调用。您是正确的,使用不会优化递归的语言来进行此操作毫无意义。但是,此答案确实(几乎)正确地显示了该概念。如果省略“ else:”,那将是一个尾部调用。不会更改行为,但是会将tail调用放置为独立的语句。我将其作为编辑提交。
ToolmakerSteve

2
因此在python中没有优势,因为每次调用tailrecsum函数都会创建一个新的堆栈框架-对吗?
Quazi Irfan

707

传统的递归中,典型的模型是先执行递归调用,然后取递归调用的返回值并计算结果。以这种方式,直到您从每个递归调用中返回后,您才能获得计算结果。

tail递归中,首先执行计算,然后执行递归调用,将当前步骤的结果传递到下一个递归步骤。这导致最后一个语句的形式为(return (recursive-function params))基本上,任何给定递归步骤的返回值都与下一个递归调用的返回值相同

这样的结果是,一旦您准备好执行下一个递归步骤,就不再需要当前的堆栈框架。这样可以进行一些优化。实际上,使用适当编写的编译器,您永远都不应通过尾部递归调用来实现堆栈溢出窃笑。只需将当前堆栈框架重用于下一步递归步骤​​。我很确定Lisp会这样做。


17
“我很确定Lisp可以做到这一点”-Scheme可以做到,但是Common Lisp并非总是如此。
亚伦,

2
@Daniel“基本上,任何给定的递归步骤的返回值都与下一个递归调用的返回值相同。”-我看不到Lorin Hochstein发布的代码段中的该参数成立。您能详细说明一下吗?
极客

8
@Geek这是一个很晚的响应,但是在Lorin Hochstein的例子中确实如此。每个步骤的计算都在递归调用之前完成,而不是在之后。结果,每个停靠点仅直接返回上一步的值。最后一个递归调用完成了计算,然后在调用堆栈中一直返回未修改的最终结果。
reirab 2014年

3
Scala可以,但是您需要指定@tailrec来实施它。
SilentDirge 2014年

2
“以这种方式,只有从每个递归调用中返回后,您才能得到计算结果。” -也许我误解了这一点,但是对于懒惰的语言而言,并不是特别如此,因为传统的递归是唯一无需调用所有递归即可实际获得结果的唯一方法(例如,用&&折叠无限的Bools列表)。
hasufell 2015年

205

重要的一点是,尾递归实质上等同于循环。这不仅是编译器优化的问题,还是表达能力的基本事实。这是双向的:您可以采用任何形式的循环

while(E) { S }; return Q

where EQ是表达式,S是语句序列,并将其转换为尾递归函数

f() = if E then { S; return f() } else { return Q }

当然ESQ必须定义来计算对一些变量的一些有趣的价值。例如,循环功能

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);
}

(这种尾部递归函数与较少参数的函数的“包装”是常见的函数习语。)


在@LorinHochstein的回答中,根据他的解释,我了解到尾递归是指递归部分跟随“ Return”,但是在您的情况下,尾递归不是。您确定您的示例已正确考虑了尾递归吗?
CodyBugstein 2013年

1
@Imray尾递归部分是sum_aux中的“ return sum_aux”语句。
克里斯·康威

1
@lmray:克里斯的代码本质上是等效的。if / then的顺序和限制测试的样式... if x == 0 vs if(i <= n)...根本无法解决。关键是每次迭代将其结果传递给下一个。
泰勒

else { return k; }可以更改为return k;
6

144

摘自《Lua编程》一书的摘录显示了如何进行适当的尾递归(在Lua中,但也应适用于Lisp)以及为什么更好。

尾调用 [尾递归]是一种跳转的穿着作为一个呼叫。当一个函数调用另一个函数作为其最后一个动作时,就会发生尾部调用,因此它无事可做。例如,在以下代码中,对的调用g是尾部调用:

function f (x)
  return g(x)
end

f来电g,它有没有别的事情可做。在这种情况下,当被调用函数结束时,程序无需返回到调用函数。因此,在进行尾部调用之后,程序无需在堆栈中保留有关调用函数的任何信息。...

因为适当的尾调用不占用堆栈空间,所以程序可以进行的“嵌套”尾调用的数量没有限制。例如,我们可以使用任何数字作为参数调用以下函数;它永远不会溢出堆栈:

function foo (n)
  if n > 0 then return foo(n - 1) end
end

就像我之前说的,尾部呼叫是goto的一种。这样,在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)。如果输入很高的数字,可能会导致堆栈溢出。


9
这是一个很好的答案,因为它解释了尾调用对堆栈大小的影响。
安德鲁·斯旺

@AndrewSwan确实,尽管我相信原始的问问者和偶尔遇到这个问题的读者可能会更好地接受已接受的答案(因为他可能不知道堆栈的实际含义。)通过我使用Jira的方式,大风扇。
霍夫曼2014年

1
我最喜欢的答案也是由于包含了堆栈大小的含义。
njk2015

80

使用常规递归,每个递归调用将另一个条目推入调用堆栈。递归完成后,该应用程序必须将所有条目从头向下弹出。

使用尾递归,根据语言的不同,编译器可能会将堆栈折叠为一个条目,从而节省了堆栈空间...大型的递归查询实际上可能导致堆栈溢出。

基本上,尾部递归可以优化到迭代中。


1
“大型递归查询实际上可能导致堆栈溢出。” 应该在第一段而不是第二段(尾递归)中?尾部递归的最大优点是可以(例如:Scheme)对其进行优化,以免在堆栈中“积累”调用,因此可以最大程度地避免堆栈溢出!
奥利维尔·杜拉克

69

术语文件说了有关尾递归的定义:

尾递归 /n./

如果您还不满意,请参阅尾递归。


68

这里有一个例子,而不是用语言解释。这是阶乘函数的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尾递归函数使用恒定的堆栈空间。


4
+1表示尾递归的最重要方面,即尾递归可以转换为迭代形式,从而将其转换为O(1)内存复杂性形式。
KGhatak '17

1
@KGhatak不完全;答案正确地讲的是“恒定堆栈空间”,而不是一般的内存。不要挑剔,只是要确保没有误会。例如,尾递归的列表尾更改list-reverse程序将在恒定的堆栈空间中运行,但将在堆上创建并增长数据结构。遍历树可以在附加参数中使用模拟堆栈。等等
Will Ness

45

尾递归是指递归调用在递归算法中的最后逻辑指令中位于最后。

通常,在递归中,您有一个基本情况,即停止递归调用并开始弹出调用堆栈。使用经典示例,尽管C函数比Lisp多,但阶乘函数说明了尾递归。检查基本情况后,将进行递归调用。

factorial(x, fac=1) {
  if (x == 1)
     return fac;
   else
     return factorial(x-1, x*fac);
}

对阶乘的初始调用将是factorial(n)其中fac=1(默认值),而n是要为其计算阶乘的数字。


我发现您的解释最容易理解,但是如果需要理解,那么尾部递归仅对具有一个语句基例的函数有用。考虑这样的方法postimg.cc/5Yg3Cdjn。注意:外层else是您可能会称为“基本案例”的步骤,但跨几行。我是在误解您还是我的假设正确?尾递归只对一个班轮有好处吗?
我想回答

2
@IWantAnswers-不,该函数的主体可以任意大。尾部调用所需要的全部是,它所在的分支将最后一次调用函数,并返回调用函数的结果。该factorial示例仅是经典的简单示例,仅此而已。
TJ Crowder

28

这意味着无需将指令指针压入堆栈,您只需跳到递归函数的顶部并继续执行即可。这允许函数无限期递归而不会溢出堆栈。

我写了一篇有关该主题的博客文章,其中有一些图形示例显示了堆栈框架的外观。


21

这是比较两个功能的快速代码段。第一种是用于查找给定数字阶乘的传统递归。第二种使用尾递归。

非常简单直观。

判断递归函数是否为尾递归的一种简单方法是在基本情况下是否返回具体值。这意味着它不会返回1或true或类似的东西。它很可能返回方法参数之一的某些变体。

另一种方法是判断递归调用是否没有任何加法,算术,修改等。这意味着它只是一个纯递归调用。

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);
    }
}

3
0!是1。因此,“ mynumber == 1”应为“ mynumber == 0”。
polerto 2014年

19

我最好的理解方法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)

^尾随

如您在一般的递归版本中所见,代码块中的最终调用是x + recsum(x - 1)。因此,在调用该recsum方法之后,还有另一个操作x + ..

但是,在尾部递归版本中,代码块中的最终调用(或尾部调用)为 tailrecsum(x - 1, running_total + x)指对方法本身进行的最后一次调用,此后不进行任何操作。

这一点很重要,因为如此处所示的尾部递归并不能使内存增加,因为当基础VM看到某个函数在尾部位置(函数中要评估的最后一个表达式)调用自身时,它将消除当前的堆栈帧,从而被称为尾叫优化(TCO)。

编辑

注意 请记住,上面的示例是用Python编写的,其运行时不支持TCO。这只是一个解释这一点的例子。TCO支持Scheme,Haskell等语言


12

在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);
}

1
这为我返回了错误的结果,对于输入8,我得到36,必须为21。我是否缺少某些内容?我正在使用Java并将其粘贴粘贴。
阿尔贝托·扎卡尼

1
这将返回[1,n]中i的SUM(i)。与斐波那契无关。对于Fibbo,你需要一个测试,其substracts iteracc时候iter < (n-1)
Askolein 2013年

10

我不是Lisp程序员,但我认为会有所帮助。

基本上,这是一种编程风格,因此递归调用是您要做的最后一件事。


10

这是一个普通的Lisp示例,它使用尾递归进行阶乘。由于没有堆栈的性质,因此可以执行疯狂的大阶乘计算...

(defun ! (n &optional (product 1))
    (if (zerop n) product
        (! (1- n) (* product n))))

然后为了娱乐,您可以尝试 (format nil "~R" (! 25))


9

简而言之,尾部递归将递归调用作为函数中的最后一条语句,因此它不必等待递归调用。

因此,这是一个尾递归,即N(x-1,p * x)是函数中的最后一条语句,编译器巧妙地指出它可以优化为for循环(阶乘)。第二参数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);
}

我确实写了一篇长文章,标题为“ 了解尾递归– Visual Studio C ++ –程序集视图

在此处输入图片说明


1
您的函数N尾递归如何?
Fabian Pijcke '16

N(x-1)是函数中的最后一条语句,编译器巧妙地指出该函数可以优化为for循环(阶乘)
doctorlai

我担心的是,您的函数N恰好是该主题接受的答案中的函数求和式(除了它是和而不是乘积),并且该求和式被认为是非尾递归的?
Fabian Pijcke

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
}

8

这摘自有关尾递归的计算机程序的结构和解释

与迭代和递归相反,我们必须注意不要将递归过程的概念与递归过程的概念相混淆。当我们将过程描述为递归时,我们指的是过程定义(直接或间接)引用过程本身的语法事实。但是,当我们按照线性递归的模式描述流程时,我们是在谈论流程的发​​展方式,而不是过程的语法。我们将诸如事实事实之类的递归过程称为生成迭代过程似乎令人不安。但是,该过程实际上是迭代的:其状态完全由其三个状态变量捕获,并且解释器只需跟踪三个变量即可执行该过程。

流程和过程之间的区别可能造成混淆的一个原因是,大多数通用语言的实现(包括Ada,Pascal和C)的设计方式都使得对任何递归过程的解释都消耗了一定数量的内存,而这种内存随即使原则上描述的过程是迭代的,过程调用的数量也是如此。结果,这些语言只能通过诉诸特殊的“循环结构”来描述迭代过程,例如“做”,“重复”,“直到”,“为时”和“为时”。Scheme的实施不存在此缺陷。即使迭代过程是通过递归过程描述的,它也会在恒定空间中执行迭代过程。具有此属性的实现称为尾递归。 通过尾递归实现,可以使用常规过程调用机制来表示迭代,因此特殊的迭代构造仅用作语法糖。


1
我在这里阅读了所有答案,但这是最清晰的解释,它触及了该概念的真正深层核心。它以一种直截了当的方式解释了它,使一切看起来都如此简单和清晰。请原谅我的无礼。不知何故,我觉得其他答案只是不为所动。我认为这就是SICP如此重要的原因。
englealuze

8

递归函数是一个自行调用的函数

它允许程序员使用最少的代码编写高效的程序。

缺点是,如果编写不正确,它们可能会导致无限循环和其他意外结果。

我将解释简单递归函数和尾递归函数

为了编写一个简单的递归函数

  1. 要考虑的第一点是何时应该决定退出循环,即if循环
  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)

  1. 代入n = 4
public static int fact(4){
  if(4 <=1)
     return 1;
  else 
     return 4 * fact(4-1);
}

If循环失败,因此进入else循环,因此返回4 * fact(3)

  1. 在堆栈内存中,我们有 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)

  1. 在堆栈内存中,我们有 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)

  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);
    }
}

  1. 代入n = 4
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)

  1. 在堆栈内存中,我们有 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)

  1. 在堆栈内存中,我们有 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)

  1. 在堆栈内存中,我们有 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的结果

在此处输入图片说明


7

尾递归是您当前的生活。您无休止地循环使用同一堆栈框架,因为没有理由或方法返回到“上一个”框架。过去已经结束,可以将其丢弃。您得到一帧,直到永远消失在未来,直到永远消失。

当您认为某些进程可能会利用其他帧,但如果堆栈没有无限增长时,仍被认为是尾递归的,这种类比就失败了。


1
分裂性人格障碍的解释下,它不会破裂。:) 心灵协会;作为社会的思想。:)
威尔·内斯

哇!现在,这就是思考的另一种方式
sutanu dalui

7

尾递归是一种递归函数,其中该函数在函数的末尾(“尾部”)调用自身,其中在递归调用返回后不进行任何计算。许多编译器进行了优化,以将递归调用更改为尾递归或迭代调用。

考虑计算阶乘的问题。

一种简单的方法是:

  factorial(n):

    if n==0 then 1

    else n*factorial(n-1)

假设您调用阶乘(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),但没有一个调用会向堆栈中添加任何额外的变量。因此,编译器可以消除堆栈。


7

尾递归与普通递归相比非常快。之所以快,是因为祖先调用的输出不会写在堆栈中以保持跟踪。但是在正常的递归中,所有祖先都调用以堆栈形式编写的输出来保持跟踪。


6

尾递归函数是一个递归函数,其中最后一个动作恢复之前它使递归函数调用。即,立即返回递归函数调用的返回值。例如,您的代码如下所示:

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个框架对象。)这会导致堆栈溢出错误。(嘿,这就是这个网站的名称!)

但是,如果递归函数做的最后一件事是进行递归调用并返回其返回值,则没有理由需要将当前帧对象保留在调用堆栈中。毕竟,如果在递归函数调用之后没有任何代码,则没有理由继续使用当前框架对象的局部变量。因此,我们可以立即摆脱当前框架对象,而不必将其保留在调用堆栈中。这样的最终结果是您的调用堆栈不会增加大小,因此不会导致堆栈溢出。

编译器或解释器必须具有尾调用优化功能,才能识别何时可以应用尾调用优化。即使这样,您也可能已经在递归函数中重新安排了代码以利用尾部调用优化,如果这种潜在的可读性下降值得优化,则取决于您。


“尾递归(也称为尾调用优化或尾调用消除)”。没有; 您可以尾调用消除或尾调用优化应用到尾递归函数中,但是它们不是一回事。您可以使用Python编写尾递归函数(如您所示),但是它们并不比非尾递归函数更有效,因为Python不会执行尾调用优化。
chepner

这是否意味着如果有人设法优化网站并呈现递归调用尾递归,我们将不再拥有StackOverflow网站?那太糟了。
Nadjib Mami

5

为了了解尾调用递归和非尾调用递归之间的一些核心区别,我们可以探索这些技术的.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尾部调用条件


4

递归有两种基本类型:头递归尾递归。

head递归中,函数进行递归调用,然后执行更多计算,例如,可能使用递归调用的结果。

尾部递归函数中,所有计算都首先发生,而递归调用是最后发生的事情。

摘自这个超级棒的帖子。请考虑阅读。


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))

可以看到,无止境的函数(过程,用Scheme术语)的最后一件事就是调用自身。另一个(更有用的)示例是:

(define (map lst op)
  (define (helper done left)
    (if (nil? left)
        done
        (helper (cons (op (car left))
                      done)
                (cdr left))))
  (reverse (helper '() lst)))

在帮助程序过程中,如果左边不为零,它做的最后一件事就是调用自身(AFTER CONS某物和CDR某物)。基本上,这就是映射列表的方式。

尾递归具有很大的优势,即解释器(或编译器,取决于语言和供应商)可以对其进行优化,并将其转换为等同于while循环的内容。实际上,在Scheme传统中,大多数“ for”和“ while”循环都是通过尾递归方式完成的(据我所知,没有for和while)。


3

这个问题有很多很好的答案...但是我不禁要提出关于如何定义“尾部递归”或至少“适当的尾部递归”的替代方法。即:是否应该将其视为程序中特定表达式的属性?还是应该将其视为编程语言实现的一种特性?

关于后一种观点的更多信息,有一篇经典论文,威尔·克林格(Will Clinger)了,“正确的尾递归和空间效率”(PLDI 1998),其中将“适当的尾递归”定义为编程语言实现的一个属性。定义的构造允许忽略执行细节(例如,是通过运行时堆栈还是通过堆的帧分配的链接列表实际表示调用堆栈)。

为了实现这一点,它使用渐进分析:不是通常所见的程序执行时间,而是程序空间使用率。这样,堆分配的链表与运行时调用栈的空间使用最终在渐近上等效。因此,人们会忽略该编程语言的实现细节(在实践中当然很重要,但是当人们试图确定给定的实现是否满足“属性尾递归”的要求时,这一细节可能会有些混乱。 )

出于以下几个原因,值得对本文进行认真研究:

  • 它给出了程序的尾部表达式尾部调用的归纳定义。(这样的定义以及为什么这样的调用很重要,似乎是这里给出的大多数其他答案的主题。)

    这些定义只是为了提供一种文字风味:

    定义1用Core Scheme编写的程序的尾部表达式归纳定义如下。

    1. Lambda表达式的主体是尾部表达式
    2. 如果(if E0 E1 E2)是尾表达式,则E1E2均为尾表达式。
    3. 没什么是尾巴表达。

    定义2尾呼叫是尾表达式,它是一个过程调用。

(尾部递归调用,或如论文所述,“自尾调用”是尾部调用的特例,其中过程本身被调用。)

  • 它提供了一个评估核心方案,其中每一台机器都有相同的观察行为的六种不同的“机器”正式定义,除了渐近空间复杂度类,每一个在不在。

    例如,在分别给出具有1.基于堆栈的内存管理,2。垃圾收集但不进行尾调用,3。垃圾收集和尾调用的计算机的定义之后,本文将继续采用更高级的存储管理策略,例如4.“evlis尾递归”,在这里不需要环境中的尾调用跨越最后一个子表达式参数的评价保存,5减少封闭的环境,只是那个封闭的自由变量,和6. Appel和Shao定义的所谓“空间安全”语义。

  • 为了证明这些机器实际上属于六个不同的空间复杂度类别,本文针对要比较的每一对机器,提供了具体的程序示例,这些程序将在一台机器上而不是另一台机器上显示渐近空间爆炸。


(现在读完我的答案,我不确定我是否能够真正掌握克林格论文的要点。但是,las,我现在不能花更多的时间来制定这个答案。)


1

许多人已经在这里解释了递归。我想引用一些关于递归从Riccardo Terrell的书“ .NET中的并发,并发和并行编程的现代模式”中获得的一些优点的想法:

“功能递归是在FP中进行迭代的自然方法,因为它避免了状态突变。在每次迭代期间,都会将新值传递到循环构造函数中,而不是进行更新(变异)。另外,可以组成一个递归函数,使您的程序更具模块化,并为利用并行化提供了机会。”

这也是同一本书中有关尾递归的一些有趣的注释:

尾调用递归是一种将常规递归函数转换为可处理大量输入而没有任何风险和副作用的优化版本的技术。

注意进行优化的主要原因是为了提高数据局部性,内存使用率和缓存使用率。通过进行尾部调用,被调用方使用与调用方相同的堆栈空间。这样可以减少内存压力。由于可以将相同的内存重新用于后续的调用者,并且可以保留在缓存中,而不是逐出较旧的缓存行为新的缓存行腾出空间,因此可以从某种程度上改善缓存。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.