从斐波那契到爬楼梯:递归优化的三重境界

149 阅读6分钟

面试官:小伙子,写个斐波那契数列吧,简单的很!

我:那必须安排!(心想:这还不是手到擒来)

面试官:嗯,不错,那你再优化一下性能?

我:......(开始冒汗)

相信很多同学都遇到过类似的场景。今天我们就来聊聊递归优化这个老生常谈却又常考常新的话题,从最基础的递归实现到高级优化技巧,带你领略算法优化的魅力!

第一境界:天真的递归 😅

让我们从最经典的斐波那契数列开始:

// 递归的基本要素
// 1. 相似的问题
// 2. 自顶向下的思考 
// 3. 明确问题的终点(退出条件)
// 4. 注意重复计算问题
// 5. 树状结构分析

function fib(n) {
  if (n <= 1) return n;  // 退出条件
  return fib(n - 1) + fib(n - 2);  // 递归调用
}

console.log(fib(10)); // 55

这个实现看起来很优雅,但是有个致命问题——重复计算

我们来分析一下 fib(10) 的调用树:

         f(10)
    f(9)        f(8)
 f(8)  f(7)  f(7)  f(6)
...

可以看到,f(8)f(7) 等被重复计算多次。时间复杂度达到了恐怖的 O(2^n)

第二境界:闭包优化记忆化 🧠

面试官最爱考的就是这个优化思路——用闭包实现记忆化

// 如何用闭包优化fib
// 核心思想:给函数添加记忆能力

function memoizedFib(){
    // 闭包的两个关键要素:
    // 1. 函数嵌套函数
    // 2. 自由变量(外层变量被内层函数引用)
    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)); // 瞬间出结果!

这里用到了闭包的核心特性:

  • 内层函数可以访问外层函数的变量(cache
  • 外层函数执行完毕后,cache 变量依然被保持在内存中
  • 每次调用 fib 都能访问到同一个 cache 对象

时间复杂度从 O(2^n) 优化到了 O(n)

实战应用:爬楼梯问题 🪜

让我们把这个思路应用到经典的爬楼梯问题上:

问题描述

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

暴力递归版本

// 自顶向下 f(n) -> f(n-1) + f(n-2) 
// 画树形结构分析(方程不明显,有利于推导)
// 问题:重复计算,函数入栈太多

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

console.log(climbStairs(100)); // 会卡死!

记忆化递归版本

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];
}

console.log(climbStairs(100)); // 秒出结果

第三境界:动态规划——自底向上 📈

虽然记忆化递归解决了重复计算问题,但还有一个隐患——调用栈过深可能导致栈溢出

让我们换个思路,自底向上思考:

// 递归是自顶向下,动态规划是自底向上
// 从 f(1) = 1, f(2) = 2 开始,逐步计算到 f(n)
// 优势:迭代方式,不需要函数入栈,也不需要额外的递归空间

const climbStairs = function(n){
    // dp 数组存储子问题的解
    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];
}

这就是动态规划的思想:

  • 重叠子问题:问题可以分解为相同的子问题
  • 最优子结构:原问题的最优解包含子问题的最优解
  • 状态转移方程dp[i] = dp[i-1] + dp[i-2]

性能对比 📊

让我们来看看三种方法的性能对比:

方法时间复杂度空间复杂度优缺点
暴力递归O(2^n)O(n)简单直观,但性能极差
记忆化递归O(n)O(n)性能优秀,但可能栈溢出
动态规划O(n)O(n)性能优秀,空间可进一步优化

进阶优化:空间优化 🎯

动态规划还能进一步优化空间复杂度:

const climbStairs = function(n) {
    if (n <= 2) return n;
    
    let prev2 = 1;  // dp[i-2]
    let prev1 = 2;  // dp[i-1]
    
    for (let i = 3; i <= n; i++) {
        const current = prev1 + prev2;
        prev2 = prev1;
        prev1 = current;
    }
    
    return prev1;
}

空间复杂度从 O(n) 优化到了 O(1)

面试官的考察思维 💡

通过这道题,面试官想考察的知识点包括:

基础能力

  • 递归思维:能否将大问题拆解为相似的小问题
  • 边界条件:是否能正确处理退出条件
  • 复杂度分析:是否能分析算法的时间和空间复杂度

进阶能力

  • 闭包理解:能否运用闭包实现函数记忆化
  • 优化思维:是否具备从递归到动态规划的优化思路
  • 工程思维:在实际项目中如何权衡性能和可读性

架构思维

  • 自顶向下 vs 自底向上:两种不同的思维模式
  • 空间换时间:缓存策略在实际开发中的应用
  • 函数式编程:闭包、高阶函数等概念的实际运用

总结 🎉

从斐波那契到爬楼梯,我们经历了递归优化的三重境界:

  1. 第一境界:能写出基础递归,但性能堪忧
  2. 第二境界:运用闭包实现记忆化,大幅提升性能
  3. 第三境界:掌握动态规划,实现最优解

这个过程不仅仅是算法的优化,更是思维方式的升级:

  • 问题导向解决方案导向
  • 单一思路多角度思考
  • 能实现功能追求最优性能

在实际开发中,我们经常需要在代码可读性执行效率之间做权衡。记忆化递归代码更直观,动态规划性能更优,具体选择需要根据实际场景来决定。

最后,送给大家一句话:算法不是为了刷题而刷题,而是为了培养解决问题的思维方式。掌握了这种思维,无论是面试还是实际开发,都能游刃有余!