1. 程式人生 > >常用資料結構思維:遞推及其寫法

常用資料結構思維:遞推及其寫法

作為小白,我看到遞迴程式只是能看懂,但是自己寫不出來,我知道要有一個臨界條件(這個並不難找),但我不知道怎麼演進,這讓我十分頭疼,因此找到了一篇個人認為寫的不錯的文章如下,根據我對遞迴的理解和疑問對原文做了一些標註,歡迎各位大佬,寫下自己對遞迴的理解,本小白感激不盡。

如何寫一個遞迴程式

總是聽到大大們說遞迴遞迴的,自己寫程式的時候卻用不到遞迴。其中的原因,一個是害怕寫遞迴,另一個就是不知道什麼時候用遞迴。這篇文章就淺析一下,希望看完之後不再害怕遞迴,這就是本文最大的目的。

遞迴到底有什麼意義?

在說怎麼寫遞迴之前必須要說一下它的意義,其實這就是為什麼大多數人在看了許多遞迴的例子後還是不明所以的原因。可以肯定的是,遞迴是個十分強大的工具,有許多演算法如果不用遞迴可能非常難寫。很多地方介紹遞迴會用階乘或者斐波那契數列作例子,這完全是在誤導初學者。儘管用遞迴實現階乘或者斐波那契數列是可以的,但是這是沒有意義的。

先掉一下書袋,遞迴的定義是這樣的:程式呼叫自身的程式設計技巧稱為遞迴(
recursion)。在函式呼叫的過程中是有一個叫函式呼叫棧的東西存在的。呼叫一個函式,首先要把原函式的區域性變數等壓入棧中,這是為了保護現場,保證呼叫函式完成後能夠順利返回繼續執行下去。當呼叫函式返回時,又要將這些區域性變數等從棧中彈出。在普通的函式呼叫中,一般呼叫深度最多不過十幾層,但是來到了遞迴的世界情況就不一樣了。先看一段隨便從網上就能找到的階乘程式:
double fab(int n) 
{ 
   if(n == 0 || n == 1){
     return 1; 
   }else{ 
     return n*fab(n-1); 
   } 
}

如果n = 100,很顯然這段程式需要遞迴地呼叫自身100次。這樣呼叫深度至少就到了100。棧的大小是有限的,當n變的更大時,有朝一日總會使得棧溢位,從而程式崩潰。除此之外,每次函式呼叫的開銷會導致程式變慢。所以說這段程式十分不好。那什麼是好的遞迴,先給出一個結論,接著看下去自然會明白。結論是如果遞迴能夠將問題的規模縮小,那就是好的遞迴。

怎樣才算是規模縮小了呢。舉個例子,比如要在一個有序陣列中查詢一個數,最簡單直觀的演算法就是從頭到尾遍歷一遍陣列,這樣一定可以找到那個數。如果陣列的大小是N,那麼我們最壞情況下需要比較N次,所以這個演算法的複雜度記為O(N)。
  簡單的分析一下二分法為什麼會快。可以發現二分法在每次比較之後都幫我們排除了一半的錯誤答案,接下去的一次只需要搜尋剩下的一半,這就是說問題的規模縮小了一半。而在直觀的演算法中,每次比較後最多排除了一個錯誤的答案,問題的規模幾乎沒有縮小(僅僅減少了1)。這樣的遞迴就稍微像樣點了。

而階乘的遞迴,每次遞迴後問題並沒有本質上的減小(僅僅減小1),這和簡單的迴圈沒有區別,但迴圈沒有函式呼叫的開銷,也不會導致棧溢位。所以結論是如果僅僅用遞迴來達到迴圈的效果,那還是改用迴圈吧。

總結一下,遞迴的意義就在於將問題的規模縮小,並且縮小後問題並沒有發生變化(二分法中,縮小後依然是從陣列中尋找某一個數),這樣就可以繼續呼叫自身來完成接下來的任務。我們不用寫很長的程式,就能得到一個十分優雅快速的實現。

怎麼寫遞迴程式?

終於進入正題了。很多初學者都對遞迴心存畏懼,其實遞迴是符合人思考方式的。寫遞迴程式是有套路的,總的來說遞迴程式有幾條法則的。

用二分查詢作為例子,先給出函式原型:
int binary_search(int* array, int start, int end, int num_wanted)

返回值是元素在陣列中的位置,如果查詢失敗返回-1。

1. 終止條件

其實在實際中,這是十分容易確定的。例如在二分查詢中,終止條件就是找到了我們想要的數或者搜尋完了整個陣列(查詢失敗)。

if(end < start){

return -1;

}else if(num_wanted == array[middle]){

return middle;

}

