LeeCode 509-斐波那契数(动态规划入门)

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

欢迎关注,一起探索知识~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值