算法系列之十五:迴圈和遞迴在演算法中的應用
一、遞迴和迴圈的關係
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 } |
迴圈還是遞迴?這是個問題。當面對一個問題的時候,不能一概認為哪種演算法好,哪種不好,而是要根據問題的型別和規模作出選擇。對於線性資料結構,比較適合用迭代迴圈方法,而對於樹狀資料結構,比如二叉樹,遞迴方法則非常簡潔優雅。