寫遞歸函數的正確思惟方法

什麼是遞歸

 

  簡單的定義: 「當函數直接或者間接調用本身時,則發生了遞歸.」 提及來簡單, 可是理解起來複雜, 由於遞歸併不直觀, 也不符合咱們的思惟習慣, 相對於遞歸, 咱們更加容易理解迭代. 由於咱們平常生活中的思惟方式就是一步接一步的, 而且可以理解一件事情作了N遍這個概念. 而咱們平常生活中幾乎不會有遞歸思惟的出現.
舉個簡單的例子, 即在C/C++中計算一個字符串的長度. 下面是傳統的方式, 咱們通常都這樣經過迭代來計算長度, 也很好理解.算法

size_t length(const char *str) { size_t length = 0; while (*str != 0) { ++length; ++str; } return length; } 

  而事實上, 咱們也能夠經過遞歸來完成這樣的任務.函數

size_t length(const char *str) { if (*str == 0) { return 0; } return length(++str) + 1; } 

  只不過, 咱們都不這麼作罷了, 雖然這樣的實現有的時候可能代碼更短, 可是很明顯, 從思惟上來講更加難以理解一些. 固然, 我是說假如你不是習慣於函數式語言的話. 這個例子相對簡單, 稍微看一下仍是能明白吧.
迭代的算法能夠這樣描述: 從第一個字符開始判斷字符串的每個字符, 當該字符不爲0的時候, 該字符串的長度加一.
遞歸的算法能夠這樣描述: 當前字符串的長度等於當前字符串除了首字符後, 剩下的字符串長度+1.
做爲這麼簡單的例子, 兩種算法其實大同小異, 雖然咱們習慣迭代, 可是, 也能看到, 遞歸的算法不管是從描述上仍是實際實現上, 並不比迭代要麻煩.測試

 

理解遞歸

  在初學遞歸的時候, 看到一個遞歸實現, 咱們老是不免陷入不停的回溯驗證之中, 由於回溯就像反過來思考迭代, 這是咱們習慣的思惟方式, 可是實際上遞歸不須要這樣來驗證. 好比, 另一個常見的例子是階乘的計算. 階乘的定義: 「一個正整數的階乘(英語:factorial)是全部小於或等於該數的正整數的積,而且0的階乘爲1。」 如下是Ruby的實現:優化

def factorial(n) if n <= 1 then return 1 else return n * factorial(n - 1) end end 

  咱們怎麼判斷這個階乘的遞歸計算是不是正確的呢? 先別說測試, 我說咱們讀代碼的時候怎麼判斷呢?
回溯的思考方式是這麼驗證的, 好比當n = 4時, 那麼factoria(4)等於4 * factoria(3), 而factoria(3)等於3 * factoria(2)factoria(2)等於2 * factoria(1), 等於2 * 1, 因此factoria(4)等於4 * 3 * 2 * 1. 這個結果正好等於階乘4的迭代定義.
用回溯的方式思考雖然能夠驗證當n = 某個較小數值是否正確, 可是其實無益於理解.
Paul Graham提到一種方法, 給我很大啓發, 該方法以下:spa

  1. 當n=0, 1的時候, 結果正確.
  2. 假設函數對於n是正確的, 函數對n+1結果也正確.
    若是這兩點是成立的,咱們知道這個函數對於全部可能的n都是正確的。

  這種方法很像數學概括法, 也是遞歸正確的思考方式, 事實上, 階乘的遞歸表達方式就是1!=1,n!=(n-1)!×n(見wiki). 當程序實現符合算法描述的時候, 程序天然對了, 假如還不對, 那是算法自己錯了…… 相對來講, n,n+1的狀況爲通用狀況, 雖然比較複雜, 可是還能理解, 最重要的, 也是最容易被新手忽略的問題在於第1點, 也就是基本用例(base case)要對. 好比, 上例中, 咱們去掉if n <= 1的判斷後, 代碼會進入死循環, 永遠不會結束.code

使用遞歸

  既然遞歸比迭代要難以理解, 爲啥咱們還須要遞歸呢? 從上面的例子來看, 天然意義不大, 可是不少東西的確用遞歸思惟會更加簡單……
