从零掌握斐波那契数列:递归、记忆化与闭包的完美演进
大家好,今天我们来聊聊一个经典到不能再经典的话题——斐波那契数列(Fibonacci Sequence)。它不仅仅是面试高频题,更是理解递归、动态规划、闭包等核心概念的绝佳载体。
为什么说它经典?因为一个小小的斐波那契,能让你从“递归爆栈”一步步进化到“优雅高效闭包实现”,过程中还能深刻体会“用空间换时间”。
一、斐波那契数列到底是什么?
斐波那契数列的定义简单得像儿歌:
- f(0) = 0
- f(1) = 1
- 对于 n > 1,f(n) = f(n-1) + f(n-2)
序列长这样:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89……
它最早出现在印度数学中,后来被意大利数学家斐波那契用来描述兔子繁殖问题(一只兔子每月生一对小兔子……)。大自然中,向日葵种子排列、松果鳞片、菠萝纹路都遵循这个比例,黄金分割(约1.618)就是相邻两项的比值趋近值。
但我们今天不聊数学美学,聊怎么高效计算它!
二、天真的递归:优雅但致命
最直观的实现就是递归。看这个代码:
// 时间复杂度O(2^n)
function fib(n){
if(n <= 1) return n; // 退出条件
return fib(n-1) + fib(n-2); // 递归公式
}
console.log(fib(10)); // 输出 55
这代码美得像诗:大问题直接拆成两个小问题,代码只有几行。
底层逻辑:递归树与指数爆炸
为什么说它“致命”?让我们画出递归调用树(以fib(5)为例):
你会发现fib(3)、fib(2)、fib(1)被重复计算了无数次!整个调用树呈指数级增长,时间复杂度是恐怖的 O(2^n)。
实际测试:
- fib(35) 在普通机器上大概需要1-2秒
- fib(40) 可能要十几秒
- fib(50)?直接等半天,调用栈还会爆(JavaScript默认调用栈大小有限,浏览器通常几万层就栈溢出)
易错提醒:
- 很多人以为递归时间复杂度是O(n),错!因为有大量重复子问题。
- 递归深度过大会导致“栈溢出”(Stack Overflow),这也是著名编程问答网站名字的由来。
- 永远记得:递归必须有退出条件,否则无限递归直接崩溃。
三、第一次优化:记忆化(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;
}
我们引入了一个全局cache对象,保存已经计算过的值。下次遇到直接命中,子问题计算次数从指数级降到线性!
为什么时间复杂度变成 O(n)?
- 每个n只被计算一次(缓存后直接返回)
- 总共需要计算 n 个不同的值
- 每次计算只需常数时间(两次查找 + 加法)
空间复杂度:O(n) 用于缓存 + O(n) 递归栈 = O(n)
实际性能:
- fib(35):几乎瞬间(<0.001秒)
- fib(1000):依然瞬间完成,返回一个超级大的数(数百位)
底层逻辑扩展: 记忆化是动态规划(DP)的核心思想之一——“自顶向下”的DP。相比“自底向上”的迭代DP,它保留了递归的优雅写法,同时避免了重复计算。
易错提醒:
- 缓存key必须唯一且正确。这里用n作为key没问题,因为n是整数。
- 如果n可能是负数或浮点数,需要特殊处理(斐波那契通常定义在非负整数)。
- 全局cache会一直占用内存,如果多次调用不同序列,需要清空或用局部cache。
四、闭包登场:把缓存藏起来
全局变量有污染风险,怎么办?用闭包!
看代码:
// cache 闭包到函数中
const fib = (function(){
// 闭包
const cache = {};
console.log('1111'); // 只打印一次,证明IIFE只执行一次
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];
}
})(); // 立即执行
这就是传说中的 IIFE(Immediately Invoked Function Expression) + 闭包组合技!
IIFE 和闭包的底层逻辑
- IIFE:
(function(){...})()立刻执行,适合创建私有作用域。 - 闭包:内部函数访问外部函数的变量(这里是
cache),即使外部函数执行完毕,cache依然活着,被内部函数“记住”。
执行过程:
- 页面加载时,IIFE立即执行一次,打印'1111',创建
cache对象 - 返回一个匿名函数赋值给
fib - 之后每次调用
fib(n),都使用同一个私有cache,互不干扰
优势:
cache成了私有变量,不会污染全局- 多个这样的函数互不影响(可以创建多个独立的fib函数)
- 完美封装
易错提醒:
- 注意递归调用时用的是外层返回的函数(所以代码中写
fib(n-1)存在问题!应该返回的函数调用自己)。 - 实际正确写法应该是给返回的函数起个名,或者用arguments.callee(不推荐),更现代的做法是:
const fib = (function() {
const cache = {};
const inner = function(n) {
if (n in cache) return cache[n];
if (n <= 1) return n;
cache[n] = inner(n-1) + inner(n-2);
return cache[n];
}
return inner;
})();
细节来了:
看了上面内容我们都知道,闭包可以被内部函数“记住”,达到复用效果,且不污染全局,这些都是闭包定义中有的,我们甚至不需要结合代码,张口就能说的出来的。
那么在这个函数中闭包是怎么“大显神通”的呢?不妨思考一下?
来让我们梳理一下,从闭包的底层出发:
闭包的创建:我们知道当满足这三个必要条件(必须同时满足):
- 函数嵌套函数(内层函数)
- 内层函数被返回到外部(或者以其他方式暴露出去),在外部可以访问
- 内层函数引用了外层函数的变量(自由变量)
就会形成闭包。
更深入思考呢?
我们会说,其实是在 函数定义阶段(创建函数对象时),引擎会扫描内部函数是否引用了外部作用域的变量(这就是“词法作用域”扫描)。
-
如果有引用(如引用了外层的 cache),就会创建一个闭包,并在外部函数执行时捕获这些变量的引用。
-
一旦返回的内部函数存活(赋值给 fib 这个变量),这些被捕获的变量就不会被垃圾回收。
-
且对比全局变量,虽然全局变量在功能上和闭包一样能持久存在,但缺点是任何代码都能访问和修改它,容易引发 bug(比如意外清空 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) —— 只用了两个变量!
实际测试:计算fib(1000)几乎是瞬间(0.0001秒级别),且不占额外空间。
为什么迭代更好?
- 无递归栈开销(适合超大n)
- 空间极致优化
- 这是典型的“自底向上”动态规划
扩展:矩阵快速幂与O(log n)
斐波那契还有数学公式:
用矩阵快速幂可以在 O(log n) 时间计算,适合计算极大数(比如fib(10^6))。但涉及大数运算,需要BigInt或库支持。
六、总结与思考
从天真递归 → 记忆化递归 → 闭包封装 → 迭代优化,我们见证了一个算法的进化史:
| 实现方式 | 时间复杂度 | 空间复杂度 | 是否爆栈 | 代码优雅度 | 推荐场景 |
|---|---|---|---|---|---|
| 天真递归 | O(2^n) | O(n) | 容易 | ★★★★★ | 教学、理解递归 |
| 记忆化递归 | O(n) | O(n) | 可能 | ★★★★ | 需要递归结构的问题 |
| 闭包记忆化 | O(n) | O(n) | 可能 | ★★★★★ | 需要私有缓存的场景 |
| 迭代 | O(n) | O(1) | 不会 | ★★★ | 生产环境、超大n |
| 矩阵快速幂 | O(log n) | O(1) | 不会 | ★★ | 计算极大斐波那契数 |
斐波那契虽小,却浓缩了算法设计的精髓:权衡时间与空间、优雅与性能。
最后送给大家一句话:递归思考,迭代实现,记忆化优化,闭包封装。掌握这些,你离算法大牛又近了一步!