斐波那契数,计算与分析

fibonacci.jpg

什么是斐波那契数列?

斐波那契数列(Fibonacci sequence)是以意大利数学家列昂纳多·斐波那契的名字命名的数列。该数列具有一些很好的性质,比如在很大时,,其中是黄金分割数,等于 。斐波那契数列在计算机里面有很多用途,例如斐波那契查找(二分查找的一种改进),斐波那契堆等。斐波那契数列定义为:

斐波那契数列的通项公式:

通项公式

第的斐波那切数可以表示为

其中和分别是黄金分割数和其共轭数,是方程的两个根。

证明

显然

满足通项公式。

假设对整数, 满足通项公式,即

那么
\begin{aligned} F_{k+1} &= F_{k} + F_{k-1}\\ &= \frac{\phi^k - {\hat \phi}^k} {\sqrt 5} + \frac{\phi^{k-1} - {\hat \phi}^{k-1}} {\sqrt 5}\\ &= \frac{(\phi^k + \phi^{k-1})-({\hat \phi}^k + {\hat \phi}^{k-1})}{\sqrt 5}\\ &= \frac{\phi^{k+1} - {\hat \phi}^{k+1}}{\sqrt 5} \end{aligned}

满足通项公式。

其中,由于和满足方程,因此
\phi^2 = \phi + 1,\quad {\hat \phi}^2 = {\hat \phi} + 1\\ \phi^3 = \phi^2 + \phi,\quad {\hat \phi}^3 = {\hat \phi}^2 +{\hat \phi}\\ \cdots \cdots \cdots \cdots \\ \phi^{k+1} = \phi^k + \phi^{k-1},\quad {\hat \phi}^{k+1} = {\hat \phi}^k +{\hat \phi}^{k-1}\\
证毕。

利用上面证明的通项公式,可以得出斐波那契数列相邻两项之间有如下近似关系

因为

所以
\begin{aligned} \lim_{n \to +\infty}{\frac{F_{n+1}}{F_n}} &= \lim_{n \to +\infty}{\frac{\phi^{n+1} - {\hat \phi}^{n+1}}{\phi^n - {\hat \phi}^n}}\\ &= \frac{\phi^{n+1}}{\phi^n}\\ &= \phi \end{aligned}

发散思维

将上面给出的等式稍作修改





这个等式是否具有普遍性?即若以

表示某一整数数列的第项,则一定为整数。

我下面将给出一个证明,这个证明通过给出的递推公式来证明的任一项都是整数。至于数列是否像斐波那契数列一样具有一些美好的性质,这就有待于兴趣者去探索了。

证明:


显然为整数。
当为大于0的整数时,
\begin{aligned} G_{m(n+1)} &= \frac{(1 + \sqrt{m})^{n+1} - (1 - \sqrt{m})^{n+1}}{\sqrt{m}}\\ &= \frac{(1 + \sqrt{m})[(1 - \sqrt{m})^n + \sqrt{m}G_{mn}] - (1 - \sqrt{m})[(1 + \sqrt{m})^n - \sqrt{m}G_{mn}]}{\sqrt{m}}\\ &= \frac{2\sqrt{m}G_{mn} + [(1 + \sqrt{m})(1 - \sqrt{m})^n - (1 - \sqrt{m})(1 + \sqrt{m})^n]}{\sqrt{m}}\\ &= 2G_{mn} + [(1 + \sqrt{m})(1 - \sqrt{m})]\frac{(1 - \sqrt{m})^{n-1} - (1 + \sqrt{m})^{n-1}}{\sqrt{m}}\\ &= 2G_{mn} + [(\sqrt{m} + 1)(\sqrt{m} - 1)]\frac{(1 + \sqrt{m})^{n-1} - (1 - \sqrt{m})^{n-1}}{\sqrt{m}}\\ &= 2G_{mn} + (m - 1)G_{m(n-1)} \end{aligned}
这表明可以由和仅通过加法和乘法得到,而我们知道,整数的加法运算和乘法运算只能产生整数,因为数列的第一二项都是整数,所以其后面的任一项也比必然是整数。