經典的例子就是斐波那契數列, 在數學上, 斐波那契數列就是用遞歸來定義的:遞歸

·F0 = 0
·F1 = 1 
·Fn = Fn – 1 + Fn – 2遊戲

  有了遞歸的算法, 用程序實現實在再簡單不過了:ip

def fibonacci(n) if n == 0 then return 0 elsif n == 1 then return 1 else return fibonacci(n - 1) + fibonacci(n - 2) end end 

  改成用迭代實現呢? 你能夠試試.
  上面講了怎麼理解遞歸是正確的, 同時能夠看到在有遞歸算法描述後, 其實程序很容易寫, 那麼最關鍵的問題就是, 咱們怎麼找到一個問題的遞歸算法呢?
Paul Graham提到, 你只須要作兩件事情:內存

  1. 你必需要示範如何解決問題的通常狀況, 經過將問題切分紅有限小並更小的子問題.
  2. 你必需要示範如何經過有限的步驟, 來解決最小的問題(基本用例).

若是這兩件事完成了, 那問題就解決了. 由於遞歸每次都將問題變得更小, 而一個有限的問題終究會被解決的, 而最小的問題僅需幾個有限的步驟就能解決.

  這個過程仍是數學概括法的方法, 只不過和上面提到的一個是驗證, 一個是證實.
如今咱們用這個方法來尋找漢諾塔這個遊戲的解決方法.(這實際上是數學家發明的遊戲)

有三根杆子A,B,C。A杆上有N個(N>1)穿孔圓盤,盤的尺寸由下到上依次變小。要求按下列規則將全部圓盤移至C杆:
1.每次只能移動一個圓盤.
2.大盤不能疊在小盤上面.

漢諾塔

這個遊戲在只有3個盤的時候玩起來較爲簡單, 盤越多, 就越難, 玩進去後, 你就會進入一種不停的經過回溯來推導下一步該幹什麼的狀態, 這是比較難的. 我記得第一次碰到這個遊戲好像是在大航海時代某一代遊戲裏面, 當時就以爲挺有意思的. 推薦你們都實際的玩一下這個遊戲, 試試你腦殼能想清楚幾個盤的狀況.
如今咱們來應用Paul Graham的方法思考這個遊戲.

通常狀況:
當有N個圓盤在A上, 咱們已經找到辦法將其移到C槓上了, 咱們怎麼移動N+1個圓盤到C槓上呢? 很簡單, 咱們首先用將N個圓盤移動到C上的方法將N個圓盤都移動到B上, 而後再把第N+1個圓盤(最後一個)移動到C上, 再用一樣的方法將在B槓上的N個圓盤移動到C上. 問題解決.

基本用例:
當有1個圓盤在A上, 咱們直接把圓盤移動到C上便可.

算法描述大概就是上面這樣了, 其實也能夠看做思惟的過程, 相對來講仍是比較天然的. 下面是Ruby解:

def hanoi(n, from, to, other) if n == 1 then puts from + ' -> ' + to else hanoi(n-1, from, other, to) hanoi(1, from, to, other) hanoi(n-1, other, to, from) end end 

當n=3時的輸出:

A -> C
A -> B
C -> B
A -> C
B -> A
B -> C
A -> C

上述代碼中, from, to, other的做用其實也就是提供一個杆子的替代符, 在n=1時, 其實也就至關於直接移動. 看起來這麼複雜的問題, 其實用遞歸這麼容易, 沒有想到吧. 要是想用迭代來解決這個問題呢? 仍是你本身試試吧, 你試的越多, 就能越體會到遞歸的好處.

遞歸的問題

固然, 這個世界上沒有啥時萬能的, 遞歸也不例外, 首先遞歸併不必定適用全部狀況, 不少狀況用迭代遠遠比用遞歸好了解, 其次, 相對來講, 遞歸的效率每每要低於迭代的實現, 同時, 內存好用也會更大, 雖然這個時候能夠用 尾遞歸來優化, 可是尾遞歸併非必定能簡單作到.
相關文章
相關標籤/搜索