介紹遞迴的原理,與經典題型:最大公因數 (GCD)、費波納契數列 (Fibonacci Sequence)、河內塔 (Hanoi Tower)、N 個字元的排列組合。
遞迴
遞迴 (Recursive) 是程式中包含自我呼叫 (self-calling)。
function Recursive(int x){ int y = 0; //y 為區域變數 ... if(x != 0){ Recursive(y); } int z = Recursive(y) + 1; //compiler會從這裡開始執行 /* compiler內部會儲存: temp = Recursive(); z = temp+1; */ }//end敘述 /*問題: 當compiler遇到end敘述時,如何判斷是什麼樣的結束? (是某次的遞迴結束而已,還是整個程式的結束?)*/
1. 當程式遇到 recursive call 時,必須保存當時的執行狀態;即 push 需要保存的內容到 stack memory 中:
- 參數值 (e.g. x 值)
- 區域/暫存變數值 (e.g. y 值)
- 返回位址 (return address),紀錄遞迴呼叫完成後、下一個可執行的程序是什麼
2. 接下來,go to 程式開端執行
3. 若執行遇到 end 敘述時,則:
if(stack == 空){ 整個程式執行結束 } else{ //stack不為空。即某一次的遞迴結束,而非整個程式執行結束 pop stack; //取出參數,區域變數及返回位址 go to "返回位址" 執行 };
由於 Stack 空間有限,若又不斷 push 的話就會爆掉,造成所謂的「Stack Overflow」:Recursive 所需的 Stack 空間= (每次 push 的量) * (recursive call 次數) 。
另外,Push/Pop 執行的時間即是一個損耗,所以 Recursive 相當花時間。
Recursive 和 Iterative 的比較
目前學者已證明了——任何一個問題的解決方式,必存在 Recursive 和 Iterative 兩種形式;也就是說,解決同一個問題,可以有遞迴和非遞迴的兩種解法。
從遞迴轉換到非遞迴的程式有一個 SOP 流程可以轉換。但從非遞迴到遞迴沒有 SOP 流程,只能從「理論上」知道一定可以成功轉換過去。
舉例來說,計算一個數字的階乘 (Factorial):
n! = 1 * 2 * 3 * ... * n-1 * n, n >= 0 0! = 1
可以有兩種寫法:
int Factorial_iterative(int n){ if(n == 0){ return 1; }else{ int k = 1; int i; for(i = 1; i <= n; i++){ k = k*i; } return k; } }
int Factorial_recursive(int n){ if(n == 0){ return 1; }else{ return n*Factorial(n-1); } }
可以看到同樣計算 5! = 120 ,Iterative 版本所需的時間更少。至於兩種方式何種較佳呢?這就牽涉到效能 (空間、時間) 的分析啦!
-
Recursive 的優缺
- 優:程式碼較為精簡
- 優:區域 (暫存) 變數較少
- 優:佔用的儲存空間較少
- 缺:程式執行的時間較長、較無效率
- 缺:需要額外的 Stack 支持
-
Iterative 的優缺
- 優:程式執行的時間較短 (不用額外處理 push/pop)
- 優:不需額外的 Stack 支持
- 缺:程式碼較冗長
- 缺:區域 (暫存) 變數較少
- 缺:佔用的儲存空間較大
經典 RECURSIVE 題型
1. 最大公因數
利用輾轉相除法:
#include <stdio.h> int GCD(int a, int b){ if(a%b == 0){ return b; }else{ return GCD(b, a%b); } } int main(){ printf("Input two numbers: "); int a, b; scanf(" %d %d", &a, &b); int result = GCD(a, b); printf("GCD result: %d\n", result); }
2. FIBONACCI 數列
一個有名的數列 Fibonacci 數列定義如下:
這個數列在 13 世紀初由義大利比薩 (Pisa) 一位叫李奧納多 (Leonardo) 的人所提出:
- 小兔出生後兩個月就能長成大兔,可以生小兔。
- 可生育的大兔子每個月可以生一對小兔,而且剛好是雄雌各一。
- 兔子永生不死。
首幾個 Fibonacci 數是:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233……
RECURSIVE VERSION
int Fibo(int n){ if(n == 0){ return 0; }else if(n == 1){ return 1; }else{ return Fibo(n-1)+Fibo(n-2); } } //Time: 近似於O(2^n)
ITERATIVE VERSION
int Fibo(int n){ if(n == 0){ return 0; }else if(n == 1){ return 1; }else{ int a = 0; int b = 1; int i, c; for(i = 1; i < n; i++){ c = a + b; a = b; b = c; } return c; } } //Time: O(n)
DP (DYNAMIC PROGRAMMING) VERSION
在計算 Recursive 題目時,會有蠻多重複的計算,比如在計算 F(4) 時得算出 F(3) / F(2) / F(1) /F(0),但計算 F(3) 時又另外把 F(2) / F(1) /F(0) 重複計算…。
因此我們使用 Dynamic Programming 的技巧來求 Fibonacci 數列,也就是使用一個一維陣列來儲存之前計算過的成果。
int Fibo(int n){ int F[n+1]; F[0] = 0; F[1] = 1; if(n < 2){ return F[n]; }else{ int i; for(i = 2; i <= n; i++){ F[i] = F[i-1] + F[i-2]; //保存先前的運算成果 } return F[n]; } } //Time: o(n)
可以發現相同的結果:F(10) = 55,Recursive、Iterative、Dynamic Programming 三個版本的時間差異是由大到小。
遞迴的 Fibonacci 數列的時間會這麼長,原因是因為先前算過的內容得重新再算一次、太多冗餘的計算。如果改成 Iterative 版本或 DP 版本就會更有效率。
3. 河內塔問題
欲將 A 柱上的 n 個盤子搬移到 C 柱,但必須遵守以下規則:每次只能移動一個圓盤,且大盤不能疊在小盤之上。
#include <stdio.h> int count = 0; void hanoi(int n, char A, char B, char C) { if (n == 1) { printf("%d: 將第 %d 個圓盤由 %c 移到 %c\n", count++, n, A, C); } else { hanoi(n - 1, A, C, B); printf("%d: 將第 %d 個圓盤由 %c 移到 %c\n", count++, n, A, C); hanoi(n - 1, B, A, C); } } int main() { int n; printf("請輸入河內塔的高度:"); scanf("%d", &n); hanoi(n, 'A', 'B', 'C'); printf("移動 %d 層河內塔共需移動 %d 次\n", n, count); return 0; }
遞迴關係式:T(n) = T(n-1) + T(1) + T(n-1),且 T(1) = 1 ;則 T(n) = 2*T(n-1) + T(1) ,解出 T(n) 為 2^n -1。
4. 列印 n 個字元的排列組合 (Permutations)
a, b, c ------- abc acb bac bca cba cab => Perm(a, b, c) 可拆解為 'a' + Perm(b, c) 'b' + Perm(a, c) 'c' + Perm(b, a)
//main function呼叫:Perm(list, 1, n) void Perm(char list[], int i, int n){ //list[i] ~ list[n] permutation int j; if(i == n){ for (j = 1; j <= n; j++){ printf("%c ", list[j]); } printf("\n"); } else{ //i < n for(j = i; j <= n; j++){ swap(&list[i], &list[j]); //list[j] as head Perm(list, i+1, n); //list[i+1] ~ list[n] permutation swap(&list[i], &list[j]); //return to the original list } } }