专栏首页C/C++基础青蛙跳台阶问题暨斐波那契数列

青蛙跳台阶问题暨斐波那契数列

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\frac{√5-1}{2}的无理数,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...f_t=f(t),t=0,1,2...,注意t的取值是离散的 一阶差分:Δyt=yt+1−yt=f(t+1)−f(t)\Delta y_t=y_{t+1}-y_t=f(t+1)-f(t) 二阶差分:

差分方程的定义: 含有自变量t和两个或两个以上的函数值yt,yt+1,...,yt+ny_t,y_{t+1},...,y_{t+n}的方程,称为差分方程。出现在差分方程中的未知函数下标的最大差称为差分方程的阶。差分方程中函数值yty_t的指数为1,称为线性查分方程,函数值yty_t的系数为常量,称为常系数查分方程。差分方程可以化简为形如:

如果f(t)=0f(t)=0,那么上面就是n阶线性齐次差分方程; 如果f(t)=0f(t)=0,那么上面就是n阶线性非齐次差分方程。 也就是说查分方程的常数项为0,就是齐次,非零就是非齐次。 如果查分方程中函数值yty_t前的系数是常量的话,那么就是常系数查分方程。

差分方程的表达式可以定义如下:

好了,了解了差分方程的阶,常系数,齐次,线性的概念,下面来辨识一下不同的差分方程吧。

有了关于差分方程的一些定义和概念,现在应该知道为什么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)=\lambda^n,那么f(n)-f(n-1)+f(n-2)=0的特征方程就是:λ2−λ+1=0\lambda^2-\lambda+1=0,求解得:λ=(1±√5)/2\lambda=(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(\sqrt n),O(n),O(nlgn),O(n2)O(n^2 ),O(n3)O(n^3 ),O(2n)O(2^n ),O(n!)。 那么上面求得的算法时间复杂度是归于哪个级别。很明显是O(2n)O(2^n)。也就是说斐波那契数列递归求解的算法时间复杂度是O(2n)O(2^n )。

关于斐波那契数列递归

求解的期间复杂度我们简化其求解过程,按照如下方式求解。

递归的时间复杂度是: 递归次数*每次递归中执行基本操作的次数。所以时间复杂度是: O(2n)O(2^n)。

3.2空间复杂度

每一次递归都需要开辟函数的栈空间,递归算法的空间复杂度是:

递归深度N∗每次递归所要的辅助空间

递归深度N*每次递归所要的辅助空间

如果每次递归所需的辅助空间是常数,则递归的空间复杂度是 O(N)。因为上面的递归实现,虽然每次递归都会有开辟两个分支,按理说递归调用了 多少次,就开辟了多大的栈空间,按照这个逻辑,那么空间复杂度与时间复杂应该是一样的, 都是O(2n)O(2^n)。那么这个逻辑错在了哪里呢?首先我们要知道函数的调用过程大概是什么样的,调用者(caller)将被调用者(callee)的实参入栈,call被调用者,被调用者中保留caller的栈底指针EBP,将ESP赋给EBP开始一个新的栈帧,函数结束后清理栈帧,pop原函数栈底指针EBP到ESP,这一步也就是恢复函数调用的现场。现在再来看看上面斐波那契数列的递归实现,因为是单线程执行,以Fib(5)为例,函数执行的过程应该是如下图所示:

可见递归的深度越深,开辟的形参栈空间就会越大。图中最深处的开辟了最大的辅助空间,当函数执行的流程向上回溯时,你就会发现,后面开辟的辅助栈空间都是在前面开辟的栈空间上开辟的,也就是空间的重复利用,所以说递归算法的空间复杂度是递归最大的深度*每次递归开辟的辅助空间,所以斐波那契数列的递归实现的空间复杂度是O(n)。

图中示例的是单线程情况下递归时的函数执行流程,但是在多线程的情况下,就不是这个样子,因为每个线程函数并发执行,拥有自己的函数栈,所以空间复杂度要另当计算,这里就不做深究,有兴趣的读者可自行研究。

4.迭代实现

递归实现虽然简单易于理解,但是O(2n)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.矩阵法

根据上面的递归公式,我们可以得到。

因而计算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.小结

历时两天,参考了很多博文资料,即当中也遇到了很多不解的问题,很痛苦,尤其是研究已经忘记了的差分方程,不过还是坚持了下来。本篇力求较全面的给出青蛙跳台阶问题分析,各种解法以及时间复杂度和空间复杂度的分析,让大家能够不留疑惑的了解斐波那契数列的求解。


参考文献

[1]斐波那契数列.百度百科 [2]青蛙跳台阶问题 [3]关于斐波那契数列三种解法及时间复杂度分析 [4]差分方程的基本概念 [5]二阶线性常系数齐次差分方程的求解 [6]时间复杂度&空间复杂度分析

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • C++11新特性——range for

    很多编程语言都有range for语法功能,自C++11起,终于将这个重要功能加入C++标准中。range for语句,可以方便的遍历给定序列中的每个元素并对其...

    Dabelv
  • Linux命令(36)——awk命令

    AWK是一个优良的文本处理工具,Linux及Unix环境中现有的功能最强大的数据处理引擎之一。数据可以来自标准输入(stdin)、一个或多个文件,或其它命令的输...

    Dabelv
  • C++ Hash表模板

    利用C++类模板实现任意类型的Hash表,提供的功能有: (1)指定shmkey或内存地址创建Hash表; (2)获取指定key元素; (3)遍历指...

    Dabelv
  • 【TPAMI重磅综述】 SIFT与CNN的碰撞:万字长文回顾图像检索任务十年探索历程(下篇)

    本文是《SIFT Meets CNN: A Decade Survey of Instance Retrieval》的下篇。在上 篇中概述了图像检索任务极其发...

    机器学习算法工程师
  • BFPRT算法

     首先将原数组分成5个一组,每组内进行排序,组间不排序,然后将每组的中位数取出再次进行上述操作,直到最后只能分成一组了,然后取出中位数,将这个中位数当作标尺进行...

    mathor
  • LeetCode215.数组中的第K个最大元素

    mathor
  • 要理解递归,先得理解递归

           对于一个整天写增删改查的java程序员,厌倦了成天搬砖,所以最近研究了一下递归。首先声明,本人非科班出身,对于刚接触递归就感觉有一种莫名高大上算法...

    我叫刘半仙
  • 邻接表模板

    l邻接表的处理方法是这样: l图中顶点用一个一维数组存储,当然,顶点也可以用单链表来存储,不过数组可以较容易地读取顶点信息,更加方便。 l图中每个顶点Vi的...

    attack
  • iOS数据结构与算法面试题合集

    static int count = 0;//目前已经放了多少个数(相当于栈顶位置)

    iOSSir
  • loj#2049. 「HNOI2016」网络(set 树剖 暴力)

    因为从一个点向上只会跳\(logn\)次,所以可以暴力的把未经过的处理出来然后每个点开个multiset维护最大值

    attack

扫码关注云+社区

领取腾讯云代金券