十八年開發經驗分享(06)遞歸程序構造

此次談談遞歸程序的問題,之因此選遞歸這個話題主要是如下三個緣由。第一個是本身的體會。在個人記憶中掌握遞歸程序是有必定難度的。最初在寫遞歸程序時是全靠腦子想,一層一層的想着程序如何遞歸下去,而後又是如何返回的,最後整個遞歸程序又是如何結束的。對於一些簡單的遞歸問題,特別是一些簡單的習題,這個做法雖然笨拙,可是卻有着至關的實用價值。只要腦子好使,一層一層的想下去,是能夠解決一部分問題的。可是對於一些邏輯有點複雜,或者遞歸層數比較多的狀況,這個方法就很差用了。尤爲是在一些遞歸深度不肯定的狀況下,單憑腦子想就很難解決問題了。 

第二個是應該有至關一部分的開發者認爲遞歸程序很差寫。這個結論來源於個人一個員工。這個員工大概有幾年的開發經驗,而且談吐處事很得體穩重,給個人印象是不錯的。在一次閒談中他將會寫遞歸程序做爲一個亮點提出來的,言下之意本身的技術是很不錯的。另外一次經驗來自一個面試的人。他把項目組的頭會寫遞歸程序做爲一個敬佩的理由。由此我判斷應該有至關一部分的開發者以爲遞歸程序很差寫。 node

第三個理由就比較簡單了,那就是遞歸程序確實頗有用,很值得去掌握。在開發Entity Model Studio的時序圖時,須要遍歷消息流通過的全部節點,從而實現一個方便的移動操做,這裏就用到了遞歸遍歷。由於結構上講是在遍歷一棵樹。面試

一.遞歸的定義數據結構

遞歸的概念的嚴格定義應該是來自數學,這個google一下就能夠知道的。固然數學上的定義確定是不太好理解的,有興趣的能夠本身看一下。這裏給一個比較容易理解的版本,也是一個比較實用的說法。若是定義一個概念的時候使用到了這個概念自己那麼這就是遞歸了。好比下面的二叉樹的定義:google

二叉樹(BinaryTree)是:n(n≥0)個結點的有限集,它或者是空集(n=0),或者由一個根結點及兩棵互不相交的、分別稱做這個根的左子樹和右子樹的二叉樹組成。spa

在上面的文字中,冒號後面的內容就是二叉樹的定義。在這個定義中又出現了二叉樹這個概念,因此這是二叉樹的遞歸定義。固然須要區分一下這和循環論證是不同的。.net

二.遞歸程序的結構設計

既然憑腦子想不能很好的解決問題,那麼咱們就須要使用一個更好的方法。咱們能夠從遞歸程序的結構出發來構造完成遞歸程序。因此這裏介紹一下遞歸程序的結構。從結構上講遞歸程序分爲三個部分:遞歸出口,邏輯處理,遞歸調用。code

1. 遞歸的出口對象

所謂遞歸的出口,就是指知足什麼條件時程序再也不須要遞歸調用了。這個時候每每是遞歸程序遞歸調用到最深層的時候,須要開始迴歸了。還有一種狀況是作出判斷決定是否執行當前的遞歸程序,好比對遞歸方法的參數的容錯處理。blog

2. 邏輯處理

在考慮寫遞歸程序的時候,至少須要知道在遞歸出口時須要執行的邏輯處理是什麼。其次就是某一次遞歸調用先後須要執行的邏輯處理是什麼。須要注意的是,這個時候的處理只是針對部分的數據,由於都是在某一次遞歸的執行中處理數據。不是對全部數據的完整的處理。完整的處理是整個遞歸程序執行完畢後才能完成的。

3. 遞歸調用

這個很好理解,就是在遞歸程序內部調用本身。

三.遞歸程序構造舉例1

爲了有一個感性的認識,這裏舉一個例子說明一下如何從遞歸程序的結構出發來完成遞歸程序的構造。這裏就用教科書上遍歷二叉樹的例子來分析一下如何處理遞歸程序。首先咱們考慮一下若是咱們須要遍歷一顆二叉樹,那麼什麼狀況下咱們能夠不用再遞歸遍歷或者不必繼續遍歷了?這個答案就是遇到一顆空樹的時候就沒有必要再遍歷了。參考下面的方法定義:

 

[csharp]  view plain copy 在CODE上查看代碼片 派生到個人代碼片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     …  
  4. }  

 

其中參數node就表示了一顆二叉樹的根節點,若是這個node的值是空的話,那麼咱們就沒有必要再遞歸了,能夠按照需求直接處理或者什麼都不作直接返回了。因此上述方法的內部須要包含以下代碼來結束遞歸,也就是所謂的遞歸的出口:

 

[csharp]  view plain copy 在CODE上查看代碼片 派生到個人代碼片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.         return;  
  5.     …  
  6. }  

