《解剖递归与动态规划:以斐波那契数列为例的思维跃迁》

147 阅读5分钟

前言

大家好,今天给大家介绍一道我们耳熟能详的算法题--斐波那契数列,通过这道题,我们给大家深度介绍递归、递归的记忆化优化、以及动态规划思想!


斐波那契数列简介

斐波那契数列是一个经典的数学序列,定义如下:

  • F(0) = 0
  • F(1) = 1
  • F(n) = F(n-1) + F(n-2) (n ≥ 2)

这个数列的前几项是:0, 1, 1, 2, 3, 5, 8, 13, 21, 34...


递归做法

如何想到递归——大问题拆解成子问题

递归是一种将大问题分解为相似子问题的解题方法。对于斐波那契数列,我们的大问题是求F(n),根据定义,F(n) = F(n-1) + F(n-2),这意味着:

  • 要计算F(n),需要先知道F(n-1)和F(n-2)
  • 而F(n-1)又依赖于F(n-2)和F(n-3),依此类推

所以这就是我们的子问题,所以我们就成功将大问题,转化成更小规模的子问题了,通过解决子问题,从而可以最终解决大问题。


自顶向下思考

递归采用的是"自顶向下"的思考方式——我们从终点问题出发,逐步将其分解为更小的子问题,直到达到已知的基础情况。

function fibonacci(n) {
    if (n === 0) return 0;
    if (n === 1) return 1;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

记忆化

重复计算问题分析

我们上面提供的解法虽然能够解决问题,但是却会出现一个显而易见的问题,重复计算,以计算F(5)为例:

image.png

F(5) = F(4) + F(3)
     = (F(3) + F(2)) + (F(2) + F(1))
     = ((F(2) + F(1)) + (F(1) + F(0))) + ((F(1) + F(0)) + F(1))

可以看到F(2)被计算了2次,F(1)被计算了5次,F(0)被计算了3次。

重复计算随之可能会带来一个问题:爆栈,我们知道递归本质上是通过栈实现的,每一次进入到递归函数则会入栈,所以重复计算会带来很多不必要的入栈,导致可能这个栈到达上限,此时再进行入栈,导致栈的大小不够用,也就是爆栈。所以我们需要优化


使用闭包实现

我们可以通过存储已计算结果来避免重复计算,在JS中,我们可以利用JS的独特的闭包来存储,我们知道,闭包是函数能够记住它所在的词法作用域,所以我们创建一个闭包环境,在递归中,记住已经计算过的数,下次再访问到这个数时,就可以直接从存储中拿,从而避免重复计算

闭包实现:

function memoizedFibonacci() {
    const cache = [0, 1];
    
    return function fib(n) {
        if (cache[n] !== undefined) return cache[n];
        cache[n] = fib(n - 1) + fib(n - 2);
        return cache[n];
    }
}

const fibonacci = memoizedFibonacci();


动态规划做法

从递归到动态规划

上文提到,我们在使用递归分析时,采用了"自顶向下"的方式,从最终的结果往下推,构造树形结构。

而我们要使用动态规划分析的话,则采用"自底向上"的方式,从基础情况开始逐步构建最终解,我们发现从第i-1个数的状态和第i-2的状态能够推出第i个数的状态,这个推导适用于整个斐波那契数列,那么我们就可以思考使用动态规划了。

动态规划做题五部曲

我们在做动态规划的题目时,我强烈建议你遵守下面由程序员卡尔提出的动规五部曲,不论是简单题,还是难题,形成这一套方法论,能让我们处理动规题时,思路更加清晰,不会出现做着做着发现自己越分析越混乱的情况。

  1. 思考dp数组以及下标的含义
  2. 思考状态转移方程
  3. 确定初始值
  4. 确定顺序
  5. 打印数组验证

在这道简单题中,我们也要用这种方式分析,目的是帮我们巩固这一套方法论,形成良好的思考习惯。

  1. 定义dp数组及下标含义

    • dp[i]表示第i个斐波那契数
  2. 状态转移方程

    • dp[i] = dp[i-1] + dp[i-2]
  3. 确定初始值

    • dp[0] = 0
    • dp[1] = 1
  4. 确定计算顺序

    • 从i=2开始到i=n依次计算
  5. 验证

    • 可以打印前几项验证结果是否正确

所以我们最终动规的代码为:

function fibonacciDP(n) {
    if (n === 0) return 0;
    if (n === 1) return 1;
    
    let dp = [0, 1];
    for (let i = 2; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    return dp[n];
}

动态规划的应用范围

动态规划特别适合解决具有以下特征的问题:

  1. 最优子结构:问题的最优解包含子问题的最优解
  2. 重叠子问题:不同的子问题会重复计算相同的更小子问题
  3. 无后效性:当前状态只与之前的状态有关,与之后的状态无关

常见应用场景包括:

  • 最长公共子序列
  • 背包问题
  • 最短路径问题
  • 股票买卖问题等

结尾

斐波那契数列问题展示了算法优化的完整路径:

  1. 从直观的递归解法开始
  2. 发现重复计算问题,引入记忆化优化
  3. 进一步转化为更高效的动态规划解法
  4. 最后进行空间优化

理解这个过程对于培养算法思维非常重要,它教会我们如何分析问题、识别问题特征,并选择最合适的解法。动态规划作为解决最值问题的强大工具,值得每个程序员深入学习和掌握。