斐波那契数列:从爆栈到优雅优化的心路历程

77 阅读5分钟

前言

大最近在复习经典递归问题时,又一次遇上了老朋友——斐波那契数列。这玩意儿看起来简单,但一不小心就能让你感受到“递归的魅力”和“爆栈的绝望”。今天我就把自己学习过程中的代码和笔记整理一下,分享给大家。希望看完这篇文章,你不只知道怎么写,还能真正理解为什么这么写,顺便思考一下递归在实际项目中的坑和优化思路。

先来认识一下斐波那契数列

斐波那契数列(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公式)近似计算大数。

但为什么我们还要学递归版本?

因为递归能让你更深刻地理解“分治”和“重叠子问题”。很多复杂问题(树遍历、回溯、动态规划)本质上都是递归思维。掌握了记忆化,你就掌握了动态规划的自顶向下写法。

最后总结一下我的心得

  1. 递归写起来爽,但别忘了它的代价:栈溢出 + 重复计算。
  2. 遇到重叠子问题,先想到记忆化,基本能救命。
  3. 闭包 + IIFE 是 JavaScript 里封装私有的经典姿势,值得多用。
  4. 最终还是要根据场景选最合适的实现:简单场景迭代够了,复杂分治场景递归+记忆化更清晰。

写这篇文章的时候,我又把代码跑了一遍,fib(100) 的结果是 354224848179261915075 —— 一个二十多位的数字,纯递归几秒钟都算不出来,加了缓存瞬间就出。

算法学习就是这样,反复折腾同一个问题,从暴力到优雅,你会发现每优化一步,理解就深一层。

微信图片_20251207162456_36_93.jpg