从爆栈到飞快:斐波那契数列的三种写法大揭秘!

1 阅读8分钟

引言—— 用 JavaScript 带你一步步理解递归、记忆化与闭包

“编程不是魔法,而是逻辑的艺术。”
—— 某位被 fib(100) 卡死又复活的程序员

大家好!今天我们要一起深入探索一个看似简单却蕴含丰富编程思想的问题:斐波那契数列(Fibonacci Sequence) 。你可能在数学课本、算法课程或技术面试中见过它。但你知道吗?仅仅计算第 100 项斐波那契数,就能暴露出代码设计中的巨大差异!

我们将通过三个 JavaScript 文件——1.js2.js3.js——层层递进,从最朴素的递归写法,到引入缓存优化,再到用闭包实现完美封装。每一步都保留原始代码(一字不变!),并详细解释其原理、优缺点和适用场景。

无论你是刚学编程的新手,还是想巩固基础的老手,这篇文章都将带你真正理解这些代码背后的思考过程。


什么是斐波那契数列?

首先,让我们明确问题本身。

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

  • f(0) = 0
  • f(1) = 1
  • n >= 2 时,f(n) = f(n-1) + f(n-2)

所以前几项是:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, ...

这个数列看起来很简单,但它的计算方式却能引出递归、重复计算、性能瓶颈、缓存优化、闭包等一系列重要概念。


第一阶段:天真递归 —— 1.js(美丽但危险)

我们先来看最直观的写法,也就是 1.js 中的内容:

// 时间复杂度: O(2^n) 指数级别
// 空间复杂度: O(n) 递归调用栈的深度
function fib(n) {
  // 退出条件,没有退出条件的话会爆栈
  if (n <= 1) return n;
  // 函数调用自己 递归
  // 递归的公式
  return fib(n - 1) + fib(n - 2);
}
// console.log(fib(100)); // 会爆栈
console.log(fib(10)); // 55

为什么说它“美丽”?

