5.1.5 函數的遞歸調用
在函數調用中,通常我們都是在一個函數中調用另外一個函數,以此來完成其中的某部分功能。例如,我們在main()主函數中調用PowerSum()函數來計算兩個數的平方和,而在PowerSum()函數中,又調用Power()函數和Add()函數來計算每個數的平方並將兩個平方加和起來成為最終的結果。除此之外,在C++中還存在另外一種特殊的函數調用方式,那就是在一個函數內部調用它自己本身,這種方式也被稱為函數的遞歸調用。
函數的遞歸調用,實際上是實現函數的一種特殊方式。當遞歸函數被調用的時候,會產生一個自己調用自己的循環,這個循環會不斷地遞歸進行下去,直到最后一次函數調用在特殊條件下,也就是滿足了遞歸的終止條件,不再繼續調用自身而是返回某個具體的結果數據。這時,所有調用這個函數的上層函數會依次返回,直到我們最初對這個函數的調用返回,獲得其結果數據。雖然函數的遞歸調用每次調用的都是自己,但是每次遞歸調用的條件,也即是函數參數,往往有所不同。正是調用條件的變化,才有可能使函數滿足終止條件並返回一個具體的結果數據,不再繼續遞歸地調用自身,這也即是遞歸調用的終點。
函數的遞歸調用雖然形式上比較復雜,但是它在處理那些可以把一個大問題分解成一個已知的結果與另一個類似的小問題,需要重復多次做相似的事情才能最終解決的問題時,因為函數的遞歸調用本身所表達的意義就是循環往復地做同一件事情,所以在處理這類問題上有着天然的優勢。例如,我們要統計某個字符在目標字符串中出現的次數。通常,我們的思路是用for循環遍歷整個字符數組,然后逐個字符地進行匹配統計。而如果采用遞歸函數的思路來解決這個問題,那么整個統計過程就變為:從目標字符串的開始位置查找這個字符,如果找到,那么字符出現的次數就成了已經找到的這一次加上在剩下的字符串中出現的次數,在程序中我們可以用“1 + CountChar(pos+1, c)”來表示,其中“1”表示已經找到的字符出現一次,而“CountChar(pos+1, c)”則代表了字符在剩下的字符串中出現的次數,加起來剛好就是字符在整個字符串中出現的次數。這里的“CountChar(pos+1, c)”就是在變更開始條件后對CountChar()函數的遞歸調用,進行第二次查找與統計。第二次查找也會進行類似的查找統計過程,如果找到則會第三次調用CountChar()函數繼續向后繼續查找統計。這個過程會不斷地持續進行下去,直到最后滿足遞歸的終止條件——查找到了字符串的結尾,再也找不到這個字符——為止。在這個過程中,有需要循環往復執行的相同動作——從字符串開始位置查找目標字符;有不同的開始條件——在字符串的不同位置開始查找;有終止條件——在字符串中再也找不到目標字符。有了這三個特征,我們就可以用函數的遞歸調用更輕松而自然地解決這個問題:
// countchar.cpp 統計一個字符串中某個字符出現的次數 #include <iostream> #include <cstring> // 引入字符查找函數strchr()所在的頭文件 using namespace std; // 用函數的遞歸調用實現統計字符在字符串中出現的次數 int CountChar(const char* str,const char c) { // 從字符串str的開始位置查找字符c char* pos = strchr(str,c); // 如果strchr()函數的返回值為nullptr,則意味着 // 在字符串中再也找不到目標字符,遞歸的終止條件得到滿足 // 則結束函數的遞歸調用,直接返回本次的查找結果0 if(nullptr == pos) { return 0; } // 如果沒有達到終止條件,則將本次查找結果1統計在內, // 並在新的開始位置pos + 1開始下一次查找,實現函數的遞歸調用 return 1 + CountChar(pos + 1,c); } int main() { // 字符串 char str[] = "Thought is a seed"; char c = 'h'; // 目標字符 // 調用CountChar()函數進行統計 int nCount = CountChar(str,c); // 輸出結果 cout<<"字符\'"<<c<<"\'在\""<<str<<"\"中出現了" <<nCount<<"次"<<endl; return 0; }
在執行的過程中,當CountChar()在主函數中第一次被調用時,第一個參數str指向的字符串是“Thought is a seed”,這時進入CountChar()函數執行,strchr()函數會在其中找到字符‘h’出現的位置並保存到字符指針pos中,此時尚不滿足終止條件(nullprt == pos), 則執行“return 1 + CountChar(pos+1,c)”,將本次查找結果統計在內,並變更遞歸的開始條件為“pos+1”,讓第二次遞歸調用CountChar()函數時參數str指向的字符串變為“ought is a seed”。在第二次進入CountChar()函數執行時,strchr()函數會找到字符‘h’第二次出現的位置,遞歸的終止條件依然無法得到滿足,則繼續將本次查找結果統計在內並修改開始條件,將CountChar()函數的str參數指向“t is a seed”,開始第三次遞歸調用。在第三次進入CountChar()函數執行時,strchr()函數在剩下的字符串中再也找不到目標字符,遞歸的終止條件得到滿足,函數直接返回本次的查找統計結果0(return 0;),不再繼續向下遞歸調用CountChar()函數,然后逐層向上返回,最終結束整個函數遞歸調用的過程,得到最終結果2,也就是目標字符在字符串中出現的次數。整個過程如下圖5-8所示。
圖5-8 CountChar()函數的遞歸調用過程
函數的遞歸調用,其實質就是將一個大問題不斷地分解成多個相似的小問題,然后通過不斷地細分,直到小問題被解決,才最終解決最開始的大問題。例如在這個例子中,我們開始的大問題是統計字符串中的目標字符的個數,然后這個大問題被分解為當前已經找到的目標字符數1和剩余字符串中的目標字符數CountChar(pos+1,c),而我們要計算剩余字符串中的目標字符數,又可以采用同樣的策略進一步細分,直至剩余字符串中沒有目標字符,無法繼續細分為止。從這里我們也可以看到,函數的遞歸調用實際上是一個循環過程,我們必須確保函數能夠達到它的遞歸終止條件,結束遞歸。例如,我們這里不斷地調整查找的開始位置,讓查找到最后再也無法找到目標字符而滿足終止條件。否則,函數會無限地遞歸調用下去,最終形成一個無限循環而永遠無法獲得結果。這一點是我們在設計遞歸函數時尤其需要注意的。
函數的遞歸調用,是通過在一個函數中循環往復地調用它自身來完成的,從本質上講,函數的遞歸調用其實是一種特殊形式的循環。所以,我們也可以將一個函數的遞歸調用改用循環結構來實現。例如,上面的CountChar()函數可以用循環結構改寫為:
// 用循環結構實現統計字符在字符串中出現的次數 int CountChar(const char* str,const char c) { int nTotal = 0; // 記錄字符出現次數 // 在字符串中查找字符,並對結果進行判斷 // 如果strchr()返回nullptr,則表示查找完畢,循環結束 while(nullptr != (str = strchr(str,c))) { ++nTotal; // 將找到的字符統計在內 ++str; // 字符串往后移動,開始下一次循環 } return nTotal; }
這里我們不禁要問,既然函數的遞歸調用可以用循環結構來實現,而函數的遞歸調用又涉及到函數調用時的那些傳遞參數保護現場的幕后工作,性能比較低下,那么我們為什么還要使用函數的遞歸調用而不是直接使用效率更高的循環結構來解決問題呢?這是因為,面對某些特殊問題,我們很難用循環結構來解決。比如,從一個數組中找出連續和值最大的數據序列,如果采用循環結構,我們幾乎無從下手,即使最后解決了但性能也是十分低下。而恰好這種問題又可以細分成多個類似的小問題,比如這里我們可以將數組分成左右兩部分,那么和值最大的數據序列要么在左邊部分,要么在右邊部分,要么跨越兩個部分。這樣,這個問題就細分成了尋找左邊部分、右邊部分和跨越左右部分的和值最大序列的三個相似的小問題。而這三個小問題又可以進一步細化,直至最后可以輕松解決的最小問題。在這種情況下使用函數的遞歸調用來解決問題,更加符合我們人類的思考方式,問題解決起來更加容易,同時其性能也會優於循環結構的實現,做到了“又好又快”。解決這類可以不斷細分的特殊問題,就是函數遞歸調用的用武之地。