这就证明了我们的猜想。

从证明的结果,的递推公式可以看出,数列

的递推公式应该是

当m等于5时,正是这篇文章的主题,斐波那契数列。

斐波那契数的计算

约定

从前文的分析我们知道,随着的增大而指数量级地增大,因此,的位数随着的增大而线性增大。事实上,的位数为

这会导致计算过程中使用的加法运算和乘法运算随着的增大而变慢。

为简单起见,后文关于时间复杂度的分析将基于下面这个不合理的约定:

不论整数长度,一律将加法和乘法的时间复杂度规定为。

朴素算法

树递归

将斐波那契数列的递推公式直接转换成Python代码如下

def fib0(n):
    if n < 2:
        return n
    else:
        return fib0(n-1) + fib0(n-2)

这个函数的效率极低,一秒内能够计算出前35项就很不错了。因为采用了树递归,时间复杂度为指数量级。简单画一下代码流程图,就可以看出这里面有着众多的重复计算。

图片来自计算机经典教材SICP

为了计算该函数的时间复杂度,我们先来计算一下递归树中的节点个数。

设为的递归树中的节点个数,由于树的递归结构,运用数学归纳法很容易证明

下面以代指,展示一种直接计算证明
\begin{aligned} Nt(N) &= Nt(N - 1) + Nt(N - 2) + 1\\ &= 2Nt(N - 2) + Nt(N - 3) + 2\\ &= 3Nt(N-3) + 2Nt(N - 4) + 4\\ &\;\; \vdots\\ &= F(k+1)T(N-k) + F(k)T(N-(k+1)) \\ &+ \sum_{i = 1}^{k}F(i) \end{aligned}
其中求和部分

运用数学归纳法很容易证明上式,下面展示一种计算证明的过程。





左右累加求和

等式两边同时减掉得



令,可得

与实际相符,验证了此公式的正确性。

因此,基于前文的约定

动态规划

简单修改一下上面的代码,将计算过程记忆化(动态规划),得到下面的代码,时间复杂度为,提升很大。

def fib1(n):
    def fibrec(n):
        if n < 2:
            return n
        if v[n] is None:
            v[n] = fibrec(n - 1) + fibrec(n - 2)

        return v[n]

    v = [None for i in range(n + 1)]
    return fibrec(n)

随便打印前100项不是什么问题,但计算前1千项会爆栈。

for i in range(100):
    print("fib1(%3d): %d" % (i, fib1(i)))

尾递归

下面试着将代码转换成尾递归的形式,方法是将中间结果作为函数参数进行传递

def fib2(n):
    def f(u, v, i):
        if i == 0:
            return u
        return f(v, u + v, i - 1)

    return f(0, 1, n)

这个版本与上面动态规划的版本一样,时间复杂度都是,对于稍微大点的数依然会爆栈,看来Python默认并不支持尾递归优化。递归会增加函数调用开销,下面考虑消除递归。

消除递归

上面都采用了自顶向下的递归方法,所以写出来的函数效率很低。考察上面图中的递归树,可以发现,只要我们从左下角的两个节点不断上行,就可以得出最终的计算结果,而其他的路径都是不必要的。因此,可以写出如下代码,随便计算前十万项不是问题。

def fib3(n):
    v = [0, 1]
    for i in range(2, n + 1):
        v.append(v[i - 1] + v[i - 2])

    return v[n]

如果要计算斐波那契数列的前项,上面这个方法很管用,但如果只是计算第项的话,内存占用还可以再优化一下。

def fib4(n):
    u, v = 0, 1
    for i in range(n):
        u, v = v, v + u
    return u

快速算法

矩阵快速幂算法

矩阵快速幂算法计算斐波那契数的原理很简单,只需了解快速幂算法的原理和矩阵乘法即可。若不懂快速幂算法,可以参考我的博文快速幂算法

算法原理

