算法回顾——斐波那契数列问题


斐波那契数列,由数学家 列昂纳多·斐波那契以兔子繁殖为例子而引入,故又称为“兔子数列”。一般我们见到的兔子繁殖问题、以1阶或2阶方式上台阶的问题,本质上都是斐波那契数列问题。

斐波那契数列递推公式:
F ( n ) = { 0 , n = 0 1 , n = 0 F ( n − 1 ) + F ( n − 2 ) , n ⩾ 2 或 F ( n ) = { 1 , n = 1 1 , n = 2 F ( n − 1 ) + F ( n − 2 ) , n ⩾ 3 F(n) = \begin{cases} 0, & n=0 \\ 1, & n = 0 \\ F(n-1) + F(n-2), & n \geqslant 2 \end{cases} \\或 \\ F(n) = \begin{cases} 1, & n=1 \\ 1, & n = 2 \\ F(n-1) + F(n-2), & n \geqslant 3 \end{cases} F(n)=0,1,F(n1)+F(n2),n=0n=0n2F(n)=1,1,F(n1)+F(n2),n=1n=2n3
通常,好像我们也没考虑第0项。
斐波那契数列求解,在计算机领域,我们可以通过多种不同的方法实现,因此也涉及到多种算法,这些算法性能不一,笔者进行了如下归纳记录,欢迎阅读指正。

递归法

时间复杂度:O(2n)
Java实现:

private static long fib(int n) {
    if (n <= 2) {
        return 1;
    }
    return fib(n - 1) + fib(n - 2);
}

实现方式非常简单粗暴,但因为每次递归调用都会对下面的 n-1,n-2项进行计算,重复计算很多,而且呈指数增长,性能不佳。以计算第6项为例:
斐波那契递归过程
光看这个图可能不直观。还是看一下实际运行情况吧,以笔者的电脑为例:mbp 2019, CPU:i7 6核,运行情况如下:

第1项:1  递归次数: 1  time cost: 0ms
第2项:1  递归次数: 1  time cost: 1ms
第3项:2  递归次数: 3  time cost: 0ms
第4项:3  递归次数: 5  time cost: 0ms
第5项:5  递归次数: 9  time cost: 0ms
第6项:8  递归次数: 15  time cost: 0ms
# 省略部分中间结果
第26项:121393  递归次数: 242785  time cost: 0ms
第27项:196418  递归次数: 392835  time cost: 1ms
第28项:317811  递归次数: 635621  time cost: 2ms
第29项:514229  递归次数: 1028457  time cost: 2ms
第30项:832040  递归次数: 1664079  time cost: 3ms
第31项:1346269  递归次数: 2692537  time cost: 5ms
第32项:2178309  递归次数: 4356617  time cost: 8ms
第33项:3524578  递归次数: 7049155  time cost: 13ms
第34项:5702887  递归次数: 11405773  time cost: 19ms
第35项:9227465  递归次数: 18454929  time cost: 27ms
第36项:14930352  递归次数: 29860703  time cost: 39ms
第37项:24157817  递归次数: 48315633  time cost: 62ms
第38项:39088169  递归次数: 78176337  time cost: 113ms
第39项:63245986  递归次数: 126491971  time cost: 167ms
第40项:102334155  递归次数: 204668309  time cost: 270ms
第41项:165580141  递归次数: 331160281  time cost: 446ms
第42项:267914296  递归次数: 535828591  time cost: 674ms
第43项:433494437  递归次数: 866988873  time cost: 1056ms
第44项:701408733  递归次数: 1402817465  time cost: 1713ms
第45项:1134903170  递归次数: 2269806339  time cost: 2771ms

可以看到:从计算第27项开始,这个递归方法就开始显得越来越力不从心了,性能下降非常明显。

递推法(动态规划)

这里和从大到小的递归法不同,用从最小的项开始往后计算的方式。因为我们只需要最后的第n项的值,所以只需要在计算过程始终保留第 n-1 ,第 n-2 项的值就够用了,而其余中间结果不需要保留。
在这里插入图片描述
时间复杂度:O(n)
Java实现:

private static long fib(int n) {
    if (n <= 2) {
        return 1;
    }
    long sub1 = 1;
    long sub2 = 1;
    long total = 2;
    for (int i = 2; i < n; i++) {
        total = sub1 + sub2;
        sub2= sub1;
        sub1 = total;
    }
    return total;
}

通项公式法

斐波那契数列第n项的通项公式为:
a n = 1 5 ⋅ [ ( 1 + 5 2 ) n − ( 1 − 5 2 ) n ] a_n = \frac{1}{\sqrt{5}} \cdot \left[\left(\frac{1+\sqrt{5}}{2}\right)^n - \left(\frac{1 - \sqrt{5}}{2}\right)^n\right] an=5 1[(21+5 )n(215 )n]
推导过程可以参考:通项公式推导
注意: 虽然是公式一步得到结果,但是注意时间复杂度可并不是 O(1) 哦,因为这里用到了幂运算,而幂运算可以通过时间复杂度为 O(logN) 的二分法实现。

时间复杂度:O(logN)
Java实现:

    private static double SQRT_5 = Math.sqrt(5);
    private static double fib3(int n) {
        return 1 / SQRT_5 * (Math.pow(((1 + SQRT_5) / 2f), n) - Math.pow(((1 - SQRT_5) / 2f), n));
    }

扩展-幂运算(Math.pow())

除了调用Math.pow()库函数外,我们也可以手动实现时间复杂度为 O(logN) 的幂运算(二分法)。

假设我们要计算a的n次方,当n为偶数时:
a n = a ⋅ a ⋯ a ⏟ n = a ⋅ a ⋯ a ⏟ n 2 ⋅ a ⋅ a ⋯ a ⏟ n 2 = ( a n / 2 ) 2 a^n = \begin{matrix} \underbrace{ a\cdot a \cdots a } \\ n \\ \end{matrix} = \begin{matrix} \underbrace{ a\cdot a \cdots a } \\ \frac{n}{2} \\ \end{matrix} \cdot \begin{matrix} \underbrace{ a\cdot a \cdots a } \\ \frac{n}{2} \\ \end{matrix} =(a^{n/2})^2 an= aaan= aaa2n aaa2n=(an/2)2
若n为奇数,那么n-1就为偶数,就可以用上面的公式计算 a 的 n-1 次方,然后我们再乘一个a,就得到了 a 的 n 次方。

所以,虽然是要计算a的n次方,但我们只要把它的一半(an/2)计算出来,就可以了。然后我们用分治的思想,把 an/2 再一分为二,以此类推,所以时间复杂度是 O(logN)。
Java实现:

private static double pow(double a, int n) {
    if (a == 0) {
        return 0;
    }
    if (n == 0) {
        return 1;
    }
    if (n == 1) {
        return a;
    }
    // 考虑指数为负数的情况
    if (n < 0) {
        a = 1 / a;
        n = -n;
    }
    double half = pow2(a, n / 2);
    // 如果是奇数次方,就用偶数次方的结果再乘一个a
    return (n & 1) == 0 ? half * half : half * half * a;
}

加速幂运算

通过位运算,还可以写出比上面二分法更快的幂运算,指数n的二进制表示中有多少位是1,就只需要计算多少次。
Java实现:

private static int pow(int a, int n) {
    if (n == 0) {
        return 1;
    }
    int res = 1;
    int tmp = a;
    while (n != 0) {
        if ((n & 1) != 0) {
            res *= tmp;
        }
        n >>= 1;
        tmp *= tmp;
    }
    return res;
}

矩阵相乘法

矩阵乘法规则:
[ a 11 a 12 a 21 a 22 ] ⋅ [ b 11 b 12 b 21 b 22 ] = [ a 11 ⋅ b 11 + a 12 ⋅ b 21 a 11 ⋅ b 12 + a 12 ⋅ b 22 a 21 ⋅ b 11 + a 22 ⋅ b 21 a 21 ⋅ b 12 + a 22 ⋅ b 22 ] \begin{bmatrix} a_{11} & a_{12} \\ a_{21} & a_{22} \end{bmatrix} \cdot \begin{bmatrix} b_{11} & b_{12} \\ b_{21} & b_{22} \end{bmatrix} = \begin{bmatrix} a_{11} \cdot b_{11} + a_{12} \cdot b_{21} & a_{11} \cdot b_{12} + a_{12} \cdot b_{22} \\ a_{21} \cdot b_{11} + a_{22} \cdot b_{21} & a_{21} \cdot b_{12} + a_{22} \cdot b_{22} \end{bmatrix} [a11a21a12a22][b11b21b12b22]=[a11b11+a12b21a21b11+a22b21a11b12+a12b22a21b12+a22b22]
基于以上矩阵乘法规则,结合斐波那契数列F(n) = F(n-1) + F(n-2)的推导公式,如果将斐波那契数列第n项和第n-1项以2x1的矩阵表示,则可以得到以下推导:

推导参考:浅谈斐波那契数列——从递推到矩阵乘法

[ F n F n − 1 ] = [ F n − 1 + F n − 2 F n − 1 ] = [ 1 × F n − 1 + 1 × F n − 2 1 × F n − 1 + 1 × 0 ] = [ 1 1 1 0 ] ⋅ [ F n − 1 F n − 2 ] = [ 1 1 1 0 ] n − 1 ⋅ [ F 1 F 0 ] = [ 1 1 1 0 ] n − 1 ⋅ [ 1 0 ] \begin{aligned} \begin{bmatrix} F_n \\ F_{n-1} \end{bmatrix} & = \begin{bmatrix} F_{n-1}+F_{n-2} \\ F_{n-1} \end{bmatrix} \\ & = \begin{bmatrix} 1×F_{n-1}+1×F_{n-2} \\ 1×F_{n-1}+1×0 \end{bmatrix} \\ & = \begin{bmatrix} 1 & 1 \\ 1 & 0 \end{bmatrix} \cdot \begin{bmatrix} F_{n-1} \\ F_{n-2} \end{bmatrix} \\& = \begin{bmatrix} 1 & 1 \\ 1 & 0 \end{bmatrix}^{n-1} \cdot \begin{bmatrix} F_1 \\ F_0 \end{bmatrix} \\& = \begin{bmatrix} 1 & 1 \\ 1 & 0 \end{bmatrix}^{n-1} \cdot \begin{bmatrix} 1 \\ 0 \end{bmatrix} \end{aligned} [FnFn1]=[Fn1+Fn2Fn1]=[1×Fn1+1×Fn21×Fn1+1×0]=[1110][Fn1Fn2]=[1110]n1[F1F0]=[1110]n1[10]
根据矩阵乘法特性,一个m行的矩阵与一个n列的矩阵相乘,将得到一个m行、n列的矩阵。
因为 [ 1 1 1 0 ] n − 1 \begin{bmatrix} 1 & 1 \\ 1 & 0 \end{bmatrix}^{n-1} [1110]n1中,相乘的所有矩阵均为2x2,所以我们可以确定 [ 1 1 1 0 ] n − 1 \begin{bmatrix} 1 & 1 \\ 1 & 0 \end{bmatrix}^{n-1} [1110]n1的结果也是一个2x2的矩阵,我们暂且将这个结果矩阵表示为 [ a b c d ] \begin{bmatrix} a & b \\ c & d \end{bmatrix} [acbd],那么就可以继续上面的推导:
[ F n F n − 1 ] = [ a b c d ] ⋅ [ 1 0 ] = [ a c ] \begin{bmatrix} F_n \\ F_{n-1} \end{bmatrix} = \begin{bmatrix} a & b \\ c & d \end{bmatrix} \cdot \begin{bmatrix} 1 \\ 0 \end{bmatrix} = \begin{bmatrix} a \\ c \end{bmatrix} [FnFn1]=[acbd][10]=[ac]
所以, [ 1 1 1 0 ] n − 1 \begin{bmatrix} 1 & 1 \\ 1 & 0 \end{bmatrix}^{n-1} [1110]n1结果矩阵中的第1行、第一列的值,就是我们要计算的斐波那契数列第n项了。

最终还是变成了一个求a的n次方问题,那就可以参考上面的幂运算或加速幂运算方法了,只不过由数值计算变成了矩阵计算。

时间复杂度:O(logN)
Java实现:

private static int fib(int n) {
    int[][] fibMagicMatrix = {
            {1, 1},
            {1, 0}
    };
    if (n <= 2) {
        return fibMagicMatrix[0][0];
    }
//    int[][] res = matrixPow(fibMagicMatrix, n - 1);
    int[][] res = matrixPowFast(fibMagicMatrix, n - 1);
    return res[0][0];
}

/**
 * 矩阵幂运算,二分法
 * @param matrix 矩阵
 * @param n 指数
 * @return 结果矩阵
 */
private static int[][] matrixPow(int[][] matrix, int n) {
    if (n == 0) {
        //返回单位矩阵
        return new int[][] {{1, 0},{0, 1}};
    }
    if (n == 1) {
        return matrix;
    }
    int[][] half = matrixPow(matrix, n / 2);
    int[][] res = matrixMultiply(half, half);
    return (n & 1) == 0 ? res : matrixMultiply(res, matrix);
}

/**
 * 矩阵幂运算,快速幂方法
 * @param matrix 矩阵
 * @param n 指数
 * @return 结果矩阵
 */
private static int[][] matrixPowFast(int[][] matrix, int n) {
    if (n == 0) {
        //返回单位矩阵
        return new int[][] {{1, 0},{0, 1}};
    }
    // 结果初始为单位矩阵
    int[][] res = {{1, 0},{0, 1}};
    int[][] tmp = matrix;
    while (n != 0) {
        if ((n & 1) != 0) {
            // 这里矩阵相乘的顺序很重要。不同于普通数值计算(axb=bxa)
            res = matrixMultiply(tmp, res);
        }
        tmp = matrixMultiply(tmp, tmp);
        n >>= 1;
    }
    return res;
}

/**
 * 2x2矩阵相乘
 * @param ma 第一个矩阵
 * @param mb 第二个矩阵
 * @return 矩阵相乘结果,也是一个2x2的矩阵
 */
private static int[][] matrixMultiply(int[][] ma, int[][] mb) {
    int[][] res = new int[2][2];
    res[0][0] = ma[0][0] * mb[0][0] + ma[0][1] * mb[1][0];
    res[0][1] = ma[0][0] * mb[0][1] + mb[0][1] * mb[1][1];
    res[1][0] = ma[1][0] * mb[0][0] + ma[1][1] * mb[1][0];
    res[1][1] = ma[1][0] * mb[0][1] + ma[1][1] * mb[1][1];
    return res;
}

查表法

这里再记录一种非常规思路的方法。细心的读者可能注意到,在写通项公式法的时候,为了减少计算,我们是把 5 \sqrt5 5 的值提前计算好作为常量直接带入的。那我们也可以把这种思路发挥到极致,直接把斐波那契数列的各项值都提前算好,要用的时候直接查出来就好了。
调试打印一下,我们就能发现,如果用 int 类型来保存,最多只能保存到第46项,第47项就溢出了:
在这里插入图片描述
即便用范围更大的 long 类型保存,最多也只能保存到第92项,第93项就溢出了:
在这里插入图片描述
所以用switch/case来实现的话,最多也就…92个case语句吧。
这种方法速度最快,O(1)时间复杂度即可解决。在某些场景下,也不失为一种解决办法,虽然和算法、代码美观等考量已经没什么关系了。

时间复杂度:O(1)
Java实现:

/**
 * 通过查表直接获取第n项的值,不需要任何计算
 * @param n 第n项
 * @return 斐波那契数列第n项值
 */
private static long fibFastest(int n) {
    switch (n) {
        case 1:
        case 2:
            return 1;
        case 3:
            return 2;
        case 4:
            return 3;
        case 5:
            return 5;
        // 省略其他 case 值...
        case 46:
            return 1836311903;
        // 省略其他 case 值...
        case 92:
            return 7540113804746346429L;
        default:
            break;
    }
    return 0;
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值