这段代码几乎一字不差地翻译了数学定义

  • 如果 n <= 1,直接返回 n(即 f(0)=0, f(1)=1
  • 否则,返回前两项之和

这种“自顶向下”的思维方式非常符合人类直觉:要算 f(5),就先算 f(4)f(3);要算 f(4),就先算 f(3)f(2)……以此类推。

但它为什么“危险”?

问题出在重复计算

想象一下计算 fib(5) 的过程:

fib(5)
├── fib(4)
│   ├── fib(3)
│   │   ├── fib(2)
│   │   │   ├── fib(1) → 1
│   │   │   └── fib(0) → 0
│   │   └── fib(1) → 1
│   └── fib(2) ← 又算了一遍!
│       ├── fib(1) → 1
│       └── fib(0) → 0
└── fib(3) ← 整个子树又重复了!
    ├── fib(2) ← 再次重复
    └── fib(1)

可以看到,fib(3) 被计算了两次,fib(2) 被计算了三次!随着 n 增大,这种重复呈指数级爆炸

时间复杂度分析:

  • 每次调用产生两个子调用,形成一棵二叉树
  • 树的高度约为 n,节点总数约为 2^n
  • 所以时间复杂度是 O(2ⁿ) —— 这是非常糟糕的!

实际后果:

  • fib(10):很快,结果是 55(如代码所示)
  • fib(40):可能需要几秒钟
  • fib(100)根本跑不完!不仅慢,还会因为递归太深导致 “Maximum call stack size exceeded” (调用栈溢出)

注:JavaScript 引擎(如 V8)对递归深度有限制,通常在几千层左右。fib(100) 的递归深度虽只有 100,但由于函数调用次数爆炸,实际栈帧数量远超限制。

小结(1.js):

  • 优点:代码简洁,逻辑清晰,教学价值高。
  • 缺点:效率极低,无法用于稍大的 n
  • 适合场景:理解递归思想,不适合生产环境。

第二阶段:记忆化缓存 —— 2.js(用空间换时间)

既然问题在于“重复计算”,那我们就记住已经算过的结果!这就是**记忆化(Memoization)**的核心思想。

来看 2.js 的实现:

const cache = {}; // 用空间换时间
function fib(n) {
  if (n in cache) return cache[n]; // 如果缓存中有,直接返回缓存中的值
  if (n <= 1) {
    cache[n] = n; // 把结果存入缓存
    return n;
  }
  const result = fib(n - 1) + fib(n - 2); // 递归计算
  cache[n] = result; // 把结果存入缓存
  return result;
}
console.log(fib(100)); // 354224848179262000000 耗时0.102秒

关键改进:引入 cache

  • 我们创建了一个全局对象 cache = {}

  • 每次计算 fib(n) 前,先检查 cache[n] 是否存在。

    • 如果存在,直接返回,跳过递归
    • 如果不存在,正常计算,并把结果存入 cache

这样,每个 n 只会被计算一次

新的时间复杂度:

  • 每个 n 从 0 到目标值只计算一次。
  • 总共 n+1 次计算。
  • 所以时间复杂度降为 O(n)

性能飞跃:

  • fib(100) 不仅能算出来,还只要 0.102 秒
  • 结果是 354224848179262000000(注意:JavaScript 的 Number 类型是双精度浮点,超过 2^53 会丢失精度,但这里仍可接受)

但有个隐患:全局变量 cache

虽然功能实现了,但 cache全局变量。这意味着:

  • 如果其他代码不小心修改了 cache,会导致 fib 出错。
  • 如果有多个斐波那契函数共用同一个 cache,可能互相干扰。
  • 不符合“封装”原则:函数的状态应该私有化。

编程最佳实践:避免不必要的全局状态

我们需要一种方式,让 cache 只属于 fib 函数,外部无法访问。

答案就是:闭包(Closure)


第三阶段:闭包 + IIFE —— 3.js(优雅封装)

现在,我们用 JavaScript 的高级特性来解决全局变量问题。

来看 3.js 的代码(一字不变):

// cache 闭包到函数中
const fib = (function() {
  // 闭包
  // 自由变量
  const cache = {};
  // IIFE
  // console.log('1111');
  return function(n) {
    if (n in cache) {
      return cache[n];
    }
    if (n <= 1) {
      cache[n] = n;
      return n;
    }
    cache[n] = fib(n-1) + fib(n-2);
    return cache[n];
  }
})()
// function外加了括号,意思是立即执行这个函数
console.log(fib(100)); // 354224848179262000000 耗时0.088秒

这段代码看起来有点“绕”,但其实非常精妙。我们一步步拆解。

步骤 1:理解 (function() { ... })()

这是 IIFE(Immediately Invoked Function Expression) ,即“立即执行函数表达式”。

  • 定义一个匿名函数:function() { ... }
  • 紧接着用 () 调用它:(function() { ... })()
  • 所以整个表达式立刻执行一次,并返回结果。

在这个例子中,IIFE 返回了一个函数:

return function(n) { ... }

这个返回的函数被赋值给 const fib

步骤 2:理解“闭包”

在 IIFE 内部,我们定义了:

const cache = {};

然后返回的内部函数可以访问这个 cache

这就是 闭包内层函数可以访问外层函数的作用域变量,即使外层函数已经执行完毕

所以:

  • cache 不是全局变量,也不是局部变量(对 fib 而言)。
  • 它是 fib 函数的“私有状态”,外部完全无法访问。
  • 每次调用 fib(n),都会使用同一个 cache,实现记忆化。

优势总结:

特性说明
封装性cache 被安全隐藏,不会被外部污染
持久性cache 在多次调用 fib 之间保持不变
性能2.js 一样是 O(n),甚至略快(0.088s vs 0.102s),因为避免了全局变量查找
模块化整个逻辑自包含,易于复用和测试

注意一个小细节:

3.js 中,递归调用写的是:

cache[n] = fib(n-1) + fib(n-2);

而不是 arguments.callee 或其他方式。这是因为 fib 已经被赋值为这个内部函数,所以可以直接调用自身。

📝 补充:在严格模式下,arguments.callee 被禁用,因此这种写法更安全。


三版本全面对比

维度1.js(朴素递归)2.js(全局缓存)3.js(闭包缓存)
代码长度最短中等稍长
可读性极高(数学直译)中(需理解闭包)
时间复杂度O(2ⁿ)O(n)O(n)
空间复杂度O(n)(栈)O(n)(栈 + 缓存)O(n)(栈 + 缓存)
能否计算 fib(100)❌ 爆栈/超时
缓存可见性无缓存全局(不安全)私有(安全)
适合场景教学、小数据快速原型生产环境、模块化代码

深入思考:还有更好的方法吗?

当然!虽然 3.js 已经很优秀,但我们可以继续优化:

方向 1:迭代代替递归(彻底避免栈开销)

function fib(n) {
  if (n <= 1) return n;
  let a = 0, b = 1;
  for (let i = 2; i <= n; i++) {
    [a, b] = [b, a + b];
  }
  return b;
}
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)
  • 无递归,无栈风险
  • 但失去了“自顶向下”的递归美感

方向 2:尾递归优化(部分引擎支持)

function fib(n, a = 0, b = 1) {
  if (n === 0) return a;
  return fib(n - 1, b, a + b);
}
  • 理论上可被优化为 O(1) 栈空间
  • JavaScript 引擎普遍不支持尾递归优化(ES6 规范允许但未强制)

所以,在现实 JavaScript 开发中,3.js 的闭包记忆化方案兼顾了性能、安全与可维护性,是非常推荐的做法。


给初学者的学习建议

  1. 先理解问题本质:斐波那契的递归定义是什么?
  2. 动手画调用树:手动模拟 fib(5),感受重复计算。
  3. 尝试自己加缓存:从 1.js 改成 2.js,体会“空间换时间”。
  4. 学习闭包概念:理解“函数 + 自由变量 = 闭包”。
  5. 不要死记代码:理解为什么用 IIFE,为什么 cache 能被访问。

💡 记住:所有高级技巧,都源于对基础问题的深入思考


结语

通过 1.js2.js3.js 的演进,我们完成了一次精彩的编程思维之旅:

  • 数学直觉出发(1.js)
  • 性能优化(2.js)
  • 再到工程封装(3.js)

这不仅是斐波那契数列的三种写法,更是编程思维升级的缩影
正确 → 高效 → 健壮

下次当你面对一个看似简单的问题时,不妨多问一句:
“我的代码,能跑 fib(100) 吗?”

真正的高手,不是写得出代码,而是知道为什么这么写。

Happy Coding! 🎉