斐波那契数列是算法题里的“万金油”,不仅用来讲递归、测试面试者的递归理解能力,还经常成为引出 记忆化、闭包、动态规划 等优化技巧的起点。
大多数人学会了基本递归就止步了,但其实这道题的优化空间非常大。本文将从最基础的写法入手,一步步带你吃透三种高效实现方式,并补充一个很多人忽略的细节陷阱。
一、递归实现:结构直观,但效率惨烈
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];
};
}
上面这个写法,在函数结果是 0、false 或 '' 的时候,都会误判为“未缓存”。
推荐写法:
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 中实用的算法题与优化技巧,欢迎关注~