什么是尾递归,尾递归的优势以及语言支持情况说明

今天在进行数据排序时候用到递归,但是耗费内存太大,于是想找一找有没有既提升效率又节省内存的算法,然后发现尾递归确实不错,只可惜php并没有对此作优化支持.

虽然如此,但还是学习了,下面总结一下:

尾递归 --概念

如果一个函数中所有递归形式的调用都出现在函数的末尾,我们称这个递归函数是尾递归的。当递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归。尾递归函数的特点是在回归过程中不用做任何操作,这个特性很重要,因为大多数现代的编译器会利用这种特点自动生成优化的代码。

实例

为了理解尾递归是如何工作的,让我们再次以递归的形式计算阶乘。首先,这可以很容易让我们理解为什么之前所定义的递归不是尾递归。回忆之前对计算n!的定义:在每个活跃期计算n倍的(n-1)!的值,让n=n-1并持续这个过程直到n=1为止。这种定义不是尾递归的,因为每个活跃期的返回值都依赖于用n乘以下一个活跃期的返回值,因此每次调用产生的栈帧将不得不保存在栈上直到下一个子调用的返回值确定。现在让我们考虑以尾递归的形式来定义计算n!的过程。
这种定义还需要接受第二个参数a,除此之外并没有太大区别。a(初始化为1)维护递归层次的深度。这就让我们避免了每次还需要将返回值再乘以n。然而,在每次递归调用中,令a=na并且n=n-1。继续递归调用,直到n=1,这满足结束条件,此时直接返回a即可。
代码实例3-2给出了一个C函数facttail,它接受一个整数n并以尾递归的形式计算n的阶乘。这个函数还接受一个参数a,a的初始值为1。facttail使用a来维护递归层次的深度,除此之外它和fact很相似。读者可以注意一下函数的具体实现和尾递归定义的相似之处。
示例3-2:以尾递归的形式计算阶乘的一个函数实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*facttail.c*/
 
#include"facttail.h"
 
/*facttail*/
 
 
int  facttail( int  n,  int  a)
{
 
     /*Compute a factorialina tail - recursive manner.*/
     
     if  (n < 0)
         return  0;    
     else  if  (n == 0)
         return  1;    
     else  if  (n == 1)
         return  a;
     else
         return  facttail(n - 1, n * a);
 
}
示例3-2中的函数是尾递归的,因为对facttail的单次递归调用是函数返回前最后执行的一条语句。在facttail中碰巧最后一条语句也是对facttail的调用,但这并不是必需的。换句话说,在递归调用之后还可以有其他的语句执行,只是它们只能在递归调用没有执行时才可以执行 。
尾递归是极其重要的,不用尾递归,函数的堆栈耗用难以估量,需要保存很多中间函数的堆栈。比如f(n, sum) = f(n-1) + value(n) + sum; 会保存n个函数调用堆栈,而使用尾递归f(n, sum) = f(n-1, sum+value(n)); 这样则只保留后一个函数堆栈即可,之前的可优化删去。
也许在C语言中有很多的特例,但编程语言不只有 C语言,在函数式语言Erlang中(亦是栈语言),如果想要保持语言的高并发特性,就必须用尾递归来替代传统的递归。

尾递归与传统递归比较

以下是具体实例:
线性递归:
1
2
3
4
5
long  Rescuvie( long  n) {
 
     return  (n == 1) ? 1 : n * Rescuvie(n - 1);
 
}
尾递归:
1
2
3
4
5
6
7
8
9
10
11
12
long  TailRescuvie( long  n,  long  a) {
 
     return  (n == 1) ? a : TailRescuvie(n - 1, a * n);
 
}
 
 
long  TailRescuvie( long  n) { //封装用的
     
     return  (n == 0) ? 1 : TailRescuvie(n, 1);
 
}
当n = 5时
对于传统线性递归, 他的递归过程如下:
Rescuvie(5)

{5 * Rescuvie(4)}

{5 * {4 * Rescuvie(3)}}

{5 * {4 * {3 * Rescuvie(2)}}}

