从递归到动态规划:JavaScript 算法优化之路 🚀

96 阅读5分钟

在算法的世界里,递归、动态规划和闭包是非常重要的概念。它们在解决各种复杂问题时发挥着关键作用。今天,我们就结合具体的代码示例,深入探讨这些概念在 JavaScript 中的应用。🎉

一、递归基础:斐波那契数列的 “朴素解法” 🌱

1.1 递归的核心思想

递归本质是 “自顶向下” 的解题思路:将大问题拆解为相似的小问题,直到触达退出条件。以斐波那契数列为例,求解 f(n) 需先得到 f(n-1) 和 f(n-2),以此类推直到 f(0) 或 f(1)

1.2 代码实现与问题分析

// 斐波那契数列递归实现
function fib(n) {
  if (n <= 1) return n; // 退出条件:f(0)=0,f(1)=1
  return fib(n - 1) + fib(n - 2);
}
console.log(fib(10)); // 输出:55

存在的问题:

  • 重复计算:以 f(10) 为例,其计算树中 f(8) 被计算 2 次,f(7) 被计算 3 次,底层节点重复次数呈指数级增长(树形结构如下):

            f(10)
      f(9)        f(8)
    f(8) f(7)   f(7) f(6)
    ...(底层节点重复计算严重)
    
  • 时间复杂度:O (2ⁿ),随 n 增大性能急剧下降。

  • 爆栈风险:函数调用需入栈,递归深度过大会导致调用栈溢出(如 fib(1000) 可能直接报错)。

二、闭包优化:用 “记忆化” 解决重复计算 🧠

2.1 闭包的记忆化原理

闭包的核心是函数嵌套 + 自由变量:外层函数定义存储容器(如 cache),内层函数通过作用域链访问该容器,实现计算结果的缓存(记忆化)。

2.2 优化后的斐波那契实现

// 闭包+记忆化优化斐波那契
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(10)); // 输出:55

优化效果:

  • 时间复杂度:降至 O (n),每个值仅计算一次。
  • 空间复杂度:O (n),需额外存储缓存数据。

三、爬楼梯问题:递归的 “进阶试炼” 🧗‍♂️

3.1 问题定义

“爬楼梯” 与斐波那契数列同属重叠子问题:每次可爬 1 或 2 阶,求到达第 n 阶的总方法数。核心逻辑:climbStairs(n) = climbStairs(n-1) + climbStairs(n-2)(最后一步要么从 n-1 阶爬 1 阶,要么从 n-2 阶爬 2 阶)。

3.2 三种解法对比

解法 1:纯递归(存在严重缺陷)

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)); // 报错:栈溢出(Maximum call stack size exceeded)
  • 问题:当 n=100 时,递归深度过大导致栈溢出,且重复计算量惊人(时间复杂度 O (2ⁿ))。

解法 2:递归 + 数组记忆化

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)); // 输出:573147844013817200000
  • 优化点:用数组 f 缓存结果,避免重复计算,时间复杂度降至 O (n),且解决了 n=100 时的计算问题。
  • 不足:依赖全局变量,可能造成命名污染。

解法 3:动态规划(迭代实现)

// 自底向上的动态规划
const climbStairs = function(n) {
  const dp = []; // dp[i] 表示到达第i阶的方法数
  dp[1] = 1; 
  dp[2] = 2;
  // 从3阶开始迭代计算,直到n阶
  for (let i = 3; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2]; // 状态转移方程
  }
  return dp[n];
};
  • 核心思想:从已知结果(dp[1]dp[2])出发,自底向上推导未知结果,无需递归调用栈。

  • 优势

    • 时间复杂度 O (n),无重复计算;
    • 空间复杂度 O (n),可进一步优化至 O (1)(只需保存前两个值);
    • 彻底避免栈溢出问题。

四、核心知识点总结 📚

方法时间复杂度空间复杂度适用场景注意事项
纯递归O(2ⁿ)O(n)小数据量、逻辑验证易栈溢出、重复计算严重
递归 + 记忆化O(n)O(n)中等数据量、需保留递归逻辑需额外缓存空间
动态规划(迭代)O(n)O(n)大数据量、性能要求高无需递归栈,适合大规模计算

关键结论:

  1. 递归是思路,动态规划是优化:当递归存在大量重复计算时,优先用动态规划的 “自底向上” 迭代法。
  2. 闭包的记忆化作用:通过自由变量缓存结果,是递归优化的 “轻量方案”,适合不想改写为迭代的场景。
  3. 栈溢出的本质:递归深度超过 JavaScript 引擎的调用栈限制(通常约 1 万层),迭代法则无此问题。

五、总结🎉

递归、动态规划和闭包在算法中都有各自的优势和适用场景。递归简洁直观,但可能存在重复计算和栈溢出问题;动态规划通过保存子问题的解避免了重复计算,适合解决具有重叠子问题和最优子结构的问题;闭包可以用于优化递归,实现记忆化功能。在实际开发中,我们需要根据具体问题选择合适的方法。希望通过今天的分享,大家对这些概念有了更深入的理解。😘