写在前面
这篇文章的代码其实早在 2020 年初的某个夜晚心血来潮已经实现过了,并且发在了自己的博客 wfk007.github.io/2020/03/28/… 上,但是,现在回看的话会发现基本都是代码,缺少解释说明,这也是我写这篇文章的目的之一。另外因为博客用 hexo 搭的,评论系统使用的是 Diaqus,国内网就没法用,很操蛋,博客的阅读量数据最开始是有设置的,但后来因为存在刷新后阅读量反而会自动减少这种莫名其妙的 bug,我把它停掉了,这也是我想把写作战场从个人博客转到掘金的原因,学习+记录+分享。最后,因为个人能力的原因,文中错误在所难免,欢迎各位大佬批评指正~
什么是斐波那契数列
上述数列就是一个斐波那契数列,数列的规律很明显:前两项是 1,从第三项开始,每一项都等于前两项之和。(想了解更加详细解释的同学,可以参见维基百科或百度百科)
数学定义为:
解法
按照上述数学定义,我们可以顺理成章地写出如下第一种解法:递归
const fibs = n => {
if (n === 1 || n === 2) return 1;
else return fibs(n - 1) + fibs(n - 2);
};
先写递归出口,当 n === 1 或 2 时(即前两项),返回 1,否则将前两项的结果相加返回。
写完代码后,我们随意测试几个数据,发现都能拿到正确结果
但当我们输入一个比较大的 n 时,比如 100,程序运行后却迟迟打印不出结果,是我们代码写的有问题么?
为了探个究竟,我简单画了当 n === 100 时,程序的递归调用过程:
时间复杂度也可以非常简单的计算出来:(递归调用的时间复杂度等于所有递归子程序的时间复杂度之和)
计算结果其实是首项为 1,公比为 2 的等比数列前 n 项之和,已知等比数列的前 n 项和公式如下:
计算得:
去掉常数项和系数,最后得到
得出结论:上述递归调用时间复杂度是指数级别,随着 n 的增加,时间复杂度呈指数形式陡增,这也解释了为什么当输入一个较大的 n 时,程序却迟迟不能得出结果。
上述递归过程耗时的一个重要原因就是重复计算,计算 fibs(100) 的时候计算了一遍 fibs(98),计算 fibs(99) 的时候又计算了一遍 fibs(98),我们可以从这里作为切入点,对上述递归过程进行优化,写出第二种解法:递归+备忘录法:
const dict = {};
const fibs = n => {
if (n === 1 || n === 2) {
return 1;
}
if (!dict[n]) {
dict[n] = fibs(n - 1) + fibs(n - 2);
}
return dict[n];
};
做的处理很简单,引入了一个 dict(或者 map) 来保存中间状态的计算结果,牺牲空间换时间。在进行递归操作之前,如果计算结果已经被 dict 缓存了,就直接从缓存中取,否则的话才会执行递归调用过程。简单画一下当 n === 100 时,程序的递归调用过程:
时间复杂度结算结果为:
计算 n === 100 的结果如下:
因为计算结果超过 Number.MAX_SAFE_INTEGER,存在被舍去的精度问题,计算结果不能代表正确结果,但在执行效率上相较于递归已经有了质的飞跃。
另外,除了用备忘录法对递归过程进行优化外,我们还可以采用尾递归进行优化,于是就有了如下第三种解法:
const fibs = n => fibsTailRecursion(1, 1, n);
const fibsTailRecursion = (ppre, pre, n) => {
if (n === 1) {
return ppre;
}
return fibsTailRecursion(pre, pre + ppre, n - 1);
};
尾递归和上述普通递归的区别是:尾递归的最后一步是调用某个函数 fibsTailRecursion(pre, pre + ppre, n - 1),而上述普通递归最后一步是调用了两个函数,并且把他们的结果相加 fibs(n - 1) + fibs(n - 2)。
递归调用的过程会在内存中形成一个递归调用栈,进行递归子程序调用前(在 A 函数中递归调用 A1),会先将当前函数(A)的上下文信息入栈,等到子程序(A1)调用结束,将会返回到原始执行子调用过程的位置(A 中调用 A1 的位置),恢复 A 的上下文(入参、局部变量、子程序的调用位置...),继续往下执行,例如上述方法一中的递归调用过程如下:(深度优先)
- 入参为 n,调用
fibs(n - 1),等待fibs(n - 1)的结果返回 - 以新的参数 n-1 入参,继续调用
fibs(n - 2),等待fibs(n - 2)结果返回 - 以新的参数 n-2 入参,继续调用
fibs(n - 3),等待fibs(n - 3)结果返回 - ...
- 以新的参数 3 入参,继续调用 fibs(2),啊,终于拿到结果,往上返回
- ...
- 经过非常复杂的递归操作,终于
fibs(n - 1)计算出来了,又重复噩梦一般的过程计算fibs(n - 2)的值 fibs(n - 1)和fibs(n - 2)都计算出来了,将两者的返回结果相加,函数返回,执行结束
但是,使用尾递归进行优化的话,函数的最后一步是调用某个函数,不会保存当前的执行上下文。当最初的入参是 1,1,n 时,函数的调用过程如下:
- 调用
fibsTailRecursion(1, 1+1, n - 1) - 以新的入参 1,2,n-1 继续调用函数
fibsTailRecursion(2, 3, n - 2) - ...
- 最后 n 减少到 1,再以新的参数 x,y,1 继续调用函数,x 的值就是最终的结果
时间复杂度:
斐波那契数列第 N 项其实也可以用动态规划的方式求解,于是就有了第四种解法。
在写动态规划算法前我们需要明确几个点:
- dp[i] 的定义
- base case 是什么
- 递推公式
对于斐波那契数列来说:
- dp[i] 表示 第 i+1 项斐波那契数,我们的结果是求 dp[i-1]
- base case 也很明显,dp[0] = 1, dp[1] = 1
- 递推公式为:dp[i] = dp[i - 1] + dp[i - 2];
一切准备就绪后开始编码,代码实现如下:
const fibs = n => {
const dp = [1, 1];
if (n === 1 || n === 2) {
return 1;
}
for (let i = 2; i < n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n - 1];
};
时间复杂度:
另外,我们也可以用中间值法来求解,从前往后遍历,循环过程中不断交换值,于是就有了第五种解法,代码如下:
const fibs = n => {
if (n === 1 || n === 2) {
return 1;
}
let ppre = 1;
let pre = 1;
let tmp;
for (let i = 2; i < n; i++) {
tmp = ppre + pre;
ppre = pre;
pre = tmp;
}
return tmp;
};
时间复杂度:
此外,我们知道斐波那契数列是有通项公式的,公式如下:
利用通项公式,我们可以用第六种方法实现,代码如下:
const fibs = n =>
Math.round(
(Math.sqrt(5) / 5) *
(Math.pow((1 + Math.sqrt(5)) / 2, n) -
Math.pow((1 - Math.sqrt(5)) / 2, n)),
);
时间复杂度:
最后一种方法是利用线性代数的矩阵乘法,原理为:
根据矩阵快速幂求解斐波那契数列这篇文章的思路,用 js 将其实现。其中,矩阵乘法部分实现的比较粗糙,最终代码如下:
// 矩阵乘法 m*n n*k = m*k
const matrixMultiply = (arr1, arr2) => {
let result = [];
for (let i = 0; i < arr1.length; i++) {
let current = [];
for (k = 0; k < arr1.length; k++) {
// 累加
let sum = 0;
for (let j = 0; j < arr1[i].length; j++) {
sum = sum + arr1[i][j] * arr2[j][k];
}
current.push(sum);
}
result.push(current);
}
return result;
};
const fibs = n => {
let result = [
[0, 1],
[0, 0],
];
const arr = [
[0, 1],
[1, 1],
];
for (let i = 0; i < n - 1; i++) {
result = matrixMultiply(result, arr);
}
return result[0][1];
};