{5 * {4 * {3 * {2 * Rescuvie(1)}}}}

{5 * {4 * {3 * {2 * 1}}}}

{5 * {4 * {3 * 2}}}

{5 * {4 * 6}}

{5 * 24}

120

对于尾递归, 他的递归过程如下:

TailRescuvie(5)                  // 所以在运算上和内存占用上节省了很多,直接传回结果

TailRescuvie(5, 1)                         return 120
                                                 ↑
TailRescuvie(4, 5)                         return 120
                                                 ↑
TailRescuvie(3, 20)                        return 120
                                                 ↑
TailRescuvie(2, 60)                        return 120
                                                 ↑
TailRescuvie(1, 120)                       return 120
                                                 ↑
120                                //当运行到最后时,return a => return 120 ,将120返回上一级

说明:

尾递归的效果就是去除了将下层的结果再次返回给上层,需要上层继续计算才得出结果的弊端,如果仔细观看例子就可以看出,其实每个递归的结果是存储在第二个参数a中的,到最后一次计算的时候,会只返回一个a的值,但是因为是递归的原理虽然仍然要返回给上层,依次到顶部才给出结果,但是不需要再做计算了,这点的好处就是每次分配的内存不会因为递归而扩大。

在效率上,两者的确差不多。


尾递归的优势

        与普通递归相比,由于尾递归的调用处于方法的最后,因此方法之前所积累下的各种状态对于递归调用结果已经没有任何意义,因此完全可以把本次方法中留在堆栈中的数据完全清除,把空间让给最后的递归调用。这样的优化便使得递归不会在调用堆栈上产生堆积,意味着即时是“无限”递归也不会让堆栈溢出。这便是尾递归的优势。

编译器优化支持尾递归说明:

尾递归在某些语言的实现上,能避免上述所说的问题,注意是某些语言上,尾递归本身并不能消除函数调用栈过长的问题,那什么是尾递归呢?在上面写的一般递归函数 func() 中,我们可以看到,func(n)  是依赖于 func(n-1) 的,func(n) 只有在得到 func(n-1) 的结果之后,才能计算它自己的返回值,因此理论上,在 func(n-1) 返回之前,func(n),不能结束返回。因此func(n)就必须保留它在栈上的数据,直到func(n-1)先返回,而尾递归的实现则可以在编译器的帮助下,消除这个限制

让我们先回顾一下函数调用的大概过程:

1)调用开始前,调用方(或函数本身)会往栈上压相关的数据,参数,返回地址,局部变量等。

2)执行函数。

3)清理栈上相关的数据,返回。

因此,在函数 A 执行的时候,如果在第二步中,它又调用了另一个函数 B,B 又调用 C.... 栈就会不断地增长不断地装入数据,当这个调用链很深的时候,栈很容易就满 了,这就是一般递归函数所容易面临的大问题。

一直在强调,尾递归的实现依赖于编译器的帮助(或者说语言的规定),为什么这样说呢?先看下面的程序:

复制代码
 1 #include 
 2 
 3 int tail_func(int n, int res)
 4 {
 5      if (n <= 1) return res;
 6 
 7      return tail_func(n - 1, n * res);
 8 }
 9 
10 
11 int main()
12 {
13     int dummy[1024*1024]; // 尽可能占用栈。
14     
15     tail_func(2048*2048, 1);
16     
17     return 1;
18 }
复制代码

上面这个程序在开了编译优化和没开编译优化的情况下编出来的结果是不一样的,如果不开启优化,直接 gcc -o tr func_tail.c 编译然后运行的话,程序会爆栈崩溃,但如果开优化的话:gcc -o tr -O2 func_tail.c,上面的程序最后就能正常运行。 

这里面的原因就在于,尾递归的写法只是具备了使当前函数在调用下一个函数前把当前占有的栈销毁,但是会不会真的这样做,是要具体看编译器是否最终这样做,如果在语言层面上,没有规定要优化这种尾调用,那编译器就可以有自己的选择来做不同的实现,在这种情况下,尾递归就不一定能解决一般递归的问题。