至此已經完成遞歸程序的第一部分了,固然也是最簡單的一部分。須要強調的是這部分雖然是最簡單的,但仍是建議你們在構思遞歸程序時最好首先明確這部分的內容。不然一個遞歸程序沒有出口的話,那麼運行起來會把棧擊穿的,從而致使崩潰。

 

下面第二步就要考慮核心的問題了,那就是若是node不爲空時咱們如何處理?首先須要明確咱們要完成的邏輯是什麼。通常教科書上的遍歷例子,不會講所謂邏輯處理的,只是描述遍歷,這裏咱們能夠假設一個虛擬的邏輯處理。咱們假設這個邏輯處理由以下的方法完成:

 

[csharp]  view plain copy 在CODE上查看代碼片 派生到個人代碼片
 
  1. public void DoSomething(NODEnode)  
  2. {  
  3.     …  
  4. }  

因而加上邏輯執行部分,咱們的遞歸程序看上去就如同下面的樣子了:

 

 

[csharp]  view plain copy 在CODE上查看代碼片 派生到個人代碼片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.         return;  
  5.     DoSomething(node);  
  6. }  

很明顯Dosomething按照既定的要求完成了對節點node的處理,可是咱們須要處理二叉樹中的每個節點,只執行DoSomething(node)這一行代碼是不夠的。因此這時咱們須要遞歸程序的第三部分,即遞歸調用。就這個例子而言,node表示一棵二叉樹的根節點,而且在VisitBinaryTree方法內部咱們調用了DoSomething方法完成了對node節點的處理。那麼剩下的工做就是要處理node的左子樹和右子樹了,只有這樣纔算是完成了對node爲根的整棵二叉樹的處理。

 

這個時候咱們能夠再繼續寫代碼來處理node的左子樹和右子樹,可是等等,因爲咱們如今構造的方法VisitBinaryTree就是用來處理二叉樹的,而左子樹或者右子樹自己也是一棵二叉樹,因此咱們就沒有必要再寫額外的代碼來處理而是直接遞歸調用該方法就能夠了。因此加入遞歸調用的代碼後,VisitBinaryTree方法差很少就是下面的樣子了:

 

[csharp]  view plain copy 在CODE上查看代碼片 派生到個人代碼片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.        return;  
  5.    
  6.     DoSomething(node);  
  7.     VisitBinaryTree(node.LeftSon);  
  8.     VisitBinaryTree(node.RightSon);  
  9. }  

至此遍歷二叉樹的方法就完成了。下面咱們來討論一個細節問題,那就是根節點的判空問題。在這個例子中node的判空處理既是遞歸的出口,也是一種容錯處理。由於若是不進行容錯處理的話,那麼DoSomething方法內容若是訪問了node對象的屬性或者方法,就會出現null對象方法的異常。可是有些人更習慣於在遞歸執行前對是否爲空值做出判斷,從而決定是否遞歸調用,這能夠保證每次遞歸調用時,傳入的值都不爲空。所以代碼差很少是下面這個樣子:

 

 

[csharp]  view plain copy 在CODE上查看代碼片 派生到個人代碼片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     DoSomething(node);  
  4.      
  5.     if (node.LeftSon != null)  
  6.         VisitBinaryTree(node.LeftSon);  
  7.     if (node. RightSon != null)  
  8.         VisitBinaryTree(node.RightSon);  
  9. }  

這樣的代碼確實保證了傳入遞歸方法的根節點參數不爲空。可是卻忽略了一個問題,那就是第一次調用VisitBinaryTree方法時node爲空的狀況沒有考慮。假設以下的狀況,咱們在一個方法OneMethod中以下調用的例子:

 

 

[csharp]  view plain copy 在CODE上查看代碼片 派生到個人代碼片
 
  1. public void OneMethod(…)  
  2. {  
  3.     …  
  4.     VisitBinaryTree(null);  
  5.     …  
  6. }  

因此爲了應對這個狀況,最初判斷node是否爲空的代碼仍是須要的,這樣代碼就變成以下的樣子了:

 

 

[csharp]  view plain copy 在CODE上查看代碼片 派生到個人代碼片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.        return;  
  5.    
  6.     DoSomething(node);  
  7.      
  8.     if (node.LeftSon != null)  
  9.        VisitBinaryTree(node.LeftSon);  
  10.     if (node. RightSon != null)  
  11.        VisitBinaryTree(node.RightSon);  
  12. }  

好,如今重點來了。上面的代碼中最後兩個判空的if語句還須要麼?答案是不須要了。假設去掉最後兩個判空的判斷,那麼傳入的參數確實有可能爲空,可是當這樣的參數傳入VisitBinaryTree方法時,該方法的最開始就對這個參數執行了判空的處理,若是爲空就直接返回了。因此達到了一樣的目的。請體會一下,遞歸程序就是這個樣子的。

 

四.遞歸程序構造舉例2