当大于时,先将与的关系用如下矩阵表示出来
\begin{aligned} \begin{vmatrix} Fib(n)\\ Fib(n-1)\\ \end{vmatrix} &= \begin{vmatrix} 1 & 1\\ 1 & 0\\ \end{vmatrix} \times \begin{vmatrix} Fib(n - 1)\\ Fib(n- 2)\\ \end{vmatrix}\\ &=\begin{vmatrix} 1 & 1\\ 1 & 0\\ \end{vmatrix} \times \begin{vmatrix} 1 & 1\\ 1 & 0\\ \end{vmatrix} \times \begin{vmatrix} Fib(n - 2)\\ Fib(n- 3)\\ \end{vmatrix}\\ &=\underbrace{\begin{vmatrix} 1 & 1\\ 1 & 0\\ \end{vmatrix} \times \cdots \times \begin{vmatrix} 1 & 1\\ 1 & 0\\ \end{vmatrix}}_{n-1} \times \begin{vmatrix} Fib(1)\\ Fib(0)\\ \end{vmatrix}\\ &= \begin{vmatrix} 1 & 1\\ 1 & 0\\ \end{vmatrix}^{n-1} \times \begin{vmatrix} Fib(1)\\ Fib(0)\\ \end{vmatrix} \end{aligned}
然后就可以用快速幂算法来加速运算了。

代码实现

def fib_fast_expt(n):
    def mul22(x, y):
        return [
            [   x[0][0] * y[0][0] + x[0][1] * y[1][0],
                x[0][0] * y[1][0] + x[0][1] * y[1][1]
            ],
            [
                x[1][0] * y[0][0] + x[1][1] * y[1][0],
                x[1][0] * y[1][0] + x[1][1] * y[1][1]
            ]
        ]

    def fast_expt(x, n):
        if n == 0:
            return [[1, 0], [0, 1]]

        m = fast_expt(x, n >> 1)
        y = mul22(m, m)

        if (n & 0x1) == 1:
            y = mul22(y, x)
        return y

    if n > 1:
        return fast_expt([[1, 1], [1, 0] ], n - 1)[0][0]
    return n

采用了快速幂算法加速的矩阵快速幂算法,时间复杂度显然是量级的。

更快的算法

我是自己无意间得出该这个公式之后才进一步从别人那儿了解到矩阵快速幂算法的,这个算法要比矩阵快速幂算法快几倍。因为公式宽度的限制,下面以表示,一步步来推导该公式。

在大于1时,不断将代入的迭代计算式右边的第一项,可把作如下展开:
\begin{aligned} F_n &= F_{n-1} + F_{n-2}\\ &=1F_{n-1}+1F_{n-2}= F_{2}F_{n-1}+F_{1}F_{n-2}\\ &= 2F_{n-2}+1F_{n-3} = F_{3}F_{n-2}+F_{2}F_{n-3}\\ &= 3F_{n-3}+2F_{n-4} = F_{4}F_{n-3}+F_{3}F_{n-4}\\ &= 5F_{n-4}+3F_{n-5} = F_{5}F_{n-4}+F_{4}F_{n-5}\\ &\ldots\ldots\ldots\ldots \end{aligned}

不难发现的展开式满足下面这个通式:

看到这个公式,你大概就知道该从哪儿着手计算过程的优化了。的取值预示着我们需要额外计算的斐波那契数的个数。

这里可以分为两种情况进行讨论。

  1. 为偶数,取,则 ,采用迭代式的第一式,提公因子,得:

  2. 为奇数,取,则 ,采用迭代式第二式,则可得:

归纳为一个公式如下:

\begin{aligned} F_n &= \begin{cases} F_{k+1}^2+F_k^2, &(n为奇数,k= \left\lfloor{n/2}\right\rfloor)\\ F_k({F_k + 2F_{k-1}}),&(n为偶数,k={n/2}) \end{cases}\\ &= \begin{cases} {(F_{k} + F_{k-1})}^2+F_k^2, &(n为奇数,k= \left\lfloor{n/2}\right\rfloor)\\ F_k({F_k + 2F_{k-1}}), &(n为偶数,k={n/2}) \end{cases}\\ \end{aligned}
乍一看,这个算法的时间复杂度应该是

但我们应该清楚,之所以这样,是因为我们像普通算法中最普通的那种递归算法那样遍历了整棵递归树,因此这里面有着很多的重复计算

