从零掌握斐波那契数列:递归、记忆化与闭包的完美演进

92 阅读8分钟

从零掌握斐波那契数列:递归、记忆化与闭包的完美演进

大家好,今天我们来聊聊一个经典到不能再经典的话题——斐波那契数列(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)为例):

b6a3b5b60f4d12a9ed8cfaed9ffb2daa.png 你会发现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 和闭包的底层逻辑
  1. IIFE(function(){...})() 立刻执行,适合创建私有作用域。
  2. 闭包:内部函数访问外部函数的变量(这里是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;
})();

细节来了:

fc962ce0cd306c49bc54248e80437e81.jpg 看了上面内容我们都知道,闭包可以被内部函数“记住”,达到复用效果,且不污染全局,这些都是闭包定义中有的,我们甚至不需要结合代码,张口就能说的出来的。

那么在这个函数中闭包是怎么“大显神通”的呢?不妨思考一下?

来让我们梳理一下,从闭包的底层出发:

闭包的创建:我们知道当满足这三个必要条件(必须同时满足):

  1. 函数嵌套函数(内层函数)
  2. 内层函数被返回到外部(或者以其他方式暴露出去),在外部可以访问
  3. 内层函数引用了外层函数的变量(自由变量)

就会形成闭包。

更深入思考呢?

我们会说,其实是在 函数定义阶段(创建函数对象时),引擎会扫描内部函数是否引用了外部作用域的变量(这就是“词法作用域”扫描)。

  • 如果有引用(如引用了外层的 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)

斐波那契还有数学公式:

image.png

用矩阵快速幂可以在 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)不会★★计算极大斐波那契数

斐波那契虽小,却浓缩了算法设计的精髓:权衡时间与空间、优雅与性能

最后送给大家一句话:递归思考,迭代实现,记忆化优化,闭包封装。掌握这些,你离算法大牛又近了一步!