【Java算法】斐波那契数列全算法+优化+代码(从入门到发疯)

【Java算法】斐波那契数列全算法+优化+代码(从入门到发疯)

Scroll Down

A.背景

所有学习算法的人应该对『斐波那契数列』问题都不陌生吧,作为算法中的一道经典算法题,很多算法入门书籍都会用它作为递归算法的案例。
对代码本身来说,递归算法当然是最简单和容易编写的,但是只要自己实际动手run一下就会发现,运行耗时长的惊人。
很多时候面试官会直接叫你写一个斐波那契或者其变种的求解方法,这可不是说是他们随便考了你一道简单的题或者说是给你放水,你这时候如果一边心中窃喜一边随手写出一个递归求解,那面试官可能会笑容满面的叫你回去等通知(咳咳,不要问我为什么知道)。
下面我会详细讲解当前已知的所有斐波那契数列的解法以及他们的优劣。
相信我,这将是能让你最容易理解的博文了~

B.定义

斐波那契数列(意大利语:Successione di Fibonacci),又译为菲波拿契数列、菲波那西数列、斐波那契数列、黄金分割数列。
在数学上,费波那契数列是以递归的方法来定义:

  • $F_0 = 0$
  • $F_1 =1$
  • $F_n = F_ + F_ (n \geq 2)$

用文字来说,就是费波那契数列由0和1开始,之后的费波那契系数就是由之前的两数相加而得出。首几个费波那契系数是:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233……
特别指出:0不是第一项,而是第零项。后面的代码也我会忽略第零项。另外由于输出的结果为long型,太大会溢出,不过不要慌问题不大

C.解法

1.递归

老牌大哥级别的经典解题方法,优点是非常明显的:简单易懂,清晰明了。但是缺点就是效率非常低,时间复杂度为$O(2_n)$。
例如计算n=6:T(6)=T(5)+T(4)=T(4)+T(3)+T(3)+T(2)=T(3)+T(2)+T(2)+T(1)+T(2)+T(1)+T(2)=T(2)+T(1)+T(2)+T(2)+T(1)+T(2)+T(1)+T(2)
这样一看你就会发现重复计算特别多,时间复杂度上将是灾难性的。

public static long f1(int n) {
    if (n <= 0) {
        throw new RuntimeException("输入参数小于1");
    }
    if (n == 1 || n == 2) {
        return 1;
    }
    return f1(n - 2) + f1(n - 1);
}

比如上面f1这个方法传入n=50,在我的电脑上竟跑出了耗时高达35.549秒的“好”成绩!可想而知如果这个传入的值更大的时候将会是怎样的灾难了。

2.递归+HashMap缓存

那我们不仅要想了,为什么递归解法的耗时如此之高呢。都是因为大量的重复计算,计算过的值如果存起来是不是就能避免这个问题了呢。然后我第一时间想到了使用HashMap进行缓存。

static HashMap<Integer, Long> map = new HashMap<>();

public static long f2(int n) {
    if (n <= 0) {
        throw new RuntimeException("输入参数小于1");
    }
    if (n == 1 || n == 2) {
        return 1;
    }
    if (!map.containsKey(n)) {
        map.put(n, f2(n - 2) + f2(n - 1));
    }
    return map.get(n);
}

这次的速度已经大大改善了,传入n=8000的时候也不过花费80毫秒!只需要加一个缓存就已经避免了单纯递归的大量重复计算的问题,是不是很棒呢!

3.递归+数组缓存

当然,上面递归+HashMap缓存方法里面的map的key和value分别是Integer和Long对象,这时候计算会有一个自动装拆箱的性能问题。如果在一个循环体自动装拆箱,会创建大量无用的中间对象,这样会增加GC压力,拉低程序的性能。
而且HashMap的存取虽然效率都很高,然而还会有自动扩容、取hashCode、hash冲突之后可能最坏$O_{\log_n}$的时间复杂度等等的因素。因此想到用数组来做缓存。

static long[] mArray = new long[8000 + 1];
public static long f3(int n) {
    if (n <= 0) {
        throw new RuntimeException("输入参数小于1");
    }
    if (n == 1 || n == 2) {
        return mArray[n] = 1;
    }
    if (mArray[n] == 0) {
        mArray[n] = f3(n - 1) + f3(n - 2);
    }
    return mArray[n];
}

同样输入n=8000,这次只花费了1.06毫秒就输出了结果!是不是有种想把自己所有程序都加上缓存的想法了呢,嘿嘿~

