背景
Fibonacci是编程基础经典题目,对于博主这样的新手码农来说,通过这道题目,对于递归和循环递推等思想的理解和体会都有非常大的帮助。Fibonacci数列其基本形式如下:
公式计算法
为了计算数列第N+1项(第一项为F(0),故F(n)为第N+1项),最直观的方式是构造其通项公式,然后带入数据进行计算,Fibonacci通项公式的推导过程这里不再赘述(详情可参考这里),推导之后的通项公式如下:
通过带入具体的N值,可以快速计算得到第N+1项的值,但是这种方法存在一些问题,首先,推导的公式相对比较繁琐,公式的结果也相对复杂,如果不理解推导过程直接记忆公式,会非常吃力且容易记错,而且该公式只能针对
这样的递推公式,其灵活性会非常差(例如,如果将最后递推公式最后一项改为F(n-3),该公式将不再适用)
递归计算法
递归的核心思想即是通过调用自身的方式来缩减问题规模,数列的递推公式已经提供了一种天然的递归调用的思路,demo代码(Java实现)如下:
public int Fibonacci(int n){
if (n < 2){
return 1;
}
return Fibonacci(n-1)+Fibonacci(n-2);
}
这种方式可以以非常少的代码实现第N+1项的计算,但是与之相对的,这种算法的代价也非常的高,计算机每次调用函数时,为了保证函数执行完毕,都必须要将函数调用时的程序现场入栈保存,递归存在大量的自身调用,势必会产生非常庞大的函数现场栈来记录数据。另外,回到算法的本身上来,以计算F(10)项为例,为了计算F(10)项,必须知道F(9)项和F(8)项的值,而为了计算F(9)项,又必须知道F(8)项和F(7)项的值……整个过程可以用下面的树形图来进行表示(图片摘自tuzhutuzhu的专栏):
我们可以看到,图中存在大量的重复节点需要计算,从而进一步导致整个算法的效率非常低下,事实上,整个算法的时间复杂度和空间复杂度都是 2n 级别的
非递归计算法
为了提高算法的性能,最直观的做法,就是将递归转为循环,我们知道,递归从逻辑上讲,是一个逆向推导的过程,而循环通常都代表着顺序的思维,这两种方法好比一个事物的两面,经常可以互相转化来解决问题。从递推公式我们可以看出,Fibonacci数列从第三项起,其值始终为该项的前两项的和,利用这个性质,我们可以从头用代码推导第N+1项的值,demo代码(Java实现)如下:
public int Fibonacci(int n){
int firstPre = 1, secondPre = 1;
for (int i = 1;i < n;i++){ //起始firstPre为F(1),故i从1开始
int tmp = firstPre+secondPre;
secondPre = firstPre;
firstPre = tmp;
}
return firstPre;
}
通过利用循环的方式,可以将算法的空间复杂度降至O(1),时间复杂度降至O(n),相对于递归算法,这一优化的效果已经是非常显著的了,但是如果我们需要计算的n值非常大的话(例如将n取遍int的非负范围),这种算法任然会产生很高昂的代价。
矩阵快速幂计算法
我们可以发现,循环算法主要的代价在于N-1次的循环,如果可以将N-1次的加法运算进一步缩减的话,就可以进一步优化我们的算法。为了达到这个目的,首先我们需要学习一种快速计算乘法运算的技巧:
我们知道计算两个数的乘积a*b,传统的做法事实上是将b个a相加得到结果,如果用代码的思维来看,这一过程需要循环执行b次加a操作,然而如果我们换一种角度来看,以7*3为例,结果也可以表示为7*2+7*1,即如果我们将3用二进制表示的话,结果等于7*10+7*01(3的二进制为11),这个新的算式可以理解为,a乘以所有b的不为0的二进制位的对应权值的和(3的二进制11,最右位代表1,从右开始第二位代表2),且因为二进制相邻的位置,左边的位置权值始终是右边的2倍,所以如果我们每次都判断b的最右二进制位是否为1,且每次都将b右移一位,a乘以2的话,我们就可以将乘法运算的次数缩减为log(b)
根据以上思路,我们可以定义一种高效的乘法函数如下(Java实现):
public int multiply(int a, int b){
int sum = 0;
while (b != 0){
if (b&1 == 1){ //将b与1按位与可以判断最右位是否为1
sum += a;
}
a *= 2;
b>>1; //二进制下的右移一位相当于除以2
}
return sum;
}
我们可以看到,利用这种算法,原本需要b次才能完成的乘法操作,现在只需要log(b)就可以完成了。
幂运算,通常也被成为乘法的乘法,同样,也可以利用这样的思路,进行快速计算,也就是所谓的快速幂。demo代码如下(Java实现):
public int multiply(int a, int b){
int sum = 1;
while (b != 0){
if (b&1 == 1){
sum *= a;
}
a = a * a;
b>>1;
}
return sum;
}
当然,我们这份代码里的普通乘法运算,同样可以利用快速幂的思想进行进一步优化。这样,我们就可以在log(n)的时间内完成n次幂运算。
接下来,我们需要考虑如何将通项公式
转化为只包含乘法或幂运算的算式,我们来看如下矩阵运算:
不难看出,我们的递推公式可以转化为这个特殊的矩阵算子
通过向初始矩阵乘以n-1次该矩阵,我们就可以得到F(n)的解,而利用我们上面提到的快速幂算法,这个过程可以在O(log(n))的时间内完成,从而进一步完成了对算法的优化。完整的demo代码如下(Java实现):
public int Fibonacci(int n){
long[][] sum = new long[][]{{1,0},{0,1}};//初始化为单位矩阵,类比普通快速幂的sum=1
long[][] basic = new long[][]{{1,1},{1,0}};//初始化算子
while (n > 1){// 计算n-1次
if (n&1 == 1){
res = multiply(sum, basic, 2);
}
basic = multiply(basic, basic, 2);
n>>1;
}
return (int)sum[0][0];
}
//矩阵乘法运算
public long[][] multiply(long[][] a, long[][] b, int N){
long[][] result = new long[N][N];
for (int k = 0;k < N;k++){
for (int i = 0;i < N;i++){
for (int j = 0;j < N;j++){
result[k][i] += a[k][j]*b[j][i];
}
}
}
return result;
}
P.S. 后记:要灵活
上面博主整理的方法都只针对了Fibonacci数列,亦即递推形为
的数列的第N+1项求值,但这些方法绝不仅限于此。例如我们如果将该数列递推改为
的话,我们同样可以利用上述方法实现F(n)求值(Java实现):
// 递归实现
public int Fabonicci(int n){
if (n < 3){
return 1;
}
return Fabonicci(n-1)+Fabonicci(n-3);
}
// 循环实现
public int Fabonicci(int n){
int thirdPre = 1,secondPre=1,firstPre=1;
for (int i = 1;i < n;i++){
int tmp = thirdPre + firstPre;
thirdPre = secondPre;
secondPre = firstPre;
firstPre = tmp;
}
return firstPre;
}
// 矩阵快速幂实现
public int Fabonicci(int n){
//博主在这里体会了好久才初窥了矩阵设计的奥秘,希望觉得本文有用的童鞋们也可以好好想一下,为什么这里的矩阵不是单位矩阵了,换成单位矩阵可以不?
long[][] sum = new long[][]{{1,0,0},{1,0,0},{1,0,0}};
long[][] basic = new long[][]{{1,0,1},{1,0,0},{0,1,0}};
while (n > 1){
if (n&1 == 1){
sum = multiply(basic, sum, 3);
}
basic = multiply(basic, basic, 3);
n>>1;
}
return (int)sum[0][0];
}