尾递归究竟是如何工作的?

Posted

技术标签:

【中文标题】尾递归究竟是如何工作的?【英文标题】:How exactly does tail recursion work? 【发布时间】:2013-03-09 06:31:46 【问题描述】:

我几乎了解尾递归的工作原理以及它与普通递归之间的区别。我只是不明白为什么它不需要需要堆栈来记住它的返回地址。

// tail recursion
int fac_times (int n, int acc) 
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);


int factorial (int n) 
    return fac_times (n, 1);


// normal recursion
int factorial (int n) 
    if (n == 0) return 1;
    else return n * factorial(n - 1);

在尾递归函数中调用函数本身后无事可做,但对我来说没有意义。

【问题讨论】:

尾递归“正常”递归。这只意味着递归发生在函数的末尾。 ... 但它可以在 IL 级别以不同于正常递归的方式实现,从而减少堆栈深度。 顺便说一句,gcc 可以对这里的“正常”示例执行尾递归消除。 @Geek - 我是 C# 开发人员,所以我的“汇编语言”是 MSIL 或只是 IL。对于 C/C++,将 IL 替换为 ASM。 @ShannonSeverance 我发现 gcc 是通过简单的权宜之计检查发出的汇编代码而没有-O3。该链接用于较早的讨论,涵盖了非常相似的基础,并讨论了实现此优化的必要条件。 【参考方案1】:

我的回答更多的是猜测,因为递归与内部实现有关。

在尾递归中,递归函数在同一函数的末尾被调用。可能编译器可以通过以下方式进行优化:

    让正在进行的函数结束(即调用使用的堆栈) 将要用作函数参数的变量存储在临时存储器中 之后,使用临时存储的参数再次调用该函数

如您所见,我们在同一函数的下一次迭代之前结束了原始函数,因此我们实际上并没有“使用”堆栈。

但我相信如果在函数内部调用析构函数,那么这种优化可能不适用。

【讨论】:

【参考方案2】:

编译器可以简单地转换它

int fac_times (int n, int acc) 
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);

变成这样:

int fac_times (int n, int acc) 
label:
    if (n == 0) return acc;
    acc *= n--;
    goto label;

【讨论】:

@Mr.32 我不明白你的问题。我将该函数转换为等效函数,但没有显式递归(即没有显式函数调用)。如果您将逻辑更改为不等价的东西,您确实可能在某些或所有情况下使函数永远循环。 所以尾递归有效,因为编译器对其进行了优化?否则,就堆栈内存而言,它与普通递归相同吗? 是的。如果编译器无法将递归减少到循环,那么您将陷入递归。全有或全无。 @AlanDert:正确。您还可以将尾递归视为“尾调用优化”的一种特殊情况,因为尾调用恰好是同一个函数。通常,如果编译器可以在将被调用函数的返回地址设置为进行尾部调用的函数的返回地址,而不是进行尾部调用的地址。 @AlanDert in C 这只是一种优化,没有任何标准强制执行,因此可移植代码不应该依赖它。但是有一些语言(Scheme 就是一个例子),尾递归优化是由标准强制执行的,所以你不必担心它会在某些环境中堆栈溢出。【参考方案3】:

编译器通常可以将尾递归转换为循环,尤其是在使用累加器时。

// tail recursion
int fac_times (int n, int acc = 1) 
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);

会编译成类似的东西

// accumulator
int fac_times (int n) 
    int acc = 1;
    while (n > 0) 
        acc *= n;
        n -= 1;
    
    return acc;

【讨论】:

不像 Alexey 的实现那么聪明......是的,这是一种恭维。 实际上,结果看起来更简单,但我认为实现这种转换的代码会比 label/goto 或只是尾调用消除更“聪明”(参见 Lindydancer 的回答)。 如果这就是尾递归的全部内容,那么为什么人们会如此兴奋呢?我没有看到有人对 while 循环感到兴奋。 @BuhBuh:这没有***,并且避免了参数的堆栈推送/弹出。对于像这样的紧密循环,它可以创造一个不同的世界。除此之外,人们不应该兴奋。【参考方案4】:

你问为什么“它不需要堆栈来记住它的返回地址”。

我想扭转局面。它确实使用堆栈来记住返回地址。诀窍在于,发生尾递归的函数在栈上有自己的返回地址,当它跳转到被调用函数时,它会将其视为自己的返回地址。

具体来说,没有尾调用优化:

f: ...
   CALL g
   RET
g:
   ...
   RET

在这种情况下,当调用g 时,堆栈将如下所示:

   SP ->  Return address of "g"
          Return address of "f"

另一方面,通过尾调用优化:

f: ...
   JUMP g
g:
   ...
   RET

在这种情况下,当调用g 时,堆栈将如下所示:

   SP ->  Return address of "f"

显然,当g 返回时,它会返回到调用f 的位置。

EDIT:上面的例子使用了一个函数调用另一个函数的情况。当函数调用自身时,机制是相同的。

