浅谈尾调用和尾递归(C语言)

淡淡的烟草味﹌ 2022-06-02 07:17 317阅读 0赞

什么是尾调用

在计算机科学里,尾调用是指一个函数里的最后一个动作是一个函数调用的情形,即这个调用的返回值直接被当前函数返回的情形。这种情形下称该调用位置称为“尾位置”。

说得通俗点,尾调用就是指某个函数的最后一步是调用另一个函数。这个调用位置称为“尾位置”。

比如有个函数叫fun,其实现是:

  1. int fun(void)
  2. {
  3. foo();
  4. }

上面代码中,函数fun的最后一步是调用函数foo,这就叫尾调用。

以下三种情况,哪种属于尾调用?

  1. // 情况一
  2. int fun(void)
  3. {
  4. int a = foo();
  5. return a;
  6. }
  7. // 情况二
  8. int g(void)
  9. {
  10. return 3+foo();
  11. }
  12. // 情况三
  13. int s(int x)
  14. {
  15. if (x > 0)
  16. return m(x);
  17. return r(x);
  18. }

情况一是调用函数foo之后,还有别的操作,所以不属于尾调用,即使语义一样。

情况二在调用后也有别的操作,所以不属于尾调用,即使写在同一行。

情况三中,不管x取什么值,最后一步操作都是函数调用,所以属于尾调用。

尾调用优化

函数调用会在栈上形成一个”栈帧”(frame stack),用来保存函数参数、返回地址、局部变量等信息。如果函数A调用函数B,那么在A的栈帧下方(假设栈从高地址向低地址生长),还会形成B的栈帧。等到B函数返回,B的栈帧才会消失。如果函数B又调用了函数C,那么B的栈帧下方又形成C的栈帧。以此类推,所有的栈帧堆叠起来,就形成了一个”调用栈”(call stack)。如下图所示:

这里写图片描述

由于尾调用是外层函数的最后一步操作,尾调用返回后,外层函数也就返回了。执行尾调用的时候,外层函数栈帧中保存的调用位置、内部变量等信息都不会再用到了,所以,可以用内层函数(即尾调用函数)的栈帧覆盖外层函数的栈帧(而不是在外层函数栈帧下面再新开一个),这样可以节省栈空间。这就叫做”尾调用优化”(Tail call optimization).

如果你觉得抽象,可以举个例子:

  1. int f(void)
  2. {
  3. int m = 1;
  4. int n = 2;
  5. return g(m + n);
  6. }

对于以上代码,f();等同于g(3);调用g(3)之后,函数f()就结束了。所以执行到g(3)的时候,完全可以用g(3)的栈帧覆盖f()的栈帧。

尾递归

若一个函数在尾位置调用自身,则称这种情况为尾递归。尾递归是递归的一种特殊情形。

尾递归优化

当编译器检测到尾递归的时候,它就覆盖当前的栈帧而不是在栈中去创建一个新的。无论调用多少次,只要每次都将栈空间覆盖(或重用),其空间占用就是一个常数,即O(1).所以,尾递归优化使原本O(n)的调用栈空间只需要O(1).

递归和尾递归的比较

我们以求阶乘为例,比较一下递归和尾递归的不同。

递归写法

  1. int factorial(int n)
  2. {
  3. if(n < 0)
  4. return 0; //参数错误则返回0
  5. else if (n == 0 || n == 1)
  6. return 1;
  7. else
  8. return n * fact(n - 1);
  9. }

假设计算factorial(5),那么栈空间的变化如下:

  1. factorial(5) 5 * factorial(4) 5 * (4 * factorial(3)) 5 * (4 * (3 * factorial(2))) 5 * (4 * (3 * (2 * factorial(1)))) 5 * (4 * (3 * (2 * 1))) 5 * (4 * (3 * 2)) 5 * (4 * 6) 5 * 24 120

可观察,栈从左到右,增加到一个峰值,然后从右到左缩小。

