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


本文作者: AceCandy
博客原文链接: https://acecandy.cn/archives/斐波那契数列全算法
版权声明: 本博客所有文章除特别声明外,均采用CC BY-NC-SA 3.0 许可协议。转载请注明出处!

A.背景

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

B.定义

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

  • F 0 = 0 F_0 = 0 F0=0
  • F 1 = 1 F_1 =1 F1=1
  • F n = F n − 1 + F n − 2 ( n ≥ 2 ) F_n = F_{n-1} + F_{n-2} (n \geq 2) Fn=Fn1+Fn2(n2)

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

C.解法

1.递归

老牌大哥级别的经典解题方法,优点是非常明显的:简单易懂,清晰明了。但是缺点就是效率非常低,时间复杂度为 O ( 2 n ) O(2_n) O(2n)
例如计算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 O_{\log_n} Ologn的时间复杂度等等的因素。因此想到用数组来做缓存。

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_0 = 0 a0=0
  • a 1 = 1 a_1 =1 a1=1
  • a n = a n − 1 + a n − 2 ( n ≥ 2 ) a_n = a_{n-1} + a_{n-2} (n \geq 2) an=an1+an2(n2)

首先构建等比数列
a n + α a n − 1 = β ( a n − 1 + α a n − 2 ) ⇒ a n = ( β − α ) a n − 1 + α β a n − 2 a_{n}+\alpha a_{n-1}=\beta (a_{n-1}+\alpha a_{n-2}) \Rightarrow a_{n}=(\beta -\alpha )a_{n-1}+\alpha \beta a_{n-2} an+αan1=β(an1+αan2)an=(βα)an1+αβan2
{ a b } \begin{Bmatrix}a\\b\end{Bmatrix} {ab}
比较系数可得:
{ β − α = 1 α β = 1 \begin{cases}\beta - \alpha = 1\\\alpha\beta = 1\end{cases} {βα=1αβ=1
不妨设 α > 0 , β > 0 \alpha > 0,\beta > 0 α>0,β>0,解得:
{ α = 5 − 1 2 β = 5 + 1 2 \begin{cases}\alpha ={\dfrac{{\sqrt{5}}-1}{2}}\\\beta ={\dfrac{{\sqrt{5}}+1}{2}}\end{cases} α=25 1β=25 +1
可以得出:
a n + 1 + α a n = ( a 2 + α a 1 ) β n − 1 = ( 1 + α ) β n − 1 = β n ⇒ a n + 1 β n + 1 + α β a n β n = 1 β \begin{aligned}a_{{n+1}}+\alpha a_{{n}}&=(a_{2}+\alpha a_{1})\beta^{{n-1}}\\&=(1+\alpha)\beta ^{{n-1}}\\&=\beta^{n}\\\end{aligned} \Rightarrow {\dfrac{a_{n+1}}{\beta ^{n+1}}}+{\dfrac{\alpha }{\beta }}{\dfrac{a_{n}}{\beta ^{n}}}={\dfrac{1}{\beta }} an+1+αan=(a2+αa1)βn1=(1+α)βn1=βnβn+1an+1+βαβnan=β1
b n = a n β n b_{n}={\dfrac {a_{n}}{\beta ^{n}}} bn=βnan
b n + 1 + λ = − α β ( b n + λ ) b_{n+1}+\lambda =-{\dfrac {\alpha }{\beta }(b_{n}+\lambda )} bn+1+λ=βα(bn+λ),解得 λ = − 1 α + β \lambda =-{\dfrac {1}{\alpha +\beta }} λ=α+β1。故数列 { b n + λ } \left\{b_{n}+\lambda \right\} {bn+λ}为等比数列
b n + λ = ( − α β ) n − 1 ( b 1 + λ ) b_{n}+\lambda=\left(-{\dfrac{\alpha}{\beta }}\right)^{n-1}\left(b_{1}+\lambda \right) bn+λ=(βα)n1(b1+λ)。而 b 1 = a 1 β = 1 β b_{1}={\dfrac{a_{1}}{\beta}}={\dfrac{1}{\beta}} b1=βa1=β1, 故有 b n + λ = ( − α β ) n − 1 ( 1 β + λ ) b_{n}+\lambda =\left(-{\dfrac{\alpha}{\beta }}\right)^{n-1}\left({\dfrac{1}{\beta}}+\lambda \right) bn+λ=(βα)n1(β1+λ)
所以可得出 a n a_{n} an表达式
a n = 5 5 ⋅ [ ( 1 + 5 2 ) n − ( 1 − 5 2 ) n ] a_{n}={\dfrac{\sqrt{5}}{5}}\cdot \left[\left({\dfrac {1+{\sqrt{5}}}{2}}\right)^{n}-\left({\dfrac{1-{\sqrt {5}}}{2}}\right)^{n}\right] an=55 [(21+5 )n(215 )n]

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 ) O(1) O(1),就算输入n=1和n=2100000000的耗时基本一样。

7.矩阵解法

计算的效率公式解法已经没得说了,那我们还纠结什么呢?原因是涉及到无理数的问题以及计算机会出现的精度丢失问题,在上述方法输入n>71的时候答案就已经不准确了。那怎么办?我们不妨用矩阵来表示一下。
( F n F n − 1 ) = ( 1 1 1 0 ) ⋅ ( F n − 1 F n − 2 ) = ( 1 1 1 0 ) 2 ⋅ ( F n − 2 F n − 3 ) = ⋅ ⋅ ⋅ = ( 1 1 1 0 ) n − 2 ⋅ ( F 2 F 1 ) \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} (FnFn1)=(1110)(Fn1Fn2)=(1110)2(Fn2Fn3)==(1110)n2(F2F1)
一目了然了吧,当前 F 1 F_1 F1 F 2 F_2 F2又是已知的,只需要求一个矩阵而已。

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.矩阵解法+快速幂

这里又设计到一个优化的地方了,求 m n m^n mn这种问题,当然不是一直for循环乘上去这么蠢的方法。
假如我们需要求 m 63 m^{63} m63,可以把63分解为32+16+8+4+2+1。 m 63 = m 32 + 16 + 8 + 4 + 2 = m 32 ∗ m 16 ∗ m 8 ∗ m 4 ∗ m 2 ∗ m 1 m^{63} = m^{32+16+8+4+2} = m^{32}\ast m^{16} \ast m^8 \ast m^4 \ast m^2 \ast m^1 m63=m32+16+8+4+2=m32m16m8m4m2m1
不管指数是多少,都可以将其分解为 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 ( l o g n ) O(logn) O(logn),输入n=2100000000(21亿)耗时0.2毫秒,这应该就是斐波那契问题的最优解了。

D.尾声

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

  • 21
    点赞
  • 84
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 13
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AceCandy

期待金主爸爸投喂~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值