509. 斐波那契数
前言
博主是前大厂程序猿,不定期分享前端知识与算法。
- 公众号:FE Corner
- wx小程序:FE Corner
欢迎关注,一起探索知识~
题目地址:509. 斐波那契数(简单)
(斐波那契数列没有求最值,所以严格来说不是动态规划问题)
标签:递归、动态规划、记忆搜索
题目描述:
斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
给定 n ,请计算 F(n) 。
思路
斐波那契数列想必大家已经非常熟悉,标准的递归思想,今天我们来深入理解一下解题思路,并引入动态规划的概念。
想看动态规划的小伙伴可以直接目录跳转
暴力递归
最简单也是最直观的递归算法,因为斐波那契数列的数学形式就是递归,因此很好理解。
/**
* @param {number} n
* @return {number}
*/
var fib = function(n) {
if(n===0 || n===1){
return n
}
return fib(n-1)+fib(n-2)
};
为什么叫暴力递归? 显然是因为过于暴力,有优化空间,既然是递归算法,那么我们就可以绘制树结构,来直观理解递归过程。
从上图的树不难看出,其实如果说n=5,计算f(5)的问题就是计算子问题f(4)与f(3),计算f(4),就要计算子问题f(3)与f(2),以此循环往下,直到最后的边界f(0),f(1)停止。
那么直接上述应用这种思路的代码有什么问题呢? 大家可能已经看出来了,在暴力递归的算法中,相同的子问题(相同背景色)计算了很多遍,时间复杂度达到了指数级别(子问题个数*子问题复杂度 =》 即2的n次方)。
这就是动态规划的第一个问题:重叠子问题。
优化-剪枝
上面通过分析暴力递归,我们已经找到了问题所在,那么思路就很清晰了,就是要将重叠的子问题变为只计算一次,因此我们可以通过创建一个数组来“缓存”已经计算过的子问题。
/**
* @param {number} n
* @return {number}
*/
//缓存函数
let memoFuc = (memory,n) =>{
if(n===0 || n===1){
return n
}
// 如果该子问题计算过,则直接返回
if(memory[n]!==-1){
return memory
}
//剩余步骤继续跟之前一样递归求解
return memoFuc(memory,n-1)+ memoFuc(memory, n-2)
}
var fib = function(n) {
// 初始化缓存数组,填充-1
const memory = new Array(n+1).fill(-1)
//进行带缓存的递归
return memoFuc(memory,n)
}
至此带“缓存”递归算法已经over了,有些小伙伴可能还是不理解发生了什么,那么我们通过树来理解上述算法,以及形象地理解“剪枝”含义。
这就是自顶向下通过剪枝完成了递归求解的全过程了,现在的递归子问题只有n个,因此时间复杂度直接从指数级变为O(n),至此算法的效率和动态规划已经基本一致,只不过我们所常说的动态规划是自底向上求解,动态规划概念引入见下一段落。
动态规划(Dynamic Programming)
前言就说本题其实不是严格意义上的动态规划问题,因为动态规划一般都是求解最值问题,因此也是穷举问题(因为要求最值,所以就是穷举所有可能找到最优解的过程)。
所以在这里引出动态规划的关键:
- 状态转移方程(解决问题的核心)
- 最优子结构(存在并找到最优子结构才能计算原问题的最值)
- 重叠子问题(采用暴力穷举的方法,效率会很低)
以上其实就是动态规划的三要点,那么我们回到斐波那契数上面,常见的动态规划都是自底向上解决问题,譬如本题找到f(1)与f(0),不断向上求解,直到f(n),这就是我们常见的动态规划其实并不是递归,而是使用循环来向上推出解的原因。
因此我们将上文提到的缓存数组memory,换一个动态规划常用的dp来命名重写算法:
/**
* @param {number} n
* @return {number}
*/
var fib = function(n) {
let dp = new Array(n+1).fill(-1)
dp[0] = 0;
dp[1] = 1;
// 状态转移
for(let i = 2;i <=n;i++){
dp[i] = dp[i-1] + dp[i-2]
}
return dp[n]
};
细心的朋友可能发现了,这其实和上文的剪枝优化本质是一样的,只不过就是由自顶向下的递归变成了自底向上的循环递推,他们的效率也大抵相同。
其实代码中 dp[i] = dp[i-1] + dp[i-2] 就是上文提到动态规划问题的核心:状态转移方程,n其实就是一个状态,将求解n的行为转移到了n-1与n-2上,这其实就是所谓的状态转移。
动态规划的优化
看到这里的小伙伴不知道发没发现,上文的dp数组的本质作用其实只需要记录状态n-1与n-2就可以,没必要将整个状态记录,因此我们可以用滚动dp的思路来优化压缩dp表。
/**
* @param {number} n
* @return {number}
*/
var fib = function(n) {
if(n===0 || n===1){
return n
}
// 分别代表 dp[i - 1] 和 dp[i - 2]
let dp0 = 0, dp1 = 1
// 状态转移
for(let i = 2;i <=n;i++){
dp_res = dp0 + dp1
// 滚动更新
dp0 = dp1
dp1 = dp_res
}
return dp_res
};
至此将空间复杂度压缩为O(1)
小结
上文其实就是动态规划的最后一步:优化dp表,如果我们发现每次状态转移只需要 dp表中的一部分,那么可以尝试只记录必要的数据,从而降低空间复杂度。
这里其实并没有涉及到动态规划的另一个核心点:最优子结构,因为本题并不是求最值的问题,博主只想带大家了解动归的初步入门,如果大家想进一步真正的求解动归问题,请关注博主,每日持续更新算法小解。
- 公众号:FE Corner
- wx小程序:FE Corner
欢迎关注,一起探索知识~