4.数组缓存+顺序递推

然而在f3方法中,我输入了n=10000试试,然后惊讶的发现——报错了,报了一个StackOverflowError栈溢出的错误。
我们知道,当一个函数被Java程序调用的时候,就会在调用栈上分配栈帧。栈帧包含被调用函数的参数、局部变量和返回地址。
返回地址指示了当函数执行完毕之后下一步该执行哪里。如果创建栈帧时没有内存空间,JVM就会抛出StackOverflowError。
而递归就是无限制的调用自身函数,然后就栈溢出了。那我们能不能避免呢,因为我们是从后往前推的所以需要不停的递归,那如果我们从前往后推呢?

public static long f4(int n) {
    if (n <= 0) {
        throw new RuntimeException("输入参数小于1");
    }
    if (n == 1 || n == 2) {
        return 1;
    }
    long temp[] = new long[n + 1];
    temp[0] = 0;
    temp[1] = 1;
    temp[2] = 1;
    for (int i = 3; i <= n; ++i) {
        temp[i] = temp[i - 1] + temp[i - 2];
    }
    return temp[n];
}

先定义好前两个值,后面的值是前两个值的和,一直顺序递推上去即可。
这次就完全消除了递归的影响了,输入n=10000000(1000w)的时候,耗时41毫秒,性能完全妥妥的。

5.顺序递推

那我们不禁又要想了,既然是顺序递推就行的话,为什么我们还需要数组进行缓存呢,况且数组是需要占用内存的,我们在只用计算后面的结果的时候,前面根本就不需要一直保存了,那不如直接取消掉缓存。另外数组的长度只能是int类型的,太长也接收不了,限制了我们能输入的n的大小。

public static long f5(int n) {
		if (n <= 0) {
			throw new RuntimeException("输入参数小于1");
		}
		if (n == 1 || n == 2) {
			return 1;
		}
		long a = 1;
		long b = 1;
		long c = 0;
		for (int i = 3; i <= n; i++) {
			c = a + b;
			a = b;
			b = c;
		}
		return c;
	}

这次我们同样输入n=10000000(1000w),耗时仅为6毫秒,而且绕了这么一大圈,原来这么简单就可以解决,让人唏嘘。看来如果要不是学习递归的时候课本样例用的斐波那契数列,我们应该第一时间也能想到这个方法吧。
那这就是极限了吗,然而并不是。

6.公式解法

这次我们尝试用初等代数的方法去做这道题。已知:

  • $a_0 = 0$
  • $a_1 =1$
  • $a_n = a_ + a_ (n \geq 2)$

首先构建等比数列
设 $a_
+\alpha a_=\beta (a_+\alpha a_) \Rightarrow a_=(\beta -\alpha )a_+\alpha \beta a_$
$\begin
a\b\end$
比较系数可得:
$\begin
\beta - \alpha = 1\\alpha\beta = 1\end$
不妨设$\alpha > 0,\beta > 0$,解得:
$\begin
\alpha ={\dfrac{{\sqrt{5}}-1}{2}}\\beta ={\dfrac{{\sqrt{5}}+1}{2}}\end$
可以得出:
$\begin
a_{{n+1}}+\alpha a_{}&=(a_{2}+\alpha a_{1})\beta{}\&=(1+\alpha)\beta {}\&=\beta\\end \Rightarrow {\dfrac{a_{n+1}}{\beta {n+1}}}+{\dfrac{\alpha }{\beta }}{\dfrac{a_}{\beta }}={\dfrac{1}{\beta }}$
令 $b_
={\dfrac {a_}{\beta
}}$
设 $b_{n+1}+\lambda =-{\dfrac {\alpha }{\beta }(b_
+\lambda )}$,解得 $\lambda =-{\dfrac {1}{\alpha +\beta }}$。故数列$\left{b_
即 $b_+\lambda \right}$为等比数列
+\lambda=\left(-{\dfrac{\alpha}{\beta }}\right)\left(b_{1}+\lambda \right)$。而 $b_{1}={\dfrac{a_{1}}{\beta}}={\dfrac{1}{\beta}}$, 故有 $b_+\lambda =\left(-{\dfrac{\alpha}{\beta }}\right)\left({\dfrac{1}{\beta}}+\lambda \right)$
所以可得出 $a_
$表达式
$a_
={\dfrac{\sqrt{5}}{5}}\cdot \left[\left({\dfrac {1+{\sqrt{5}}}{2}}\right)-\left({\dfrac{1-{\sqrt {5}}}{2}}\right)\right]$

