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

前言

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

1123581321345589...

用大白话概括,我们可以认为一开始有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

这种实现方式很直白,跟上面的数学归纳是完全且一一对应的。可是,这样就结束了吗?
上面例子传入的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

在循环法当中,我们在循环外层定义了三个变量: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)
}

我们定义了两个方法: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)
}
...

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

1、没有返回值

const func1 = (n)=>{
	return n*2
}
const func2 = (n)=>{
	func1(n)
}

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

const func2 = (n)=>{
	func1(n)
	return undefined
}

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

2、返回的方式做了加工

const func1 = (n) =>{
	return n * 2
}
const func2 = (n) =>{
	return func1(n) * func1(n+1)
}
或者
const func2 = (n) =>{
	return func1(n) * 3 + 2
}

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

const func1 = (n) = >{
	return n * 2
}
const func2 = (n) = >{
	let a = n + 1
	return func1(a)
}

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

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

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

咋一看是不是懵逼了。这是什么神仙写法。
蒙蔽
莫急莫急,且听我慢慢道来。
我们已经知道尾递归的原理就是当前函数要返回上一个函数。并且返回的函数不能引用当前函数的变量。那么操作的话就只能放在参数里面了。没看错,操作放在参数里面。这也是将普通递归改为尾递归的精髓之处。
我们定义的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。也就是斐波那契数列前两个数的定值。

结束语

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

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值