2. 不斷演進,推出演進的公式

可以先找解決問題的第一步,然後再找第二步時,發現第二步和第一步相同,只不過規模變小了,則此時可以考慮用遞迴。
如下二分查詢中,就是繼續查詢剩下的一半陣列。

if(num_wanted > array[middle]){

index = binary_search(array, middle+1, end, num_wanted);

}else{

index = binary_search(array, start, middle-1, num_wanted);

}

3. 用人的思考方式設計

這條法則我認為是非常重要的,它不會出現在編碼中,但卻是理解遞迴的一條捷徑。它的意思是說,在一般的程式設計實踐中,我們通常需要用大腦模擬電腦執行每一條語句,從而確定編碼的正確性,然而在遞迴編碼中這是不需要的!
遞迴編碼的過程中,只需要知道前兩條法則就夠了。之後我們就會看到這條法則的如何工作的了。

4. 有限次的呼叫

現在我們可以寫出我們完整的二分法的程式了:

int binary_search(int* array, int start, int end, int num_wanted)

{

int middle = (end - start)/2 + start; // 1

if(end < start){

return -1;

}else if(num_wanted == array[middle]){

return middle;

}

int index;

if(num_wanted > array[middle]){

index = binary_search(array, middle+1, end, num_wanted); // 2

}else{

index = binary_search(array, start, middle-1, num_wanted); // 3

}

return index; // 4

}

編寫的時候只要認為2或者3一定會正確執行,並且立刻返回,不要考慮2和3內部是如何執行的,因為這就是你現在在編寫的。
4是一個比較關鍵的步驟,經常容易被忘記。在這裡只需要將找到的index返回就可以了。

不適合遞迴的方法:

我們可以看一下斐波那契數列的遞迴實現:

long int fib(int n)

{

if(n <= 1){

return 1;

}else{

return fib(n-1) + fib(n-2); // 1

}

}

乍看之下,這段程式很精練,它也是一段正確的遞迴程式,有基準條件、不斷推進。但是如果仔細分析一下它的複雜度可以發現,如果我們取n=N,那麼每次fib呼叫會增加額外的2次fib呼叫(在1處),即fib的執行時間T(N) = T(N-1) + T(N-2),可以得到其複雜度是O(2^N),

幾乎是可見的複雜度最大的程式了(其中詳細的計算各位有興趣可以google一下,這裡就不展開了。所以如果在一個遞迴程式中重複多次地呼叫自身,又不縮小問題的規模,通常不是個好主意。

PS. 大家可以比較一下二分法與斐波那契數列的遞迴實現的區別,儘管二分法也出現了2次呼叫自身,但是每次執行只有其中一個會被真正執行。

到此其實你已經可以寫出任何一個完整的遞迴程式了,雖然上面的例子比較簡單,但是方法總是這樣的。不過我們可以對遞迴程式再進一步分析。二分查詢的遞迴演算法中我們注意到在遞迴呼叫之後僅僅是返回了其返回值,這樣的遞迴稱作尾遞迴。儘管在編寫的時候不必考慮遞迴的呼叫順序,但真正執行的時候,遞迴的函式呼叫過程可以分為遞和歸兩部分。在遞迴呼叫之前的部分稱作遞,呼叫之後的部分稱作歸。而尾遞迴在歸的過程中實際上不做任何事情,對於這種情況可以很方便的將這個遞迴程式轉化為非遞迴程式(好處就是不會導致棧的溢位)。
  
舉例:

  1. 輾轉相除法
    輾轉相除法基於如下原理:兩個整數的最大公約數等於其中較小的數和兩數的相除餘數的最大公約數。例如,252和105的最大公約數是21;252= 2 *105 + 42 ,同除以21,可知左右都是整數,所以21也是42的約數。
    int gcd(int x, int y);
    void main()
    {
    int m, n;
    cout<< “輸入兩個數字:”;
    cin >> m >> n;
    cout<< “最大公約數:”;
    cout<< gcd(m, n) << endl;
    }
    int gcd(int a, int b)
    {
    int i;
    if (b == 0)
    i= a;
    else
    i= gcd(b, a%b);
    return i;
    }

  2. 十進位制轉八進位制
    #include “iostream”
    using namespace std;
    int func(int x)
    {
    int res;
    if(x / 8 == 0)
    {
    printf("%d\n", x % 8);
    return x % 8;
    }
    res= x % 8;
    printf("%d\n",res);
    return res + 10* func(x / 8);
    }
    int main()
    {
    int n;
    cin >> n;
    printf("%d\n",func(n));
    system(“pause”);
    return 0;
    }