public static long f6(int n) {
    double result = 0;
    double temp = Math.sqrt(5.0);
    result = (Math.pow((1 + temp) / 2, n) - Math.pow((1 - temp) / 2, n)) / temp;
    return (long) result;
}

这个是真的厉害了,公式直接计算就可以得出结果,忽略任何逻辑,看来数学是真的很神奇了,输入n=2100000000(21亿)耗时也不过0.1毫秒。时间复杂度为$O(1)$,就算输入n=1和n=2100000000的耗时基本一样。

7.矩阵解法

计算的效率公式解法已经没得说了,那我们还纠结什么呢?原因是涉及到无理数的问题以及计算机会出现的精度丢失问题,在上述方法输入n>71的时候答案就已经不准确了。那怎么办?我们不妨用矩阵来表示一下。

$\begin{pmatrix} F_n\\F_{n-1} \end{pmatrix}=\begin{pmatrix}1&1\\1&0\end{pmatrix} \cdot \begin{pmatrix}F_{n-1}\\F_{n-2}\end{pmatrix}={\begin{pmatrix}1&1\\1&0\end{pmatrix}}^2 \cdot \begin{pmatrix}F_{n-2}\\F_{n-3}\end{pmatrix}=···={\begin{pmatrix}1&1\\1&0\end{pmatrix}}^{n-2} \cdot \begin{pmatrix} F_2\\F_1 \end{pmatrix}$

一目了然了吧,当前$F_1$和$F_2$又是已知的,只需要求一个矩阵而已。

static long[][] initMatirx = {{1, 1}, {1, 0}};
public static long f7(int n) {
    if (n <= 0) {
        throw new RuntimeException("输入参数小于1");
    }
    if (n == 1 || n == 2) {
        return 1;
    }
    long[][] tem = initMatirx;
    for (int i = 1; i < n - 2; i++) {
        tem = matirxMulti(tem, initMatirx);
    }
    return tem[0][0] + tem[1][0];
}
private static long[][] matirxMulti(long[][] a, long[][] b) {
    long[][] temp = new long[2][2];
    temp[0][0] = a[0][0] * b[0][0] + a[0][1] * b[1][0];
    temp[0][1] = a[0][0] * b[0][1] + a[0][1] * b[1][1];
    temp[1][0] = a[1][0] * b[0][0] + a[1][1] * b[1][0];
    temp[1][1] = a[1][0] * b[0][1] + a[1][1] * b[1][1];
    return temp;
}

这就是把公式中可以抽象出来的地方进行矩阵计算,输入n=10000000(1000w),耗时在280毫秒左右。比起上面的顺序递推法的6毫秒还是有不少差距。

8.矩阵解法+快速幂

这里又设计到一个优化的地方了,求$mn$这种问题,当然不是一直for循环乘上去这么蠢的方法。
假如我们需要求$m
{63}$,可以把63分解为32+16+8+4+2+1。$m{63} = m{32+16+8+4+2} = m{32}\ast m{16} \ast m8 \ast m4 \ast m2 \ast m1$。
不管指数是多少,都可以将其分解为 2 的倍数的和,因为任何整数都能够写成 2 进制的形式。这就是关于求次方的快速幂算法。

static long[][] initMatirx = {{1, 1}, {1, 0}};
static long[][] unitMatrix = {{1, 0}, {0, 1}};//单位矩阵
public static long f8(int n) {
    if (n <= 0) {
        throw new RuntimeException("输入参数小于1");
    }
    if (n == 1 || n == 2) {
        return 1;
    }
    long[][] result = unitMatrix;
    long[][] tem = initMatirx;
    int m = n - 2;
    while (m != 0) {
        if ((m & 1) == 1) {
            result = matirxMulti(tem, result);
        }
        tem = matirxMulti(tem, tem);
        m >>= 1;
    }
    return result[0][0] + result[1][0];
}

时间复杂度优化到$O(logn)$,输入n=2100000000(21亿)耗时0.2毫秒,这应该就是斐波那契问题的最优解了。

D.尾声

介绍的全算法以及优化都在这儿了,怎么说面试的时候总够用了吧~
至于如果还有其它方法,也逃不过这几种思想,欢迎大家大开脑洞~