求解斐波那契数列是一道经典的算法问题,本文在总结常规解法的基础上,进一步探讨算法的优化方法。

斐波那契数列的定义

我们定义满足如下表达式的数列为斐波那契数列:

$f(n) = 1 ~~~n=1,2$

$f(n) = f(n-1)+f(n-2) ~~~n>2$

递归方法

我们根据表达式,可以很快用递归的方法写出代码:

#include <stdio.h>
#include<time.h>
unsigned long fibonacci(int n);

int main(void) {

    clock_t start, finish;
    unsigned long result;
    int n = 30;
    start=clock();
    result = fibonacci(n-1);
    finish = clock();
    printf("%lu\n", result);
    printf("%lf s\n", (double)(finish - start)/CLOCKS_PER_SEC);

    return 0;
}

unsigned long fibonacci(int n) {

    if (n < 2)
        return 1;
        
    return fibonacci(n-1) + fibonacci(n-2);
}
n 运行时间(秒)
30 0.002975
40 0.392288
50 45.450654

可见随着 n 的增加,程序所需时间急速增加。

性能分析

递归算法虽然很简洁,但在时间复杂度和空间复杂度上存在巨大的缺陷。

我们由斐波那契数列的递推公式可得:

$$T(n) = T(n-1) + T(n-2)~~~~~S(n) = S(n-1)+S(n-2)$$

使用差分方程,我们可以求得:

$$T(n) \sim O(k^n) ~~~~ S(n) \sim O(k^n) ~~~~ k =\frac{1+\sqrt{5}}{2} \approx 1.62$$

由于 $k > 1$,当 n 增大时,程序所需的时间和空间都会指数级的上升。我们可以看一下系统的栈空间限制:

$ ulimit -s
8192

当 n 足够大时,可能出现内存溢出

动态规划

我们将每次计算的结果保存在一个数组中,这样我们省略大量重复的计算,具体代码如下:

#include <stdio.h>
#include<time.h>
#include <stdlib.h>
unsigned long fibonacci(int n, unsigned long *array);

int main(void) {

    clock_t start, finish;
    unsigned long result, *array;
    int n = 1000;
    start=clock();
    array = malloc(sizeof(unsigned long) * (n-1));
    result = fibonacci(n-1, array);
    finish = clock();
    printf("%lu\n", result);
    printf("%lu s\n", finish - start);
    free(array);
    array = NULL;

    return 0;
}

unsigned long fibonacci(int n, unsigned long *array) {

    if (n < 2)
        array[n] = 1;
    else
        array[n] = fibonacci(n-1, array) + array[n-2];

    return array[n];
}

n = 10000时,仅需要 245μs,此时算法的时间复杂度和空间复杂度均减少到了 $O(n)$。

尾递归

此时时间复杂度已经很好,但考虑到每次递归都会在栈空间上开辟一块空间,用来调用函数,我们可以用尾递归的方式对空间复杂度做进一步的优化。

#include <stdio.h>
#include<time.h>
unsigned long fibonacci(int n, unsigned long f1, unsigned long f2);

int main(void) {

    clock_t start, finish;
    unsigned long result;
    int n = 10000;
    start=clock();
    result = fibonacci(n, 1, 1);
    finish = clock();
    printf("%lu\n", result);
    printf("%lu μs\n", finish - start);

    return 0;
}

unsigned long fibonacci(int n, unsigned long f1, unsigned long f2) {

    if (n == 1)
        return f2;
    else
        return fibonacci(--n, f1+f2, f1);
}

通过尾递归,虽然时间复杂度不变,但空间复杂度降至 $O(1)$。n = 10000时,时间为 234μs。

循环

我们可以直接用循环计算斐波那契数列,避免大量的函数调用开销,代码为:

#include <stdio.h>
#include<time.h>

int main(void) {

    clock_t start, finish;
    start = clock();
    unsigned long pre = 1, result = 1, media;
    int i, n = 10000;
    for (i = 0; i < n-2; i++) {
        media = pre;
        pre = result;
        result += media;
    }
    finish = clock();
    printf("%lu\n", result);
    printf("%lu μs\n", finish - start);

    return 0;
}

此时时间复杂度为 $O(n)$,空间复杂度为 $O(1)$,n = 10000时,运行时间直接降至 25μs。

矩阵分解计算

到了这里,单纯的使用算法很难再进一步优化性能。因此我们使用数学的方法,对斐波那契数列的表达式进行优化。我们将他的递推公式写成矩阵的形式: matrix 接下来,我们的问题就是如何快速的求解变换矩阵的n-1次方,这里我们可以对矩阵进行特征分解。

特征分解

我们首先回顾下特征值和特征向量的定义:

$$Ax = \lambda x$$

A 为 $ n \times n$ 的矩阵,x是一个 n 维向量,$\lambda$ 是一个常数。这个表达式的几何意义是,对向量 x 做空间变换 A,仅仅缩放 x 的模,并不改变 x 在空间中的角度。

我们求出矩阵 A 的 n 个特征值 $\lambda_1,…,\lambda_n$,以及对应的特征向量 $x_1,…,x_n$,我们可以将矩阵 A 写成如下形式:

$$A = W\Sigma W^{-1} = [x_1,…,x_n] diag(\lambda_1,…,\lambda_n) [x_1,…,x_n]^{-1} $$

此时我们可以很方便的计算 A 的 n 次方:

$$A^{n} = W\Sigma^n W^{-1} = Wdiag(\lambda_1^n,…,\lambda_n^n) W^{-1}$$

现在,我们求解变换矩阵的特征值和特征向量:

$\lambda(\lambda-1)-1 = 0 ~~~ \rightarrow ~~\lambda_1=\frac{1+\sqrt{5}}{2} ~~~\lambda_2 = \frac{1-\sqrt{5}}{2}$

$w_1 = [1, \frac{\sqrt{5}-1}{2}]^{\top}~~~~~w_2=[1, -\frac{\sqrt{5}+1}{2}]^{\top}$

将以上式子代入最初的表达式,我们可以求得斐波那契数列的表达式:

$$f(n) = \frac{1}{\sqrt{5}} [(\frac{1+\sqrt{5}}{2})^n - (\frac{1-\sqrt{5}}{2})^n]$$

代码如下

#include <stdio.h>
#include <math.h>
#include<time.h>

int main(void) {

    clock_t start, finish;
    start = clock();
    unsigned long result;
    int n = 10000;
    
    if (n <= 2)
        result = 1;
    result = (unsigned long)((pow((1+sqrt(5))/2,n)-pow((1-sqrt(5))/2,n))/sqrt(5));
    
    finish = clock();
    printf("%lu\n", result);
    printf("%lu μs\n", finish - start);

    return 0;
}

当 n = 10000 时,耗时仅为 11μs。