【讨论】:

这是一个比其他答案更好的答案。编译器很可能没有一些神奇的特殊情况来转换尾递归代码。它只是执行一个正常的最后调用优化,恰好转到同一个函数。【参考方案5】:

下面是一个简单的例子,展示了递归函数的工作原理:

long f (long n)


    if (n == 0) // have we reached the bottom of the ocean ?
        return 0;

    // code executed in the descendence

    return f(n-1) + 1; // recurrence

    // code executed in the ascendence


尾递归是一个简单的递归函数,递归在函数的末尾完成,因此没有代码按顺序完成,这有助于大多数高级编程语言的编译器执行所谓的Tail Recursion Optimization,还有一个更复杂的优化,称为Tail recursion modulo

【讨论】:

【参考方案6】:

递归函数中必须存在两个元素:

    递归调用 保存返回值计数的地方。

“常规”递归函数将 (2) 保留在堆栈帧中。

正则递归函数的返回值由两种类型的值组成:

其他返回值 拥有函数计算的结果

让我们看看你的例子:

int factorial (int n) 
    if (n == 0) return 1;
    else return n * factorial(n - 1);

例如,框架 f(5) “存储”了它自己的计算结果 (5) 和 f(4) 的值。如果我调用阶乘(5),就在堆栈调用开始崩溃之前,我有:

 [Stack_f(5): return 5 * [Stack_f(4): 4 * [Stack_f(3): 3 * ... [1[1]]

请注意,除了我提到的值之外,每个堆栈都存储了函数的整个范围。因此,递归函数 f 的内存使用量为 O(x),其中 x 是我必须进行的递归调用的数量。所以,如果我需要 1kb 的 RAM 来计算阶乘(1)或阶乘(2),我需要 ~100k 来计算阶乘(100),依此类推。

一个尾递归函数将 (2) 放入它的参数中。

在尾递归中,我使用参数将每个递归帧中的部分计算结果传递给下一帧。让我们看看我们的阶乘示例,尾递归:

int factorial (int n) 
    int helper(int num, int accumulated)
        
            if num == 0 return accumulated
            else return helper(num - 1, accumulated*num)
        
    return helper(n, 1)    

让我们看看它在阶乘(4)中的帧:

[Stack f(4, 5): Stack f(3, 20): [Stack f(2,60): [Stack f(1, 120): 120]]]]

看到差异了吗? 在“常规”递归调用中,返回函数递归地组成最终值。在尾递归中,它们只引用基本情况(最后一个评估)。我们将 accumulator 称为跟踪旧值的参数。

递归模板

正则递归函数如下:

type regular(n)
    base_case
    computation
    return (result of computation) combined with (regular(n towards base case))

要将其转换为尾递归,我们:

引入一个带有累加器的辅助函数 在主函数中运行辅助函数,并将累加器设置为基本情况。

看:

type tail(n):
    type helper(n, accumulator):
        if n == base case
            return accumulator
        computation
        accumulator = computation combined with accumulator
        return helper(n towards base case, accumulator)
    helper(n, base case)

看到区别了吗?

尾调用优化

由于尾​​调用堆栈的无边界情况没有存储任何状态,因此它们并不那么重要。一些语言/解释器然后用新堆栈替换旧堆栈。因此,由于没有限制调用次数的堆栈帧,在这些情况下,尾调用的行为就像一个 for 循环

是否优化取决于您的编译器。

【讨论】:

【参考方案7】:

编译器有足够的智能来理解尾递归。如果从递归调用返回时,没有挂起的操作并且递归调用是最后一条语句,则属于尾递归的范畴。 编译器基本上执行尾递归优化,删除堆栈实现。考虑下面的代码。

void tail(int i) 
    if(i<=0) return;
    else 
     system.out.print(i+"");
     tail(i-1);
    
   

经过优化,上面的代码转换成下面的代码。

void tail(int i) 
    blockToJump:
    if(i<=0) return;
    else 
     system.out.print(i+"");
     i=i-1;
     continue blockToJump;  //jump to the bolckToJump
    
    
   

这就是编译器进行尾递归优化的方式。

【讨论】:

【参考方案8】:

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

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

缺点是如果没有正确编写,它们可能导致无限循环和其他意想不到的结果。

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

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

    要考虑的第一点是你什么时候决定出柜 if 循环的循环 第二个是如果我们是自己的函数要做什么流程

从给定的例子:

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),让我们看看内部会发生什么

    代入 n=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);
    


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

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

的结果

【讨论】:

以上是关于尾递归究竟是如何工作的?的主要内容,如果未能解决你的问题,请参考以下文章

#yyds干货盘点#尾递归比递归好在哪儿

尾递归 - 以斐波那契数列为例说明

如何使涉及期货尾递归的函数?

python中使用尾递归源码范例

嵌套递归调用 - 这是尾递归吗?

从示例逐渐理解Scala尾递归