目錄

  1. 什麼是遞迴?
  2. 遞迴的三大要素
  3. 遞迴的執行原理:Call Stack
  4. 經典遞迴範例
  5. 遞迴的優點
  6. 遞迴的缺點
  7. 遞迴 vs 迭代 比較表
  8. 如何將遞迴改成非遞迴(迭代)
  9. 轉換技巧總結
  10. 進階:尾遞迴優化
  11. C++ 程式碼參照

1. 什麼是遞迴?

遞迴 (Recursion) 是指一個函式直接或間接地呼叫自己的程式技巧。

直接遞迴:                   間接遞迴:
void f(int n) {             void A(int n) {
    ...                         ...
    f(n - 1);  ← 呼叫自己       B(n - 1);  ← 呼叫 B
    ...                         ...
}                           }
                            void B(int n) {
                                ...
                                A(n - 1);  ← B 再呼叫 A
                                ...
                            }

生活中的遞迴

  • 俄羅斯娃娃:打開一個娃娃,裡面還有一個,直到最小的。
  • 鏡中鏡:兩面鏡子對照,無限反射。
  • 資料夾結構:資料夾裡面還有資料夾。

2. 遞迴的三大要素

每個正確的遞迴函式都必須具備:

要素一:Base Case(基底條件 / 終止條件)

何時停止遞迴?沒有 base case → 無窮遞迴 → Stack Overflow!

if (n == 0) return 1;  // ← base case

要素二:Recursive Case(遞迴步驟)

如何把問題縮小,並呼叫自己?

return n * factorial(n - 1);  // ← 問題從 n 縮小到 n-1

要素三:Progress(進展)

每次遞迴呼叫都必須朝 base case 靠近

factorial(5) → factorial(4) → factorial(3) → ... → factorial(0) ← base case
                            ↑ 每次 n 減少 1,必定到達 n=0

四大準則(Weiss 教科書)

  1. 至少一個 base case,不用遞迴就能解決。
  2. 每次遞迴呼叫必須朝 base case 前進
  3. 假設所有遞迴呼叫都能正確運作(設計要訣)。
  4. 不要重複計算(複利法則)— 否則效率爆炸。

3. 遞迴的執行原理:Call Stack

每次函式呼叫,系統會在 Call Stack 上建立一個 Stack Frame,裡面存: - 參數值 - 區域變數 - 返回地址

factorial(4) 的 Call Stack 變化:

呼叫:                             返回:
┌──────────────┐                 ┌──────────────┐
│ factorial(0) │ ← 頂端           │ factorial(0) │ return 1
│ factorial(1) │                 │ factorial(1) │ return 1*1 = 1
│ factorial(2) │                 │ factorial(2) │ return 2*1 = 2
│ factorial(3) │                 │ factorial(3) │ return 3*2 = 6
│ factorial(4) │ ← 底端           │ factorial(4) │ return 4*6 = 24
└──────────────┘                 └──────────────┘

重點: Stack 的深度 = 遞迴的最大深度。若太深 → Stack Overflow


4. 經典遞迴範例

4.1 階乘 (Factorial)

n! = n × (n-1)!,  0! = 1

5! = 5 × 4! = 5 × 4 × 3! = ... = 5 × 4 × 3 × 2 × 1 = 120
  • 遞迴深度:O(N)
  • 時間:O(N)
  • 可輕易改為迭代

4.2 費氏數列 (Fibonacci)

F(0) = 0, F(1) = 1
F(n) = F(n-1) + F(n-2)

序列: 0, 1, 1, 2, 3, 5, 8, 13, 21, ...
  • 天真遞迴:O(2^N) — 大量重複計算!
  • 改用迭代或 memoization:O(N)
                    fib(5)
                   /      \
              fib(4)       fib(3)        ← fib(3) 被算了兩次!
             /    \        /    \
         fib(3)  fib(2)  fib(2) fib(1)   ← fib(2) 被算了三次!
        /    \
    fib(2)  fib(1)

4.3 河內塔 (Tower of Hanoi)

將 N 個盤子從柱 A 移到柱 C(經由柱 B),規則:一次移一個,大盤不能疊在小盤上。

hanoi(n, from, to, aux):
    if n == 1: 移動盤 1 from → to
    else:
        hanoi(n-1, from, aux, to)   // 上面 n-1 個搬到 aux
        移動盤 n from → to          // 最大的搬到 to
        hanoi(n-1, aux, to, from)   // n-1 個從 aux 搬到 to
  • 時間:O(2^N) — 無法改善,問題本身需要這麼多步
  • 移動次數 = 2^N − 1
binarySearch(arr, low, high, target):
    if low > high: return -1
    mid = (low + high) / 2
    if arr[mid] == target: return mid
    if target < arr[mid]: return binarySearch(arr, low, mid-1, target)
    else: return binarySearch(arr, mid+1, high, target)
  • 時間:O(log N)
  • 屬於尾遞迴,可輕易改為迭代

