單元二 遞迴(Recursion) === 遞迴是一個在程式設計中極具力量和靈活性的概念,它能夠以簡潔而優雅的方式解決許多複雜的問題。透過遞迴,我們可以將一個大問題分解成更小的子問題,從而更容易地理解和解決它們。 「遞迴是大事化小,要記得小事化無。」 此章節將簡單講述遞迴觀念以及一些例子。 --- 觀念 --- ### 遞迴是甚麼? - 遞迴是一種解題的方法,主要是透過「重複呼叫自身程式碼」,將大問題切成小問題來找到解答。 ### 他跟迭代有甚麼區別? - **迭代**是利用迴圈(例如 for 或 while) 重複循環程式,來得到答案。 - **recursive(遞迴)** 與 **iterative(迭代)** 時常一起被提起,因為通常可以利用遞迴/迭代實作的程式,往往也能使用另一種方法實作。 ### 比較 1. **概念複雜度:** - **遞迴**通常更容易理解,因為它直接將問題分解為更小的子問題,並通過基本案例來終止遞迴。 - **迭代**需要額外的迴圈控制變量和條件,可能需要較多的思考和設計,尤其是對於複雜的問題。 2. **程式碼結構:** - **遞迴**的程式碼通常較為簡潔明瞭,因為它將問題的解決方案直接表達為函式調用自身的方式。 - **迭代**的程式碼可能較為冗長,因為它需要額外的迴圈結構和變量。 :::spoiler #### **範例程式碼:** (僅演示遞迴和迭代的差別,相同範例但寫法不同) ```cpp= // 遞迴函式計算二元樹的最大深度 int maxDepthRecursive(TreeNode* node) { if (node == nullptr) return 0; int leftDepth = maxDepthRecursive(node->left); int rightDepth = maxDepthRecursive(node->right); return max(leftDepth, rightDepth) + 1; } ``` ```cpp= // 迭代函式計算二元樹的最大深度 int maxDepthIterative(TreeNode* root) { if (root == nullptr) return 0; queue<TreeNode*> q; q.push(root); int depth = 0; while (!q.empty()) { int levelSize = q.size(); for (int i = 0; i < levelSize; ++i) { TreeNode* node = q.front(); q.pop(); if (node->left != nullptr) q.push(node->left); if (node->right != nullptr) q.push(node->right); } depth++; } return depth; } ``` 操作相同件事的程式碼,遞迴只用到了六行,而迭代卻需要十七行。 ::: 3. **處理效率:** * **迭代往往在處理效率方面表現得比較好**。因為遞迴每次呼叫自身時,都需要在堆疊上保存當前的狀態(包括參數、局部變數等),這個過程在深層遞迴時會**消耗大量的記憶體和時間**。然而迭代卻只使用固定的記憶體空間,因此開銷更小。 --- 例題 --- 學習遞迴是一件重要的事,不過遞迴的思考方式與一般的不同,它不是直接的給予答案,而是間接的說明答案與答案的關係。然而,傳統線性的思維模式不利於我們去撰寫遞迴,因此會有不少初學者對遞迴感到困難。 讓我們先以簡單的階乘為例: ### 例題一、階乘 數學定義: $fac(n)=1\times2\times3\times\dots\times n,\forall n\gt 0$ 讓我們以這個例子撰寫一段C++ 程式,輸出 $n$ 階乘: ```cpp= int factorial(int n){ if(n == 1 || n == 0) return 1; return n * factorial(n-1); } ``` 這段程式碼中的<font color="#e04">`factorial`</font>函式是一個遞迴函式。當輸入一個正整數 $n$ 時,該函式會遞迴地計算 $n$ 的階乘,直到達到基本案例,然後回溯並計算每個子問題的結果。最終,函式返回 $n$ 的階乘。 ![B0BF6412-37AE-46CA-A2C4-1A0519375B7B](https://hackmd.io/_uploads/SJxYyN9ZgA.jpg) ### 例題二、費波納契數列 數學定義: \begin{equation*} F_n = \left\{ \begin{array}{} 0 &, n=0 \\ 1 &, n=1 \\ F_{n-1}+F_{n-2}&, n>1 \\ \end{array} \right. \end{equation*} ```cpp= int fibonacci(int n) { // 基本案例:當 n 為 0 或 1 時,返回 n if (n == 0 || n == 1) { return n; } else { // 遞迴步驟:費波納契數列的第 n 個數等於前兩個費波納契數的和 return fibonacci(n - 1) + fibonacci(n - 2); } } ``` 這段程式碼中的<font color="#e04">`fibonacci`</font>函式是一個遞迴函式。當輸入一個正整數$\mathit n$ 時,該函式會遞迴地計算費波納契數列的第$\mathit n$ 個數,直到達到基本案例,然後回溯並計算每個子問題的結果。最終,函式返回費波納契數列的第$\mathit n$ 個數。 --- 實戰演練 --- ### 簡單直觀題 [c002: 10696 - f91](https://zerojudge.tw/ShowProblem?problemid=c002) McCarthy是一個有名的資訊專家。他定義了一個遞迴的函數叫做 f91 。它輸入一個正整數N並且依據以下的規則傳回一個正整數: 如果 $N <= 100$, 那麼 $f91(N) = f91( f91( N+11) )$, 如果 $N >= 101, 那麼 f91(N) = N-10$, 請你寫一個程式來計算 $f91$。 :::spoiler Ans: ```C++= #include <bits/stdc++.h> using namespace std; int f91(int x){ if(x >= 101) return x-10; else return f91(f91(x+11)); } int main(){ int x; while(cin>>x){ if(x == 0) break; else cout << "f91(" << x << ") = " << f91(x) << '\n'; } return 0; } ``` ::: ### 有點難度題 [習題 Q-1-8. 子集合的和](https://zerojudge.cchs.chc.edu.tw/ShowProblem?problemid=a150) #### 內容 輸入 $n$ 個正整數,請計算各種組合中,其和最接近 $P$ 但不超過 $P$ 的和是多少。 每個元素可以選取或不選取但不可重複選,輸入的數字可能重複。 $P≤1000000009,0<n<26。$ #### 輸入說明 第一行是 $n$ 與 $P$, 第二行 $n$ 個整數是 $A[i]$, 同行數字以空白間隔。 #### 輸出說明 最接近 $P$ 但不超過 $P$ 的和。 :::spoiler Ans: ```C++= #include <bits/stdc++.h> using namespace std; int arr[26]; // 儲存輸入數值的陣列 int n; // 數列的長度 int result; // 目標數值 int ans = 0; // 最接近目標數值且不超過它的數列子集合總和 // 遞迴函式,用於尋找最接近目標值的子集合總和 void fun(int index, int sum) { if(index == n) { // 如果已經考慮完所有元素 return; } else if(sum >= result) { // 如果當前總和已經大於或等於目標值 return; } fun(index + 1, sum); // 選擇不包含當前元素的情況 sum += arr[index]; // 將當前元素加到總和中 if(result - sum <= result - ans && result - sum > 0) { // 如果新的總和更接近目標值 ans = sum; // 更新最佳解 } fun(index + 1, sum); // 繼續尋找包含當前元素的情況 } int main() { ios::sync_with_stdio(0); // 加速C++輸入輸出 cin.tie(0); // 解除cin和cout的綁定,進一步加速輸入輸出 cin >> n >> result; for(int i = 0; i < n; i++) { cin >> arr[i]; } fun(0, 0); // 從第0個元素開始,嘗試找到最佳解 cout << ans; return 0; } ``` 程式碼使用遞迴函數<font color = "#a6f">`fun`</font>來尋找一個最適合的子集合,使得子集合的數字總和最接近`result`但不超過它。遞迴函數嘗試包含或不包含當前索引的元素,進而遍歷所有可能的子集合。 在遞迴的每一步,它都會檢查目前的總和是否比之前找到的任何總和更接近目標值。如果是,則將這個總和設定為新的最佳解。 ::: --- 結語 --- 在本篇講義中,我們介紹了遞迴這一編程技巧的核心概念、運用場景以及其在解決問題過程中的強大能力。透過具體例子的演練,我們理解了遞迴如何在程式碼中被實現。 遞迴作為一種思維模式,要求我們跳出傳統的線性解題思路,學會從問題的整體與部分之間建立聯繫,透過自我引用的方式逐步推進到解決方案 然而,遞迴的使用也需謹慎。不當的遞迴設計可能導致效率低下甚至執行失敗的問題,例如:堆疊溢位( stack overflow )。因此,掌握何時以及如何使用遞迴,是每一位優秀程式設計師所必須具備的能力。 總的來說,遞迴不僅豐富了我們解決問題的工具箱,也提升了我們的邏輯思維能力。希望透過本講義的學習,你能更加自信地運用遞迴,以創新的方式解決那些看似錯綜複雜的問題。