缘起
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.递归函数是要有进展的,即朝基本情况靠近。
对于递归版的算法,我们知道基本情况就是我们的初始情况 。所以我们可以用初始值作为基本情况。
//基本情况设计,初始值
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);
}
接下来,我们从数学的视角来分析两种设计。
对于未优化的递归版,其时间复杂度的分析我们可以列出一下几条式子。
我们发现其时间复杂度符合我们的 Fibonacci数列。所以它应该满足我们 Fibonacci数列的通项公式。即 T(n) 大约满足以下方程:
根据上述表达式,我们可以看出未优化的递归函数的时间复杂度在指数层级。所以,未优化的递归算法只能处理小当量的数据。
我们反观优化后的递归算法的时间复杂度,我们发现每一项都仅仅被计算一次。所以优化的递归算法时间复杂度为O(n)。
递推版分析
基于初始条件,所以我们的递推已知状态可以使用两者作为已知的解集合。接着我们通过状态转移方程不断的扩张我们的已知解集合,直至我们的已知解集合包含第 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 类型取余数。所以这个层面上,我们是困难的。
似乎一切都来到了死胡同!但是线性递推式与矩阵的关系让我们“柳暗花明又一村”。
我们知道对于表达式 ,我们可以将其转换成如下的矩阵关系式:
又因为
以此类推,所以有
我们记系数矩阵 A = ,输入数组为 B =
。
至此我们就可以是用 A 的幂运算和 B 来计算最后的答案了。
为了快速的计算 我们可以采用快速幂算法。快速幂算法是基于 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为矩阵的行列数。当然这还不是最快的。
我们将在稍后介绍一种更快的递推方式,这种递推方式不是使用矩阵,而是将所有的式子全都表述为初始项的表达式。
在介绍更快的递推式之前,我们先来扩展一下矩阵与线性递推式。
扩展
更快的递推式
之前我们说过,还有更快的递推计算方式。
为了方便理解,我们类比于其他的数学概念。这有点像向量,我们知道在一个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 的关系的颠倒的,即 对应
。