经典题:Fibonacci数列精讲

缘起

Fibonacci数列是一个比较经典的算法题。在这里我个人收录的大部分算法,以此作为一个收集、记录自己的成长。

当然,这一切都是在阅读一点算法书籍之后,获得的一些感悟。在惊叹中获取了一些新的视角来看待递推式,加强了数据结构与算法 和 数学原理之间的连接关系、理解。

好,闲言少叙,书归正传!下面我们先行给出Fibonacci数列的定义。

题目描述

上述问题就是Fibonacci数列的定义,这个定义非常的简单。简单到我们可以马上设计出两种符合我们直观理解的代码。

我们先行简单地给出这两种代码,再者我们一一对其进行讲解分析。

递归版:

int Fibonacci(int n) {
    if (n == 1) return 1;
    if (n == 2) return 1;
    
    return Fibonacci(n - 1) + Fibonacci(n - 2);
}

递推版:

int Fibonacci[INF] = {0, 1, 1};//定义足够大的长度

int solution (int n) {
    for (int i = 3; i <= n; ++i) {
        Fibonacci[i] = Fibonacci[i-1] + Fibonacci[i-2];
    }

    return Fibonacci[n];
}

首先我们进入到递归版的讲解与分析。

递归版分析

首先,递归版自然是基于函数实现的。在设计递归版的时候,我们采用如下的规则进行设计与探讨。

递归设计的规则与建议:

1.设计递归函数的基本情况和递归情况,即终止条件和递归表达式。

2.假定递归会一直展开,通过是否必然会碰触基本情况来进行判断设计的合理性。

3.注意算法的合成效益法则(例如剪枝、避免重复计算)。

4.算法正确性建立于数学归纳法之上。

5.递归函数是要有进展的,即朝基本情况靠近。

对于递归版的算法,我们知道基本情况就是我们的初始情况 Fibonacci_{1} = 1, Fibonacci_{2} = 1。所以我们可以用初始值作为基本情况

//基本情况设计,初始值

if (n == 1) return 1;

if (n == 1) return 1;

如果当我们的 n != 1 且 n != 2 时,视作递归情况使用递归表达式向基本情况发展

return Fibonacci(n - 1) + Fibonacci(n - 2);//递归表达式

我们可以看出不论时 Fibonacci(n - 1) 还是 Fibonacci(n - 2) 会向基本情况 Fibonacci(1) 或 Fibonacci(2) 靠近。同时,我们也可以知道任意正整数都是能被 1 或 2 完全表达所以最终一定可以碰触到我们的边界条件。至此我们分析出我们的算法设计是合理且正确的。

但是当我们展开执行图时,我们发现问题所在。我们以 n = 5 为案例进行分析

 

我们发现其中出现了,重复计算的过程。这不符合我们的合成效益法则。所以我们需要优化代码。那么最简单的一种想法就是,我既然之前就计算过 n = 3 的情况,那么第二次计算或者第n次计算 n = 3 的情况,我们就可以直接拿第一次运算的结果来就可以了,这就是记忆化

同时,我们需要注意 Fibonacci数列 并非从第 0 项开始,而是从第 1 项开始,所以我们的n值不会是0,这也意味着我们的Fibonacci的值不会为0。因此我们可以让0来作为未计算的标志。

因此,我们的代码修改为如下情形。

int memory[INF] = {0, 1, 1};

int Fibonacci(int n) {
    if (memory[n] != 0) return memory[n];//n不可能为0,故0可以时未计算标记
    
    return memory[n] = Fibonacci(n - 1) + Fibonacci(n - 2);
}

接下来,我们从数学的视角来分析两种设计。

对于未优化的递归版,其时间复杂度的分析我们可以列出一下几条式子。

T(n) = O(1), n == 1 || n == 2

T(n) = T(n-1) + T(n-2), n > 3

我们发现其时间复杂度符合我们的 Fibonacci数列。所以它应该满足我们 Fibonacci数列的通项公式。即 T(n) 大约满足以下方程:

T(n) = \frac{((\frac{1 + \sqrt{5}}{2})^{n} - (\frac{1 - \sqrt{5}}{2})^{n})}{\sqrt{5}}

根据上述表达式,我们可以看出未优化的递归函数的时间复杂度在指数层级。所以,未优化的递归算法只能处理小当量的数据

我们反观优化后的递归算法的时间复杂度,我们发现每一项都仅仅被计算一次。所以优化的递归算法时间复杂度为O(n)

递推版分析

基于初始条件Fibonacci_{1} = 1, Fibonacci_{2} = 1,所以我们的递推已知状态可以使用两者作为已知的解集合。接着我们通过状态转移方程不断的扩张我们的已知解集合直至我们的已知解集合包含第 N 项 Fibonacci数列即可。

其中,我们只需要一个循环便可以获取第 N 项的值。值得一提的是,我们需要 max{0, N - 2} 次计算就可以获得第 N 项的答案。所以我们的时间复杂度为 O(n)。

对于递推和优化后的递归,我们可以发现优化后的递归中的 memory数组 实际上对应着递推中的 Fibonacci数组。从这个角度上讲,递推实际上也使用了 记忆化 的技巧。

别的算法

在整数讲解其他算法之前,我们先来给原始题目明确加上一个初始条件 对于 100% 的 n 有 n <= 10^16,时间限制 1 s

通过上述两种符合之间的算法分析,我们知道它们最优只能处理 10^8次ps。所以,上述两种不可能完成任务。

