斐波那契数列第 N 项七种解法的 JavaScript 实现

2,131 阅读6分钟

写在前面

这篇文章的代码其实早在 2020 年初的某个夜晚心血来潮已经实现过了,并且发在了自己的博客 wfk007.github.io/2020/03/28/… 上,但是,现在回看的话会发现基本都是代码,缺少解释说明,这也是我写这篇文章的目的之一。另外因为博客用 hexo 搭的,评论系统使用的是 Diaqus,国内网就没法用,很操蛋,博客的阅读量数据最开始是有设置的,但后来因为存在刷新后阅读量反而会自动减少这种莫名其妙的 bug,我把它停掉了,这也是我想把写作战场从个人博客转到掘金的原因,学习+记录+分享。最后,因为个人能力的原因,文中错误在所难免,欢迎各位大佬批评指正~

什么是斐波那契数列

1,1,2,3,5,8,13,21...1,1,2,3,5,8,13,21...

上述数列就是一个斐波那契数列,数列的规律很明显:前两项是 1,从第三项开始,每一项都等于前两项之和。(想了解更加详细解释的同学,可以参见维基百科或百度百科)

数学定义为:

  • F(1)=1,F(2)=1F(1) = 1, F(2) = 1
  • F(n)=F(n1)+F(n2)n3nN)F(n) = F(n-1) + F(n-2)(n ≥ 3,n ∈ N*)

解法

按照上述数学定义,我们可以顺理成章地写出如下第一种解法:递归

const fibs = n => {
  if (n === 1 || n === 2) return 1;
  else return fibs(n - 1) + fibs(n - 2);
};

先写递归出口,当 n === 1 或 2 时(即前两项),返回 1,否则将前两项的结果相加返回。

写完代码后,我们随意测试几个数据,发现都能拿到正确结果

但当我们输入一个比较大的 n 时,比如 100,程序运行后却迟迟打印不出结果,是我们代码写的有问题么?

为了探个究竟,我简单画了当 n === 100 时,程序的递归调用过程:

时间复杂度也可以非常简单的计算出来:(递归调用的时间复杂度等于所有递归子程序的时间复杂度之和)

O(n)=1+21+22+...+2n1O(n) = 1 + 2^1 + 2^2 + ... + 2^{n-1}

计算结果其实是首项为 1,公比为 2 的等比数列前 n 项之和,已知等比数列的前 n 项和公式如下:

Sn=a1(1qn)1q(q1)S_n = \frac{a_1(1-q^n)}{1-q} (q \neq 1)

计算得:

O(n)=2n1O(n) = 2^n - 1

去掉常数项和系数,最后得到 O(n)=2nO(n) = 2^n

得出结论:上述递归调用时间复杂度是指数级别,随着 n 的增加,时间复杂度呈指数形式陡增,这也解释了为什么当输入一个较大的 n 时,程序却迟迟不能得出结果。

上述递归过程耗时的一个重要原因就是重复计算,计算 fibs(100) 的时候计算了一遍 fibs(98),计算 fibs(99) 的时候又计算了一遍 fibs(98),我们可以从这里作为切入点,对上述递归过程进行优化,写出第二种解法:递归+备忘录法:

const dict = {};
const fibs = n => {
  if (n === 1 || n === 2) {
    return 1;
  }
  if (!dict[n]) {
    dict[n] = fibs(n - 1) + fibs(n - 2);
  }
  return dict[n];
};

做的处理很简单,引入了一个 dict(或者 map) 来保存中间状态的计算结果,牺牲空间换时间。在进行递归操作之前,如果计算结果已经被 dict 缓存了,就直接从缓存中取,否则的话才会执行递归调用过程。简单画一下当 n === 100 时,程序的递归调用过程:

时间复杂度结算结果为:

O(n)=1+2+2+2+...+2=2(n1)+1=2n1=nO(n) = 1 + 2 + 2 + 2 + ... + 2 = 2(n - 1) + 1 = 2n - 1 = n

计算 n === 100 的结果如下:

因为计算结果超过 Number.MAX_SAFE_INTEGER,存在被舍去的精度问题,计算结果不能代表正确结果,但在执行效率上相较于递归已经有了质的飞跃。

另外,除了用备忘录法对递归过程进行优化外,我们还可以采用尾递归进行优化,于是就有了如下第三种解法:

const fibs = n => fibsTailRecursion(1, 1, n);

const fibsTailRecursion = (ppre, pre, n) => {
  if (n === 1) {
    return ppre;
  }
  return fibsTailRecursion(pre, pre + ppre, n - 1);
};

尾递归和上述普通递归的区别是:尾递归的最后一步是调用某个函数 fibsTailRecursion(pre, pre + ppre, n - 1),而上述普通递归最后一步是调用了两个函数,并且把他们的结果相加 fibs(n - 1) + fibs(n - 2)

