引言—— 用 JavaScript 带你一步步理解递归、记忆化与闭包
“编程不是魔法,而是逻辑的艺术。”
—— 某位被fib(100)卡死又复活的程序员
大家好!今天我们要一起深入探索一个看似简单却蕴含丰富编程思想的问题:斐波那契数列(Fibonacci Sequence) 。你可能在数学课本、算法课程或技术面试中见过它。但你知道吗?仅仅计算第 100 项斐波那契数,就能暴露出代码设计中的巨大差异!
我们将通过三个 JavaScript 文件——1.js、2.js 和 3.js——层层递进,从最朴素的递归写法,到引入缓存优化,再到用闭包实现完美封装。每一步都保留原始代码(一字不变!),并详细解释其原理、优缺点和适用场景。
无论你是刚学编程的新手,还是想巩固基础的老手,这篇文章都将带你真正理解这些代码背后的思考过程。
什么是斐波那契数列?
首先,让我们明确问题本身。
斐波那契数列是一个经典的整数序列,定义如下:
f(0) = 0f(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 的闭包记忆化方案兼顾了性能、安全与可维护性,是非常推荐的做法。
给初学者的学习建议
- 先理解问题本质:斐波那契的递归定义是什么?
- 动手画调用树:手动模拟
fib(5),感受重复计算。 - 尝试自己加缓存:从
1.js改成2.js,体会“空间换时间”。 - 学习闭包概念:理解“函数 + 自由变量 = 闭包”。
- 不要死记代码:理解为什么用 IIFE,为什么
cache能被访问。
💡 记住:所有高级技巧,都源于对基础问题的深入思考。
结语
通过 1.js → 2.js → 3.js 的演进,我们完成了一次精彩的编程思维之旅:
- 从数学直觉出发(1.js)
- 到性能优化(2.js)
- 再到工程封装(3.js)
这不仅是斐波那契数列的三种写法,更是编程思维升级的缩影:
正确 → 高效 → 健壮。
下次当你面对一个看似简单的问题时,不妨多问一句:
“我的代码,能跑 fib(100) 吗?”
真正的高手,不是写得出代码,而是知道为什么这么写。
Happy Coding! 🎉