今天无意中翻到闰土大叔的一篇推文,关于面试题斐波拉契数列,求第n项的值,如第1000位。
首先它的特征长这样: 1,2,3,5,8,13,21,34…
常见的方案是这样的:
const f = (n) => {
if(n === 0) return 0;
if(n === 1) return 1;
return f(n - 1) + f(n -2);
}
聪明的你肯定能第一时间写出递归形式的解法。我们测一下性能:
console.time('fibonacci')
const f = (n) => {
if(n === 0) return 0;
if(n === 1) return 1;
return f(n - 1) + f(n -2);
}
f(40) // 165580141
console.timeEnd('fibonacci')
发现计算 f(40) 即斐波拉契的第40个数,得到 fibonacci: 8574.739013671875ms
花了8574ms,如果计算 f(50),等了很久,调用栈溢出,浏览器崩溃。~
如果面试官让你算出第1000个数呢,岂不是gg。所以面试者如果答成这样,可能很难通过的,就算你写出非递归等其它写法,也悬。
很明显,这个题目虽然简单,但人家考察你的可能是对新技术的追求 es6(不能说新了)…
如何优化
这个题,要运用到es6的尾调用和默认参数了。先上代码:
console.time('fibonacci')
const f = (n, prev = 1, next = 1) => {
if (n < 2) {
return next
}
return f(n - 1, next, prev + next)
}
f(1475)
console.timeEnd('fibonacci')
可以算 f(1475) 值为1.3069892237633987e+308,f(1476) Infinity,正常执行。看下执行时间: fibonacci: 0.1630859375ms,执行5次也稳定在0.2ms以下。
尾调用原理
根据深入理解ES6这本书p68,尾调用是指函数执行的最后一步是调用另一个函数。如果满足以下条件,则尾调用不再创建新的帧栈,而是清除并重用当前帧栈。
1. 尾调用不访问当前帧栈的变量(也就是说函数不是一个闭包)
2. 在函数内部,尾调用是最后一条语句
3. 尾调用的结果作为函数值返回
这样,满足上面三个条件,可以被Javascript引擎自动优化。
所以,一般形式如下:
function a() {
...
return b()
}
最后一步一定返回 b(),如果返回1+ b(),都不能被引擎优化。
看过你不知道的javascript那3本书,你可能知道函数调用的原理:函数调用会在内存形成一个“调用记录”,又称“调用帧”,保存调用位置和内存变量等信息。如果在函数A的内部调用函数B,那么在A的调用帧上方,还会形成一个B的调用帧。等到B运行结束,将结果返回到A,B的调用帧才会消失。如果函数B内部还调用函数C,那就还有一个C的调用帧,依次类推。所有的调用帧,就形成一个“调用栈”。
所以,尾调用具体原理:尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。
如上面的例子,正常function a 里调用b,a是b的外层函数,等程序运行到b内部的时候,a的调用帧还会保留,这样普通的递归,会保留很多帧,最后导致内存溢出(具体浏览器或Node最大的堆栈大小不清楚…)。而尾调用由javascript引擎优化,a返回了b,a的调用帧直接被b的调用帧替代,如此,整个调用栈只保留一条,永远不可能溢出。