斐波那契数列又叫黄金分割数列或者兔子数列。(废话不多说) 有许多算法题最后的答案也都是斐波那契数列。比如最经典的上楼梯问题。(美团春招)
楼梯问题
题目:假设这里有n级阶梯,小明一次可以上一个阶梯,也可以一次上两个阶梯。请问小明一共有几种不同的方法到达n级阶梯?
这个问题上至算法大佬,下到代码萌新都能给出答案。但是写法却很能反应个人水平。
标准版
function fibnacci(n) {
if (n <= 0) { // 判断是否小于0
console.log('请输入一共大于0的数');
}
if (n == 1 || n == 2) { // 斐波那契数列的第一项和第二项都是1
return 1
} else {
return fibnacci(n - 1) + fibnacci(n - 2)
// 后面一项为前面两项之和
}
}
本质就是运用了递归,非常朴素的方法。代码的结构也很清晰。
极简版
let fibnacci = n =>
n <= 0 ? 0 : n == 1 ? 1 : fibnacci(n - 2) + fibnacci(n - 1)
三目表达式一行搞定 但是这里的代码不仅难看,而且难看。
传统递归方法的弊端
如果你细心的话就会发现,上面三种方法在 n 较小的时候,运行效果十分不错,随着n越来越大,程序运行的速度会越来越慢。
实际上,以上三种方法的运行时间是随指数增长的,当n>30时,你就可以明显感受到延迟。利用传统递归的方法,其时间复杂度为2^N。
// 采用递归的算法可以将代码简单理解为
// f(5) = f(4) + f(3)
// = f(3) + f(2) + f(2) + f(1)
// = f(2) + f(1) + f(2) + f(2) + f(1)
相当于你将它们最终都转换为了f(1)和f(2),以上楼梯这个题目来理解的话,相当于你用了穷举法来逐个列出了上楼梯的不同方法,这毫无疑问是十分不理智的。只是我们使用了计算机来代替我们自己进行穷举,但随着n值的不断增加,即使是计算机也会承受不住。
此外,如果你了解一点函数执行栈的知识就会知道,在函数运行时,会被放入执行栈内,如果这个函数内部含有函数那么就会继续入栈。也就是说,在每一次递归之后,都有一个函数被压入执行栈。在执行栈里执行一个,弹出一个,速度自然就慢了很多。此外,当压入的函数太多,还有可能给你报错:堆栈溢出。
所以说以上两种种方法只有理论价值,没有工程价值,所以,一个好的程序员需要对这个算法进行优化。
优化
尾调用优化
// 依然是双指针,但函数最后返回一个函数的调用。
function fibnacci(n, a = 1, b = 1) {
if (n <= 2) {
return b
}
return fibnacci(n - 1, b, a + b) // 返回函数的调用
// return fibnacci(n-1) + fibnacci(n-2) 这种方法不属于尾递归,因为他返回的是两个函数的值,而不是函数的调用。
}
这里我们每次递归结束后,都会返回一个函数的调用,而这时上一个函数实际上已经结束了,他会很顺理成章的从执行栈中被弹出,此时,执行栈中就只存在这个我们通过return返回的函数。也就是说,执行栈内始终只有一个函数,大大节约了时间成本和空间成本。不会出现堆栈溢出的报错。
for循环优化(双指针)
// 求斐波那契数列主要就是要知道前一项(a)和前两项(b)的值
function fibnacci(n) {
var a = 1, // 用来存储上上的值,实际上它是一个指针
b = 1, // 存储上一项的值
sum = 0; // 当前项的值,由于利用了上一项和上上项,所以n必须大于3。
if (n == 1 || n == 2) {
return 1 // 小于三的时候直接返回1
} else {
for (var i = 3; i <= n; i++) {
sum = a + b // 得到当前项
// 之后两个指针向下滑动
a = b // 原本存储上上项的a用来存储上一项
b = sum // 原本用来存储上一项的b用来存储当前项。
}
return sum; // 输出结果项
}
}
这种方法的精髓在于利用了双指针,将上一项和上上项存储起来。双指针是算法中一种十分重要的思想。
如果你仔细观察的话,会发现这种方法和尾递归的方法有异曲同工之妙。(我连参数都用的同一个a,b)实际上,尾调用只不过把计算过程放入了递归函数的参数中。
缓存优化
// 定义一个斐波那契数组,每一项计算的值都存入这个数组,使用的时候直接从数组中取值。
function fibnacci(n) {
let arr = [0, 1, 1] // 由于数组下标从0开始,因此第一项的值定义为0,相当于占位符。
if (n == 1 || n == 2) {
return 1
}
for (let i = 3; i <= n; i++) {
arr[i] = arr[i - 1] + arr[i - 2] // 将计算结果添加到arr斐波那契数组中
}
// console.log(arr)
return arr[n]
}
如果你在函数内部打印arr数组就会发现,当n越来越大的时候,这个数组也会变得非常庞大,相当于将传统递归方法中数量众多的f(1)和f(2)替换成了数组的项数,是一种牺牲内存空间来换取计算效率的方法。并不推荐。
总结
利用尾调用优化最好
function fibnacci(n, cur = 1, next = 1) {
if (n <= 2) {
return next
}
return fibnacci(n - 1, next, cur + next)
}