什么是尾递归?
函数调用自身,称为递归。如果尾部调用自身,就称为尾递归。
递归本来非常耗内存,因为需要同时保存成千上百个调用帧,很容易发生"栈溢出"错误。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生"栈溢出"错误。
function factorial(n){
if(n === 1) return 1;
return n * factorial( n -1 );
}
factorial(5) // 120
//上面代码是一个阶乘函数,计算n的阶乘,最多需要保存n个调用记录,复杂度O(n)。
//如果改成尾递归,只保留一个调用记录,复杂度O(1)。
function factorial(n,total){
if(n === 1) return total;
return factorial(n -1, n * total);
}
factirial(5,1) //120
还有一个比较著名的例子,就是计算Fibonacci数列,也能充分说明尾递归优化的重要性。
非尾递归的Fibonacci数列实现如下。
function Fibonacci(n){
if(n <= 1){return 1};
return Fibonacci(n -1)+Fibonacci(n-2);
}
Fibonacci(10) //89
Fibonacci(100)//超时
Fibonacci(500)//超时
尾递归优化过的Fibonacci数列实现如下。
function Fibonacci2(n,ac1 = 1,ac2 =1){
if( n<=1){return ac2};
return Fibonacci2(n -1 ,ac2,ac1+ac2);
}
Fibonacci2(100) //573147844013817200000
Fibonacci2(1000) //7.0330367711422765e+208
Fibonacci2(10000) //Infinity
//由此可见,"尾调用优化"对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6亦是如此,第一次明确规定,所有EMAScript的实现,都必须部署"尾部调用优化"。这就是说,ES6中只要使用尾递归,就不会发生栈溢出(或者层层递归造成的超时),相对节省内存。
递归函数的改写
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。比如上面的例子,阶乘函数factorial需要用到一个中间变量total,那就把这个中间变量改写成函数的参数。这样做的缺点就是不太直观,第一眼很难看出来,为什么计算5的阶乘,需要传入两个参数5和1?
两个方法可以解决这个问题。方法一是在尾递归函数之外,再提供一个正常形式的函数。
function tailFactorial(n,total){
if(n === 1) return total;
return tailFactorial(n -1,n * total);
}
function factorial(n){
return tailFactorial(n,1);
}
factorial(5) // 120
上面代码通过一个正常形式的阶乘函数factorial,调用尾递归函数tailFactorial,看起来就正常多了。
函数是变成有一个概念,叫做柯里化(currying),意思试讲多参数的函数转化成单参数的形式。这里也可以使用柯里化。
function currying(fn,n){
return function (m){
return fn.call(this,m,n);
}
}
fucntion tailFactorial(n,total){
if(n === 1) return total;
return railFactorial(n -1, n * total);
}
const factorial = currying(tailFactorial,1);
factorial(5) // 120
上面代码通过柯里化,将尾递归函数tailFactorial变为只接受一个参数的factorial。
第二种方法就简单多了,就是采用ES6的函数默认和值。
fucntion factorial(n,total = 1){
if(n ===1) return total;
return factorial(n -1, n*total);
}
factorial(5) // 120
上面代码中,参数total有默认值1,所以调用时不同提供这个值。
总结
递归本质上是一种循环操作。纯粹的函数式编程语言没有训话操作命令,所有的循环都用递归实现,这就是为什么尾递归对着写语言及其重要。对于其他支持"尾调用优化"的语言(比如lua,ES6),只需要知道循环可以用递归代替,而一但是用递归,就最好使用尾递归。
声明:这片文章也是参考大佬阮一峰前辈
ES6 阮一峰