在传统的递归中,典型的模型是首先执行递归调用,然后获取递归调用的返回值并计算结果。以这种方式,在每次递归调用返回之前,您不会得到计算结果。传统地递归过程就是函数调用,涉及返回地址、函数参数、寄存器值等压栈(在x86-64上通常用寄存器保存函数参数),这样做的缺点有二:
- 效率低,占内存
- 如果递归链过长,可能会statck overflow
尾递归的原理:
当编译器检测到一个函数调用是尾递归的时候,它就覆盖当前的活动记录而不是在栈中去创建一个新的。编译器可以做到这点,因为递归调用是当前活跃期内最后一条待执行的语句,于是当这个调用返回时栈帧中并没有其他事情可做,因此也就没有保存栈帧的必要了。通过覆盖当前的栈帧而不是在其之上重新添加一个,这样所使用的栈空间就大大缩减了,这使得实际的运行效率会变得更高。
在尾递归中,首先执行计算,然后执行递归调用,将当前递归计算的结果传递给下一个递归。这导致最后一个语句采用的形式return (recursive-function params)
。基本上,任何给定递归步骤的返回值与下一个递归调用的返回值相同。
我们考虑一个最基本的关于N的求和函数,(例如sum(5) = 1 + 2 + 3 + 4 + 5 = 15)。
这是一个使用JavaScript实现的递归函数:
function calcSum(x) {
if (x===1) {
return x;
} else {
return x + recsum(x-1);
}
}
如果你调用calcSum(5),JavaScript解释器将会按照下面的次序来计算:
calcSum(5)
5 + calcSum(4)
5 + (4 + calcSum(3))
5 + (4 + (3 + calcSum(2)))
5 + (4 + (3 + (2 + calcSum(1))))
5 + (4 + (3 + (2 + 1)))
15
注意在JavaScript解释器计算calcSum(5)之前,每个递归调用必须全部完成。
这是同一函数的尾递归版本:
function tailSum(num, total = 0) {
if (num ===0 ) {
return total ;
} else {
return tailSum(num -1, total +num);
}
}
下面是当你调用tailSum(5)的时候实际的事件调用顺序:
tailSum(5, 0)
tailSum(4, 5)
tailSum(3, 9)
tailSum(2, 12)
tailSum(1, 14)
tailSum(0, 15)
15
在尾递归的情况下,每次递归调用的时候,total
都会更新。
如此, 每次执行完递归, 占用内存的只有一个方法,可以有效的避免因为太多调用而导致的内存泄露
网友评论