动态规划:从斐波那契数列入门

315 阅读2分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 5 天,点击查看活动详情

0.斐波那契数列

相信"斐波那契数列"这个概念大家都不会觉得陌生,又称黄金分割数列,具体内容不再过多解释,其定义为:F(0)=0,F(1)=1, F(n)=F(n - 1)+F(n - 2)(n ≥ 2,n ∈ N*);属于比较基础的算法入门题,如果用最基本的解法实现,大概如下:

// 解法一:递归
const fib = function(n) { 
    return n>1? fib(n-1)+fib(n-2) : n 
}

这个解法使用的是递归解法,写法简单易理解。但这种解法直接用的数学定义公式,性能不好,计算过程如图示:

image.png 简单来说,计算 f(6),依赖 f(5)和 f(4);计算 f(5) 依赖 4 和 3;计算 4 又依赖 3 和 2。。。可以看到这中间 f(4),f(3),f(2) 等都在重复计算,根据数学求值公式:

image.png 可知空间复杂度为 O(n),时间复杂度为 O(2^n);指数级的时间复杂度肯定是不能接受的,上面的方法在fib(35) 左右就会让你的浏览器卡崩(不建议尝试!)。

1.数据记忆化

通过我们上面的分析,既然存在数据重复计算,我们就把已经算好的结果存起来不就行了。说起数据存储很容易想到用数组,改进后的例子如下:

// 解法二:记忆存储
const fib = function(n) { 
    if(n<2) return n
    let arr = [0,1]; // 前两位是一定的
    for(let i=2;i<=n;i++){
        arr[i] = arr[i-1] + arr[i-2]
    }
    return arr[n]
}

这个解法巧妙的运用了斐波那契当前项数据是由前两项数据相加结果的特性,递归给数组赋值,最后得到想要的结果,并且整个数组可以告诉你从 0 到 n 中任何一项的结果。空间复杂度为 O(n),时间复杂度为 O(n),性能大幅度提升。你用这个方法别说计算第 35 位,哪怕 3500 位也不在话下。

这种方法花了 n 的空间存储数据,但进一步想想,我们其实单纯只想知道 fib(n) 的值,没有必要存储 0 到 n-1 中间其他项的值。既然斐波那契数列特点是:【当前项 = 【当前-1项】 + 【当前-2项】,那我们可以假设已经一个只有两项的数组,接受指定 n 时,如果大于 2,则遍历 n,重复给数组进行赋值替换处理。尝试做如下改造:

// 解法三:记忆存储优化
const fib = function(n) { 
    if(n<2) return n
    let dep = [0,1]; // 前一项和当前项
    for(let i=2;i<=n;i++){
        let sum = dep[0] + dep[1]
        dep[0] = dep[1]
        dep[1] = sum
    }
    return dep[1]
}

这里我们数组就保留两个值:一个存放前一项的值,一个存放当前项的值;遍历从 2 开始,每次更新前一项和当前值,最后返回的 dep[1] 永远是当前 fib(n) 想要的结果。和解法二相比,这个解法优化了空间,因为只有两项数据,可以理解为空间复杂度降为 O(1),时间复杂度还是不变的 O(n)。

2.动态规划

有了上面的实操,再引入动态规划的概念就比较好理解。动态规划概念描述如下:

动态规划在查找有很多重叠子问题的情况的最优解时有效。它将问题重新组合成子问题。为了避免多次解决这些子问题,它们的结果都逐渐被计算并被保存,从简单的问题直到整个问题都被解决

结合刚才的斐波那契数列理解,即把递归的结果做存储,避免重复问题的计算;f(5)计算依赖 f(4) 和 f(3),f(4) 的计算又依赖 f(3),那么把 f(3) 存储下来,就避免了重复计算;综合结果在做动态规划过程中,要注意以下两点:

  • 基础状态:dep = [0,1]
  • 递推公式:dep[i] = dep[i-1] + dep[i-2]

当然斐波那契的递推公式和基础状态都比较简单,碰到更复杂的问题可能就没那么简单了。后续会陆续更新相关问题。

以上,感谢阅读。