在數據結構的教材上,遍歷二叉樹的方法有六種不一樣的版本,最經常使用的只有三種,分別是:先序優先遍歷,左序優先遍歷,右序優先遍歷。上面的例子是用的先序優先遍歷。下面看看用一樣的方法來構造左序優先遍歷的遞歸方法。所謂左序優先,是要求先遍歷處理完二叉樹的左子樹,而後處理根節點,而後再遍歷處理完二叉樹的右子樹。

好,咱們仍是首先考慮遞歸的出口,對於遍歷二叉樹而言,其出口仍舊不變,仍是判空,若是爲空就直接返回不處理了。因此第一步的代碼是同樣的,就再也不列出來了。下面是若是節點不爲空,咱們須要先遍歷處理左子樹,再處理根節點,而後再遍歷處理右子樹。根據這個要求咱們明確了能夠執行的邏輯是處理根節點。至此,第二部分完成一半了,列出代碼以下:

 

[csharp]  view plain copy 在CODE上查看代碼片 派生到個人代碼片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.        return;  
  5.    
  6.     DoSomething(node);  
  7. }  

下面就是關鍵了,那就是如何遞歸調用了。爲了易於理解,咱們能夠先假設,須要處理的二叉樹是沒有任何左子樹的,也就是說要麼沒有任何子樹,要麼就只有右子樹。這樣咱們只須要考慮右子樹就能夠了,把左子樹忘了吧。根據遍歷處理的要求是先處理根節點而後再處理左子樹,因此咱們的代碼以下:

 

 

[csharp]  view plain copy 在CODE上查看代碼片 派生到個人代碼片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.        return;  
  5.    
  6.     DoSomething(node);  
  7.     VisitBinaryTree(node.RightSon);  
  8. }  

當VisitBinaryTree被遞歸調用時,傳入的是node的右子樹的根節點。這個右子樹根節點,傳入後首先是被判空,而後是調用DoSomething執行邏輯處理,而後再次遞歸調用來處理右子樹的右子樹。顯然這樣的遞歸調用邏輯是對的。

 

如今再假設須要處理的二叉樹是沒有任何右子樹的,也就是說要麼沒有任何子樹,要麼就只有左子樹。這樣咱們只須要考慮左子樹就能夠了,此次能夠把右子樹忘了吧。根據遍歷處理的要求是先處理左子樹而後再根節點,因此咱們的代碼以下:

 

[csharp]  view plain copy 在CODE上查看代碼片 派生到個人代碼片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.        return;  
  5.    
  6.     VisitBinaryTree(node.LeftSon);  
  7.     DoSomething(node);  
  8. }  

當VisitBinaryTree被遞歸調用時,傳入的是node的左子樹的根節點。這個左子樹根節點,傳入後首先是被判空,而後是再次遞歸調用VisitBinaryTree遍歷處理左子樹的左子樹。而後再處理根節點,顯然這樣的邏輯也是對的。好了,至此咱們能夠考慮既有左子樹又有右子樹的通常狀況了,把兩部分的代碼合起來就能夠了,以下所示:

 

 

[csharp]  view plain copy 在CODE上查看代碼片 派生到個人代碼片
 
  1. public void VisitBinaryTree(NODE node)  
  2. {  
  3.     if (node == null)  
  4.        return;  
  5.    
  6.     VisitBinaryTree(node.LeftSon);  
  7.     DoSomething(node);  
  8.     VisitBinaryTree(node.RightSon);  
  9. }  

五.遞歸程序構造的比較

舉例1的構造過程是從遞歸程序結構自己直接推導出來的,是一個很天然的過程。在構造時並無考慮是否爲後續遍歷,只是構造完成後正好和後續遍歷一致。在舉例2中的構造過程當中使用了一點技巧,那就是爲了簡化問題,看清遞歸調用的位置,前後假設不存在左子樹和右子樹的狀況,而後再將兩部分合並,從而完成遞歸程序的構造。這就是說舉例2中在使用這個方法時多了一個簡化問題的步驟,這是使用已知的知識解決問題的一個例子。關於解決問題的更多討論能夠參考本系列中問題解決篇的討論。再將這兩個例子和教科書上的例子作一個比較。這裏的討論給出了遞歸程序構造的詳細步驟,相比教科書上直接給出結果來講,我以爲這裏討論更容易理解。另外一個區別是,因爲本文的例子是從遞歸的結構出發完成構造遞歸程序的,因此沒有涉及討論所謂遞歸程序執行時會用到的工做棧的問題。有興趣的能夠再看一下其它相關的資料,對工做棧的瞭解應該多少對遞歸程序的認識是有幫助的。

此次就寫到這裏,感謝閱讀。下一篇仍是談談遞歸程序,介紹一個更強更"廣譜適用"的方法來完成遞歸程序的設計。推廣一下個人創業羣:244054966,歡迎加入

相關文章
相關標籤/搜索