面試官問你斐波那契數列的時候不要高興得太早

suliver發表於2021-09-09

前言

假如面試官讓你編寫求斐波那契數列的程式碼時,是不是心中暗喜?不就是遞迴麼,早就會了。如果真這麼想,那就危險了。


遞迴求斐波那契數列

遞迴,在數學與電腦科學中,是指在函式的定義中使用函式自身的方法。
斐波那契數列的計算表示式很簡單:

1F(n) = n; n = 0,1
2F(n) = F(n-1) + F(n-2),n >= 2;

因此,我們能很快根據表示式寫出遞迴版的程式碼:

 1/*fibo.c*/
2#include <stdio.h>
3#include <stdlib.h>
4/*求斐波那契數列遞迴版*/
5unsigned long fibo(unsigned long int n)
6{
7    if(n <= 1)
8        return n;
9    else 
10        return fibo(n-1) + fibo(n-2);
11}
12int main(int argc,char *argv[])
13{
14    if(1 >= argc)
15    {
16       printf("usage:./fibo numn");
17       return -1;
18    }
19    unsigned long  n = atoi(argv[1]);
20    unsigned long  fiboNum = fibo(n);
21    printf("the %lu result is %lun",n,fiboNum);
22    return 0;
23}

關鍵程式碼為3~9行。簡潔明瞭,一氣呵成。
編譯:

1gcc -o fibo fibo.c

執行計算第5個斐波那契數:

1$ time ./fibo 5
2the 5 result is 5
3
4real    0m0.001s
5user    0m0.001s
6sys    0m0.000s

看起來並沒有什麼不妥,執行時間也很短。
繼續計算第50個斐波那契數列:

1$ time ./fibo 50
2the 50 result is 12586269025
3
4real    1m41.655s
5user    1m41.524s
6sys    0m0.076s

計算第50個斐波那契數的時候,竟然花了一分多鐘!

遞迴分析

為什麼計算第50個的時候竟然需要1分多鐘。我們仔細分析我們的遞迴演算法,就會發現問題,當我們計算fibo(5)的時候,是下面這樣的:

 1                         |--F(1)
2                  |--F(2)|
3           |--F(3)|      |--F(0)
4           |      |
5    |--F(4)|      |--F(1)
6    |      |      
7    |      |      |--F(1)
8    |      |--F(2)|
9    |             |--F(0)
10F(5)|             
11    |             |--F(1)
12    |      |--F(2)|
13    |      |      |--F(0)
14    |--F(3)|
15           |
16           |--F(1)

為了計算fibo(5),需要計算fibo(3),fibo(4);而為了計算fibo(4),需要計算fibo(2),fibo(3)……最終為了得到fibo(5)的結果,fibo(0)被計算了3次,fibo(1)被計算了5次,fibo(2)被計算了2次。可以看到,它的計算次數幾乎是指數級的!

因此,雖然遞迴演算法簡潔,但是在這個問題中,它的時間複雜度卻是難以接受的。除此之外,遞迴函式呼叫的越來越深,它們在不斷入棧卻遲遲不出棧,空間需求越來越大,雖然訪問速度高,但大小是有限的,最終可能導致棧溢位
在linux中,我們可以透過下面的命令檢視棧空間的軟限制:

1$ ulimit -s
28192

可以看到,預設棧空間大小隻有8M。一般來說,8M的棧空間對於一般程式完全足夠。如果8M的棧空間不夠使用,那麼就需要重新審視你的程式碼設計了。

迭代解法

既然遞迴法不夠優雅,我們換一種方法。如果不用計算機計算,讓你去算第n個斐波那契數,你會怎麼做呢?我想最簡單直接的方法應該是:知道第一個和第二個後,計算第三個;知道第二個和第三個後,計算第四個,以此類推。最終可以得到我們需要的結果。這種思路,沒有冗餘的計算。基於這個思路,我們的C語言實現如下:

 1/*fibo1.c*/
2#include <stdio.h>
3#include <stdlib.h>
4/*求斐波那契數列迭代版*/
5unsigned long  fibo(unsigned long  n)
6{
7    unsigned long  preVal = 1;
8    unsigned long  prePreVal = 0;
9    if(n <= 2)
10        return n;
11    unsigned long  loop = 1;
12    unsigned long  returnVal = 0;
13    while(loop < n)
14    {
15        returnVal = preVal +prePreVal;
16        /*更新記錄結果*/
17        prePreVal = preVal;
18        preVal = returnVal;
19        loop++;
20    }
21    return returnVal;
22}
23/**main函式部分與fibo.c相同,這裡省略*/