4.5 樹的遍歷

inorder(node):
    if node == NULL: return
    inorder(node->left)
    visit(node)
    inorder(node->right)
  • 遞迴深度 = 樹的高度
  • 改為非遞迴需要手動模擬 Stack

4.6 快速排序 (Quick Sort)

quicksort(arr, low, high):
    if low < high:
        pivot = partition(arr, low, high)
        quicksort(arr, low, pivot - 1)
        quicksort(arr, pivot + 1, high)
  • 平均 O(N log N),最壞 O(N²)
  • 改為非遞迴:用 Stack 存待排序的 (low, high) 區間

4.7 合併排序 (Merge Sort)

mergesort(arr, left, right):
    if left < right:
        mid = (left + right) / 2
        mergesort(arr, left, mid)
        mergesort(arr, mid+1, right)
        merge(arr, left, mid, right)
  • 時間一律 O(N log N)
  • 遞迴深度 O(log N)

5. 遞迴的優點

優點 說明
程式碼簡潔 遞迴解通常比迭代解短得多(例如樹的遍歷、河內塔)
自然表達 問題本身具有遞迴結構時(樹、圖、分治),遞迴最直觀
分治法 Divide and Conquer 天然適合遞迴:分割 → 遞迴解 → 合併
容易證明正確性 可用數學歸納法證明:base case 正確 + 遞迴步驟正確 → 整體正確
回溯法 Backtracking(如八皇后、迷宮)用遞迴最自然

6. 遞迴的缺點

缺點 說明
Stack Overflow 遞迴太深(如 N = 100000)→ 超出 Call Stack 空間 → 程式崩潰
函式呼叫開銷 每次呼叫需要:保存暫存器、建立 Stack Frame、跳轉,比迭代慢
重複計算 天真遞迴可能重複解同一子問題(如 Fibonacci → O(2^N))
空間浪費 每層遞迴都佔用 Stack 空間,即使很多資訊不需要保留
難以 Debug 遞迴呼叫層層嵌套,追蹤困難

Stack Overflow 實例

void infinite(int n) {
    infinite(n + 1);  // 永遠不會結束 → Stack Overflow
}

void tooDeep(int n) {
    if (n == 0) return;
    tooDeep(n - 1);   // n = 1000000 → Stack Overflow(一般 stack 約 1MB ~ 8MB)
}

7. 遞迴 vs 迭代 比較表

項目 遞迴 (Recursion) 迭代 (Iteration)
程式碼 通常較短、較直觀 可能較長、但更直接
速度 較慢(函式呼叫開銷) 較快(直接迴圈)
空間 O(N) stack 空間(遞迴深度) 通常 O(1)(或 O(N) 若需手動 stack)
Stack Overflow 可能(深度太大) 不會
可讀性 樹/圖/分治 → 遞迴更好 簡單迴圈 → 迭代更好
適用場景 分治、回溯、樹結構 線性掃描、簡單累加

原則: 能用迭代就用迭代;問題本身遞迴結構明顯時才用遞迴。


8. 如何將遞迴改成非遞迴(迭代)

方法一:直接用迴圈取代(線性遞迴)

適用於:線性遞迴(每次只有一個遞迴呼叫),如階乘、Fibonacci。

遞迴:                           迭代:
int factorial(int n) {          int factorial(int n) {
    if (n == 0) return 1;           int result = 1;
    return n * factorial(n-1);      for (int i = 1; i <= n; i++)
}                                       result *= i;
                                    return result;
                                }

轉換步驟: 1. 找出 base case 的值 → 設為初始值 2. 找出遞迴的方向(從 n 到 0)→ 反過來用迴圈(從 1 到 n) 3. 每次迭代做原來遞迴步驟中的運算

方法二:手動模擬 Stack(樹型遞迴)

適用於:樹的遍歷多分支遞迴(需要保存「待辦工作」)。

遞迴 inorder:                   非遞迴 inorder(手動 stack):
void inorder(Node* n) {         void inorder(Node* root) {
    if (!n) return;                 stack<Node*> s;
    inorder(n->left);               Node* cur = root;
    visit(n);                       while (cur || !s.empty()) {
    inorder(n->right);                  while (cur) {
}                                           s.push(cur);
                                            cur = cur->left;
                                        }
                                        cur = s.top(); s.pop();
                                        visit(cur);
                                        cur = cur->right;
                                    }
                                }

轉換步驟: 1. 建立一個 stack(模擬 Call Stack) 2. 把遞迴呼叫前要保存的狀態 push 進 stack 3. 用 while 迴圈取代遞迴:每次 pop 一個狀態來處理 4. 對每個狀態,把「需要稍後處理的子問題」push 回 stack

