1.问题描述
一只青蛙一次可以跳上 1 级台阶,也可以跳上2 级。求该青蛙跳上一个n 级的台阶总共有多少种跳法。
2.问题分析
设f(n)表示青蛙跳上n级台阶的跳法数。当只有一个台阶时,
即n = 1时, 只有1中跳法;
当n = 2时,有两种跳法;
当n = 3 时,有3种跳法;
当n很大时,青蛙在最后一步跳到第n级台阶时,有两种情况:
一种是青蛙在第n-1个台阶跳一个台阶,那么青蛙完成前面n-1个台阶,就有f(n-1)种跳法,这是一个子问题。
另一种是青蛙在第n-2个台阶跳两个台阶到第n个台阶,那么青蛙完成前面n-2个台阶,就有f(n-2)种情况,这又是另外一个子问题。
两个子问题构成了最终问题的解,所以当n>=3时,青蛙就有f(n)=f(n-1)+f(n-2)种跳法。上面的分析过程,其实我们用到了动态规划的方法,找到了状态转移方程,用数学方程表达如下:
仔细一看,这不就是传说中的著名的斐波那契数列,但是与斐波那契数列的还是有一点区别,斐波那契数列从0开始,f(0)=0,f(1)=1,f(2)=1。斐波那契数列(Fibonacci Sequence),又称黄金分割数列,因为当n趋于无穷大时,前一个数与后一个数的比值无限接近于黄金比例(√5−12√5−12的无理数,0.618…)。
3.递归实现
有了初始状态和状态转移方程,那么编程实现求解就不难了,参考下面的递归实现。
int fib(int n){ if (n <= 0) return -1; if (1 == n) return 1; if (2 == n) return 2; return fib(n-1)+fib(n-2); }
3.1时间复杂度分析
以递归实现斐波那契数,效率是非常低下的,因为对子问题的求解fib(n-1)和fib(n-2)两者存在重叠的部分,对重叠的部分重复计算造成了浪费。但递归求解其优点也是显而易见的,代码简单,容易理解。
设f(n)为参数为n时的时间复杂度,很明显:f(n)=f(n-1)+f(n-2)变为f(n)-f(n-1)+f(n-2)=0,仔细一看,这就是数学上的二阶线性常系数齐次差分方程,求该差分方程的解,就是求得f(n)的非递归表达式,也就得到了上面递归算法的时间复杂度。关于齐次二阶常系数线性差分方程可能大家已经没有什么概念了,乍一听一脸懵逼,包括我自己,大学的高数基本已经还给老师了,但是涉及到算法,数学还是相当的重要并且扮演者不可替代的角色。这里简单解释一下我自己温习后对齐次二阶常系数线性差分方程的理解,不清楚的,大家还是要搜索相关资料,恶补一下吧!
差分概念:
“二阶线性常系数齐次”是对差分方程的修饰,“差分”也是对方程的修饰,先看一下差分的概念:
给定函数:ft=f(t),t=0,1,2...ft=f(t),t=0,1,2...,注意t的取值是离散的
一阶差分:Δyt=yt+1−yt=f(t+1)−f(t)Δyt=yt+1−yt=f(t+1)−f(t)
差分方程的定义:
含有自变量t和两个或两个以上的函数值yt,yt+1,...,yt+nyt,yt+1,...,yt+n的方程,称为差分方程。出现在差分方程中的未知函数下标的最大差称为差分方程的阶。差分方程中函数值ytyt的指数为1,称为线性查分方程,函数值ytyt的系数为常量,称为常系数查分方程。差分方程可以化简为形如:
如果f(t)=0f(t)=0,那么上面就是n阶线性齐次差分方程;
如果f(t)=0f(t)=0,那么上面就是n阶线性非齐次差分方程。
也就是说查分方程的常数项为0,就是齐次,非零就是非齐次。
如果查分方程中函数值ytyt前的系数是常量的话,那么就是常系数查分方程。
差分方程的表达式可以定义如下:
好了,了解了差分方程的阶,常系数,齐次,线性的概念,下面来辨识一下不同的差分方程吧。
有了关于差分方程的一些定义和概念,现在应该知道为什么f(n)-f(n-1)+f(n-2)=0叫作二阶线性常系数齐次差分方程了吧。因为n-(n-2)=2,所以是二阶,函数值f(n),f(n-1)和f(n-2)的指数是1,且系数均是常数,所以是线性常系数,又因为常数项为0,即等号右边为0,所以是齐次的。因为是根据函数值的表达式求函数的表达式,所以差分的,所以该方程就是恶心的二阶线性常系数齐次差分方程。
差分方程求解:
对于二阶线性常系数齐次差分方程的求解过程是,确定特征方程->求特征方程的根->由求特征方程的根确定通解的形式->再由特定值求得特解。
下面给出f(n)-f(n-1)+f(n-2)=0的解过程。
设f(n)=λnf(n)=λn,那么f(n)-f(n-1)+f(n-2)=0的特征方程就是:λ2−λ+1=0λ2−λ+1=0,求解得:λ=(1±√5)/2λ=(1±√5)/2。所以,f(n)的通解为:
由f(1)=1,f(2)=2可解得c1=(5+√5)/10, c2 ==(5-√5)/10,最终可得时间复杂度为:
我知道时间度的复杂常见的有且依序复杂度递增:
O(1), O(lgn),O(n‾√)O(n),O(n),O(nlgn),O(n2)O(n2),O(n3)O(n3),O(2n)O(2n),O(n!)。
那么上面求得的算法时间复杂度是归于哪个级别。很明显是O(2n)O(2n)。也就是说斐波那契数列递归求解的算法时间复杂度是O(2n)O(2n)。
关于斐波那契数列递归求解的期间复杂度我们简化其求解过程,按照如下方式求解。
递归的时间复杂度是: 递归次数*每次递归中执行基本操作的次数。所以时间复杂度是: O(2^n)。
3.2空间复杂度
每一次递归都需要开辟函数的栈空间,递归算法的空间复杂度是:
递归深度N∗每次递归所要的辅助空间
递归深度N∗每次递归所要的辅助空间
如果每次递归所需的辅助空间是常数,则递归的空间复杂度是 O(N)。因为上面的递归实现,虽然每次递归都会有开辟两个分支,按理说递归调用了 多少次,就开辟了多大的栈空间,按照这个逻辑,那么空间复杂度与时间复杂应该是一样的, 都是O(2^n)。那么这个逻辑错在了哪里呢?首先我们要知道函数的调用过程大概是什么样的,调用者(caller)将被调用者(callee)的实参入栈,call被调用者,被调用者中保留caller的栈底指针EBP,将ESP赋给EBP开始一个新的栈帧,函数结束后清理栈帧,pop原函数栈底指针EBP到ESP,这一步也就是恢复函数调用的现场。现在再来看看上面斐波那契数列的递归实现,因为是单线程执行,以Fib(5)为例,函数执行的过程应该是如下图所示:
可见递归的深度越深,开辟的形参栈空间就会越大。图中最深处的开辟了最大的辅助空间,当函数执行的流程向上回溯时,你就会发现,后面开辟的辅助栈空间都是在前面开辟的栈空间上开辟的,也就是空间的重复利用,所以说递归算法的空间复杂度是递归最大的深度*每次递归开辟的辅助空间,所以斐波那契数列的递归实现的空间复杂度是O(n)。
图中示例的是单线程情况下递归时的函数执行流程,但是在多线程的情况下,就不是这个样子,因为每个线程函数并发执行,拥有自己的函数栈,所以空间复杂度要另当计算,这里就不做深究,有兴趣的读者可自行研究。
4.迭代实现
递归实现虽然简单易于理解,但是O(2^n)的时间复杂度和O(n)的空间却让人无法接受,下面迭代法的具体实现,比较简单,就不再赘述实现步骤。时间复杂度为O(n),空间复杂度为O(1)。
int fibIteration(int n){ if (n <= 0) return -1; if (1 == n) return 1; if (2 == n) return 2; int res=0,a=1,b=2; for(int i=3;i<=n;++i){ res=a+b; a=b; b=res; } return res; }
这个方法是求斐波那契数列的最快方法吗?当然不是,最快的应该是下面的矩阵法。
5.矩阵法(java还没实现这个方法,等待补充)
根据上面的递归公式,我们可以得到。
因而计算f(n)就简化为计算矩阵的(n-2)次方,而计算矩阵的(n-2)次方,我们又可以进行分解,即计算矩阵(n-2)/2次方的平方,逐步分解下去,由于折半计算矩阵次方,因而时间复杂度为O(logn)。
下面给出网友beautyofmath在文章关于斐波那契数列三种解法及时间复杂度分析中的实现。
#include <iostream> using namespace std; class Matrix { public: int n; int **m; Matrix(int num) { m=new int*[num]; for (int i=0; i<num; i++) { m[i]=new int[num]; } n=num; clear(); } void clear() { for (int i=0; i<n; ++i) { for (int j=0; j<n; ++j) { m[i][j]=0; } } } void unit() { clear(); for (int i=0; i<n; ++i) { m[i][i]=1; } } Matrix operator=(const Matrix mtx) { Matrix(mtx.n); for (int i=0; i<mtx.n; ++i) { for (int j=0; j<mtx.n; ++j) { m[i][j]=mtx.m[i][j]; } } return *this; } Matrix operator*(const Matrix &mtx) { Matrix result(mtx.n); result.clear(); for (int i=0; i<mtx.n; ++i) { for (int j=0; j<mtx.n; ++j) { for (int k=0; k<mtx.n; ++k) { result.m[i][j]+=m[i][k]*mtx.m[k][j]; } } } return result; } }; int main(int argc, const char * argv[]) { unsigned int num=2; Matrix first(num); first.m[0][0]=1; first.m[0][1]=1; first.m[1][0]=1; first.m[1][1]=0; int t; cin>>t; Matrix result(num); result.unit(); int n=t-2; while (n) { if (n%2) { result=result*first; } first=first*first; n=n/2; } cout<<(result.m[0][0]+result.m[0][1])<<endl; return 0; }
有兴趣的读者可自行给出实现,本人后续再补充代码。
6.问题拓展
青蛙跳台阶问题可以引申为如下问题:
一只青蛙一次可以跳上1级台阶,也可以跳上2 级,……,也可以跳上n 级,此时该青蛙跳上一个n级的台阶总共有多少种跳法?
6.1问题分析
当n = 1 时, 只有一种跳法,即1阶跳:Fib(1) = 1;
当n = 2 时, 有两种跳的方式,一阶跳和二阶跳:Fib(2) = Fib(1) + Fib(0) = 2;
当n = 3 时,有三种跳的方式,第一次跳出一阶后,后面还有Fib(3-1)中跳法; 第一次跳出二阶后,后面还有Fib(3-2)中跳法,一次跳到第三台阶,Fib(3) = Fib(2) + Fib(1)+Fib(0)=4;
当n = n 时,共有n种跳的方式,第一次跳出一阶后,后面还有Fib(n-1)中跳法; 第一次跳出二阶后,后面还有Fib(n-2)中跳法….第一次跳出n阶后, 后面还有Fib(n-n)中跳法。所以Fib(n) = Fib(n-1)+Fib(n-2)+Fib(n-3)+……….+Fib(0),又因为Fib(n-1)=Fib(n-2)+Fib(n-3)+…+Fib(0),两式相减得:Fib(n)-Fib(n-1)=Fib(n-1),所以Fib(n) = 2*Fib(n-1),n >= 2。递归等式如下:
6.2具体实现
递归等式是一个以2为公比的等比数列,所以递归和迭代实现起来都比较简单,参考如下:
//递归法 //时间复杂度O(n),空间复杂度O(n) int fib(int n){ if (1 == n) return 1; return 2*fib(n-1); } //迭代法 //时间复杂度O(n),空间复杂度O(1) int fib(int n){ int res=1; if (1 == n) return res; for(int i=2;i<=n;++i) res=2*res; return res; }
7.小结
历时两天,参考了很多博文资料,即当中也遇到了很多不解的问题,很痛苦,尤其是研究已经忘记了的差分方程,不过还是坚持了下来。本篇力求较全面的给出青蛙跳台阶问题分析,各种解法以及时间复杂度和空间复杂度的分析,让大家能够不留疑惑的了解斐波那契数列的求解。
转载于 https://blog.csdn.net/K346K346/article/details/52576680
参考文献
[1]斐波那契数列.百度百科
[2]青蛙跳台阶问题
[3]关于斐波那契数列三种解法及时间复杂度分析
[4]差分方程的基本概念
[5]二阶线性常系数齐次差分方程的求解
[6]时间复杂度&空间复杂度分析
补充
java 代码实现
一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法(先后次序不同算不同的结果)。
1 非递归
//利用斐波那契数列从下往上算,避免重复计算,提高效率 //这个问题用递归确实开销会很大,因为递归里面有很多重复计算,最好用迭代。 public class Solution1 { public int JumpFloor(int target) { if (target <= 0) { return 0; } if (target == 1) { return 1; } if (target == 2) { return 2; } int one = 1; int two = 2; int result = 0; for (int i = 2; i < target; i++) { result = one + two; one = two; two = result; } return result; } }
2 递归
public class Solution { public int JumpFloor(int target) { if(target<=0){ return 0; } if(target==1){ return 1; } if(target==2){ return 2; } return JumpFloor(target-1)+JumpFloor(target-2); } }
题目升级:
一只青蛙一次可以跳上1级台阶,也可以跳上2级……它也可以跳上n级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
方法1:
public class Solution { public int JumpFloorII(int target) { if (target == 0 || target == 1) { return 1; } int sum = 1; for (int i = 2; i <= target; i++) { sum = 2 * sum; } return sum; } }
方法2:
public class Solution1 { public int JumpFloorII(int target) { if(target == 0) { return 0; } int[] dp = new int[target + 1]; dp[0] = 1; dp[1] = 1; for(int i = 2;i <= target;i++) { dp[i] = 0; for(int j = 0;j < i;j++) { dp[i] += dp[j]; } } return dp[target]; } }
方法3 递归:
//递归方法 /* 假设一共有n阶,同样共有f(n)种跳法,那么这种情况就比较多, 最后一步超级蛙可以从n-1阶往上跳,也可以n-2阶,也可以n-3…等等等,一次类推。 所以,可知: 式1: f(n) = f(n-1) + f(n-2) + ... + f(2) + f(1) 而且,容易得出: 式2: f(n-1) = f(n-2) + f(n-3) + ... + f(2) + f(1) 将式1中的f(n-2) + f(n-3) + … + f(2) + f(1) 替换成式2,可知: */ public class Solution2 { public int JumpFloorII(int target) { if (target == 1) { return 1; } else { return 2 * JumpFloorII(target - 1); } } }
方法4 :
左移
public class Solution { /* 其实是隔板问题,假设n个台阶,有n-1个空隙,可以用0~n-1个隔板分割,c(n-1,0)+c(n-1,1)+...+c(n-1,n-1)=2^(n-1),其中c表示组合。 有人用移位1<<--number,这是最快的。直接连续乘以2不会慢多少,编译器会自动优化。不过移位还是最有启发的 */ public int JumpFloorII(int target) { if(target<=0) return 0; return 1<<(target-1); } }