那么,有的同学就会说了,我们可以使用Fibonacci数列的通项公式在 O(1) 的时间内求解,这是正确的。事实上,这仅仅是解决了我们的数据范围问题

在此,我们再次添加条件,求出 Fibonacci数列第 N 项对数 M 取余的值

于是我们获取到了一个崭新的题目。

对于此时的题目要求,如果我们采用公式法,虽然我们可以处理 n 数据范围的问题,但是我们不容易对 double 类型取余数。所以这个层面上,我们是困难的。

似乎一切都来到了死胡同!但是线性递推式与矩阵的关系让我们“柳暗花明又一村”。

我们知道对于表达式 F_{n + 1} = F_{n} + F_{n - 1},我们可以将其转换成如下的矩阵关系式:

                ​​​​​​​  

又因为

 以此类推,所以有

 我们记系数矩阵 A = ,输入数组为 B = 

至此我们就可以是用 A 的幂运算和 B 来计算最后的答案了。

为了快速的计算 A^{n} 我们可以采用快速幂算法。快速幂算法是基于 2进制可以完全表示所有整数的前提条件下展开的。

matrix multiply(matrix& A, matrix& B) {
    //构建临时的存放矩阵C
    matrix C = matrix(A.size(), vec(B[0].size()));

    //计算
    for(int i = 0; i < A.size(); ++i) {
        for (int k = 0; k < B.size(); ++k) {
            for (int j = 0; j < B[0].size(); ++j) {
                C[i][j] = (C[i][j] + A[i][k] * B[k][j]) % MODEN;
            }
        }
    }
    
    return C;
}

//计算矩阵A^n次
matrix pow(matrix A, long long n) {
    //构建矩阵B--数列B
    matrix B = matrix(A.size(), vec(A.size()));
    
    //初始化为单元矩阵
    for (int i = 0; i < A.size(); ++i) {
        B[i][i] = 1;
    }

    for (; n; n >>= 1) {
        if (n & 1) B = multiply(A, B);

        A = multiply(A, A);
    }

    return B;
}

当我们需要求解第 N 项时,使用以下语句即可

 A = pow(A, n);

 B = multiply(A, B);

printf("%d", B[0][1]); 

注意我们这里的 matrix 可以是我们自己编写的类,简单点可以是两句话

using vec = std::vector<int>;

using matrix = std::vector<vec>;

这里我们就可以用 k ^ {3} * log^{n}的时间复杂度来完成此事,其中k为矩阵的行列数。当然这还不是最快的。

我们将在稍后介绍一种更快的递推方式,这种递推方式不是使用矩阵,而是将所有的式子全都表述为初始项的表达式

在介绍更快的递推式之前,我们先来扩展一下矩阵与线性递推式。

扩展

 

 

 

 

 更快的递推式

之前我们说过,还有更快的递推计算方式

为了方便理解,我们类比于其他的数学概念。这有点像向量,我们知道在一个n维空间中中,只要选取恰当的n个基向量。那么这些基向量可以表示该维度中的所有向量。

对于一个线性递推式子也是如此,我们可以用初始项来表达所有的数列元素。

假设我们具有以下k项递推式子:

;

基于一种想法,所有的数列元素可以被其初始元素所表述。

所以我们不妨假设,有以下式子成立:

那么,如果我们需要计算am+n,就可以将其表述为:

再一次迭代得到:

于是式子最后被2*k – 2项所表达。为了消除多余的部分,我将上述式子再一次变换

于是我们可以用初始表达式将ai再一次展开,值得注意的是,后项的展开会影响前向的计算。所以我们需要反向递推。

Ps:这件事情也比较好理解,后项展开会包括前项。

#define N 100
#define MOD 10000
using i64 = long long;
#define add(x,y) ( (((x) += (y)) >= MOD) && ((x) -= MOD) )

int a[N], r[N];

//根据系数p,q计算新系数rs, 共 n 项
inline void PolyMul(const int* p, const int* q, int* rs, int n) {
    int t[N << 1]; memset(t, 0, ((n + 1) * 2) * sizeof(int));
    
    //第一次展开a(m+n)
    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < n; ++j) {
            add(t[i + j], (i64)p[i] * q[j] % MOD);
        }
    }

    //第二次展开ai,仅保留前 n 项
    for (int i = (n << 1) - 2; i >= n; --i) {
        if (t[i]) {
            for (int j = 0; j < n; ++j) { 
               if (r[j]) {
                   add(t[i - j - 1], (i64)r[j] * t[i] % MOD);
               }
            }
        }
    }
    for (int i = 0; i < n; ++i) rs[i] = t[i];
}

int LinearRecurrence(int n, int k) {
    int tmp[N] = { 0 }, res[N] = {0};
    tmp[1] = res[0] = 1;
    
    //计算系数
    for (; n; n >>= 1) {
        if (n & 1) PolyMul(tmp, res, res, k);
        PolyMul(tmp, tmp, tmp, k);
    }

    //计算答案
    int ans = 0;
    for (int i = 0; i < k; ++i)
        add(ans, (i64)a[i] * res[i] % MOD);
    return ans;
}

至此我们获取得到了 O(k^2 * logn) 的做法。但是这也不是最快的计算方式,奈何本人知识水平不够无法参透 O(k * logk * logn) 层级的算法。

PS:在更快的算是表达式中,注意系数 r 和初始值 a 的关系的颠倒的,即 a_{n - 1} 对应 r_{0}

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值