方法三:用 Queue/Stack 存待處理區間(分治型)

適用於:Quick SortMerge Sort 等分治演算法。

遞迴 quicksort:                 非遞迴 quicksort:
void qs(int a[], int l, int r){ void qs(int a[], int l, int r) {
    if (l >= r) return;             stack<pair<int,int>> s;
    int p = partition(a,l,r);       s.push({l, r});
    qs(a, l, p-1);                  while (!s.empty()) {
    qs(a, p+1, r);                      auto [lo, hi] = s.top(); s.pop();
}                                       if (lo >= hi) continue;
                                        int p = partition(a, lo, hi);
                                        s.push({lo, p-1});
                                        s.push({p+1, hi});
                                    }
                                }

方法四:Memoization(記憶化)+ 迭代(DP)

適用於:有重疊子問題的遞迴(Fibonacci、動態規劃)。

天真遞迴 O(2^N):               迭代 DP O(N):
int fib(int n) {               int fib(int n) {
    if (n <= 1) return n;          if (n <= 1) return n;
    return fib(n-1)+fib(n-2);      int prev=0, curr=1;
}                                  for (int i=2; i<=n; i++) {
                                       int next = prev + curr;
                                       prev = curr;
                                       curr = next;
                                   }
                                   return curr;
                               }

方法五:Morris Traversal(不用 Stack 的樹遍歷)

利用 threaded binary tree 的概念,O(1) 空間完成 inorder 遍歷(進階技巧)。


9. 轉換技巧總結

遞迴類型 轉換方法 範例
線性遞迴(一個遞迴呼叫) 直接用 for/while 迴圈 Factorial, 線性搜尋
尾遞迴(遞迴在最後一步) 直接用 while 迴圈 Binary Search, GCD
樹型遞迴(多個遞迴呼叫,如遍歷) 手動模擬 Stack Inorder, Preorder, Postorder
分治型遞迴 Stack 存待處理區間 Quick Sort, Merge Sort
重疊子問題 Memoization 或 Bottom-up DP Fibonacci, 背包問題
回溯型遞迴 Stack 存狀態 + 手動回溯 N-Queens, 迷宮

10. 進階:尾遞迴優化

10.1 什麼是尾遞迴 (Tail Recursion)?

遞迴呼叫是函式的最後一個動作,呼叫完後不再做任何運算

// 尾遞迴(遞迴呼叫是最後一步,return 之後沒有其他運算)
int factorial_tail(int n, int acc) {
    if (n == 0) return acc;
    return factorial_tail(n - 1, n * acc);  // ← 最後一步
}

// 非尾遞迴(遞迴呼叫後還要乘以 n)
int factorial(int n) {
    if (n == 0) return 1;
    return n * factorial(n - 1);  // ← 還要做乘法,不是尾遞迴
}

10.2 尾遞迴優化 (Tail Call Optimization, TCO)

如果編譯器支援 TCO,尾遞迴可以被自動改為迴圈,不會增加 Stack 深度!

factorial_tail(5, 1)
→ factorial_tail(4, 5)    ← 重用同一個 Stack Frame
→ factorial_tail(3, 20)
→ factorial_tail(2, 60)
→ factorial_tail(1, 120)
→ factorial_tail(0, 120)
→ return 120

注意: C++ 標準不保證 TCO,但 GCC/Clang 在 -O2 以上通常會做。

10.3 如何把非尾遞迴改成尾遞迴?

技巧:加一個 accumulator 參數,把「回來後要做的運算」提前到參數裡。

// 原始(非尾遞迴)
int sum(int n) {
    if (n == 0) return 0;
    return n + sum(n - 1);  // 回來後還要加 n
}

// 尾遞迴版
int sum_tail(int n, int acc = 0) {
    if (n == 0) return acc;
    return sum_tail(n - 1, acc + n);  // 加 n 的動作提前到參數
}

11. C++ 程式碼參照

配套的完整 C++ 程式碼見 recursion_examples.cpp,包含:

編號 範例 遞迴版 迭代版 轉換方法
1 階乘 factorialRec factorialIter 直接迴圈
2 階乘(尾遞迴) factorialTail 累加器參數
3 Fibonacci fibRec (O(2^N)) fibIter (O(N)) DP / 迴圈
4 Fibonacci(Memoization) fibMemo (O(N)) 記憶化陣列
5 二元搜尋 bsRec bsIter 尾遞迴 → while
6 GCD gcdRec gcdIter 尾遞迴 → while
7 河內塔 hanoiRec hanoiIter 手動 Stack
8 二元樹 Inorder inorderRec inorderIter 手動 Stack
9 反轉字串 reverseRec reverseIter 雙指標
10 Quick Sort qsRec qsIter Stack 存區間

編譯方式:

g++ -std=c++11 -Wall -o recursion_examples recursion_examples.cpp
./recursion_examples