一只青蛙一次可以跳上 1 级台阶,也可以跳上 2 级。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。
easy。
★★★★☆
出题公司:腾讯、富途证券。
设 f(n) 表示青蛙跳上 n 级台阶的跳法数。当只有一个台阶时, 即 n = 1 时, 只有 1 中跳法; 当 n = 2 时,有 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 趋于无穷大时,前一个数与后一个数的比值无限接近于黄金比例(
的无理数 0.618…)。
有了初始状态和状态转移方程,那么编程实现求解就不难了,参考下面的递归实现。
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);
}
以递归实现斐波那契数,效率是非常低下的,因为对子问题的求解 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) 的非递归表达式,也就得到了上面递归算法的时间复杂度。
关于二阶常系数线性齐次差分方程可能大家已经没有什么概念了,乍一听一脸懵逼,包括我自己,大学的高数基本已经还给老师了,但是涉及到算法,数学还是相当的重要并且扮演者不可替代的角色。这里简单解释一下我自己温习后对齐次二阶常系数线性差分方程的理解,不清楚的,大家还是要搜索相关资料,恶补一下吧!
差分概念: “二阶线性常系数齐次”是对差分方程的修饰,“差分”也是对方程的修饰,先看一下差分的概念。
给定函数:
,注意 t 的取值是离散的。
一阶差分:
二阶差分:
差分方程的定义: 含有自变量 t 和两个或两个以上的函数值
的方程,称为差分方程。出现在差分方程中的未知函数下标的最大差称为差分方程的阶。差分方程中函数值
的指数为1,称为线性查分方程,函数值
的系数为常量,称为常系数差分方程。
差分方程可以化简为形如:
如果
,那么上面就是 n 阶线性齐次差分方程; 如果
,那么上面就是 n 阶线性非齐次差分方程。 也就是说差分方程的常数项为 0,就是齐次,非零就是非齐次。 如果差分方程中函数值
前的系数是常量的话,那么就是常系数差分方程。
差分方程的表达式可以定义如下:
好了,了解了差分方程的阶、线性、常系数、齐次的概念,下面来辨识一下不同的差分方程吧。
有了关于差分方程的一些定义和概念,现在应该知道为什么 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 ) = λ n f(n)=\lambda^n f(n)=λn,那么f(n)-f(n-1)+f(n-2)=0的特征方程就是: λ 2 − λ + 1 = 0 \lambda^2-\lambda+1=0 λ2−λ+1=0,求解得:\lambda=(1±√5)/2 。所以,f(n)的通解为:
由f(1)=1,f(2)=2可解得c1=(5+√5)/10, c2 ==(5-√5)/10,最终可得时间复杂度为:
我知道时间度的复杂常见的有且依序复杂度递增: O(1) > O(logn) >
> O(n) > O(nlgn) >
>
>
> O(n!)。
那么上面求得的算法时间复杂度是归于哪个级别。很明显是
。也就是说斐波那契数列递归求解的算法时间复杂度是
。
关于斐波那契数列递归求解的期间复杂度我们简化其求解过程,按照如下方式求解。
递归的时间复杂度是:递归次数*每次递归中执行基本操作的次数。所以时间复杂度是:
。
每一次递归都需要开辟函数的栈空间,递归算法的空间复杂度是:
如果每次递归所需的辅助空间是常数,则递归的空间复杂度是 O(N)。因为上面的递归实现,虽然每次递归都会有开辟两个分支,按理说递归调用了 多少次,就开辟了多大的栈空间,按照这个逻辑,那么空间复杂度与时间复杂应该是一样的, 都是
。那么这个逻辑错在了哪里呢?首先我们要知道函数的调用过程大概是什么样的,调用者 (caller) 将被调用者 (callee) 的实参入栈,call 被调用者,被调用者中保留caller 的栈底指针 EBP,将 ESP 赋给 EBP 开始一个新的栈帧,函数结束后清理栈帧,pop 原函数栈底指针 EBP 到 ESP,这一步也就是恢复函数调用的现场。
现在再来看看上面斐波那契数列的递归实现,因为是单线程执行,以 Fib(5) 为例,函数执行的过程应该如下图所示:
可见递归的深度越深,开辟的实参栈空间就会越大。图中最深处的开辟了最大的辅助空间,当函数执行的流程向上回溯时,你就会发现,后面开辟的辅助栈空间都是在前面开辟的栈空间上开辟的,也就是空间的重复利用,所以说递归算法的空间复杂度是递归最大的深度*每次递归开辟的辅助空间,所以斐波那契数列的递归实现的空间复杂度是 O(n)。
图中示例的是单线程情况下递归时的函数执行流程,但是在多线程的情况下,就不是这个样子,因为每个线程函数并发执行,拥有自己的函数栈,所以空间复杂度要另当计算,这里就不做深究,有兴趣的读者可自行研究。
递归实现虽然简单易于理解,但是
的时间复杂度和 O(n) 的空间却让人无法接受,下面采用迭代的方式来实现,时间复杂度为 O(n),空间复杂度为 O(1)。
// numWays 青蛙跳台接。
int numWays(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;
}
// numWays 青蛙跳台阶。
func numWays(n int) int {
if n <= 0 {
return 1
}
if n == 1 {
return 1
}
if n == 2 {
return 2
}
a, b := 1, 2
for i := 3; i <= n; i++ {
a, b = b, a+b
}
return b
}
迭代法是求斐波那契数列的最快方法吗?当然不是,最快的应该是下面的矩阵法。
根据上面的递归公式,我们可以得到。
因而计算 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;
}
有兴趣的读者可自行给出实现,本人后续再补充代码。
青蛙跳台阶问题可以引申为如下问题: 一只青蛙一次可以跳上1级台阶,也可以跳上2 级,……,也可以跳上n 级,此时该青蛙跳上一个n级的台阶总共有多少种跳法?
当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。递归等式如下:
递归等式是一个以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;
}
历时两天,参考了很多博文资料,即当中也遇到了很多不解的问题,很痛苦,尤其是研究已经忘记了的差分方程,不过还是坚持了下来。
本篇力求较全面地给出青蛙跳台阶问题分析,各种解法以及时间复杂度和空间复杂度的分析,让大家能够不留疑惑地了解斐波那契数列的求解。
剑指 Offer 10- II. 青蛙跳台阶问题 - leetcode 斐波那契数列.百度百科 青蛙跳台阶问题 关于斐波那契数列三种解法及时间复杂度分析 差分方程的基本概念 二阶线性常系数齐次差分方程的求解 时间复杂度&空间复杂度分析