别再死磕递归了!一文吃透斐波那契的三种高效写法(含闭包 + 动态规划)

343 阅读5分钟

斐波那契数列是算法题里的“万金油”,不仅用来讲递归、测试面试者的递归理解能力,还经常成为引出 记忆化闭包动态规划 等优化技巧的起点。

大多数人学会了基本递归就止步了,但其实这道题的优化空间非常大。本文将从最基础的写法入手,一步步带你吃透三种高效实现方式,并补充一个很多人忽略的细节陷阱。


一、递归实现:结构直观,但效率惨烈

function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
}

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

这是最常见的递归写法,写法直观,符合定义。但它的性能非常差。为什么?因为会不断地重复计算子问题,比如:

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

可以看到,f(8)f(7) 这些节点被重复计算了好几次,时间复杂度是指数级的 O(2^n)。而且递归调用过深还容易栈溢出。


二、闭包 + 记忆化:缓存中间结果,大幅提升效率

背景先理解一下:为什么需要缓存?

递归在处理斐波那契数列时存在大量“重复子问题”。比如你在算 fib(20) 时会先算 fib(19)fib(18),但 fib(19) 也会再算一遍 fib(18)fib(17)

这就是所谓的 重叠子问题

记忆化(Memoization) 的目标就是:
👉 “一个子问题只算一次,之后都从缓存里拿。”

为了避免重复计算,我们可以用一个对象缓存中间结果。这样,如果某个 n 的值之前计算过了,就直接返回结果,不再重复计算。

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

const fib = memoizedFib();
console.log(fib(20)); // 输出 6765

这里使用了一个闭包结构:外层函数 memoizedFib 提供缓存对象 cache,内部的递归函数 fib 每次都会优先查找缓存。

为什么判断要写成 cache[n] !== undefined

有些人会这样写:

if (cache[n]) return cache[n];

这种写法在多数情况下能跑,但潜藏一个 bug。

举个例子,fib(0) 的值是 0,但在 JavaScript 中,0 被认为是“假值(falsy)”。于是 if (cache[0]) 会被判断为 false,进而误以为没有缓存,又去重复计算一遍。

在我们这个 fib 函数里可能暂时没问题,因为我们在第一行就写了:

if (n <= 1) return n;

所以当 n = 0 时会直接返回,不会进入缓存判断的逻辑。

但在稍微复杂点的场景,比如你写了一个通用的 memoize(fn) 工具函数时,这种假值判断就很容易踩坑:

function memoize(fn) {
  const cache = {};
  return function (...args) {
    const key = args.join(',');
    if (cache[key]) return cache[key]; // ❌ 假值(例如 0)会被误判
    cache[key] = fn.apply(this, args);
    return cache[key];
  };
}

上面这个写法,在函数结果是 0false'' 的时候,都会误判为“未缓存”。

推荐写法:

if (cache[key] !== undefined) return cache[key];

这是判断缓存是否存在的更安全、更健壮方式。


三、动态规划:自底向上,效率更稳定

如果说递归是“从大到小”分解问题,动态规划就是“从小到大”逐步构建答案。

用迭代的方式,从已知结果 f(1)f(2) 开始,逐步推到 f(n),不仅高效,还能避免递归带来的栈溢出问题。

function fib(n) {
  if (n === 1) return 1;
  if (n === 2) return 1;

  let prev2 = 1, prev1 = 1, current;
  for (let i = 3; i <= n; i++) {
    current = prev1 + prev2;
    prev2 = prev1;
    prev1 = current;
  }
  return current;
}

console.log(fib(1000)); // 轻松处理大输入

优势总结:

  • 没有递归,避免爆栈
  • 空间复杂度是 O(1),只用三个变量
  • 稳定高效,面试推荐写法

四、变种问题:爬楼梯 = 斐波那契

LeetCode 上的经典题“爬楼梯”,本质上就是斐波那契数列的变种:

题目是这样的:

有 n 阶楼梯,每次可以爬 1 阶或 2 阶,有多少种爬法?

分析后可以得出状态转移方程:

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;

  let prev2 = 1, prev1 = 2, current;
  for (let i = 3; i <= n; i++) {
    current = prev1 + prev2;
    prev2 = prev1;
    prev1 = current;
  }
  return current;
}

console.log(climbStairs(1000)); // 同样高效

转移方程为:
f(n) = f(n - 1) + f(n - 2),和斐波那契一模一样,只是初始条件不同。


五、总结对比:哪种写法更适合你?

解法思维方式时间复杂度空间复杂度特点
普通递归自顶向下O(2^n)O(n)简单易懂,但性能极差
记忆化递归自顶向下 + 缓存O(n)O(n)使用闭包缓存,提升性能
动态规划迭代自底向上O(n)O(1)性能最优,代码更工程化

写在最后

递归的难点从来不在“写出来”,而是写得高效。从最基础的暴力递归,到引入闭包缓存优化,再到最终使用迭代的动态规划结构,斐波那契数列这道题几乎涵盖了所有经典优化思路。

另外,细节同样重要。比如 cache[n] 的判断逻辑,如果你能主动写成 !== undefined,面试官会直接看出你对 JavaScript 机制的理解远超普通选手。

最后,如果你能从这道题中真正理解“重复子问题”“状态转移方程”“空间优化”的思想,那其他大多数动态规划类问题也就差不多掌握了。


✅ 如果你觉得有用,欢迎点赞、收藏,支持一下作者!
📌 后续我也会分享更多 JS 中实用的算法题与优化技巧,欢迎关注~