• 关于js实现斐波那契数列的一些思考(递归、循环、尾递归优化)


    前言

    最近在研究航线的生成算法,然后就需要使用到递归。想到递归,就会想到经典的斐波那契数列。这是计算机乃至数学领域一个老生常谈的话题。斐波那契数列是由中世纪意大利(约1200年)的一名叫做莱昂纳多斐波那契的数学家在他的数学著作《计算之书》中提到的兔子繁殖问题引申出来的。简单来说呢,就是要构造下面的一个数列:

    1123581321345589...
    
    • 1

    用大白话概括,我们可以认为一开始有1,1两个数,然后从第三个数开始,它的值是它前两个数的和。
    用数学归纳思想,则可概括为:F(0)=0,F(1)=1, F(n)=F(n - 1)+F(n - 2)(n ≥ 2,n ∈ N*
    知道了原理,怎么用javascript实现获取这个数列中第n个数是多少的方法呢?

    递归法实现

    const fab = (n) => {
    	if(n === 0){
    		return 0
    	}
    	if(n ===1 ){
    		return 1
    	}
    	return fab(n-1) + fab(n+1)
    }
    const result=fab(10)
    console.log(result) // 55
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    这种实现方式很直白,跟上面的数学归纳是完全且一一对应的。可是,这样就结束了吗?
    上面例子传入的n=10,那要是n=100呢?
    大家可以自己试一下,如果n=100,电脑就一直处于运算状态。为什么会这样?
    首先需要明白js里面一个叫做调用帧的概念。所谓的调用帧就是调用方法函数的一个过程。我们每次用js代码调用一个方法来获取所需结果的时候,都会在电脑内存里面保留这个过程。调用一个函数就是一次调用帧。等到函数执行完毕,调用帧才会释放,内存才会释放。以时间复杂度的观念来看待递归的话,n次递归的复杂度为O(n),然后上面例子的结果还是两个递归的和,那么复杂度可以简单看作O(n)*2。整个计算中途都不会释放内存,因为没有得到结果,只有递归完O(n)*2次后,才会释放,这个消耗是很大的。
    那该怎么解决呢?从问题产生的原因触发,既然,电脑无响应的原因是调用帧太多且没有释放,那我们只需要在中途释放一下或者减帧不就好了嘛。中途释放目前没想到怎么玩。减帧大法倒是有,而且可以直接减到只剩一帧。

    循环法实现

    const fab2 = (n)=>{
    	if(n === 0){
    		return 0
    	}
    	if(n === 1){
    		return 1
    	}
    	let total = 0
    	let f1 = 1
    	let f2 = 1
    	for(let i = 2;i < n;i++){
    		total=f1+f2
    		f1=f2
    		f2=total
    	}
    	return total
    }
    const result = fab2(10)
    console.log(result) // 55
    const result2 = fab2(100)
    console.log(result2) // 573147844013817200000
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    在循环法当中,我们在循环外层定义了三个变量:total、f1和f2。其中f1,f2和n的关系可以表示成下图所示:
    1
    total用于暂存当前循环的最终结果,也就是可以看作第n个斐波那契数是多少的一个缓存。因此,f1,f2就分别代表这个斐波那契数的前两个数。每次循环开始,先计算得到当前循环层的斐波那契数(把它前两个数相加)是多少,然后准备下一次循环的条件(重新设置f1和f2)。新的f1就应该是上一次的f2,新的f2就应该是上一次的斐波那契数,也就是f2=total。循环是从第二个数开始直至第n个数,则最终的total就是所求的斐波那契数。
    这个方法的思路其实很巧妙。将一个O(n)的问题变成了O(1)。以调用帧的思想来看,只有1帧。中途循环的结果都放在了total里面。自然在运算的时候不会消耗太多资源。

    尾递归实现

    es6中对某类递归进行了优化,例如:

    const func1 = (n)=>{
    	return n*2
    }
    const func2 = (n)=>{
    	return func1(n)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    我们定义了两个方法:func1和func2。其中func1是输入一个数得到这个数的2倍值。func2则是通过func1处理后再返回这个处理的值。这种方式其实就是一个简单的递归。如果我们此时调用func2(5),那么es6会将这种调用简化为调用func1(5)。由之前的2个调用帧转为1个调用帧。不单单只是对两次调用有优化效果,如果里面嵌套了多层,如:

    const func1 = (n)=>{
    	return n*2
    }
    const func2 = (n)=>{
    	return func1(n)
    }
    const func3 = (n)=>{
    	return func2(n)
    }
    ...
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    新增一个func3,返回的是func2的处理值。最终也会优化为1帧。
    但是,需要注意的是,尾调用优化的范围是,当前方法要直接返回上一个方法,中途不能有加工操作,且返回的上一个方法中的参数不能含有当前方法的变量。什么意思呢?

    1、没有返回值

    const func1 = (n)=>{
    	return n*2
    }
    const func2 = (n)=>{
    	func1(n)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    这种不行,因为func2没有返回值。func2其实可以看做:

    const func2 = (n)=>{
    	func1(n)
    	return undefined
    }
    
    • 1
    • 2
    • 3
    • 4

    这样的话就好理解了,返回的不是前一个函数。

    2、返回的方式做了加工

    const func1 = (n) =>{
    	return n * 2
    }
    const func2 = (n) =>{
    	return func1(n) * func1(n+1)
    }
    或者
    const func2 = (n) =>{
    	return func1(n) * 3 + 2
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    这种不行,没有直接返回,而是加工了结果

    const func1 = (n) = >{
    	return n * 2
    }
    const func2 = (n) = >{
    	let a = n + 1
    	return func1(a)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这种也不行,返回的函数里面的参数引用了当前函数的内部变量。

    知道这个原理后,怎么操作呢?有点绕,而且一般人还真不能够直接理解。下面先上代码:

    const fab3(n,f1 = 1,f2 = 1){
    	if(n === 0){
    		return 0
    	}
    	if(n === 1){
    		return 1
    	}
    	if(n === 2){
    		return f2
    	}
    	return fab3(n-1,f2,f1+f2)
    }
    const result=fab3(100)
    console.log(result) // 354224848179262000000
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    咋一看是不是懵逼了。这是什么神仙写法。
    蒙蔽
    莫急莫急,且听我慢慢道来。
    我们已经知道尾递归的原理就是当前函数要返回上一个函数。并且返回的函数不能引用当前函数的变量。那么操作的话就只能放在参数里面了。没看错,操作放在参数里面。这也是将普通递归改为尾递归的精髓之处。
    我们定义的fab3有三个参数:n,f1,f2。和在循环法中那张关系图类似,就是少了total这个变量。函数体中,n=1和n=0时不做解释了。n=2的时候就返回了f2。然后后面的操作就是进入下一次的递归。也就是说在当前函数体没有任何计算过程,都是直接返回一个死值。
    再看看下一次的递归操作,n进行了减1操作,f2赋值给了f1,f1+f2赋值给了f2。
    n-1是对整个递归操作进行收敛,总不能无限递归吧。然后这个过程就好比是上面循环法的逆向过程。循环法是从2n进行运算,尾递归法是从n2进行运算。每次的运算巧妙地放在了参数当中。只要n-1还没到2,就会不断地进行f1+f2,并且这个和会覆盖当前的f2,然后进入下一次递归。
    当然,这个方法还有一个巧妙的地方,就是使用了es6的默认参数设值,f1和f2默认都设置成了1。也就是斐波那契数列前两个数的定值。

    结束语

    尾递归这个东西,的确有点绕。可以我现在写这篇博客的时候还清楚,过段时间还要回来查资料。不过,研究这个东西还是挺有意思的。发明这种算法的人也雀食牛逼。

    在这里插入图片描述

  • 相关阅读:
    基于SpringBoot+Redis的前后端分离外卖项目-苍穹外卖(五)
    【Vue.js 3.0源码】Transition 组件:过渡动画的实现原理是怎样的?
    JavaEE-文件IO操作
    Shell入门2
    S/4 HANA 大白话 - 财务会计-1
    Tomcat 漏洞处理
    鸿鹄工程项目管理系统 Spring Cloud+Spring Boot+前后端分离构建工程项目管理系统
    安利几个小技巧教会你ppt如何转pdf
    0基础学习VR全景平台篇 第99篇:百度地图如何上传全景图
    kafka 三高架构设计剖析
  • 原文地址:https://blog.csdn.net/cjs1534717040/article/details/126384330