尾递归写法

  1. int factorial_tail(int n, int product_from_n)
  2. {
  3. if (n < 0)
  4. return 0; //参数错误则返回0
  5. else if (n == 0)
  6. return 1;
  7. else if (n == 1)
  8. return product_from_n;
  9. else
  10. return factorial_tail(n - 1, n * product_from_n);
  11. }

初次接触这种写法一定会觉得很别扭,求n!为什么要传入2个参数呢?先不着急回答,我们先模拟计算机算一遍。

如果求n!,则需要传入参数n和1(为什么?后文会说明).所以5!=factorial_tail(5,1);
计算过程如下:

  1. factorial_tail(5,1)
  2. factorial_tail(4,5*1) = factorial_tail(4,5)
  3. factorial_tail(3,4*5*1) = factorial_tail(3,20)
  4. factorial_tail(2,3*4*5*1) = factorial_tail(2,60)
  5. factorial_tail(1,2*3*4*5*1) = factorial_tail(1,120)
  6. 120

factorial_tail(x,y)这个函数的作用是求((x!)*y).
显而易见,当x==1时,结果就是y,所以有

  1. else if (n == 1)
  2. return product_from_n;

当x!=1的时候,将(x!)*y恒等变形,变为(x-1)!*(x*y),所以调用factorial_tail(x,y)就变成了调用factorial_tail(x-1,x*y)

于是有代码中的

  1. return factorial_tail(n - 1, n * product_from_n);

欲求n的阶乘,当然要使y=1,所以,5!=factorial_tail(5,1);

不难看出,这种思路的本质是:将单次计算的结果缓存起来,作为参数传递给下一次调用,每一次调用都离最终结果近了一步,相当于是迭代。

本来还想从汇编代码的角度分析一下尾递归优化,囿于篇幅,只能下次谈了。

参考资料
[1]https://zh.wikipedia.org/wiki/%E5%B0%BE%E8%B0%83%E7%94%A8
[2]http://www.voidcn.com/article/p-qdsabmbw-xk.html
[3]http://www.ruanyifeng.com/blog/2015/04/tail-call.html

发表评论

表情:
评论列表 (有 0 条评论,317人围观)

还没有评论,来说两句吧...

相关阅读

    相关 C语言实现

    普通递归在执行时需要保存当前数据,并在内存中重新开辟栈。所以普通递归的空间复杂度比较高,效率比较低。所以,我们一般倾向于把递归方式转化成迭代的方式进行计算。但是如果递归语句是整

    相关

    尾递归 如果要说尾递归的话,那么首先应该先说一下递归函数。递归函数的优点是定义简单,逻辑清晰。理论上,所有的递归函数都可以写成循环的方式,但是循环的逻辑不如递归清晰易理解

    相关 的优化

    在[ 浅谈尾调用和尾递归 ][Link 1]这篇博文中,我谈了什么是尾递归以及编译器如何优化尾递归。这篇文章,咱来个具体的例子,通过汇编代码来看看优化和不优化的区别。 求阶

    相关

    什么是尾递归 什么是尾递归呢?(tail recursion), 顾名思议,就是一种“不一样的”递归,说到它的不一样,就得先说说一般的递归。对于一般的递归,比如下面

    相关

    尾递归就是说一个递归函数,在return语句中调用了这个递归函数本身,如图所示。从理论上来说,尾递归都可以用非递归的方法实现。 ![这里写图片描述][70] [70]

    相关

    要说尾递归先理解尾调用 尾调用定义 > 来自 [尾调用维基百科][Link 1] 在计算机学里,尾调用是指一个函数里的最后一个动作是返回一个函数的调用结果的情形,即

    相关

    1、递归 简单的来说递归就是一个函数直接或间接地调用自身,是为直接或间接递归。一般来说,递归需要有边界条件、递归前进段和递归返回段。当边界条件不满足时,递归前进;当边界条件满