从斐波那契到爬楼梯:递归与动态规划的奇妙之旅

114 阅读7分钟

大家好,我是你们的老朋友FogLetter。今天我们要一起探索算法世界中两个非常重要的概念——递归和动态规划。这两个概念听起来可能有点吓人,但相信我,通过斐波那契数列和爬楼梯这两个经典问题,你会发现它们其实既有趣又实用!

一、斐波那契数列:递归的经典案例

1.1 什么是斐波那契数列?

斐波那契数列是一个非常有趣的数列,它的定义如下:

  • 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...

1.2 最直观的递归实现

当我们第一次学习递归时,斐波那契数列往往是最先接触的例子之一。用JavaScript实现起来非常简单:

function fib(n) {
    if(n <= 1) return n;
    return fib(n-1) + fib(n-2);
}
console.log(fib(10)); // 输出55

这段代码看起来简洁优雅,完美体现了数学定义。但是,当我们尝试计算较大的斐波那契数时,比如fib(50),就会发现程序运行得非常慢,这是为什么呢?

1.3 递归的缺陷:重复计算

让我们画出fib(5)的递归调用树:

fib(5)
├── fib(4)
│   ├── fib(3)
│   │   ├── fib(2)
│   │   │   ├── fib(1)
│   │   │   └── fib(0)
│   │   └── fib(1)
│   └── fib(2)
│       ├── fib(1)
│       └── fib(0)
└── fib(3)
    ├── fib(2)
    │   ├── fib(1)
    │   └── fib(0)
    └── fib(1)

可以看到,fib(3)被计算了2次,fib(2)被计算了3次,fib(1)和fib(0)被计算的次数更多。这种重复计算导致了指数级的时间复杂度O(2^n),效率极低。

二、闭包与记忆化:优化递归的利器

2.1 什么是记忆化(Memoization)?

记忆化是一种优化技术,它通过存储已经计算过的结果来避免重复计算。对于斐波那契数列来说,我们可以创建一个缓存对象,在每次计算前先检查缓存中是否已有结果。

2.2 使用闭包实现记忆化

闭包是JavaScript中一个强大的特性,它允许函数访问并记住其词法作用域中的变量,即使函数在其词法作用域之外执行。我们可以利用闭包来实现记忆化:

function memoizedFib() {
    const cache = {}; // 缓存对象,自由变量
    
    return function fib(n) {
        if(n <= 1) return n;
        if(cache[n]) return cache[n]; // 如果缓存中有,直接返回
        
        cache[n] = fib(n-1) + fib(n-2); // 计算并存入缓存
        return cache[n];
    }
}

const fib = memoizedFib();
console.log(fib(100)); // 354224848179262000000

这个版本的斐波那契函数可以轻松计算出fib(100),而原始递归版本可能永远无法完成这个任务。

2.3 为什么记忆化如此有效?

记忆化将时间复杂度从指数级的O(2^n)降低到了线性的O(n),因为我们只需要计算每个斐波那契数一次。空间复杂度也是O(n),因为我们需要存储n个结果。

三、爬楼梯问题:从递归到动态规划

3.1 问题描述

爬楼梯是另一个经典问题:假设你正在爬楼梯,每次你可以爬1个或2个台阶。问爬到第n个台阶有多少种不同的方法?

例如:

  • n=1:1种方法(1)
  • n=2:2种方法(1+1或2)
  • n=3:3种方法(1+1+1,1+2,2+1)

3.2 递归解法

仔细观察,你会发现爬楼梯问题与斐波那契数列非常相似。要到达第n个台阶,你可以从n-1台阶跨1步上来,或者从n-2台阶跨2步上来。因此:

const climbStairs = function(n) {
    if(n == 1) return 1;
    if(n == 2) return 2;
    return climbStairs(n-1) + climbStairs(n-2);
}

同样地,这个递归解法也存在重复计算的问题。

3.3 记忆化递归解法

我们可以使用与斐波那契数列相同的记忆化技术来优化:

const f = [];
const climbStairs = function(n) {
    if(n == 1) return 1;
    if(n == 2) return 2;
    if(f[n] === undefined) f[n] = climbStairs(n-1) + climbStairs(n-2);
    return f[n];
}

3.4 动态规划解法

动态规划(Dynamic Programming, DP)是一种更系统化的优化方法。它通常用于解决具有重叠子问题和最优子结构性质的问题。对于爬楼梯问题,我们可以使用动态规划:

const climbStairs = function(n) {
    const dp = [];
    dp[1] = 1;
    dp[2] = 2;
    for(let i = 3; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2]; // 状态转移方程
    }
    return dp[n];
}

3.5 动态规划的优势

动态规划解法有以下几个优点:

  1. 时间复杂度O(n):只需一次遍历即可得到结果
  2. 空间复杂度O(n):使用数组存储中间结果
  3. 更直观:明确展示了状态转移过程

我们还可以进一步优化空间复杂度到O(1),因为实际上我们只需要前两个状态:

const climbStairs = function(n) {
    const dp = [];
    dp[1]=1;
    dp[2]=2;
    for(let i=3;i<=n;i++) {
        dp[i] = dp[i-1] + dp[i-2]; // 状态转移方程
    }
    return dp[n];
}

四、递归与动态规划的思维升华

4.1 闭包思想的延伸

在解决这些问题时,我们运用了闭包思想:

  1. 大问题拆解为小问题:将fib(n)拆解为fib(n-1)和fib(n-2)
  2. 退出条件:n <= 1时直接返回
  3. 避免重复计算:通过缓存存储已计算结果
  4. 防止爆栈:虽然记忆化优化了时间复杂度,但递归深度仍然可能导致栈溢出

4.2 自顶向下 vs 自底向上

  • 递归+记忆化是自顶向下的方法:从目标问题开始,逐步分解
  • 动态规划是自底向上的方法:从基础情况开始,逐步构建

4.3 树形结构的思维

递归问题通常可以表示为树形结构:

  • 每个节点代表一个子问题
  • 子节点的合并得到父节点的解
  • 记忆化避免了子树重复计算

五、实际应用与扩展

5.1 斐波那契数列的应用

斐波那契数列在自然界中广泛存在,如:

  • 花瓣的数量
  • 菠萝的螺旋
  • 向日葵的种子排列
  • 股票市场分析(斐波那契回调)

5.2 动态规划的更多应用

动态规划可以解决许多经典问题:

  • 背包问题
  • 最长公共子序列
  • 最短路径问题
  • 编辑距离
  • 股票买卖问题

5.3 面试中的考察点

在面试中,面试官通常会考察:

  1. 能否识别问题可以使用递归/DP解决
  2. 能否正确写出状态转移方程
  3. 能否优化空间复杂度
  4. 边界条件的处理能力

六、总结

通过斐波那契数列和爬楼梯问题,我们深入探讨了递归和动态规划这两个强大的算法范式。关键点总结:

  1. 递归是解决问题的自然思路,但需要注意重复计算和栈溢出问题
  2. 记忆化通过缓存结果大幅提升递归效率
  3. 动态规划提供了更系统化的解决方案,通常更高效
  4. 闭包是实现记忆化的有力工具
  5. 理解状态转移方程是解决DP问题的核心

记住,掌握这些算法思想不仅可以帮助你通过技术面试,更能提升你解决实际问题的能力。希望这篇笔记对你有所帮助,如果有任何问题,欢迎在评论区留言讨论!

最后留个思考题:如果每次可以爬1、2或3个台阶,爬n个台阶有多少种方法?如何用动态规划解决?