--- title: 遞迴 # 簡報的名稱 tags: 7th 教學 # 簡報的標籤 --- # 遞迴 #### Author: PixelCat ## 概要 1. 遞迴的架構 2. 遞迴問題複雜度分析 3. 例題們 3.1. 河內塔 3.2. 費氏數列 3.3. 快速冪 3.4. 等比級數 4. 結語 ## 遞迴的架構 記得c++裡面的函數嗎?函數可以被呼叫,假如某個函數呼叫了自己,就叫做遞迴 ```cpp= void func(){ cout << "func() called\n"; func(); //recursion } void f1(){ cout << "f1() called\n"; f2(); //recursion? } void f2(){ cout << "f2() called\n"; f1(); //recursion? } // f1() -> f2() -> f1() -> f2() .... // recursion indeed ``` 但是執行`func()`,你發現他一直輸出,停不下來。為什麼?因為`func()`輸出一行,呼叫了(第二層)`func()`,第二層輸出完呼叫第三層,第三層呼叫第四層...沒完沒了。 像這樣不會結束的遞迴,叫做**無窮遞迴**。為了避免無窮遞迴,我們要設定一個遞迴的終止條件(或者叫base case)。 ```cpp= void func(int n){ cout << "func(" << n << ") called\n"; if(n == 0) return; // end of recursion func(n - 1); cout << "func(" << n << ") ended\n"; } ``` 注意觀察每一層遞迴執行的順序。 ## 遞迴問題複雜度分析 先來一個遞迴的例子吧。 ```cpp= int count(int n){ if(n == 0) return 0; // base case int ans = count(n/2); // recursion ans = ans + n % 2; return ans; } ``` 從上面的例子可以把遞迴拆成三個部份: 1. 做事,不做事你寫程式幹嘛 2. 遞迴,不遞迴還叫遞迴嗎 3. 終止條件,不設終止條件會造成無窮遞迴停不下來 真正在花時間的是做事的部份,棘手的是這個函數可能被呼叫多次,所以要把每一次呼叫的複雜度加起來。通常我們會畫一棵「遞迴樹」來找出函數被呼叫了幾次,以上面的例子來說,呼叫`count(36)`會有這棵遞迴樹(因為count太長了所以用f代替): ![](https://i.imgur.com/Xfni2ik.png) count被呼叫了七次。 假如呼叫`count(n)`呢? ![](https://i.imgur.com/NxDJdXT.png) 每次n都變一半,直到n變0,需要 $1+\log n$ 次,時間複雜度 $O(\log n)$。 不過當然也不是所有遞迴樹都這麼和善,比如下面的例題二。 ## 例題們 ### 例題一:河內塔 [green judge b023 河內塔](http://www.tcgs.tc.edu.tw:1218/ShowProblem?problemid=b023) 給你三跟柱子和n個圓盤,每個圓盤大小不同,疊在柱子上,小的不可以疊在大的下面。現在所有圓盤疊在第一根柱子上,你每次可以把某堆最上面的一個圓盤換到另外一根柱子上,要如何操作使所有圓盤疊到第三根柱子上? green judge已經幫你破梗了,假如要把圓盤1~n從a移動到b,我們可以 1. 將圓盤1\~n-1從a移動到另一根柱子c 2. 將圓盤n從a移動到b 3. 將圓盤1\~n-1從c移動到另一根柱子b 其中,1和3是一個模式完全相同的問題,可以遞迴解決,終止條件是當 $n=0$ 時,什麼都不需要移動,直接結束回傳。 ![](https://i.imgur.com/lX31TCT.png) :::spoiler 參考程式碼(未經實測) ```cpp= // move disk 1~n from a to b through c void solve(int n,int a,int b,int c){ if(n == 0) return; // 終止條件 solve(n-1, a, c, b); //遞迴 cout << "Ring " << n << " from " << a << " to " << b << "\n"; solve(n-1, c, b, a); //遞迴 } solve(n, 1, 3, 2); ``` ::: <br/> 每次呼叫都會呼叫兩次下一層的遞迴,直到n是0,複雜度 $O(2^n)$。 遞迴樹如下圖。 ![](https://i.imgur.com/6zE9G0x.png) ### 例題二:費氏數列 給你一個函數,也就是知名的費氏數列: $$ fib(n) = \begin{cases} 1, & \text{if } n\leq 2 \\ fib(n-1)+fib(n-2), & \text{otherwise} \\ \end{cases} $$ 請你求出數列的第n項。 既然都給你遞迴式了,直接寫出來就好。 ```cpp= int fib(int n){ if(n <= 2) return 1; return fib(n-1) + fib(n-2); } ``` 他的複雜度是多少?我們可以畫出他的遞迴樹 ![](https://i.imgur.com/WCBZaTb.png) 想一想你會發現,函數總共被呼叫的次數剛好是費氏數列的前n項加起來,而根據費氏數列的一般項公式,我們知道費氏數列的第n項大概在 $O(\phi^n)$,其中 $\phi=\frac{1+\sqrt{5}}{2}=1.618\dots$是黃金比例。 因此總時間複雜度是 $O(\phi^n)$。 :::spoiler 題外話 在上面的遞迴樹中,我們看到同一項被呼叫很多次,比如`fib(3)`被呼叫3次,`fib(2)`被呼叫5次。可是`fib(3)`走到哪裡都是`fib(3)`,何必算這麼多次? 這就是動態規劃的戰場了。 ::: ### 例題三:快速冪 給你正整數 $a,b,m$,請求出 $a^b\bmod m$。 $a,b\leq 10^{18}$,$m\leq 10^9$,取餘數是因為 $a^b$ 可能超級無敵大。 乍看只能做b次慢慢乘,但根據指數律我們觀察到 $$ a^b = \begin{cases} 1, & \text{if } b=0 \\ a^k\times a^k, & \text{if } b=2k \\ a^k\times a^k \times a, & \text{if } b=2k+1 \end{cases} $$ $a^k$ 出現了這麼多次,但是 $a^k$ 走到哪裡都還是 $a^k$,完全可以重複利用。於是我們每次遞迴都可以把 $b$ 變一半,複雜度進步成 $O(\log b)$ ```cpp= long long fpow(long long a, long long b, int m){ if(b == 0) return 1; int ak = fpow(a, b/2, m); if(b%2 == 0) return ak * ak % m; else return ak * ak % m * a % m; } ``` 這個技巧很好實做,非常常用。這裡提供一種迴圈式的寫法,常數比遞迴稍小但不會差太多 :::spoiler 迴圈式快速冪實做 ```cpp= long long fpow(long long b, long long p, int mod){ long long ans = 1; while(p){ if(p & 1) ans = ans * b % mod; b=b*b%mod; p/=2; } return ans; } ``` ::: ### 例題四:等比級數 給你 $r,n,m$,請你求出等差級數和 $\left(1+r+r^2+\dots+r^{n-1}\right)\bmod m$。 $r,n\leq 10^{18}$,$m\leq 10^9$,取模是因為級數可能宇宙無敵大。 延續上一題的思路,我們又要來找有沒有重複的東西可以用了。 為了說明方便,我們令 $$ f(r,n)=\sum^{n-1}_{k=0}r^k\bmod m $$ 模數m在過程中不會變所以視為常數。 **++想法一,從中間分一半++** 分成 $n$ 是奇數和偶數的情況觀察,你發現 $$ f(r,2k+1) = 1+r\times f(r,2k) $$ 奇數的情況直接可以變成偶數,不理他。 $$ \begin{align} f(r,2k) &= f(r,k)+r^k\times f(r,k) \\ &= f(r,k)\times\left(1+r^k\right) \end{align} $$ 偶數的情況中 $f(r,k)$ 可以直接遞迴重複利用,至於 $r^k$ 可以在遞迴的時候一併計算,一次回傳兩個答案。 終止條件在遞迴到 $n=0$ 時,$f(r,n)=1$,$r^n=1$。 ```cpp= // same as #define ll long long using ll = long long; // pair{ f(r,n) , r^n } pair<ll, ll> f(ll r, ll n, ll m){ if(n == 0){ // base case return make_pair(1, 1); } if(n%2 == 0){ // even auto res = f(r, n/2, m); return make_pair( res.first * (1 + res.second) % m, res.second * res.second % m ); }else{ // odd auto res = f(r, n-1, m); return make_pair( (1 + r * res.first) % m, res.second * res.second % m * r % m ); } } ``` 其實本質上就是把一個快速冪和等比級數包在一起,複雜度 $O(\log n)$。 **++想法二,分奇偶項++** 快速冪看起來還是有點冗,能不能去掉那項 $r^k$? 奇數一樣不用管他,來看看n是偶數的情況 $$ \begin{align} f(r,2k) &= 1+r+r^2+\dots+r^{2k-2}+r^{2k-1} \\ &= \left(1+r^2+\dots+r^{2k-2}\right)+\left(r+r^3+\dots+r^{2k-1}\right) \\ &= f(r^2,k)+rf(r^2,k) \end{align} $$ 乾淨簡潔! 終止條件依然是在 $n=0$ 時。 ```cpp= using ll = long long; ll f(ll r, ll n, ll m){ if(n == 0) // base case return 1; else if(n%2 == 1) // odd return (1 + r * f(r, n-1, m)) % m; else // even return f(r*r%m, n/2, m) * (1+r) % m; } ``` 複雜度同樣是 $O(\log n)$,不過因為少了快速冪的部份所以常數小了不少。 :::spoiler 題外話 假如你學過等比級數,你可能會想到 $$ f(r,n)=\sum^{n-1}_{k=0}r^k=\frac{r^n-1}{r-1} $$ 既然 $r^k$ 可以快速冪,那問題就解決了,嗎? 不幸的是,除法在取模下是不生效的,就算你會模逆元,還是會有模逆元不存在的情況(比如說r=3,m=4,r-1和m不互質),因為題目沒有跟你保證m是質數。 所以還是乖乖遞迴吧! ::: ## 結語 這篇本來想跟分治放一起的,但是寫一寫就變超級長,想起什麼例題都想丟進去。遞迴是一個重要的概念,幾乎所有領域都有需要遞迴的時候,就連二分搜也可以視為遞迴問題的一種,因此請一定要看懂遞迴,至少要能看懂複雜度分析和例題一、二。 學習遞迴還有一個重要的目的是為分治鋪路,希望大家有學起來(也希望我寫出來的東西是人話)!