編譯並計算第50個斐波那契數:

1$ gcc -o fibo1 fibo1.c
2$ time ./fibo1 50
3the 50 result is 12586269025
4
5real    0m0.002s
6user    0m0.001s
7sys    0m0.002s

可以看到,計算第50個斐波那契數只需要0.002s!時間複雜度為O(n)。

尾遞迴解法

同樣的思路,但是採用尾遞迴的方法來計算。要計算第n個斐波那契數,我們可以先計算第一個,第二個,如果未達到n,則繼續遞迴計算,尾遞迴C語言實現如下:

 1/*fibo2.c*/
2#include <stdio.h>
3#include <stdlib.h>
4/*求斐波那契數列尾遞迴版*/
5unsigned long fiboProcess(unsigned long n,unsigned long  prePreVal,unsigned long  preVal,unsigned long begin)
6{
7    /*如果已經計算到我們需要計算的,則返回*/
8    if(n == begin)
9        return preVal+prePreVal;
10    else
11    {
12        begin++;
13        return fiboProcess(n,preVal,prePreVal+preVal,begin);
14    }
15}
16
17unsigned long  fibo(unsigned long  n)
18{
19    if(n <= 1)
20        return n;
21    else 
22        return fiboProcess(n,0,1,2);
23}
24
25/**main函式部分與fibo.c相同,這裡省略*/

效率如何呢?

1$ gcc -o fibo2 fibo2.c
2$ time ./fibo2 50
3the 50 result is 12586269025
4
5real    0m0.002s
6user    0m0.001s
7sys    0m0.002s

可見,其效率並不遜於迭代法。尾遞迴在函式返回之前的最後一個操作仍然是遞迴呼叫。尾遞迴的好處是,進入下一個函式之前,已經獲得了當前函式的結果,因此不需要保留當前函式的環境,記憶體佔用自然也是比最開始提到的遞迴要小。時間複雜度為O(n)。

遞迴改進版

既然我們知道最初版本的遞迴存在大量的重複計算,那麼我們完全可以考慮將已經計算的值儲存起來,從而避免重複計算,該版本程式碼實現如下:

 1/*fibo3.c*/
2#include <stdio.h>
3#include <stdlib.h>
4/*求斐波那契數列,避免重複計算版本*/
5unsigned long fiboProcess(unsigned long *array,unsigned long n)
6{
7    if(n < 2)
8        return n;
9    else
10    {
11        /*遞迴儲存值*/
12        array[n] = fiboProcess(array,n-1) + array[n-2];
13        return array[n];
14    }
15}
16
17unsigned long  fibo(unsigned long  n)
18{
19    if(n <= 1)
20        return n;
21    unsigned long ret = 0;
22    /*申請陣列用於儲存已經計算過的內容*/
23    unsigned long *array = (unsigned long*)calloc(n+1,sizeof(unsigned long));
24    if(NULL == array)
25    {
26        return -1;
27    }
28    array[1] = 1;
29    ret = fiboProcess(array,n);
30    free(array);
31    array = NULL;
32    return ret;
33}
34/**main函式部分與fibo.c相同,這裡省略*/

效率如何呢?

1$ gcc -o fibo3 fibo3.c
2$ time ./fibo3 50
3the 50 result is 12586269025
4
5real    0m0.002s
6user    0m0.002s
7sys    0m0.001s

可見效率是不遜於其他兩種最佳化演算法的。但是特別注意的是,這種改進版的遞迴,雖然避免了重複計算,但是呼叫鏈仍然比較長。

其他解法

其他兩種時間複雜度為O(logn)的矩陣解法以及O(1)的通項表示式解法本文不介紹。歡迎留言補充。

總結

總結一下遞迴的優缺點:
優點:

  • 實現簡單

  • 可讀性好

缺點:

  • 遞迴呼叫,佔用空間大

  • 遞迴太深,易發生棧溢位

  • 可能存在重複計算

可以看到,對於求斐波那契數列的問題,使用一般的遞迴併不是一種很好的解法。
所以,當你使用遞迴方式實現一個功能之前,考慮一下使用遞迴帶來的好處是否抵得上它的代價。

原文出處:https://www.cnblogs.com/bianchengzhuji/p/10240897.html  

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2370/viewspace-2819893/,如需轉載,請註明出處,否則將追究法律責任。

相關文章