先来看维基百科上的解释:
在计算机科学里,尾调用是指一个函数里的最后一个动作是一个函数调用的情形:即这个调用的返回值直接被当前函数返回的情形。这种情形下称该调用位置为尾位置。若这个函数在尾位置调用本身(或是一个尾调用本身的其他函数等等),则称这种情况为尾递归,是递归的一种特殊情形。尾调用不一定是递归调用,但是尾递归特别有用,也比较容易实现。
尾调用的重要性在于它可以不在调用栈上面添加一个新的堆栈帧——而是更新它,如同迭代一般。尾递归因而具有两个特征:
调用自身函数(Self-called);
计算仅占用常量栈空间(Stack Space)。
而形式上只要是最后一个return语句返回的是一个完整函数,它就是尾递归。
由于当前函数帧上包含局部变量等等大部分的东西都不需要了,当前的函数帧经过适当的更动以后可以直接当作被尾调用的函数的帧使用,然后程序即可以跳到到被尾调用的函数。产生这种函数帧更动代码与 “jump”(而不是一般常规函数调用的代码)的过程称作尾调用消除(Tail Call Elimination)或尾调用优化(Tail Call Optimization, TCO)。尾调用优化让位于尾位置的函数调用跟 goto 语句性能一样高,也因此使得高效的结构编程成为现实。
一般来说,尾调用消除是可选的。然而,在函数编程语言中,语言标准通常会要求虚拟机实现尾调用消除,这让程序员可以用递归取代循环而不丧失性能。
当一个函数调用发生时,电脑必须 “记住” 调用函数的位置 — 返回位置,才可以在调用结束时带着返回值回到该位置,返回位置一般存在调用栈上。在尾调用的情况中,电脑不需要记住尾调用的位置而可以从被调用的函数直接带着返回值返回调用函数的返回位置(相当于直接连续返回两次),尾调用消除即是在不改变当前调用栈(也不添加新的返回位置)的情况下跳到新函数的一种优化(完全不改变调用栈是不可能的,还是需要校正调用栈上形参与局部变量的信息。)
对函数调用在尾位置的递归或互相递归的函数,由于函数自身调用次数很多,递归层级很深,尾递归优化则使原本 O(n) 的调用栈空间只以 Python 为例,主要区分普通递归和尾递归对栈空间的使用:
def recsum(x):
if x == 1:
return x
else:
return x + recsum(x - 1)
调用recsum(5)为例,SICP中描述了相应的栈空间变化:
recsum(5)
5 + recsum(4)
5 + (4 + recsum(3))
5 + (4 + (3 + recsum(2)))
5 + (4 + (3 + (2 + recsum(1))))
5 + (4 + (3 + (2 + 1)))
5 + (4 + (3 + 3))
5 + (4 + 6)
5 + 10
15
可观察,堆栈从左到右,增加到一个峰值后再计算从右到左缩小,这往往是我们不希望的,所以在C语言等语言中设计for, while, goto等特殊结构语句,使用迭代、尾递归,对普通递归进行优化,减少可能对内存的极端消耗。修改以上代码,可以成为尾递归:
def tailrecsum(x, running_total=0):
if x == 0:
return running_total
else:
return tailrecsum(x - 1, running_total + x)
或者使用迭代:
for i in range(6):
sum += i
对比后者尾递归对内存的消耗:
tailrecsum(5, 0)
tailrecsum(4, 5)
tailrecsum(3, 9)
tailrecsum(2, 12)
tailrecsum(1, 14)
tailrecsum(0, 15)
15
则是线性的。需要 O(1)。
作为一种线性迭代,尾递归函数的最后一步操作是递归,也即在进行递归之前,把全部的操作先执行完,这样的好处是,不用花费大量的栈空间来保存上次递归中的参数、局部变量等,这是因为上次递归操作结束后,已经将之前的数据计算出来,传递给当前的递归函数,这样上次递归中的局部变量和参数等就会被删除,释放空间,从而不会造成栈溢出。
为了进一步熟悉尾递归的使用方式,我们再用著名的“菲波纳锲”数列作为一个例子。传统的递归方式如下:
public static int FibonacciRecursively(int n)
{
if (n < 2) return n;
return FibonacciRecursively(n - 1) + FibonacciRecursively(n - 2);
}
而改造成尾递归,我们则需要提供两个累加器:
public static int FibonacciTailRecursively(int n, int acc1, int acc2)
{
if (n == 0) return acc1;
return FibonacciTailRecursively(n - 1, acc2, acc1 + acc2);
}
于是在调用时,需要提供两个累加器的初始值:
FibonacciTailRecursively(10, 0, 1)
这里介绍一种尾递归的实现方式:汇编重组
对于直接生成汇编的编译器,尾部调用消除很简单:只要校正栈上的形参之后把 “call” 的机器码换成一个 “jump” 的就行了。从编译器的观点,以下代码
function foo()
return a()
先会被翻译成(这是合法的 x86 汇编):
foo:
call a
ret
然后,尾部调用消除指的是将最后两个指令以一个 “jump” 指令替换掉:
foo:
jmp a
在 a 函数完成的时候,它会直接返回到 foo 的返回地址,省去了不必要的 ret 指令。
函数调用可能带有参数,因此生成的汇编必须确保被调用函数的函数帧在跳过去之前已设置好。举例来说,若是平台的调用栈除了返回位置以外还有函数参数,编译器需要输出调整调用栈的指令。在这类平台上,考虑代码:
function foo(data1, data2)
a(data1)
return b(data2)
其中 data1、data2 是参数。编译器会把这个代码翻译成以下汇编:
foo:
mov reg,[sp+data1] ; 透过栈指针(sp)取得 data1 并放到暂用暂存器。
push reg ; 将 data1 放到栈上以便 a 使用。
call a ; a 使用 data1。
pop ; 把 data1 從栈上拿掉。
mov reg,[sp+data2] ; 透过栈指針(sp)取得 data2 並放到暂用暂存器。
push reg ; 将 data2 放到栈上以便 b 使用。
call b ; b 使用 data2。
pop ; 把 data2 從栈上拿掉。
ret
尾部调用优化会将代码变成:
foo:
mov reg,[sp+data1] ; 透过栈指针(sp)取得 data1 并放到暂用暂存器。
push reg ; 将 data1 放到栈上以便 a 使用。
call a ; a 使用 data1。
pop ; 把 data1 從栈上拿掉。
mov reg,[sp+data2] ; 透过栈指針(sp)取得 data2 並放到暂用暂存器。
mov [sp+data1],reg ; 把 data2 放到 b 预期的位置。
jmp b ; b 使用 data2 並返回到调用 foo 的函数。
更改后的代码不管在执行速度或是栈空间的使用上的性能都比较好。
我的理解:
本质上讲递归的问题源于将没有数据依赖性的问题搞得好像有数据依赖性一样,就好像如果我是项目经理,然后指示小弟干活,干完了向自己汇报,自己再向大老板汇报,如果目的就是让大老板知道,要我干嘛,浪费资源(好吧,我承认这个例子好像不太合适,额),实际上造成了时间上和空间上的浪费,他们把one pass可以干完的活弄成two pass,还要用更多的资源,这也是递归和迭代的区别,而许多递归的问题实际上是没有数据依赖性的问题的,完全没有必要保留那么多的中间状态,而尾递归的本质就是打破特权,消除了中间状态,节省了资源和时间。
话说回来,递归真的是非常精妙伟大的思想,简单,直观,易懂,反倒是尾递归,我实在不知他的缘起,为什么要绕这么大一个圈,从递归扯到尾递归,做着和正向迭代差不多的事,可能是函数式编程语言没有循环操作命令,所有的循环都用递归实现但又效率太低,好吧,我知道的太少了。
参考:
https://gist.github.com/jasonlvhit/841e3ffb4431a2ff18c2
https://zh.wikipedia.org/wiki/%E5%B0%BE%E8%B0%83%E7%94%A8
http://blog.zhaojie.me/2009/03/tail-recursion-and-continuation.html
版权声明:本文为博主原创文章,未经博主允许不得转载。
原文地址:http://blog.csdn.net/kzq_qmi/article/details/46745379