Fibonacci数列高效解法大全及时间复杂度分析 连载【5】
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Fibonacci数列高效解法大全及时间复杂度分析 连载【5】相关的知识,希望对你有一定的参考价值。
参考技术A ……续上回 Fibonacci数列高效解法大全及时间复杂度分析 连载【4】来看profile的记录分析,看时间具体用在哪个部分了
一看,绝大部分时间耗在两句results上了
看来主要都用来大整数运算了
下面来试一下
把这程序里两句“results = ”后面的大数运算注释掉,换成1。也就是两句都成“results = 1”
再运行计时看看
Total time: 0.000753秒
很惊人,去掉大数运算后,运行时间缩短成了原用时的1%。也就是99%时间消耗在Python内置的大数运算上了
下面试下用号称地球上最好的大数运算库替换掉Python内置的大数运算
9. 应用GMP库
全称是GNU Multiple Precision Arithmetic Library,即GNU高精度算术运算库,这是一个C写成的高效大数运算库
gmpy2是Python下对GMP库的封装
安装很简单,在操作系统下打命令pip install gmpy2,就安装好了
应用到程序也很简单
把上面的二分迭代解法程序开头添加一行
再把程序里
改成
就可以了
运行看一下用时
Total time: 0.00689297秒
是原用Python内置大数运算用时的9%
效果显著。可见Python内置大数运算效率确实不怎么样
相关大整数乘法高效算法的介绍可参见这篇《 【算法】大数乘法问题及其高效算法 》
极大整数乘法的时间复杂度低至近似O(n*log n)
前面二分解法本身时间复杂度是O(log n)
现在把大数因素考虑进去。大数时间复杂度的n可以用二进制位数表示
第n项斐波那契数的二进制位数k跟n是线性关系,n*10,那位数k也是*10
现在把极大整数乘法时间复杂度代入,O(n*log n)*O(log n)=O(n*(log n)^2)
也就是在大数情况下二分解法的时间复杂度为O(n*(log n)^2)
可以看这篇《 为什么算法渐进复杂度中对数的底数总为2 》解释
10. 矩阵解法
斐波那契数列和矩阵的关系推导我看到GoCalf Blog里写的一段非常清晰,特在此引用
这解法就是求矩阵的n-1次幂。矩阵幂运算也能根据下面公式迭代二分加速
就是所谓的矩阵快速幂
Python里库很丰富,大名鼎鼎的numpy就是一个有关矩阵的库。这库是有优化的,算矩阵幂就不用个人再写什么矩阵快速幂函数了
用numpy库就能很简单的写出来
因为numpy没有大数支持,大数运算还是要用GMP库
同上测用时
Total time: 0.042466秒
这幂运算是二分加速的,时间复杂度为O(log n)
对于固定阶矩阵相乘,乘的次数是个常数,也就是O(1)。虽然这个常数比较大^_*
代入大数时间复杂度,总体复杂度也是O(n*(log n)^2)
这儿来解释下为何矩阵快速幂比二分递归解法时间常数大
我们再来仔细看看斐波那契数列的矩阵形式:
会发现 z 和 y 必然相等,z 没必要再计算一遍。
t = x - y,因此 t 也没必要再计算一遍。
只需要计算矩阵第一列的那两个元素即可:
矩阵快速幂中两个矩阵相乘实际可分解为8次两个大整数乘法,而二分递归中只需要3次两个大整数乘法。所以二分递归时间常数小。
未完待续……
Fibonacci数列高效解法大全及时间复杂度分析 连载【6】
你真的懂斐波那契数列吗?Fibonacci四种解法满足你!
动态规划
动态规划(Dynamic programming,简称 DP) 是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划算法的核心就是记住已经解决过的子问题的解;
而记住求解的方式有两种:自顶向下的备忘录法、自底向上的动态规划。
Fibonacci Sequence
1 1 2 3 5 8 13…
要求第n个斐波那契数,子问题就是求每一个斐波那契数的前一项和前二项之和,考虑使用递归完成动态规划算法。
自顶向下的备忘录法
解法一:
public static int fibonacci(int n) {
if (n <= 1)
return 1;
if (n == 2)
return 2;
return fibonacci(n-1) + fibonacci(n-2);
}
这个算法的时间复杂度怎么算呢?
递归的时间复杂度:递归次数 * 一次递归的基本语句执行次数。
递归的空间复杂度:在栈空间最大的临时变量个数。
如果求 f(5) 画出调用过程的递归树就是:
f(5)
f(4) f(3)
f(3) f(2) f(2) f(1)
f(2) f(1)
一个树状结构,先从f5->f4->f3-f2->f1->f2左子树就递归完了(这个过程伴随着不断进栈与出栈,其中包含许多重复的计算过程)。
时间复杂度:每一个结点都是一次递归,每一个递归里只有一个基本语句,而结点树大约等与2^(N-2)次,常数忽略也就是2 ^ N次递归调用,所以O(2^n).
空间复杂度:由图可以看出f5->f2之后递归f1,之后归值给f3,这时f2和f1的空间已经回收了,所以最大的空间占用也只是f5->f1,即O(n)。
下图表示斐波那契算法在输入规模n=6时的递归结构,其中的每个F(*)表示对递归函数的一次调用,叶结点表示递归终止时的调用,即斐波那契算法F(n)中第2行的返回值。
我们分析上面的递归就会发现有很多节点被重复执行,如果在执行的时候把执行过的子节点保存起来,后面要用到的时候直接查表调用的话可以节约大量的时间。
解法二:
public class Fibonacci {
public static void main(String[] args) {
//创建备忘录
int[] memo = new int[n+1];
System.out.println(fibonacci(7));
}
/**
* 自顶向下备忘录法
* @param n
* @param memo 备忘录
* @return
*/
public static int fibonacci(int n, int[] memo) {
// 如果已经求出了fibonacci(n)的值直接返回
if(memo[n] != 0) return memo[n];
// 否则将求出的值保存在 memo 备忘录中。
if(n<=2)
memo[n]=1;
else {
memo[n] = fibonacci(n-1, memo) + fibonacci(n-2, memo);
}
return memo[n];
}
}
这个方法是由上至下,比如求f(5),我们要求f(4)和f(3),求出来后放入备忘录,当求f(4)时需要f(3)和f(2),我们可以直接从备忘录取f(3)而不是再去求一遍。
自底向上的动态规划
备忘录法是利用了递归,上面算法不管怎样,计算 fib(6)的时候最后还是要计算出 fib(1), fib(2), fib(3) ……,那么何不先计算出 fib(1), fib(2), fib(3) ……,呢?这也就是动态规划的核心,先计算子问题,再由子问题计算父问题。
解法三:
public class FibonacciPlus {
/**
* 自底向上的动态规划
* @param n
* @return
*/
public static int fib(int n) {
if(n<=0)return -1;
//创建备忘录
int[] memo = new int[n+1];
memo[0]=0;
memo[1]=1;
for(int i=2;i<=n;i++) {
memo[i]=memo[i-1]+memo[i-2];
}
return memo[n];
}
/**
* 参与循环的只有 i, i-1 , i-2 三项,可以优化空间
* @param n
* @return
*/
public static int fibPlus(int n) {
if(n<=0)return -1;
int memo_i_2=0;
int memo_i_1=1;
int memo_i=1;
for(int i=2;i<=n;i++) {
memo_i = memo_i_1+memo_i_2;
memo_i_2 = memo_i_1;
memo_i_1 = memo_i;
}
return memo_i;
}
}
循环迭代的精髓是每次计算的结果参与下次的计算,这种解法也是最优的,时间复杂度O(n),空间复杂度O(1).
尾递归
递归的名次解释:
程序调用自身的编程技巧称为递归( recursion)。
递归做为一种算法在程序设计语言中广泛应用。一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。递归的能力在于用有限的语句来定义对象的无限集合。一般来说,递归需要有边界条件、递归前进段和递归返回段。当边界条件不满足时,递归前进;当边界条件满足时,递归返回。
------百度百科
尾递归(tail recursive),是某种形式的递归。简单的说递归就是函数自己调用自己。那尾递归和递归之间的差别体现在参数上面。
尾部递归是一种编程技巧。
递归函数是指一些会在函数内调用自己的函数,如果在递归函数中,递归调用返回的结果总被直接返回,则称为尾部递归。尾部递归的函数有助将算法转化成函数编程语言,而且从编译器角度来说,亦容易优化成为普通循环。这是因为从电脑的基本面来说,所有的循环都是利用重复移跳到代码的开头来实现的。如果有尾部归递,就只需要叠套一个堆栈,因为电脑只需要将函数的参数改变再重新调用一次。利用尾部递归最主要的目的是要优化,例如在Scheme语言中,明确规定必须针对尾部递归作优化。可见尾部递归的作用,是非常依赖于具体实现的。------维基百科wiki
解法四:
public class Main {
public static void main(String[] args) {
System.out.println(fib(1, 1, 5));
}
public static long fib(int first, int second, int n) {
if (n < 2) // n=1:递归结束位置
return first;
printf("factorial_tail(%d, %d, %d) \\n",n-1,second,first+second);
return fib(second, first+second, n-1);
}
}
程序运行结果:
请输入斐波那契数n:10
factorial_tail(9, 1, 2)
factorial_tail(8, 2, 3)
factorial_tail(7, 3, 5)
factorial_tail(6, 5, 8)
factorial_tail(5, 8, 13)
factorial_tail(4, 13, 21)
factorial_tail(3, 21, 34)
factorial_tail(2, 34, 55)
factorial_tail(1, 55, 89)
55
Process returned 0 (0x0) execution time : 1.393 s
fib(second, first+second, n-1): 真是神来之笔,原本朴素的递归产生的栈的层次像二叉树一样,以指数级增长,但是现在栈的层次却像是数组,变成线性增长了,实在是奇妙,总结起来也很简单,原本栈是先扩展开,然后边收拢边计算结果,现在却变成在调用自身的同时通过参数来计算。
总结
尾递归的本质是:将单次计算的结果缓存起来,传递给下次调用,相当于自动累积。
在Java等命令式语言中,尾递归使用非常少见,因为我们可以直接用循环解决。而在函数式语言中,尾递归却是一种神器,要实现循环就靠它了。
很多人可能会有疑问,为什么尾递归也是递归,却不会造成栈溢出呢?因为编译器通常都会对尾递归进行优化。编译器会发现根本没有必要存储栈信息了,因而会在函数尾直接清空相关的栈。
百度百科对尾递归的一些解释如下:
如果一个函数中所有递归形式的调用都出现在函数的末尾,我们称这个递归函数是尾递归的。
当递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归。
尾递归函数的特点是在回归过程中不用做任何操作,这个特性很重要,因为大多数现代的编译器会利用这种特点自动生成优化的代码。
原理:
当编译器检测到一个函数调用是尾递归的时候,它就覆盖当前的活动记录而不是在栈中去创建一个新的。
编译器可以做到这点,因为递归调用是当前活跃期内最后一条待执行的语句,于是当这个调用返回时栈帧中并没有其他事情可做,因此也就没有保存栈帧的必要了。
通过覆盖当前的栈帧而不是在其之上重新添加一个,这样所使用的栈空间就大大缩减了,这使得实际的运行效率会变得更高。
虽然编译器能够优化尾递归造成的栈溢出问题,但是在编程中,我们还是应该尽量避免尾递归的出现,因为所有的尾递归都是可以用简单的goto循环替代的。
尾递归的精髓就是吸取循环的精华——把每次计算的结果当作参数传递给下一次计算,递归函数的返回值不参与计算,而一般的递归,当前函数需要下一递归函数的返回值进行计算,因此需要在栈中保留每次递归的栈帧,以便返回结果。
时间复杂度: O(n)
空间复杂度: O(1)
拓展知识
栈又称堆栈,存放程序的局部变量(不包括静态局部变量,static变量存在静态区)。除此以外,在函数被调用时,栈用来传递参数和返回值。由于栈的先进后出的特点,所以栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。
当程序中调用了一个函数时,栈中会分配一块空间来保存与这个调用相关的信息,每一个调用都被当作是活跃的。栈上的那块存储空间称为活跃记录或者栈帧
栈帧由5个区域组成:输入参数、返回值空间、计算表达式时用到的临时存储空间、函数调用时保存的状态信息以及输出参数。
栈是用来存储函数调用信息的绝好方案,然而栈也有一些缺点:
栈维护了每个函数调用的信息直到函数返回后才释放,这需要占用相当大的空间,尤其是在程序中使用了许多的递归调用的情况下。除此之外,因为有大量的信息需要保存和恢复,因此生成和销毁活跃记录(栈帧)需要消耗一定的时间。
简而言之,递归过的压栈和出栈,时间和空间都有很大的消耗。
以尾递归方式实现阶乘函数的实现:
int facttail(int n, long res)
{
if (n < 0)
return 0;
else if(n == 0)
return 1;
else if(n == 1)
return res;
else
return facttail(n - 1, n * res);
}
参考博客:
衷心感谢!
站在巨人肩膀上前进
加油!
以上是关于Fibonacci数列高效解法大全及时间复杂度分析 连载【5】的主要内容,如果未能解决你的问题,请参考以下文章