一、遞歸和循環的關係
1、 遞歸的定義
順序執行、循環和跳轉是馮·諾依曼計算機體系中程序設計語言的三大基本控制結構,這三種控制結構構成了千姿百態的算法,程序,乃至整個軟件世界。遞歸也算是一種程序控制結構,但是普遍被認爲不是基本控制結構,因爲遞歸結構在一般情況下都可以用精心設計的循環結構替換,因此可以說,遞歸就是一種特殊的循環結構。因爲遞歸方法會直接或間接調用自身算法,因此是一種比迭代循環更強大的循環結構。
2、 遞歸和循環實現的差異
循環(迭代循環)結構通常用在線性問題的求解,比如多項式求和,爲某一結果的精度進行的線性迭代等等。一個典型的循環結構通常包含四個組成部分:初始化部分,循環條件部分,循環體部分以及迭代部分。以下代碼就是用循環結構求解階乘的例子:
86 /*循環算法計算小數字的階乘, 0 <= n < 10 */ 87 int CalcFactorial(int n) 88 { 89 int result = 1; 90 91 int i; 92 for(i = 1; i <= n; i++) 93 { 94 result = result * i; 95 } 96 97 return result; 98 } |
遞歸方法通常分爲兩個部分:遞歸關係和遞歸終止條件(最小問題的解)。遞歸方法的關鍵是確定遞歸定義和遞歸終止條件,遞歸定義就是對問題分解,是指向遞歸終止條件轉化的規則,而遞歸終止條件通常就是得出最小問題的解。遞歸結構與人類解決問題的方式類似,算法簡潔且易於理解,用較少的步驟就能描述解題的全過程。遞歸方法的結構中還隱含了一個步驟,就是“回溯”,對於需要“先進後出”結構進行操作時,使用遞歸方法會更高效。以下代碼就是用遞歸方法求解階乘的例子:
100 /*遞歸算法計算小數字的階乘, 0 <= n < 10 */ 101 int CalcFactorial(int n) 102 { 103 if(n == 0) /*最小問題的解,也就是遞歸終止條件*/ 104 return 1; 105 106 return n * CalcFactorial(n - 1); /*遞歸定義*/ 107 } |
從上面兩個例子可以看出:遞歸結構算法代碼結構簡潔清晰,可讀性強,非常符合“代碼就是文檔”的軟件設計哲學。但是遞歸方法的缺點也很明顯:運行效率低,對存儲空間的佔用也比迭代循環方法多。遞歸方法通過嵌套調用自身達到循環的目的,函數調用引起的參數入棧等開銷會降低算法效率,同樣,對存儲空間的佔用也體現在入棧參數以及局部變量所佔用的棧空間。正因爲這兩點,遞歸方法的應用以及解題的規模都受系統任務或線程棧空間大小的影響,在一些嵌入式系統中,任務或線程的棧空間只有幾千個字節,在設計算法上要慎用遞歸結構算法,否則很容易導致棧溢出而系統崩潰。
3、 濫用遞歸的一個例子
關於使用遞歸方法導致棧溢出的例子有很多,網上流傳一個判斷積偶數的例子,本人已經不記得具體內容了,只記得大致是這樣的:
115 /*從網上摘抄的某人寫的判斷積偶數的代碼,使用了遞歸算法*/ 116 bool IsEvenNumber(int n) 117 { 118 if(n >= 2) 119 return IsEvenNumber(n - 2); 120 else 121 { 122 if(n == 0) 123 return true; 124 else 125 return false; 126 } 127 } |
據說這個例子是某個系統中真是存在的代碼,它經受住了最初的測試並被髮布出去,當用戶的數據大到一定的規模時崩潰了。本人在Windows系統上做過測試,當n超過12000的時候就會導致棧溢出,本系列的下一篇文章,會有一個有關Windows系統上棧空間的有趣話題,這裏不再贅述。下面就是一個合理的、中規中矩的實現:
109 bool IsEvenNumber(int n) 110 { 111 return ((n % 2) == 0); 112 } |
二、遞歸還是循環?這是個問題
1、 一個簡單的24點程序
下面本文將通過兩個題目實例,分別給出用遞歸方法和循環方法的解決方案以及解題思路,便於讀者更好地掌握兩種方法。首先是一個簡單的計算24點的問題(爲了簡化問題,我們假設只使用求和計算方法):
從1-9中任選四個數字(數字可以有重複),使四個數字的和剛好是24。
題目很簡單,數字都是個位數,可以重複且之用加法,循環算法的核心就是使用四重循環窮舉所有的數字組合,對每一個數字組合進行求和,判斷是否是24。使用循環的版本可能是這個樣子:
8 const unsigned int NUMBER_COUNT = 4; //9 9 const int NUM_MIN_VALUE = 1; 10 const int NUM_MAX_VALUE = 9; 11 const unsigned int FULL_NUMBER_VALUE = 24;//45; 40 void PrintAllSResult(void) 41 { 42 int i,j,k,l; 43 int numbers[NUMBER_COUNT] = { 0 }; 44 45 for(i = NUM_MIN_VALUE; i <= NUM_MAX_VALUE; i++) 46 { 47 numbers[0] = i; /*確定第一個數字*/ 48 for(j = NUM_MIN_VALUE; j <= NUM_MAX_VALUE; j++) 49 { 50 numbers[1] = j; /*確定第二個數字*/ 51 for(k = NUM_MIN_VALUE; k <= NUM_MAX_VALUE; k++) 52 { 53 numbers[2] = k; /*確定第三個數字*/ 54 for(l = NUM_MIN_VALUE; l <= NUM_MAX_VALUE; l++) 55 { 56 numbers[3] = l; /*確定第四個數字*/ 57 if(CalcNumbersSum(numbers, NUMBER_COUNT) == FULL_NUMBER_VALUE) 58 { 59 PrintNumbers(numbers, NUMBER_COUNT); 60 } 61 } 62 } 63 } 64 } 65 } |
這個PrintAllSResult()函數看起來中規中矩,但是本人的編碼習慣很少在一個函數中使用超過兩重的循環,更何況,如果題目修改一下,改成9個數字求和是45的組合序列,就要使用9重循環,這將使PrintAllSResult()函數變成臭不可聞的垃圾代碼。
現在看看如何用遞歸方法解決這個問題。遞歸方法的解題思路就是對題目規模進行分解,將四個數字的求和變成三個數字的求和,兩個數字的求和,當最終變成一個數字時,就達到了遞歸終止條件。這個題目的遞歸解法非常優雅:
67 void EnumNumbers(int *numbers, int level, int total) 68 { 69 int i; 70 71 for(i = NUM_MIN_VALUE; i <= NUM_MAX_VALUE; i++) 72 { 73 numbers[level] = i; 74 if(level == (NUMBER_COUNT - 1)) 75 { 76 if(i == total) 77 { 78 PrintNumbers(numbers, NUMBER_COUNT); 79 } 80 } 81 else 82 { 83 EnumNumbers(numbers, level + 1, total - i); 84 } 85 } 86 } 87 88 void PrintAllSResult2(void) 89 { 90 int numbers[NUMBER_COUNT] = { 0 }; 91 92 EnumNumbers(numbers, 0, FULL_NUMBER_VALUE); 93 } |
如果題目改成“9個數字求和是45的組合序列”,只需將NUMBER_COUNT的值改成9,FULL_NUMBER_VALUE的值改成45即可,算法主體部分不需做任何修改。
2、 單鏈表逆序
第二個題目是很經典的“單鏈表逆序”問題。很多公司的面試題庫中都有這道題,有的公司明確題目要求不能使用額外的節點存儲空間,有的沒有明確說明,但是如果面試者使用了額外的節點存儲空間做中轉,會得到一個比較低的分數。如何在不使用額外存儲節點的情況下使一個單鏈表的所有節點逆序?我們先用迭代循環的思想來分析這個問題,鏈表的初始狀態如圖(1)所示:
圖(1)初始狀態
初始狀態,prev是NULL,head指向當前的頭節點A,next指向A節點的下一個節點B。首先從A節點開始逆序,將A節點的next指針指向prev,因爲prev的當前值是NULL,所以A節點就從鏈表中脫離出來了,然後移動head和next指針,使它們分別指向B節點和B的下一個節點C(因爲當前的next已經指向B節點了,因此修改A節點的next指針不會導致鏈表丟失)。逆向節點A之後,鏈表的狀態如圖(2)所示:
圖(2)經過第一次迭代後的狀態
從圖(1)的初始狀態到圖(2)狀態共做了四個操作,這四個操作的僞代碼如下:
head->next = prev;
prev = head;
head = next;
next = head->next;
這四行僞代碼就是循環算法的迭代體了,現在用這個迭代體對圖(2)的狀態再進行一輪迭代,就得到了圖(3)的狀態:
圖(3)經過第二次迭代後的狀態
那麼循環終止條件呢?現在對圖(3)的狀態再迭代一次得到圖(4)的狀態:
圖(4)經過第三次迭代後的狀態
此時可以看出,在圖(4)的基礎上再進行一次迭代就可以完成鏈表的逆序,因此循環迭代的終止條件就是當前的head指針是NULL。
現在來總結一下,循環的初始條件是:
prev = NULL;
循環迭代體是:
next = head->next;
head->next = prev;
prev = head;
head = next;
循環終止條件是:
head == NULL
根據以上分析結果,逆序單鏈表的循環算法如下所示:
61 LINK_NODE *ReverseLink(LINK_NODE *head) 62 { 63 LINK_NODE *next; 64 LINK_NODE *prev = NULL; 65 66 while(head != NULL) 67 { 68 next = head->next; 69 head->next = prev; 70 prev = head; 71 head = next; 72 } 73 74 return prev; 75 } |
現在,我們用遞歸的思想來分析這個問題。先假設有這樣一個函數,可以將以head爲頭節點的單鏈表逆序,並返回新的頭節點指針,應該是這個樣子:
77 LINK_NODE *ReverseLink2(LINK_NODE *head) |
現在利用ReverseLink2()對問題進行求解,將鏈表分爲當前表頭節點和其餘節點,遞歸的思想就是,先將當前的表頭節點從鏈表中拆出來,然後對剩餘的節點進行逆序,最後將當前的表頭節點連接到新鏈表的尾部。第一次遞歸調用ReverseLink2(head->next)函數時的狀態如圖(5)所示:
圖(5)第一次遞歸狀態圖
這裏邊的關鍵點是頭節點head的下一個節點head->next將是逆序後的新鏈表的尾節點,也就是說,被摘除的頭接點head需要被連接到head->next才能完成整個鏈表的逆序,遞歸算法的核心就是一下幾行代碼:
84 newHead = ReverseLink2(head->next); /*遞歸部分*/ 85 head->next->next = head; /*回朔部分*/ 86 head->next = NULL; |
現在順着這個思路再進行一次遞歸,就得到第二次遞歸的狀態圖:
圖(6)第二次遞歸狀態圖
再進行一次遞歸分析,就能清楚地看到遞歸終止條件了:
圖(7)第三次遞歸狀態圖
遞歸終止條件就是鏈表只剩一個節點時直接返回這個節點的指針。可以看出這個算法的核心其實是在回朔部分,遞歸的目的是遍歷到鏈表的尾節點,然後通過逐級回朔將節點的next指針翻轉過來。遞歸算法的完整代碼如下:
77 LINK_NODE *ReverseLink2(LINK_NODE *head) 78 { 79 LINK_NODE *newHead; 80 81 if((head == NULL) || (head->next == NULL)) 82 return head; 83 84 newHead = ReverseLink2(head->next); /*遞歸部分*/ 85 head->next->next = head; /*回朔部分*/ 86 head->next = NULL; 87 88 return newHead; 89 } |
循環還是遞歸?這是個問題。當面對一個問題的時候,不能一概認爲哪種算法好,哪種不好,而是要根據問題的類型和規模作出選擇。對於線性數據結構,比較適合用迭代循環方法,而對於樹狀數據結構,比如二叉樹,遞歸方法則非常簡潔優雅。