# 遞迴 `第12週社課` ## 定義 函式自己呼叫自己, 呼叫下一個函式時,原本的函式狀態會被儲存在記憶體的stack空間中, 被呼叫的函式結束後,才會從stack取出目前的狀態,因此還是單執行緒。 (要寫多執行緒沒這麼簡單!) ### 為什麼是stack? 因為遞迴滿足後呼叫的函式先結束,也就是先取出。 ## 終止條件 就跟數學中的遞迴一樣,程式中的遞迴必須有終止條件(base case), 否則會變成無限遞迴,然後就會stack overflow(也可以說是爆記憶體)。 ::: spoiler 實驗 這個code可以在一瞬間製造stack overflow的情況,然後程式就會crash掉, 如果想要觀察得更清楚一點,可以把第7行的for迴圈取消註解, 然後打開工作管理員來觀察程式的記憶體使用量。 ```cpp= #include <bits/stdc++.h> using namespace std; int f(); int g(); int f(){ //for(int i = 1; i <= 1000000; i++); return g(); } int g(){ return f(); } int main(){ f(); } ``` ::: <a> </a> 例如:計算費氏數列時,終止條件為$n=1$或$n=2$,算到這個的時候就可以不用再遞迴下去了。 下面的數學式即為費氏數列的遞迴式。 $$ F_n=\left\{ \begin{aligned} {1\quad \quad }\quad {n = 1}\\ 1\quad \quad \quad {n = 2}\\ F_{n-1} + F_{n-2}\quad {n \geq 3} \end{aligned} \right. $$ 下面的程式可以用來計算費氏數列,寫法幾乎跟上面的式子一樣。 ```cpp #include <bits/stdc++.h> using namespace std; int F(int n){ if(n == 1 || n == 2) return 1; return F(n-1) + F(n-2); } int main(){ cout << F(5) << "\n"; } ``` 如果還不熟悉遞迴,可以先在紙上列出遞迴式,再開始寫程式碼。 ## 另一個範例 GCD(最大公因數) $$ gcd(a,b)=\left\{ \begin{aligned} gcd(b,a)\quad\,{a<b}\\ b \quad\quad\;\;{r=0}\\ gcd(b,r)\quad{r\neq0}\\ \end{aligned} \right. \,\,\,\,(r為a~÷~b所得的餘數) $$ ```cpp #include <bits/stdc++.h> using namespace std; int gcd(int a, int b){ if(b > a) return gcd(b ,a); int r = a % b; return (r == 0) ? b : gcd(b, r); } int main(){ cout << gcd(21,6); // 3 } ``` ## 圖解 ```cpp #include <bits/stdc++.h> using namespace std; int F(int n){ if(n == 1 || n == 2) return 1; return F(n-1) + F(n-2); } int main(){ cout << F(5) << "\n"; } ``` 以費氏數列來舉例,最初被呼叫的函式就是樹的根,新呼叫的函式可以被視為樹的分支。 對比程式碼與下圖,每個分支點是先呼叫左分支,回傳之後再呼叫右分支。 ![](https://i.imgur.com/gPHbB2A.png) 順帶一提,這張圖又被稱為遞迴樹。 $$ \\ $$ debug時,可以邊遞迴邊紀錄遞迴的層數, 在輸出時以空格數來表示層數,就可以看出是誰呼叫誰。</br> Code: ```cpp #include <bits/stdc++.h> using namespace std; int F(int n, int layer){ for(int i = 0; i < layer; i++){ cout << " "; } cout << "F(" << n << ")\n"; if(n == 1 || n == 2) return 1; return F(n-1, layer + 1) + F(n-2, layer + 1); } int main(){ F(5,0); } ``` 輸出: ``` F(5) F(4) F(3) F(2) F(1) F(2) F(3) F(2) F(1) ``` ![](https://i.imgur.com/ZQcMvad.png) ## 優化 ### 複雜度優化 如果用上面的程式來計算$F(50)$?(記得把int改成long long) 欸?算不出來? 如果照上面的寫法,計算$F(50)$時總共要計算幾次$F(3)$? 又沒有可能把次數壓到一次? ::: spoiler 解答 紀錄所有算過的狀態的答案! 對於費氏數列,我們可以用一維陣列(其他的可能需要更多維)來實現, 儲存一次該狀態的答案後,下次再呼叫該狀態,就可以直接回傳該陣列的值! ```cpp #include <bits/stdc++.h> using namespace std; vector<long long> ans; long long F(int n){ if(ans[n] != -1) return ans[n]; ans[n] = F(n-1) + F(n-2); return ans[n]; } int main(){ ans.resize(100, -1); ans[1] = 1; ans[2] = 1; cout << F(50) << "\n"; } ``` 此時的複雜度從$\large O(2^n)$(其實是$\large O(1.618^n)$)變成了$\large O(n)$ 這個技巧被稱為***Memoization***,之後教DP時會再次提到。 ![](https://i.imgur.com/KJsWDI0.png) ::: ### 常數優化 減少遞迴函式的參數個數,可以提升執行效率。 直接舉例:<a href="https://zerojudge.tw/ShowProblem?problemid=d304">ZJ d304</a> ::: spoiler AC code ```cpp #include <bits/stdc++.h> #define loop(i,a,b) for(int i=a;i<b;i++) #define pb push_back using namespace std; int a[10000],n,ways,press,ans_i; vector<int> ans; void sim(int cnt,int cp,int i){ if(cnt>n||i>press){ return; } else if(cnt==n){ if(cnt!=cp){ if(press>i){ press=i; ways=0; ans.clear(); } if(i==press){ ans.pb(0); //c=>-1 v=>N end=>0 loop(j,1,i) ans.pb(a[j]); } ways++; return; } } //Ctrl C if(cnt!=cp){ a[i]=-1; sim(cnt,cnt,i+1); a[i]=0; } //Ctrl V a[i]=1; sim(cnt+cp,cp,i+1); a[i]=0; return; } int main(){ while(scanf("%d",&n)!=EOF){ ans.clear(); ways=0; press=10000; ans_i=0; sim(1,1,1); printf("min : %d\nway : %d\n",press,ways); loop(i,0,ans.size()){ if(ans[i]==0){ if(i>0) printf("\n"); printf("Ctrl C"); } else if(ans[i]==-1) printf(" + C"); else printf(" + V"); } printf("\n"); } return 0; } ``` ::: ::: spoiler TLE code ```cpp #include <bits/stdc++.h> #define loop(i,a,b) for(int i=a;i<b;i++) #define pb push_back using namespace std; int a[1000],n,ways,press,ans_i; vector<int> ans; void sim(bool cmd,int cnt,int cp,int i,int p){ if(cnt>n||p>press){ return; } else if(cnt==n){ if(!cmd){ if(press>p){ press=p; ways=0; ans_i=ans.size(); } if(p==press){ ans.pb(0); //c=>-1 v=>N end=>0 loop(j,1,i+1) ans.pb(a[j]); } ways++; return; } } else if(cmd){ a[i]=-1; sim(false,cnt,cnt,i+1,p+1); a[i]=0; } else{ a[i]++; cnt+=cp; sim(true,cnt,cp,i+1,p+1); sim(false,cnt,cp,i,p+1); a[i]=0; } } int main(){ while(scanf("%d",&n)!=EOF){ ways=0; press=10000; ans_i=0; sim(true,1,0,0,0); printf("min : %d\nway : %d\n",press,ways); loop(i,ans_i,ans.size()){ if(ans[i]==0){ if(i>ans_i) printf("\n"); printf("Ctrl C"); } if(ans[i]==-1) printf(" + C"); else loop(j,0,ans[i]) printf(" + V"); } printf("\n"); } return 0; } ``` ::: 不過要注意,不要過度依賴全域變數,不一定會比較快。 重點是減少不必要的參數「傳遞」。 確實,如果把上面的星星數換成全域會直接TLE掉(實際測過) <!-- TO DO --> ## 應用 ### 依遞迴式求出單一狀態 優點:想法直接 範例:費氏數列,不再贅述 ### 模擬所有情況 <!-- 遞迴為什麼適合用來模擬所有組合? --> 遞迴之所以適合用來模擬所有情況是因為: 他可以用一個狀態來表達一層重複結構。 例如:今天我想要得到一個集合所有的子集合的和,最直接也是最簡單的方法就是用n個for迴圈包他,但是顯然這個方法實作下來很不實際,所以遞迴就會派上用場了,我們可以用多層遞迴來取代多層for迴圈。(如果你要寫不確定幾層的for迴圈,用遞迴就對了) 來看看code。 ```cpp #include <bits/stdc++.h> using namespace std; const int MAX_N = 22; int n, k = 0, element[MAX_N], subset_sum[1 << MAX_N]; bitset<MAX_N> state; // 用bitset儲存一個「狀態」,雖然可以用一個int進行 // 狀態壓縮,但那會變得比較抽象,所以先不教 int get_sum() { int i, result = 0; for (i = 0; i < n; ++i) { if (state[i]) { result += element[i]; } } return result; } void enum_state(int pos) { if (pos == n) { subset_sum[k++] = get_sum(); return; } state[pos] = 0; enum_state(pos + 1); state[pos] = 1; enum_state(pos + 1); } int main() { cin >> n; for (int i = 0; i < n; ++i) cin >> element[i]; enum_state(0); sort(subset_sum, subset_sum + k); for (int i = 0; i < k; ++i) cout << subset_sum[i] << ' '; } ``` 執行結果: ![](https://i.imgur.com/7brSLd4.png) 複雜度:$O(n\times 2^n)$ 因為我們模擬了所有$2^n$種狀態,輸出時$O(n)$計算該狀態的值。 ::: spoiler 優化 其實有方法可以把那個$\large O(n)$去掉,就是把目前取到的總和當作參數傳遞下去。 ```cpp #include <bits/stdc++.h> using namespace std; const int MAX_N = 22; int n, k = 0, element[MAX_N], subset_sum[1 << MAX_N]; void enum_state(int pos, int now) { if (pos == n) { subset_sum[k++] = now; return; } enum_state(pos + 1, now); enum_state(pos + 1, now + element[pos]); return; } int main() { cin >> n; for (int i = 0; i < n; ++i) cin >> element[i]; enum_state(0, 0); sort(subset_sum, subset_sum + k); for (int i = 0; i < k; ++i) cout << subset_sum[i] << ' '; } ``` ::: ## 剪枝 對於一個不可能成為解的路線後面全部不要去訪問。 例如:最經典的括號匹配問題 可以先從手算的情況開始想,通常我們都會畫樹狀圖來表達分支, 如果這個分支不可能是解,那麼它的分支也不會是解,所以可以直接把它劃掉。 ![](https://i.imgur.com/K53w8T1.png) * 遞迴目標:一個位置的左括號或右括號 * 不可能成為解的情形:左括號或右括號個數大於總括號組數、右括號個數大於左括號個數 題外話:如果你在剪枝的時候需要debug然後在遞迴裡面印狀態發現怎麼code變很慢,有的時候不要怕,你已經剪對了,只是I/O慢爆了 ### 下次課程:分治法 將原本的問題拆成多個子問題分開計算,再把這些子問題的答案合併成大問題的答案。 會這樣做是因為拆解再合併的複雜度較優,如果沒有比較好,則應該嘗試別的方法。