【遞歸算法】人腦理解遞歸

遞歸真是個奇妙的思維方式。自打我大二學習遞歸以來,對一些簡單的遞歸問題,我總是驚歎於遞歸描述問題和編寫代碼的簡潔。但是總感覺沒能融會貫通地理解遞歸,有時嘗試用大腦去深入“遞歸”,層次較深時便常產生進不去,出不來的感覺。這種狀態也導致我很難靈活地運用遞歸解決問題。有一天,我看到一句英文:“To Iterate is Human, to Recurse, Divine.”中文譯爲:“人理解迭代,神理解遞歸。”然後,我心安理得地放棄了對遞歸的深入理解。直到看到王垠談程序語言最精華的原理時提到了遞歸,並說遞歸比循環表達能力強很多,而且效率幾乎一樣。再次喚醒了我對遞歸的理解探索。

我首先在知乎上發現了下面兩個例子,對比了遞歸和循環。例子來源於知乎用戶李繼剛的回答。

遞歸:你打開面前這扇門,看到屋裏面還有一扇門(這門可能跟前面打開的門一樣大小(靜),也可能門小了些(動)),你走過去,發現手中的鑰匙還可以打開它,你推開門,發現裏面還有一扇門,你繼續打開,。。。, 若干次之後,你打開面前一扇門,發現只有一間屋子,沒有門了。 你開始原路返回,每走回一間屋子,你數一次,走到入口的時候,你可以回答出你到底用這鑰匙開了幾扇門。

循環:你打開面前這扇門,看到屋裏面還有一扇門,(這門可能跟前面打開的門一樣大小(靜),也可能門小了些(動)),你走過去,發現手中的鑰匙還可以打開它,你推開門,發現裏面還有一扇門,(前面門如果一樣,這門也是一樣,第二扇門如果相比第一扇門變小了,這扇門也比第二扇門變小了(動靜如一,要麼沒有變化,要麼同樣的變化)),你繼續打開這扇門,。。。,一直這樣走下去。 入口處的人始終等不到你回去告訴他答案。

該用戶這麼總結到:

遞歸就是有去(遞去)有回(歸來)。

*具體來說,爲什麼可以”有去“? *
這要求遞歸的問題需要是可以用同樣的解題思路來回答除了規模大小不同其他完全一樣的問題。

爲什麼可以”有回“?
這要求這些問題不斷從大到小,從近及遠的過程中,會有一個終點,一個臨界點,一個baseline,一個你到了那個點就不用再往更小,更遠的地方走下去的點,然後從那個點開始,原路返回到原點。

上面的解釋幾乎回答了我已久的疑問:爲什麼我老是有遞歸沒有真的在解決問題的感覺?
因爲遞是描述問題,歸是解決問題。而我的大腦容易被遞佔據,只往遠方去了,連盡頭都沒走到,何談回的來。

《漫談遞歸:遞歸的思想》這篇文章將遞歸思想歸納爲:

遞歸的基本思想是把規模大的問題轉化爲規模小的相似的子問題來解決。在函數實現時,因爲解決大問題的方法和解決小問題的方法往往是同一個方法,所以就產生了函數調用它自身的情況。另外這個解決問題的函數必須有明顯的結束條件,這樣就不會產生無限遞歸的情況了。

需注意的是,規模大轉化爲規模小是核心思想,但遞歸並非是只做這步轉化,而是把規模大的問題分解爲規模小的子問題和可以在子問題解決的基礎上剩餘的可以自行解決的部分。而後者就是歸的精髓所在,是在實際解決問題的過程。

我試圖把我理解到遞歸思想用遞歸用程序表達出來,確定了三個要素:遞 + 結束條件 + 歸。

recursion(大規模)
{
     if (end_condition)
     {
          end;     
     }
     else
     {     //先將問題全部描述展開,再由盡頭“返回”依次解決每步中剩餘部分的問題
          recursion(小規模);     //go;
          solve;                //back;
     }
}

但是,我很容易發現這樣描述遺漏了我經常會遇到的一種遞歸情況,比如遞歸遍歷的二叉樹的先序。我將這種情況用如下遞歸程序表達出來。

recursion(大規模)
{
     if (end_condition)
     {
          end;     
     }
     else
     {     //在將問題轉換爲子問題描述的每一步,都解決該步中剩餘部分的問題。
          solve;                //back;
          recursion(小規模);     //go;
     }
}

總結到這裏,我突然發現遞歸是爲了最能表達這種思想,所以用“遞歸”這個詞,其實遞歸可以是“有去有回”,也可以是“有去無回”。但其根本是“由大往小地去,由近及遠地去”。“遞”是必需,“歸”並非必需,依賴於要解決的問題,有的需要去的路上解決,有的需要回來的路上解決。有遞無歸的遞歸其實就是我們很容易理解的一種分治思想。

其實理解遞歸可能沒有“歸”,只有去(分治)的情況後,我們應該想到遞歸也許可以既不需要在“去”的路上解決問題,也不需要在“歸”的路上解決問題,只需在路的盡頭解決問題,即在滿足停止條件時解決問題。遞歸的分治思想不一定是要把問題規模遞歸到最小,還可以是將問題遞歸窮舉其所有的情形,這時通常遞歸的表達力體現在將無法書寫的嵌套循環(不確定數量的嵌套循環)通過遞歸表達出來。
將這種遞歸情形用遞歸程序描述如下:

recursion()
{
     if (end_condition)
     {
          solve;     
     }
     else
     {     //在將問題轉換爲子問題描述的每一步,都解決該步中剩餘部分的問題。
          for () { recursion();     //go; }
     }
}

例如,字符串的全排列就可以用這種遞歸簡潔地表達出來,如下:

void permute(const string &prefix, const string &str)
{
    if(str.length() == 0)
        cout << prefix << endl;
    else
    {   
        for(int i = 0; i < str.length(); i++)
            permute(prefix+str[i], str.substr(0,i)+str.substr(i+1,str.length()));
    }   
}

由這個例子,可以發現這種遞歸對遞歸函數參數出現了設計要求,即便遞歸到盡頭,組合的字符串規模(長度)也沒有變小,規模變小的是遞歸函數的一個參數。可見,這種變化似乎一下將遞歸的靈活性大大地擴展了,所謂的大規模轉換爲小規模需要有一個更爲廣義的理解了。

對遞歸的理解就暫時到這裏了,可以看出文章中提到關於“打開一扇門”的遞歸例子來解釋遞歸併不準確,例子只描述了遞歸的一種情況。而“遞歸就是有去(遞去)有回(歸來)”的論斷同樣不夠準確。要爲只讀了文章前半部分的讀者惋惜了。我也給出自己對遞歸思想的總結吧:

遞歸的基本思想是廣義地把規模大的問題轉化爲規模小的相似的子問題或者相似的子問題集合來解決。廣義針對規模的,規模的縮小具體可以是指遞歸函數的參數,也可以是其參數之一。相似是指解決大問題的方法和解決小問題的方法往往是同一個方法,還可以是指解決子問題集的各子問題的方法是同一個方法。解決大問題的方法可以是由解決次規模問題的方法和解決剩餘部分的方法組成,也可以是由一系列解決次規模問題的方法組成。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章