前言
大最近在复习经典递归问题时,又一次遇上了老朋友——斐波那契数列。这玩意儿看起来简单,但一不小心就能让你感受到“递归的魅力”和“爆栈的绝望”。今天我就把自己学习过程中的代码和笔记整理一下,分享给大家。希望看完这篇文章,你不只知道怎么写,还能真正理解为什么这么写,顺便思考一下递归在实际项目中的坑和优化思路。
先来认识一下斐波那契数列
斐波那契数列(Fibonacci Sequence)大概是算法入门里最经典的例子之一了。它长这样:
- f(0) = 0
- f(1) = 1
- f(n) = f(n-1) + f(n-2) (n ≥ 2)
数列就是:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89... 每一项都是前两项之和。
这个数列在自然界里到处都是,比如兔子繁殖问题、向日葵种子排列、金字塔层数啥的。但我们今天不聊数学背景,就专注怎么用代码实现它。
最直观的实现:纯递归
很多人第一次接触递归,就是从斐波那契开始的。代码超级简洁:
function fib(n) {
if (n <= 1) return n;
return fib(n-1) + fib(n-2);
}
console.log(fib(10)); // 55
这代码多优雅啊!直接把数学公式翻译成了JavaScript。
但如果你兴冲冲地跑 fib(100),恭喜你,浏览器或Node很可能直接报错:Maximum call stack size exceeded(调用栈溢出)。
为什么会这样?
咱们来想想递归的执行过程:
- 计算 fib(5) 需要 fib(4) + fib(3)
- fib(4) 需要 fib(3) + fib(2)
- fib(3) 需要 fib(2) + fib(1)
- ......
它会形成一棵巨大的递归树。fib(5) 的调用树大概长这样(简化版):
text
fib(5)
/ \
fib(4) fib(3)
/ \ / \
fib(3) fib(2) fib(2) fib(1)
...
注意 fib(3)、fib(2) 被重复计算了很多次!整个树的分支是指数级的,时间复杂度直接飙到 O(2^n),n 一大就彻底扛不住。
更要命的是,每次函数调用都要入栈,栈空间是有限的。n 到 40 左右可能还能勉强,100 绝对爆栈。
你有没有想过:如果项目里不小心用了类似这种没优化的递归,会不会哪天上线后因为某个极端输入直接挂掉?
第一个优化思路:用缓存避免重复计算(记忆化)
递归最大的问题就是重复计算子问题。那我们能不能把算过的结果记下来,下次直接拿?
这就引出了“记忆化”(Memoization)技巧。用一个对象当缓存:
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(10)); // 55
console.log(fib(100)); // 一个超级大的数,瞬间出结果
现在时间复杂度直接降到 O(n),空间复杂度 O(n)(缓存对象)。
递归树从“树”变成了“有向无环图”,大量分支直接复用缓存,避免了指数爆炸。
这个版本已经能轻松算 fib(100) 甚至更大了。
想想看:很多实际问题(如动态规划)本质上都是有“重叠子问题”的,用记忆化往往能从天文级复杂度降到线性。
更进一步:用闭包把缓存藏起来
上面的 cache 是全局变量,如果你写多个类似函数,容易冲突。能不能把缓存“私有化”?
JavaScript 的闭包正好能干这事儿。我们可以用立即执行函数表达式(IIFE)来创建一个私有作用域:
const fib = (function() {
const cache = {}; // 这里只有返回的函数能访问到
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); // 注意这里直接用 fib 调用自己
return cache[n];
};
})();
console.log(fib(10)); // 55
这个写法看起来有点“绕”,但本质是:
- IIFE 立刻执行,创建了一个私有 cache。
- 返回一个匿名函数,这个函数“记住”了 cache(闭包)。
- 外面只能通过 fib 这个变量调用,cache 完全被封装起来了。
好处显而易见:避免全局污染,多个这样的函数互不干扰。
你有没有在项目里用过类似技巧封装工具函数?比如创建一个只暴露必要接口的模块。
其实还有更优的解法
看到这里,你可能已经觉得记忆化递归很完美了。但其实斐波那契还有 O(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)。更高效,也不会爆栈。
甚至可以用矩阵快速幂做到 O(log n),或者直接用黄金分割的闭式公式(Binet公式)近似计算大数。
但为什么我们还要学递归版本?
因为递归能让你更深刻地理解“分治”和“重叠子问题”。很多复杂问题(树遍历、回溯、动态规划)本质上都是递归思维。掌握了记忆化,你就掌握了动态规划的自顶向下写法。
最后总结一下我的心得
- 递归写起来爽,但别忘了它的代价:栈溢出 + 重复计算。
- 遇到重叠子问题,先想到记忆化,基本能救命。
- 闭包 + IIFE 是 JavaScript 里封装私有的经典姿势,值得多用。
- 最终还是要根据场景选最合适的实现:简单场景迭代够了,复杂分治场景递归+记忆化更清晰。
写这篇文章的时候,我又把代码跑了一遍,fib(100) 的结果是 354224848179261915075 —— 一个二十多位的数字,纯递归几秒钟都算不出来,加了缓存瞬间就出。
算法学习就是这样,反复折腾同一个问题,从暴力到优雅,你会发现每优化一步,理解就深一层。