斐波那契数列的算法分析-程序员宅基地

  版权申明:本文为博主窗户(Colin Cai)原创,欢迎转帖。如要转贴,必须注明原文网址

  http://www.cnblogs.com/Colin-Cai/p/9717119.html 

  作者:窗户

  QQ/微信:6679072

  E-mail:[email protected] 

         看过我其他一些文章的人,可能想象不出我会写一篇关于斐波那契数列的文章。因为可能会感觉1,1,2,3…这样一个数列能讲出什么高深的名堂?嗯,本篇文章的确是关于斐氏数列,但我的目的还是为了说一些应该有95%以上程序员不明白的东西。如果能够跟着我弄明白文中分析的手法,其好处是不言而喻的。请听我细细道来。

 

 斐波那契数列

 

  什么叫斐波那契数列(Fibonacci Sequence)呢?

  数学家斐波那契在自己的著作中用兔子繁殖模型引入了这样一个数列:1,1,2,3,5,8,13…

  这个数列的第1项和第2项都为1,以后的项都是前面两项之和。

  写成递推公式如下:

 

  f(n) = 1                                  n=1,2

  f(n) = f(n-1)+f(n-2)                 n>2

 

 

  通项公式

 

  我们都知道对于斐波那契数列,随着n趋向于无穷大,f(n+1)/f(n)是存在极限的,我们在此略去在不知道通项公式的情况下对上述比值存在极限的数学证明(数学里的一切并不是想当然)。

  假设有极限,我们知道

     

  而f(n+2)=f(n+1)+f(n),所以

     

  从而

     

  又因为这个极限大于0,解方程得

     

 

 

  斐波那契数列不仅有递推公式,也有通项公式。

  既然上述极限存在,我们完全可以猜测它是多个等差数列的和,然后用待定系数法算出来。

  通项公式如下:

    

 

 

         树递归

        

         现在我们就开始本节的重点,如何计算斐波那契数列的第n项

         既然已经知道斐波那契数列的递推公式,那么很容易就给出一个递归函数的版本,因为涉及到大数,我们可以采用Python来描述,本文后续主要采用Python:        

def f(n):
    if n<3:
        return 1
    return f(n-1)+f(n-2)

 

  JavaScript版本当然如下(只可惜不是自身支持大数,需要实现,否则我更愿意用js描述):

function f(n) {
    if(n<3)
        return 1;
    return f(n-1)+f(n-2);
}

 

  如果用Scheme或Common Lisp来表示除了前缀表达式古怪一点,看起来也差别不大:

(define (f n)
 (if (< n 2) 1
  (+ (f (- n 1)) (f (- n 2)))
 )
)

  上面是Scheme的描述;Common Lisp只要把开头的define改成defun。

 

  我们用Python来计算一下数列的前40项:

  print(map(f, range(1,41)))

  运行好慢啊,我的机器上运行了1分多钟才出来了结果

[1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229, 832040, 1346269, 2178309, 3524578, 5702887, 9227465, 14930352, 24157817, 39088169, 63245986, 102334155]

 

  运算量如此之大,这是怎么回事呢?

 

 

  我们来看看机器是怎么计算这段递归的,我们就以f(5)的计算为例子:

     

 

  展开就是这么一个样子,树中的每个节点都在计算过程中出现,树的规模是指数级(f(6)比f(5)多了6个节点),也就是运算时间是指数级

  这个太夸张了,难怪这么慢!

        

 

  带缓存的递归

        

  我们发现上面计算f(5)的递归计算的树里,f(3)是重复展开计算了。从而推断,之所以树递归计算规模这么大,原因就在于出现了大量的重复计算。

  于是我们自然就想到了,如何避免计算过的值再次递归展开重复计算呢?换句话说,怎么让计算机知道谁计算过了谁没有计算过?

  其实我们只需要引入一段缓存,就可以解决这个问题,程序每当要计算一个值时,先到缓存里查一下就可以避免重复展开了。

  我们用Python生成一个数组来做这段cache,没有计算过的是None。代码如下:

#http://cnblogs.com/colin-cai
def
f2(n,cache): if cache[n]!=None: return cache[n] ret = f2(n-1, cache) + f2(n-2, cache) cache[n] = ret return ret def f(n): if n<3: return 1 cache = [0,1,1]+[None]*(n-1) return f2(n, cache)

 

  当然,上面缓存刚产生的时候,第0个元为0其实没有用到,算是浪费了,其实去掉这个0再做一点点简单的修改就可以了,这个留给读者吧。

 

  我们还是运算一下数列的前40个元素:

  print(map(f, range(1,41)))

  结果出的非常之快。

 

 

  迭代

 

  试想一下,如果让我们在黑板上写出斐波那契数列的前40项,我们会怎么做?

 

  我们会先把数列的第1项和第2项都写成1;

  然后从第3项开始,每一项都用前两项相加,算出一项往黑板上写一项,一项一项往前推进,直到写完。

 

  每一项的产生在是相互关联的,而我们之前用Python里的map函数生成数列的前40项,过程中每次调用f都是孤立的。

 

  原来,如果我们的目的是生成斐波那契数列的前n项,刚才写黑板的算法就已经非常棒。用Python描述一下如下:

#http://cnblogs.com/colin-cai
def
list_f(n): if n<3: return [1]*n a = [1,1]+[None]*(n-2) for i in range(2,n): a[i]=a[i-1]+a[i-2] return a

 

  这个for循环就是从第3项起不断的算一项写一项的过程。

 

  我们在黑板上写各项的过程中,每次写新项都只关心它前面两项,而在此之前的项根本不关心。我们要生成新的一项,只要每次保持住前面的两项即可。

  我们每次都只需要记得当前是第几项,已写数列的最后两项是什么,然后算出新项,然后再只需要记得当前的项数以及最后两项……如此不断反复,直到写完。

 

  这给了我们一个启示,我们其实是在不断的推进状态:

当前项数n

第n-1项

第n项

2

1

1

3

1

2

4

2

3

5

3

5

 

  我们要计算斐波那契数列的第n项,就不断的如此状态转换,直到当前项数达到我们的要求则停止计算。

  于是就有了以下的Python迭代版本:

#http://cnblogs.com/colin-cai
def
f(n): if n<3: return 1 stat = { "n":2, "f(n-1)":1, "f(n)":1}; while stat["n"]<n: stat["f(n-1)"],stat["f(n)"] = stat["f(n)"],stat["f(n-1)"]+stat["f(n)"] stat["n"] += 1 return stat["f(n)"

 

  这个看似有着不错的效率了。

 

 

  上述迭代的效率

 

  我们试图用上述迭代版本的f计算斐波那契的第1000000项,结果我的计算机花了半分钟以上。

  版本中使用了字典,可能效率低一些。于是改用数组来代替字典如下:

#http://cnblogs.com/colin-cai
def
f(n): if n<3: return 1 stat = [2,1,1] while stat[0]<n: stat[1],stat[2] = stat[2],stat[1]+stat[2] stat[0] += 1 return stat[2]

 

  甚至于,有人可能觉得完全基于状态推演的迭代看起来不那么亲切,那么咱们换个写法:

#http://cnblogs.com/colin-cai
def
f(n): if n<3: return 1 x,y = 1,1 for m in range(2,n): x,y = y,x+y return y

 

  结果发现折腾来折腾去并没有明显的好转。

 

  这个迭代的算法稍有算法功底的人就可以看的出来这是一个线性时间复杂度的算法。但是,我们之所以说是线性时间复杂度是建立在里面的所有加法、比较、赋值操作都是常数级时间。

  然而,此处认为加法是常数级时间未必合适。在迭代的过程中,我们的stat[1]、stat[2]的长度应该是在上升的,准确的说,长度应该是线性的,也就是O(n)。从而,加法也是O(n)级的。我们基于此,可以认为整个迭代是O(n2)。于是,我们也就可以理解虽然1000000并不是大到离谱,但迭代计算时间却如此之慢了。

 

 

  最终算法

 

  我们回头去看看斐波那契数列的通项公式,是可以由两个等比数列合成。

    

 

  关于求整数次幂显然有快速的算法,乘法的次数为对数级,这个我在之前好几篇博文里都有说到过,可以认为这个是基本算法。

  an是n个a相乘,平凡的算法下我们要计算n-1个乘法。

  但我们注意到如下的计算:

  a×a=a2

  a2×a2=a4

  a4×a4=a8

  a8×a8=a16

  a16×a16=a32

  a32×a32=a64

  …

  以上短短的几个乘法就得到了a2a4a8a16a32a64 …这些指数都是2的整数次幂

  而我们所要算的幂的指数显然可以表示为二进制,从而表示为1248163264…这些2的整数次幂的一部分之和。

  例如我们要算a57

  57用二进制表示为111001,也就是

  57=1+8+16+32

  所以a57=a×a8×a16×a32

 

  以上的分析表明,快速的求幂算法是存在的。当然,我们甚至没有必要把指数先展开成二进制方式,而是选择直接迭代就可以做出来。

  其实,对于

  b×an      当然,这里b不为0,n也为非负整数。

  如果n=0,那么

  b×an = b

  此外如果n是偶数,那么

  b×an = b×(a×a)n/2

  剩下的情况,n是奇数,那么

  b×an = (a×b)×(a×a)(n-1)/2

  以上三条规则不断迭代1×an自然可以得到最后的解。

 

  以上的分析写成Python如下:

#http://cnblogs.com/colin-cai
def
exp(a,n): def exp2(b,a,n): if n==0: return b; elif n%2==0: return exp2(b,a*a,n/2) return exp2(a*b,a*a,(n-1)/2) return exp2(1,a,n)

 

  既然乘方存在快速算法,那么我们就有理由怀疑斐波那契数列求项也存在快速算法。可惜,通项公式里那两个底数都是无理数,近似的算算没大问题,但是不能称之为真正解决的算法。而把幂展开来来推演算法实在太麻烦(悄悄地说,这个真的可行),在这里不作讲解。

 

  我们把之前迭代中每一次向后推一项,状态的转换称之为T变换,也就是

  T:      (a,b)->(b,a+b)

  这是一个函数

  状态我们就用一个元组(tuple)来表示,现在我们用Python改写一下最后出现的那个迭代版本,结果如下:

#http://cnblogs.com/colin-cai
def
f(n): if n<3: return 1 T = lambda (a,b):(b,a+b) r = (1,1) for m in range(2,n): r = T(r) return r[1]

 

  顺带说一句,Python就不能学学js那样,用箭头来表示lambda?多简洁啊。

        

  我们可以构造一个算子mul_f来计算两个函数的积,然后通过mul_f算子来构造exp_f算子来计算函数的幂

  那么我们求数列第2项之后的第n项就相当于T变换的n-2次幂作用于(1,1)。

  于是我们可以根据这个改写一下代码:

#http://cnblogs.com/colin-cai
def
f(n): def mul_f(f1,f2): return lambda x:f1(f2(x)) def exp_f(func, n): if n==0 : return lambda x:x; return mul_f(func, exp_f(func, n-1)) if n<3: return 1 return exp_f(lambda (a,b):(b,a+b),n-2)((1,1))[1]

 

  带有算子、lambda的代码对于不是很习惯于函数式编程的可能看起来有一点难懂,不过这里只要理解了函数的幂即可。

 

  我们利用mul_f函数乘积算子还可以模仿之前快速求幂算法,代码如下:

#http://cnblogs.com/colin-cai
def
f(n): def mul_f(f1,f2): return lambda x:f1(f2(x)) def f2(T1,T2,n): if n==0: return T1 elif n%2==0: return f2(T1,mul_f(T2,T2),n/2) return f2(mul_f(T1,T2),mul_f(T2,T2),(n-1)/2) if n<3: return 1 return f2(lambda (a,b):(a,b), lambda (a,b):(b,a+b), n-2)((1,1))[1]

 

  函数在这里运算,代码有点难懂,再继续忍耐忍耐吧,马上就要OK了。

  上面的代码里面有一堆函数的运算,但是最终运算还是要落实到实际机器上,从而不仅不减少运算量,甚至还多了一点函数的运算。

 

  说白了,那个mul_f实在太“虚”了。如果我们可以让这里的函数乘积变成真正的数字计算,那么我们上述的思路就成立了。

  我们尝试着去手动计算T变换的幂:

  T:      (a,b)->(b,a+b)

  T2:    (a,b)->(a+b,a+2b)

  T3:    (a,b)->(a+2b,2a+3b)

  …

  T的任何次幂,都是把(a,b)转换为a、b的两个线性组合组成的元组。

  于是我们受到了启发:从现在开始,我们完全可以用一个四元组(m,n,p,q)来表示T的任意次幂,也就是上述任何的mul_f的结果。(m,n,p,q)代表

    (a,b)->(m*a+n*b,p*a+q*b)

 

  函数的乘法mul_f我们需要仔细想想,写出来似乎长了一点。其他地方简单的改写一下,最后函数求值函数采用lambda的编写就很简单了。最终代码如下:

#http://cnblogs.com/colin-cai
def
f(n): mul_f = lambda (m1,n1,p1,q1),(m2,n2,p2,q2) \ : (m2*m1+n2*p1, m2*n1+n2*q1, p2*m1+q2*p1, p2*n1+q2*q1) def f2(T1,T2,n): if n==0: return T1 elif n%2==0: return f2(T1,mul_f(T2,T2),n/2) return f2(mul_f(T1,T2),mul_f(T2,T2),(n-1)/2) if n<3: return 1 return (lambda (m,n,p,q),(a,b) : p*a+q*b)\ (f2((1,0,0,1), (0,1,1,1), n-2),(1,1))

 

  运算一下f(1000000),在我的电脑不到5秒就算了出来。不过看来,计算真未必是Python的长项,它还是做粘合剂比较合适。

  当然,最后还是再配上我喜欢的Scheme的实现版本:

(define (f n)
 (define mul
  (lambda (a b)
   (let ((s1 (list (car a) (caddr a)))
     (s2 (list (cadr a) (cadddr a)))
     (s3 (list (car b) (cadr b)))
     (s4 (cddr b))
     (c (lambda (x y) (apply + (map * x y)))))
    (list
     (c s1 s3)
     (c s2 s3)
     (c s1 s4)
     (c s2 s4)))))
 (define (f2 T1 T2 n)
  (cond
   ((zero? n) T1)
   ((even? n) (f2 T1 (mul T2 T2) (/ n 2)))
   (else (f2 (mul T1 T2) (mul T2 T2) (/ (- n 1) 2)))))
(if (< n 3) 1
   ((lambda (T init) (apply + (map * (cddr T) init)))
   (f2 '(1 0 0 1) '(0 1 1 1) (- n 2))
   '(1 1))))

 

 

  后记

 

  本文我决定使用Python作为问题描述的主要语言,而不以我以往描述问题习惯使用的C、Scheme、bc,谁叫Python流行呢。

  C语言作为“高级汇编”,描述算法确实很吃力,本文中涉及到大数运算,C语言描述并不那么直接;当然,C++也一样不能直接描述大数运算,虽然C++有着很多的抽象能力,比如模板,但它身上有“高级汇编”的根,这点无论怎么包装都是瞒不住的。我是越来越不倾向于用这样底层的语言来描述算法。不过,从底层更加容易理解我这里提到的大数加法的O(n)时间复杂度问题倒是真的。

  最后那个对数级的快速算法,我当初是先知道它的存在,然后硬是不看答案甚至不看提示自己推出来,推的过程乃至代码描述和本文有很大的不同。但在写这篇文章的时候,推着推着,觉得文中这样的推理过程更加合理,从而更能让人接受,于是就按文所写了。

转载于:https://www.cnblogs.com/Colin-Cai/p/9717119.html

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/weixin_34066347/article/details/94072349

智能推荐

oracle 12c 集群安装后的检查_12c查看crs状态-程序员宅基地

文章浏览阅读1.6k次。安装配置gi、安装数据库软件、dbca建库见下:http://blog.csdn.net/kadwf123/article/details/784299611、检查集群节点及状态:[root@rac2 ~]# olsnodes -srac1 Activerac2 Activerac3 Activerac4 Active[root@rac2 ~]_12c查看crs状态

解决jupyter notebook无法找到虚拟环境的问题_jupyter没有pytorch环境-程序员宅基地

文章浏览阅读1.3w次,点赞45次,收藏99次。我个人用的是anaconda3的一个python集成环境,自带jupyter notebook,但在我打开jupyter notebook界面后,却找不到对应的虚拟环境,原来是jupyter notebook只是通用于下载anaconda时自带的环境,其他环境要想使用必须手动下载一些库:1.首先进入到自己创建的虚拟环境(pytorch是虚拟环境的名字)activate pytorch2.在该环境下下载这个库conda install ipykernelconda install nb__jupyter没有pytorch环境

国内安装scoop的保姆教程_scoop-cn-程序员宅基地

文章浏览阅读5.2k次,点赞19次,收藏28次。选择scoop纯属意外,也是无奈,因为电脑用户被锁了管理员权限,所有exe安装程序都无法安装,只可以用绿色软件,最后被我发现scoop,省去了到处下载XXX绿色版的烦恼,当然scoop里需要管理员权限的软件也跟我无缘了(譬如everything)。推荐添加dorado这个bucket镜像,里面很多中文软件,但是部分国外的软件下载地址在github,可能无法下载。以上两个是官方bucket的国内镜像,所有软件建议优先从这里下载。上面可以看到很多bucket以及软件数。如果官网登陆不了可以试一下以下方式。_scoop-cn

Element ui colorpicker在Vue中的使用_vue el-color-picker-程序员宅基地

文章浏览阅读4.5k次,点赞2次,收藏3次。首先要有一个color-picker组件 <el-color-picker v-model="headcolor"></el-color-picker>在data里面data() { return {headcolor: ’ #278add ’ //这里可以选择一个默认的颜色} }然后在你想要改变颜色的地方用v-bind绑定就好了,例如:这里的:sty..._vue el-color-picker

迅为iTOP-4412精英版之烧写内核移植后的镜像_exynos 4412 刷机-程序员宅基地

文章浏览阅读640次。基于芯片日益增长的问题,所以内核开发者们引入了新的方法,就是在内核中只保留函数,而数据则不包含,由用户(应用程序员)自己把数据按照规定的格式编写,并放在约定的地方,为了不占用过多的内存,还要求数据以根精简的方式编写。boot启动时,传参给内核,告诉内核设备树文件和kernel的位置,内核启动时根据地址去找到设备树文件,再利用专用的编译器去反编译dtb文件,将dtb还原成数据结构,以供驱动的函数去调用。firmware是三星的一个固件的设备信息,因为找不到固件,所以内核启动不成功。_exynos 4412 刷机

Linux系统配置jdk_linux配置jdk-程序员宅基地

文章浏览阅读2w次,点赞24次,收藏42次。Linux系统配置jdkLinux学习教程,Linux入门教程(超详细)_linux配置jdk

随便推点

matlab(4):特殊符号的输入_matlab微米怎么输入-程序员宅基地

文章浏览阅读3.3k次,点赞5次,收藏19次。xlabel('\delta');ylabel('AUC');具体符号的对照表参照下图:_matlab微米怎么输入

C语言程序设计-文件(打开与关闭、顺序、二进制读写)-程序员宅基地

文章浏览阅读119次。顺序读写指的是按照文件中数据的顺序进行读取或写入。对于文本文件,可以使用fgets、fputs、fscanf、fprintf等函数进行顺序读写。在C语言中,对文件的操作通常涉及文件的打开、读写以及关闭。文件的打开使用fopen函数,而关闭则使用fclose函数。在C语言中,可以使用fread和fwrite函数进行二进制读写。‍ Biaoge 于2024-03-09 23:51发布 阅读量:7 ️文章类型:【 C语言程序设计 】在C语言中,用于打开文件的函数是____,用于关闭文件的函数是____。

Touchdesigner自学笔记之三_touchdesigner怎么让一个模型跟着鼠标移动-程序员宅基地

文章浏览阅读3.4k次,点赞2次,收藏13次。跟随鼠标移动的粒子以grid(SOP)为partical(SOP)的资源模板,调整后连接【Geo组合+point spirit(MAT)】,在连接【feedback组合】适当调整。影响粒子动态的节点【metaball(SOP)+force(SOP)】添加mouse in(CHOP)鼠标位置到metaball的坐标,实现鼠标影响。..._touchdesigner怎么让一个模型跟着鼠标移动

【附源码】基于java的校园停车场管理系统的设计与实现61m0e9计算机毕设SSM_基于java技术的停车场管理系统实现与设计-程序员宅基地

文章浏览阅读178次。项目运行环境配置:Jdk1.8 + Tomcat7.0 + Mysql + HBuilderX(Webstorm也行)+ Eclispe(IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持)。项目技术:Springboot + mybatis + Maven +mysql5.7或8.0+html+css+js等等组成,B/S模式 + Maven管理等等。环境需要1.运行环境:最好是java jdk 1.8,我们在这个平台上运行的。其他版本理论上也可以。_基于java技术的停车场管理系统实现与设计

Android系统播放器MediaPlayer源码分析_android多媒体播放源码分析 时序图-程序员宅基地

文章浏览阅读3.5k次。前言对于MediaPlayer播放器的源码分析内容相对来说比较多,会从Java-&amp;amp;gt;Jni-&amp;amp;gt;C/C++慢慢分析,后面会慢慢更新。另外,博客只作为自己学习记录的一种方式,对于其他的不过多的评论。MediaPlayerDemopublic class MainActivity extends AppCompatActivity implements SurfaceHolder.Cal..._android多媒体播放源码分析 时序图

java 数据结构与算法 ——快速排序法-程序员宅基地

文章浏览阅读2.4k次,点赞41次,收藏13次。java 数据结构与算法 ——快速排序法_快速排序法