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

Ala*_*ano 120 c algorithm recursion tail-recursion

我几乎理解尾递归是如何工作的以及它与正常递归之间的区别.我只是不明白为什么它要求堆栈来记住它的返回地址.

// 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);
}
Run Code Online (Sandbox Code Playgroud)

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

Ale*_*nze 166

编译器只能转换它

int fac_times (int n, int acc) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}
Run Code Online (Sandbox Code Playgroud)

进入这样的事情:

int fac_times (int n, int acc) {
label:
    if (n == 0) return acc;
    acc *= n--;
    goto label;
}
Run Code Online (Sandbox Code Playgroud)

  • 是的.如果编译器无法减少递归到循环,则会遇到递归问题.全有或全无. (34认同)
  • 所以尾递归是有效的****因为**编译**优化它?否则它在堆栈内存方面与正常递归相同? (18认同)
  • @AlanDert:对.您还可以将尾递归视为"尾调用优化"的特殊情况,特别是因为尾调用恰好是相同的函数.通常,如果编译器可以在一个调用中进行调用,那么任何尾部调用(对于"尾部递归都没有工作"的相同要求,以及直接返回尾调用的返回值)都可以进行优化.将被调用函数的返回地址设置为进行尾调用的函数的返回地址的方式,而不是进行尾调用的地址. (3认同)
  • @ Mr.32我不明白你的问题.我将函数转换为等效函数但没有显式递归(即没有显式函数调用).如果将逻辑更改为非等效的逻辑,则可能确实在某些或所有情况下使函数循环. (2认同)
  • @AlanDert in C 这只是一个没有被任何标准强制执行的优化,所以可移植代码不应该依赖它。但是有些语言(Scheme 就是一个例子),尾递归优化是由标准强制执行的,所以你不必担心它在某些环境下会堆栈溢出。 (2认同)

Lin*_*cer 56

你问为什么"它不需要堆栈来记住它的返回地址".

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

具体而言,没有尾调用优化:

f: ...
   CALL g
   RET
g:
   ...
   RET
Run Code Online (Sandbox Code Playgroud)

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

   SP ->  Return address of "g"
          Return address of "f"
Run Code Online (Sandbox Code Playgroud)

另一方面,尾部调用优化:

f: ...
   JUMP g
g:
   ...
   RET
Run Code Online (Sandbox Code Playgroud)

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

   SP ->  Return address of "f"
Run Code Online (Sandbox Code Playgroud)

很明显,当g返回时,它将返回到f被调用的位置.

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

  • 这是一个比其他答案更好的答案.编译器很可能没有一些神奇的特殊情况来转换尾递归代码.它只是执行正常的最后一次调用优化,恰好会转到同一个函数. (8认同)

mep*_*ell 11

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

// tail recursion
int fac_times (int n, int acc = 1) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}
Run Code Online (Sandbox Code Playgroud)

会编译成类似的东西

// accumulator
int fac_times (int n) {
    int acc = 1;
    while (n > 0) {
        acc *= n;
        n -= 1;
    }
    return acc;
}
Run Code Online (Sandbox Code Playgroud)

  • 不像Alexey的实施那么聪明......是的,这是一种恭维. (3认同)

Luc*_*iro 11

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

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

"常规"递归函数将(2)保持在堆栈帧中.

常规递归函数中的返回值由两种类型的值组成:

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

我们来看看你的例子:

int factorial (int n) {
    if (n == 0) return 1;
    else return n * factorial(n - 1);
}
Run Code Online (Sandbox Code Playgroud)

例如,帧f(5)"存储"它自己的计算(5)的结果和f(4)的值.如果我调用factorial(5),就在堆栈调用开始崩溃之前,我有:

 [Stack_f(5): return 5 * [Stack_f(4): 4 * [Stack_f(3): 3 * ... [1[1]]
Run Code Online (Sandbox Code Playgroud)

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

Tail Recursive函数将(2)放入其参数中.

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

int factorial(int n){int helper(int num,int accumulation){if num == 0 return accumulation else return helper(num - 1,cumulative*num)} return helper(n,1)
}

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

[Stack f(4, 5): Stack f(3, 20): [Stack f(2,60): [Stack f(1, 120): 120]]]]
Run Code Online (Sandbox Code Playgroud)

看到差异?在"常规"递归调用中,返回函数以递归方式组成最终值.在Tail Recursion中,它们仅引用基本案例(最后一个评估).我们将accumulator称为跟踪旧值的参数.

递归模板

常规递归函数如下:

type regular(n)
    base_case
    computation
    return (result of computation) combined with (regular(n towards base case))
Run Code Online (Sandbox Code Playgroud)

要在Tail递归中转换它,我们:

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

看:

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)
Run Code Online (Sandbox Code Playgroud)

看到不同?

尾调用优化

由于没有状态存储在Tail Call堆栈的非边界案例中,因此它们并不那么重要.然后,一些语言/解释器将旧堆栈替换为新堆栈.因此,在没有堆栈帧限制调用次数的情况下,Tail Calls在这些情况下的行为就像for循环一样.

由编译器来优化它,或者不是.


Kha*_*d.K 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

}
Run Code Online (Sandbox Code Playgroud)

尾递归是一个简单的递归函数,其中重复在函数结束时完成,因此没有代码在ascendence中完成,这有助于大多数高级编程语言的编译器执行所谓的尾递归优化,也有一个更复杂的优化称为Tail递归模