尾递归如何工作?


121

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

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

在尾部递归函数中调用函数本身之后,无需执行任何操作,但对我而言这没有意义。


16
尾递归 “正常”递归。这仅意味着递归发生在函数的末尾。
Pete Becker 2013年

7
...但是,它可以在IL级别上以与正常递归不同的方式实现,从而减少了堆栈深度。
KeithS

2
顺便说一句,gcc可以在此处的“普通”示例中执行尾递归消除。
dmckee ---前版主小猫,

1
@Geek-我是C#开发人员,所以我的“汇编语言”是MSIL或仅仅是IL。对于C / C ++,将IL替换为ASM。
KeithS 2013年

1
@ShannonSeverance我发现gcc通过使用权衡简单的权宜之计检查发出的汇编代码,而没有使用-O3。该链接用于更早的讨论,涵盖了非常相似的基础,并讨论了实现此优化所需的条件。
dmckee ---前主持人小猫,

Answers:


169

编译器只需能够转换它

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

2
@ Mr.32我不明白你的问题。我将函数转换为等效函数,但没有显式递归(即,没有显式函数调用)。如果您将逻辑更改为非等效逻辑,则在某些或所有情况下,您的确可能会永久使函数循环。
Alexey Frunze

18
那么尾部递归由于编译器优化而有效吗?否则,就堆栈内存而言,它是否与普通递归相同?
艾伦·科罗马诺

34
是的 如果编译器无法将递归减少到循环中,则您将无法使用递归。全部或全无。
Alexey Frunze 2013年

3
@AlanDert:正确。您还可以将尾部递归视为“尾部调用优化”的特例,这很特别,因为尾部调用恰好是同一功能。一般而言,如果编译器可以在一个将调用函数的返回地址设置为进行尾部调用的函数的返回地址,而不是进行尾部调用的地址的方法。
史蒂夫·杰索普

1
C语言中的@AlanDert只是一个没有任何标准强制执行的优化,因此可移植代码不应依赖于此。但是在某些语言中(方案是一个示例),该标准强制执行尾递归优化,因此您不必担心它会在某些环境中堆栈溢出。
2013年

57

您问为什么“不需要堆栈来记住其返回地址”。

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

具体来说,没有尾部呼叫优化:

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被调用的位置。

编辑:上面的示例使用一种功能调用另一种功能的情况。函数调用自身时的机制相同。


8
这是比其他答案更好的答案。编译器很可能没有用于转换尾部递归代码的神奇特殊情况。它只是执行正常的最后一次调用优化,而恰好是在执行相同的功能。
艺术

12

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

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

3
不像Alexey的实现那么聪明...是的,这是一种赞美。
Matthieu M.

1
实际上,结果看起来更简单,但我认为实现此转换的代码比使用标签/转到或尾部消除功能更“聪明”(请参阅​​Lindydancer的答案)。
Phob

如果这只是尾巴递归,那么为什么人们对此感到如此兴奋?我看不到有人对while循环感到兴奋。
Buh Buh

@BuhBuh:这没有stackoverflow,并避免了堆栈压入/弹出参数。对于这样的紧密循环,可以带来不同的世界。除此之外,人们不应该感到兴奋。
Mooing Duck

11

递归函数中必须包含两个元素:

  1. 递归调用
  2. 一个保留返回值计数的地方。

“常规”递归函数将(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)放在其参数中。

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

int阶乘(int n){int helper(int num,int累积){如果num == 0返回累积,否则返回helper(num-1,累积* num)}返回helper(n,1)
}

让我们看一下阶乘(4)中的帧:

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

看到差异了吗?在“常规”递归调用中,返回函数以递归方式组成最终值。在Tail Recursion中,它们仅引用基本案例(评估的最后一个案例)。我们称累加器为跟踪较早值的参数。

递归模板

常规递归函数如下:

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循环

取决于您的编译器对其进行优化,还是不进行优化。


6

这是一个简单的示例,显示了递归函数的工作方式:

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

}

尾递归是一个简单的递归函数,其中递归在函数末尾完成,因此无需递增代码即可完成,这有助于大多数高级编程语言的编译器执行所谓的尾递归优化,并且具有更复杂的优化称为尾递归模


1

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

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

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

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

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

  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的结果

在此处输入图片说明


0

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

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

  1. 让正在进行的函数结束(即,调用使用过的堆栈)
  2. 将将用作函数参数的变量存储在临时存储中
  3. 此后,使用临时存储的参数再次调用该函数

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

但是我相信,如果在函数内部调用析构函数,则此优化可能不适用。


0

编译器足够智能,能够理解尾递归。编译器基本上执行尾递归优化,删除堆栈实现。请考虑以下代码。

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

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

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.