我们可以先看看上面的例子在开优化与没开优化的情况下,编译出来的汇编代码有什么不同,首先是没开优化编译出来的汇编tail_func:

复制代码
 1 .LFB3:
 2         pushq   %rbp
 3 .LCFI3:
 4         movq    %rsp, %rbp
 5 .LCFI4:
 6         subq    $16, %rsp
 7 .LCFI5:
 8         movl    %edi, -4(%rbp)
 9         movl    %esi, -8(%rbp)
10         cmpl    $1, -4(%rbp)
11         jg      .L4
12         movl    -8(%rbp), %eax
13         movl    %eax, -12(%rbp)
14         jmp     .L3
15 .L4:
16         movl    -8(%rbp), %eax
17         movl    %eax, %esi
18         imull   -4(%rbp), %esi
19         movl    -4(%rbp), %edi
20         decl    %edi
21         call    tail_func
22         movl    %eax, -12(%rbp)
23 .L3:
24         movl    -12(%rbp), %eax
25         leave
26         ret
复制代码

注意上面标红色的一条语句,call 指令就是直接进行了函数调用,它会先压栈,然后再 jmp 去 tail_func,而当前的栈还在用!就是说,尾递归的作用没有发挥。

再看看开了优化得到的汇编:

复制代码
 1 tail_func:
 2 .LFB13:
 3         cmpl    $1, %edi
 4         jle     .L8
 5         .p2align 4,,7
 6 .L9:
 7         imull   %edi, %esi
 8         decl    %edi
 9         cmpl    $1, %edi
10         jg      .L9
11 .L8:
12         movl    %esi, %eax
13         ret
复制代码

注意第7,第10行,尤其是第10行!tail_func() 里面没有函数调用!它只是把当前函数的第二个参数改了一下,直接就又跳到函数开始的地方。此处的实现本质其实就是:下一个函数调用继续延用了当前函数的栈!

这就是尾递归所能带来的效果: 控制栈的增长,且减少压栈,程序运行的效率也可能更高!

上面所写的是 c 的实现,正如前面所说的,这并不是所有语言都摆支持,有些语言,比如说 python, 尾递归的写法在 python 上就没有任何作用,该爆的时候还是会爆。

复制代码
def func(n, res):

    if (n <= 1):
        return res

    return func(n-1, n*res)

if __name__ =='__main__':
    print func(4096, 1)
复制代码

不仅仅是 python,据说 C# 也不支持,我在网上搜到了这个链接:https://connect.microsoft.com/VisualStudio/feedback/details/166013/c-compiler-should-optimize-tail-calls,微软的人在上面回答说,实现这个优化有些问题需要处理,并不是想像中那么容易,因此暂时没有实现,但是这个回答是在2007年的时候了,到现在岁月变迁,不知支持了没?我看老赵写的尾递归博客是在2009年,用 c# 作的例子,估计现在 c# 是支持这个优化的了(待考).

尾调用

前面的讨论一直都集中在尾递归上,这其实有些狭隘,尾递归的优化属于尾调用优化这个大范畴,所谓尾调用,形式它与尾递归很像,都是一个函数内最后一个动作是调用下一个函数,不同的只是调用的是谁,显然尾递归只是尾调用的一个特例。

复制代码
int func1(int a)
{
   static int b = 3;
   return a + b;
}

int func2(int c)
{
    static int b = 2;

    return func1(c+b);
}
复制代码

上面例子中,func2在调用func1之前显然也是可以完全丢掉自己占有的栈空间的,原因与尾递归一样,因此理论上也是可以进行优化的,而事实上这种优化也一直是程序编译优化里的一个常见选项,甚至很多的语言在标准里就直接要求要对尾调用进行优化,原因很明显,尾调用在程序里是经常出现的,优化它不仅能减少栈空间使用,通常也能给程序运行效率带来比较大的提升。 



文章参考链接:
说说尾递归

漫谈递归:尾递归与CPS


php可以采用函数自行优化,可以参考

http://www.nowamagic.net/librarys/veda/detail/2334


你可能感兴趣的:(算法)