递归调用的过程会在内存中形成一个递归调用栈,进行递归子程序调用前(在 A 函数中递归调用 A1),会先将当前函数(A)的上下文信息入栈,等到子程序(A1)调用结束,将会返回到原始执行子调用过程的位置(A 中调用 A1 的位置),恢复 A 的上下文(入参、局部变量、子程序的调用位置...),继续往下执行,例如上述方法一中的递归调用过程如下:(深度优先)

  • 入参为 n,调用 fibs(n - 1),等待 fibs(n - 1) 的结果返回
  • 以新的参数 n-1 入参,继续调用 fibs(n - 2),等待 fibs(n - 2) 结果返回
  • 以新的参数 n-2 入参,继续调用 fibs(n - 3),等待 fibs(n - 3) 结果返回
  • ...
  • 以新的参数 3 入参,继续调用 fibs(2),啊,终于拿到结果,往上返回
  • ...
  • 经过非常复杂的递归操作,终于 fibs(n - 1) 计算出来了,又重复噩梦一般的过程计算 fibs(n - 2) 的值
  • fibs(n - 1)fibs(n - 2) 都计算出来了,将两者的返回结果相加,函数返回,执行结束

但是,使用尾递归进行优化的话,函数的最后一步是调用某个函数,不会保存当前的执行上下文。当最初的入参是 1,1,n 时,函数的调用过程如下:

  • 调用 fibsTailRecursion(1, 1+1, n - 1)
  • 以新的入参 1,2,n-1 继续调用函数 fibsTailRecursion(2, 3, n - 2)
  • ...
  • 最后 n 减少到 1,再以新的参数 x,y,1 继续调用函数,x 的值就是最终的结果

时间复杂度:O(n)O(n)

斐波那契数列第 N 项其实也可以用动态规划的方式求解,于是就有了第四种解法。

在写动态规划算法前我们需要明确几个点:

  1. dp[i] 的定义
  2. base case 是什么
  3. 递推公式

对于斐波那契数列来说:

  1. dp[i] 表示 第 i+1 项斐波那契数,我们的结果是求 dp[i-1]
  2. base case 也很明显,dp[0] = 1, dp[1] = 1
  3. 递推公式为:dp[i] = dp[i - 1] + dp[i - 2];

一切准备就绪后开始编码,代码实现如下:

const fibs = n => {
  const dp = [1, 1];
  if (n === 1 || n === 2) {
    return 1;
  }
  for (let i = 2; i < n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n - 1];
};

时间复杂度:O(n)O(n)

另外,我们也可以用中间值法来求解,从前往后遍历,循环过程中不断交换值,于是就有了第五种解法,代码如下:

const fibs = n => {
  if (n === 1 || n === 2) {
    return 1;
  }
  let ppre = 1;
  let pre = 1;
  let tmp;
  for (let i = 2; i < n; i++) {
    tmp = ppre + pre;
    ppre = pre;
    pre = tmp;
  }
  return tmp;
};

时间复杂度:O(n)O(n)

此外,我们知道斐波那契数列是有通项公式的,公式如下:

an=[(1+52)n(152)n]a_n = [(\frac{1 + \sqrt 5}{2})^{n} - (\frac{1 - \sqrt 5}{2})^{n}]

利用通项公式,我们可以用第六种方法实现,代码如下:

const fibs = n =>
  Math.round(
    (Math.sqrt(5) / 5) *
      (Math.pow((1 + Math.sqrt(5)) / 2, n) -
        Math.pow((1 - Math.sqrt(5)) / 2, n)),
  );

时间复杂度:O(1)O(1)

最后一种方法是利用线性代数的矩阵乘法,原理为:[[0,1],[0,0]][[0,1],[1,1]]n1=[[F(n1),F(n)],[0,0]][[0,1],[0,0]] * [[0,1],[1,1]]^{n-1} = [[F(n-1),F(n)],[0,0]]

根据矩阵快速幂求解斐波那契数列这篇文章的思路,用 js 将其实现。其中,矩阵乘法部分实现的比较粗糙,最终代码如下:

// 矩阵乘法 m*n n*k = m*k
const matrixMultiply = (arr1, arr2) => {
  let result = [];
  for (let i = 0; i < arr1.length; i++) {
    let current = [];
    for (k = 0; k < arr1.length; k++) {
      // 累加
      let sum = 0;
      for (let j = 0; j < arr1[i].length; j++) {
        sum = sum + arr1[i][j] * arr2[j][k];
      }
      current.push(sum);
    }
    result.push(current);
  }
  return result;
};

const fibs = n => {
  let result = [
    [0, 1],
    [0, 0],
  ];
  const arr = [
    [0, 1],
    [1, 1],
  ];
  for (let i = 0; i < n - 1; i++) {
    result = matrixMultiply(result, arr);
  }
  return result[0][1];
};