运行下面的代码

isodd = lambda x: bool(x & 0x1)
def fib_record(n):
    def fib(n):
        if n not in numbers:
            numbers[n] = 0
        numbers[n] += 1
        if n < 2:
            return n
        x = fib((n >> 1) - 1)
        y = fib(n >> 1)
        if isodd(n):
            x += y
            return x * x + y * y
        else:
            return y * (y + 2 * x)
    numbers = {}
    return (fib(n), numbers)
for i in sorted(r[1]):
    print("%3d: %d" % (i , r[1][i]))
函数调用记录

可以看到,其中有许多重复计算。因此,我们完全可以运用动态规划的思想来优化算法。当然,即使不进行优化,这个公式也会比一般的算法要快,为什么?我想仔细看看上图你就会明白。

根据动态规划的思想,我们要么记录子问题的解,要么自底向上进行求解。

记录子问题解的方法较为简单,下面是代码

def fast_fib_memory1(n):
    def dp(F, n):
        if n not in F:
            k = n >> 1
            dp(F, k - 1)
            dp(F, k)
            if isodd(n):
                F[k + 1] = F[k] + F[k - 1]
                F[n] = F[k]**2 + F[k + 1]**2
            else:
                F[n] = F[k] * (F[k] + 2 * F[k - 1])

    F = {0: 0, 1: 1}
    dp(F, n)

    return F[n]

记忆化该算法后,其时间复杂度与矩阵快速幂算法一致,都是,不过常数因子要小得多,所以要相较而言要快几倍。

另外,我们完全可以用Pythonlru_cache自动实现记忆化,就效率和便捷性而言,非常好。

from functools import lru_cache
@lru_cache(maxsize=128)
def fast_fib_lur_cache(n):
    if n < 2:
        return n
    x = fast_fib_lur_cache((n >> 1) - 1)
    y = fast_fib_lur_cache(n >> 1)
    if isodd(n):
        x += y
        return x * x + y * y
    else:
        return y * (y + 2 * x)

下面是一个自底向上的版本,代码不是很容易理解,不过从中很容易看出这个算法的高效性——计算次数正比于输入的二进制长度。理解这段代码的关键依然是二进制,你可以从计算所需额外计算的其他那里发现这个诀窍。

def fast_fib_bottom_up2(n):
    x, y, l = 1, 0, n.bit_length()
    for i in range(l - 1, 0, -1):
        if isodd(n >> i):
            x, y = y * (y + 2 * x), (x * x + y * y)
            y += x
        else:
            y, x = y * (y + 2 * x), (x * x + y * y)

    if isodd(n):
        x += y
        return x * x + y * y
    return y * (y + 2 * x)

下面是另一个自底向上的版本,可以从中发现许多对称性

def fast_fib_bottom_up1(n):
    x, y, l = 1, 0, n.bit_length()
    for i in range(l - 1, 0, -1):
        u = x**2
        v = y**2
        z = (x + y)**2
        if isodd(n>>i):
            x = z - u
            y = z + v
        else:
            x = u + v
            y = z - u

    z = (x + y)**2
    if isodd(n):
        return z + y**2
    else:
        return z - x**2

最后,附一份完整代码和测试截图。fibonacci.py

耗时测试截图

2019/12/30 更新。
好久没登知乎了,今天登录知乎,发现有大佬评论我的斐波那契数快速计算的文章。跟着评论中的链接,读了两位大佬的斐波那契数快速算法的文章,了解到了Cassini公式

比如,

这个公式很简单,证明过程也很简单,可以对下列等式两端的矩阵同时取行列式,也可以从上面给出的斐波那切数列的通项公式着手。

Cassini公式还可以变形为



利用Cassini公式,我们便可以对上面的快速算法进行优化,请看

将公式

重新写为

将上面变形后的Cassini公式带入,得

最后我们得到如下这个公式,每次迭代只需计算两次乘法

这就是GMP大数运算库用来计算fibonacci数的公式。

你可能感兴趣的:(斐波那契数,计算与分析)