# Ch15 動態規劃 > 搭配[green judge解題系統](http://www.tcgs.tc.edu.tw:1218/) > Special thanks to [台中女中sagit老師](http://www.tcgs.tc.edu.tw/~sagit/index.htm) \<\(\_ \_\)\> ## > 上一章:[貪婪演算法](https://hackmd.io/s/rkhigGlTG) > 下一章:[回溯法](https://hackmd.io/s/B1Q0_-whQ) > 回目錄:[國立科學園區實驗中學C++程式語言自學講義](https://hackmd.io/s/B18yT_i5Z) ## <font color='darkblue'>什麼是動態規劃</font> 雖名動態規劃(Dynamic Programming,簡稱DP) 其實它既非動態,也非規劃,名字取得不太恰當XD 動態規劃的核心精神為: <font color='darkpink'>**同樣的問題不要再算第二遍**</font> 接下來你在這一章會看到的所有題目 都是能夠用一般的方法解決的 但這些問題都很冗,需要很多步驟才能算完 用一般的方法來解,讓電腦真的做這麼多步驟,太暴力也太天真了 若是在Green Judge上送出這樣天真的程式 大多會得到TLE(超過時限)或是SE(系統中斷) 動態規劃的目的就在於「盡可能地減少無謂的運算」 來加快程式總執行的速度 ## <font color='darkblue'>同樣的問題不要再算第二遍</font> 如果一模一樣的問題會一直重複遇到 那麼就把已經算過的問題用表格存起來 日後再遇到相同的問題時,就不用重新算一遍,直接從表格裡拿答案來用就好! 到底怎麼樣的題目會一直需要算重複的問題? 請見以下例子 <font color='darkorange'>【例題】</font> > 還記得費氏數列吧! 它可以用遞迴函數來處理 > F(0)=0 > F(1)=1 > F(n)=F(n-1)+F(n-2) > 輸入一個n,請輸出F(n) > n的範圍可以到1000000 這題我們已經在遞迴的章節練習過了 遞迴的程式可以這樣寫 ```cpp= long long f(int n) { if(n==0) return 0; if(n==1) return 1; return f(n-1)+f(n-2); } int main() { int n; cin>>n; cout<<f(n)<<endl; } ``` 不過因為當時的題目n最大也不超過35 但現在你把這支程式拿去執行,n輸入1000000 你會發現它跑到天荒地老都不會算完 這就是為什麼我們需要動態規劃 <font color='darkpink'>**它為什麼慢?**</font> 假設今天要計算的是f(100) 為了要算出f(100),必須先算出<font color='blue'>f(98)</font>與<font color='green'>f(99)</font> 為了要算出<font color='blue'>f(98)</font>,必須先算出f(96)與<font color='red'>f(97)</font> 為了要算出<font color='green'>f(99)</font>,必須先算出<font color='red'>f(97)</font>與<font color='blue'>f(98)</font>,可是<font color='red'>f(97)</font>與<font color='blue'>f(98)</font>已經算過了,再算一次就很浪費時間 再繼續推下去你會發現,f(96)、f(95)...一直到f(1),都被重複計算了好幾次 如果毫不簡化,計算f(100)就需要$3*10^{20}$次的運算 f(1000000)更是不得了的天文數字 雖然看似多次,但其實很多地方的運算都可以省下來 如果第一次算的時候有把結果先存起來 那麼後面需要這些值的時候就可以直接拿來用 例如 剛才如果有把<font color='red'>f(97)</font>與<font color='blue'>f(98)</font>的值用變數儲存起來的話 在計算<font color='green'>f(99)</font>時就可以直接取這兩個值相加 但光是存<font color='red'>f(97)</font>與<font color='blue'>f(98)</font>絕對不夠,乾脆用個陣列把所有f(x)都存起來吧! 接下來就來修改我們剛才的程式 ```cpp= long long F[1000005]; //用來存算完的結果 long long f(int n) { if(n==0) return 0; if(n==1) return 1; //發現f(n)已經計算過了 if(F[n]!=-1) return F[n]; //可以直接從陣列裡拿值出來用 //f(n)還沒被計算過,那就算一下吧 F[n]=f(n-1)+f(n-2); //算完先存起來 return F[n]; } int main() { for(int i=0;i<1000000;i++) F[i]=-1; //先把F[i]通通改成-1,代表還沒算過f(i) int n; cin>>n; cout<<f(n)<<endl; } ``` 如此一來,就算n是1000000,程式也頂多運算1000000次,1秒就能解決了! ## <font color='darkblue'>適用情境:計算排列組合數量</font> 建議做題目的步驟可以是: 1.用紙筆或是在腦海中推出它的遞迴式 2.用程式將它的遞迴函數寫出來 3.加上動態規劃的表格陣列、查表格、存表格 <font color='darkpink'>**遞迴式怎麼推?**</font> 對於這些走路方法組合數量的問題 可以先想想第一步有哪些走法 走了其中一種走法後,問題就會變成剩下的路的走法組合數量 將所有第一步走完後的結果加起來,就是所有組合數量 <font color='darkorange'>【例題】</font> > 樓梯有n階,每次可以走1階或是3階,請問n階樓梯總共有幾種走法? 假設n階樓梯總共有f(n)種走法 我們知道 1. 剩下的階梯數量少於3階時,一定只能一步一步走完,也就是只有1種走法 2. 否則,第一步有兩種選擇: - 第一步走了1階的話,會剩下n-1階,也就是還有f(n-1)種走法 - 第一步走了3階的話,會剩下n-3階,也就是還有f(n-3)種走法 所以它的遞迴式可以列成 ```cpp= long long f(int n) { if(n<3) return 1; else return f(n-1)+f(n-3); } ``` 再把動態規劃的部分補上去,這一題就完成了 <font color="darkgreen"> 【學生練習題】</font> > - [ ] [Green Judge b024: 指南宮的階梯 ](http://www.tcgs.tc.edu.tw:1218/ShowProblem?problemid=b024) 如果遞迴式子有兩個參數 像是f(x, y) 既然參數有2個 那麼動態規劃時用來儲存答案的表格就也要是2維的陣列 <font color='darkorange'>【例題】</font> > 已知函數f(x,y)的定義為: > 如果 $x$ 是 0,則 $f(x,y)$ 為 2 > 否則,如果 $y$ 是 0,則 $f(x,y)$ 為 3 > 否則,$f(x,y)$ 為 $f(x-1,y)+f(x,y-1)$ > > 已知x和y都確定小於1000 > 輸入x與y,輸出f(x, y) 首先先將遞迴式寫出來 ```cpp= int f(int x, int y) { if(x==0) return 2; if(y==0) return 3; return f(x-1, y)+f(x, y-1); } ``` 接下來是動態規劃的部分 利用一個二維陣列儲存已經算好的結果 ```cpp= int F[1005][1005] //陣列開在函式外面,大小比x和y的極限稍微大一點就可以了 int f(int x, int y) { if(x==0) return 2; if(y==0) return 3; if(F[x][y]!=-1) return F[x][y]; F[x][y]=f(x-1, y)+f(x, y-1); return F[x][y]; } int main() { for(int i=0;i<1003;i++){ for(int j=0;j<1003;j++){ F[i][j]=-1; } } int x, y; cin >> x >> y; cout << f(x, y) << endl; return 0; } ``` 簡單來講,可以看遞迴式有幾個參數 就用幾維度的陣列去儲存運算結果 <font color="darkgreen"> 【學生練習題】</font> > - [ ] [Green Judge b025: 棋盤格城市 ](http://www.tcgs.tc.edu.tw:1218/ShowProblem?problemid=b025) 以上兩題是遞迴式已經擺明給你了 接下來要面臨的挑戰是需要自己設計適合的遞迴式的 <font color='darkorange'>【例題】</font> > 好熱阿~ > 草莓冰2元 > 檸檬冰5元 > 巧克力冰10元 > 牛奶冰20元 > 你有N元,希望「正好花完」,請問買的冰有幾種組合? 首先可以先列舉草莓冰要買幾杯 假設N是21元 那麼草莓冰可以買0杯、或1杯...或10杯,總共有11種可能 若買0杯草莓冰的話,還會剩21元,接下來要考慮檸檬、巧克力、牛奶各要買幾杯來湊滿21元 若買1杯草莓冰的話,還會剩19元,接下來要考慮檸檬、巧克力、牛奶各要買幾杯來湊滿19元 . . . 若買10杯草莓冰的話,還會剩1元,接下來要考慮檸檬、巧克力、牛奶各要買幾杯來湊滿1元 總之,第一層已經列舉所有可能的草莓數量了 所以後面就只需列舉檸檬巧克力牛奶了,**再也不考慮買草莓** 因此 遞迴式需要的參數有 1. 這時考慮的是第幾種口味 2. 剩多少錢要湊 遞迴式結束的情境有 1. 只剩0元了,湊滿了,回傳1代表「這是其中一種組合」 2. 還有剩下的錢,但所有種類冰都考慮完了,回傳0代表「這不是其中一種組合」 遞迴式的本體 1. 對於當下考慮的口味,用迴圈列舉可以買的杯數 2. 在迴圈中呼叫自己,並且把考慮的口味改成下一個口味,剩下的錢改成剩下的錢 3. 把回傳的所有數加總,並且回傳 範例如下 ```cpp= int buyIceCream(int index, int remain) { if(remain==0) return 1; if(index>=4) return 0; int ans=0; for(int i=0;i*price[index]<=remain;i++){ ans = ans + buyIceCream(index+1, remain-i*price[index]); } return ans; } ``` 還沒完,還要加上動態規劃的表格 ```cpp= int dp[10][1005]; int buyIceCream(int index, int remain) { if(remain==0) return 1; if(index>=4) return 0; if(dp[index][remain]!=-1) return dp[index][remain]; int ans=0; for(int i=0;i*price[index]<=remain;i++){ ans = ans + buyIceCream(index+1, remain-i*price[index]); } dp[index][remain]=ans; return ans; } ``` ```cpp=19 int main() { int n; cin>>n; memset(dp, -1, sizeof(dp)); //把 dp 表格全部洗成-1 int ans=buyIceCream(0, n); cout<<ans; } ``` 動態規劃可以省掉什麼案例呢 例如草莓冰買了5杯,檸檬冰買了0杯,剩下11元要湊巧克力冰跟牛奶冰 或是草莓冰買了0杯,檸檬冰買了2杯,剩下11元要湊巧克力冰跟牛奶冰 一樣是剩下11元要湊巧克力冰跟牛奶冰 如果有存下結果,就不需要一直重新計算了 <font color="darkgreen"> 【學生練習題】</font> > - [ ] [Green Judge b029: 福袋!福袋! ](http://www.tcgs.tc.edu.tw:1218/ShowProblem?problemid=b029) ## <font color='darkblue'>適用情境:找最佳解(最大值、最小值)</font> <font color='darkorange'>【例題】</font> > 好熱阿~ > 草莓冰2元 > 檸檬冰5元 > 巧克力冰10元 > 牛奶冰20元 > 你有N元,希望「正好花完」,最少要買多少杯冰? 這題跟剛才那題超級像的喔 還記得上一題是想要算出「總共有幾種買法」 所以要用迴圈把每一種買法都加起來 而這一題是問「最少杯的買法」 所以一樣要用迴圈 只是要改成紀錄「最小的數字」 首先可以先列舉草莓冰要買幾杯 假設N是21元 那麼草莓冰可以買0杯、或1杯...或10杯,總共有11種可能 買0杯草莓冰的話,還會剩21元,接下來要考慮檸檬、巧克力、牛奶最少買幾杯可以湊滿21元,再加上0杯就會是這種組合的總杯數 買1杯草莓冰的話,還會剩19元,接下來要考慮檸檬、巧克力、牛奶最少買幾杯可以湊滿19元,再加上1杯就會是這種組合的總杯數 . . . 買10杯草莓冰的話,還會剩1元,接下來要考慮檸檬、巧克力、牛奶最少買幾杯可以湊滿1元,再加上10杯就會是這種組合的總杯數 這11種草莓冰買法裡面選最少杯的就會是草莓冰的最佳買法 考慮每一種冰時,選擇所有買法裡面最少杯的就是答案了 要記得很可能會出現湊不出來的情況 例如所有種類的冰都考慮完了,卻還有剩下的錢 這種情況就要特地回傳一種奇怪數字來幫助辨別 例如我習慣回傳-1,因為 -1 絕對不會是正常可能出現的答案 (哪會有-1杯呢?) 但是如果在湊不出來時選擇回傳3就不行了,因為你無法分辨是真的算出來3杯,還是在表示湊不出來 遞迴式需要的參數和上一題是一樣的 1. 這時考慮的是第幾種口味 2. 剩多少錢要湊 遞迴式結束的情境有 1. 只剩 0 元了,那就是 0 杯,回傳 0 代表「再買 0 杯可以花完」 2. 還有剩下的錢,但所有種類冰都考慮完了,回傳 -1 來提醒自己「這不是一個可行的組合」 遞迴式的本體 1. 對於當下考慮的口味,用迴圈列舉可以買的杯數 2. 在迴圈中呼叫自己,並且把考慮的口味改成下一個口味,剩下的錢改成剩下的錢 3. 如果得到 -1,則跳過;否則看看是不是一種能夠讓總杯數更少的買法 範例如下 ```cpp= int buyIceCream(int index, int remain) { if(remain==0) return 0; if(index>=4) return -1; int ans=999999; for(int i=0;i*price[index]<=remain;i++){ int t=buyIceCream(index+1, remain-i*price[index]); if(t>=0){ if(t+i<ans) ans=t+i; } } return ans; } ``` 還沒完,還要加上動態規劃的表格 ```cpp= int dp[10][1005]; int buyIceCream(int index, int remain) { if(remain==0) return 0; if(index>=4) return -1; if(dp[index][remain]!=-1) return dp[index][remain]; int ans=999999; for(int i=0;i*price[index]<=remain;i++){ int t=buyIceCream(index+1, remain-i*price[index]); if(t>=0){ if(t+i<ans) ans=t+i; } } dp[index][remain] = ans; return ans; } ``` ```cpp=20 int main() { int n; cin>>n; memset(dp, -1, sizeof(dp)); int ans=buyIceCream(0, n); cout<<ans; } ``` <font color="darkgreen"> 【學生練習題】</font> > - [ ] [Green Judge b028: 忙碌的超商店員 ](http://www.tcgs.tc.edu.tw:1218/ShowProblem?problemid=b028) ## <font color='darkblue'>經典題型:背包問題</font> <font color='darkorange'>【例題】</font> > 你有一個背包,容量為w > 還有各式各樣的物品,分別的體積與價值都不同 > 你可以決定要將哪些物品裝進背包裡 > 但這些物品的體積加起來不能超過背包的容量 > 請問總共最多可以裝到多大的價值? 有限的背包容量 每一樣物品都可以選擇要裝或不裝 每一樣物品也有不同的體積與價值 首先會想猜測,是不是從CP值最高的開始裝呢? 假設背包的容量為12單位 外套的體積為9,價值為20 麵包的體積為4,價值為8 水壺的體積為6,價值為13 乍看之下覺得外套的CP值最高 所以先把外套放進背包裡 這時背包的容量只剩3了,再也裝不下任何東西 因此總價值為20 但是如果放的是麵包和水壺 背包的容量剩下2,總價值為21 你會發現這樣反而能達到更大的價值 因此「先放CP值最高的物品」不是正確的策略了 必須每一種可能都試試看才知道哪種是最好的 ### 列出遞迴式 對於每一種物品,都有「拿或不拿」兩種選擇 如果拿了外套,那麼總價值就是「剩餘容量為3的背包決定其他兩種物品」加上外套的價值 如果不拿外套,那麼總價值就是「剩餘容量為12的背包決定其他兩種物品」 因此決定要不要拿外套,就是在這兩者中選較大價值的那種 決定完外套之後,接下來就是決定要不要拿麵包 等到所有物品都被決定過後,就算是看過所有情況了 但有一種情況你別無選擇 那就是你的剩餘容量小於目前正在被決定的物品的體積 這樣的話,一定得跳過這項物品了 接下來可以寫出遞迴式囉 ```cpp= int knapsack(int index, int capacity) { if(index==n) return 0; int dont_take = knapsack(index+1, capacity); if(capacity<volumn[index]){ return dont_take } else{ int take=value[index]+knapsack(index+1, capacity-volumn[index]); return max(dont_take, take); } } ``` ### 加上動態規劃 以上的做法,因為每一種物品都被決定了要拿或是不拿 因此可能的組合就有2^n種,實在太多了 但是相同的情況常常重複出現 舉例來說,假設容量為100的書包 要考慮帽子、餅乾、水壺、麵包、鉛筆 在選了帽子而不選餅乾水壺後,剩下80容量要考慮麵包和鉛筆 在選了餅乾和水壺而不選帽子後,一樣是剩下80容量要考慮麵包和鉛筆 我們就不須重新算一次80容量考慮麵包和鉛筆可以得到多少價值了 所以可以加上動態規劃的表格 ```cpp= int DP[105][1005]; int knapsack(int index, int capacity) { if(index==n) return 0; if(DP[index][capacity]!=-1) return DP[index][capacity]; int dont_take = knapsack(index+1, capacity); if(capacity<volumn[index]){ DP[index][capacity]=dont_take; return DP[index][capacity]; } else{ int take=value[index]+knapsack(index+1, capacity-volumn[index]); DP[index][capacity]=max(dont_take, take); return DP[index][capacity]; } } ``` <font color="darkgreen"> 【學生練習題】</font> > - [ ] [Green Judge b030: 隨選視訊 ](http://www.tcgs.tc.edu.tw:1218/ShowProblem?problemid=b030) ### 同一種物品可以拿無限多次 如果同一種物品可以拿無限多次的話 拿了一個之後還是可以考慮要不要拿剛才的那個 除非不拿了,才要換成考慮下一個 ```cpp= int knapsack(int index, int capacity) { if(index==n) return 0; int dont_take = knapsack(index+1, capacity); if(capacity<volumn[index]){ return dont_take } else{ int take=value[index]+knapsack(index, capacity-volumn[index]); return max(dont_take, take); } } ``` 這裡的程式唯一跟上一題不一樣的地方 只有第10行傳入函式的index+1改成index了 因為拿完之後可能還會想拿剛才那種 所以還是把index停留在原樣 最後加上動態規劃的表格就完成囉 請自行修改 <font color="darkgreen"> 【學生練習題】</font> > - [ ] [Green Judge b031: 吃到飽餐廳 ](http://www.tcgs.tc.edu.tw:1218/ShowProblem?problemid=b031) ## <font color='darkblue'>經典題型:最長遞增子序列</font> <font color='darkorange'>【例題】</font> > 從班上抓若干個人請他們照座號順序排好 > 如果要求他們照座號排好後的身高順序也是絕對遞增的 > 請問最多可以抓幾個人? - 如果班上只有1號一個人的話,那答案當然是1 - 如果班上只有1號和2號兩個人的話,那麼就看看2號是不是比1號高,是的話答案 就是2,否則答案會是1 - 如果班上有1、2、3三個人的話,那麼3號要看的是 1.他比1號高嗎?是的話,那1號的答案再加一就會是一個可能的答案 2.他比2號高嗎?是的話,那2號的答案再加一就會是另一個可能的答案 這兩個答案取較大者就是答案 - 如果班上有1、2、3、4四個人的話,那4號要看的就是 1.他比1號高嗎?是的話,那1號的答案再加一就會是一個可能的答案 2.他比2號高嗎?是的話,那2號的答案再加一就會是另一個可能的答案 3.他比3號高嗎?是的話,那3號的答案再加一就會是另一個可能的答案 這三個答案取較大者就是答案 - 以下,以此類推 接下來要用程式寫出這個函式 來算出「如果n號當最後一個的話,最多可以取幾個」 ```cpp= int LIS(int n) { if(n==1) return 1; //因為1號要當最後一個的話,只能拿1號了 int ans=1; //因為前面都不拿,只拿n號的話,就是1個 for(int i=1;i<n;i++){ if(a[n]>a[i]){ int candidate=LIS(i)+1; if(candidate>ans) ans=candidate; } } return ans; } ``` 該加上動態規劃囉 ```cpp= int DP[105]; int LIS(int n) { if(n==1) return 1; //因為1號要當最後一個的話,只能拿1號了 if(DP[n]!=-1) return DP[n]; int ans=1; //因為前面都不拿,只拿n號的話,就是1個 for(int i=1;i<n;i++){ if(a[n]>a[i]){ int candidate=LIS(i)+1; if(candidate>ans) ans=candidate; } } DP[n]=ans; return ans; } ``` ```cpp=18 int main() { for(int i=0;i<105;i++){ DP[i]=-1; } int N; cin>>N; for(int i=1;i<=N;i++){ cin>>a[i]; } int ans=1; for(int i=1;i<=N;i++){ int q=LIS(i); if(q>ans) ans=q; } cout<<ans<<endl; } ``` <font color="darkgreen"> 【學生練習題】</font> > - [ ] [Green Judge b032: 持續進步獎 ](http://www.tcgs.tc.edu.tw:1218/ShowProblem?problemid=b032) ## <font color='darkblue'>經典題型:最長共同子序列</font> <font color='darkorange'>【例題】</font> > 4班的同學照座號排排站 > 5班的同學也照座號排排站 > 想要從4班和5班裡面各取N個人出來 > 使得在維持原本順序的情況下 > 倆倆並排的人們身高都相同 > 請問最多可以取幾個人出來? 先從1號開始想看看: - 如果4班的1號和5班的1號身高相同,那麼絕對要取他們,再從2號比2號開始試 - 如果1號們身高不同,那麼有兩種可能 1.不取4班的1號了,4班從2號開始試 2.不取5班的1號了,5班從2號開始試 以上這兩者取較大的答案,就是答案 我們來用代數表示看看: - 如果4班的a號和5班的b號身高相同,那麼絕對要取他們,再從a+1號比b+1號開始試 - 如果4班的a和5班的b身高不同,那麼有兩種可能 1.不取4班的a號了,4班從a+1號開始試 2.不取5班的b號了,5班從b+1號開始試 以上這兩者取較大的答案,就是答案 還要記得,如果其中一班已經試完所有人了,那答案就會是0 ```cpp= int LCS(int a, int b) { //如果已經試完所有人了,答案就是0 if(a==N4) return 0; if(b==N5) return 0; //如果4班的a號和5般的b號一樣高,答案就是1加上取完這兩人後剩下的結果 if(C4[a]==C5[b]) return 1+LCS(a+1, b+1); //否則,各自拿掉第一人試試看 int candidate1=LCS(a+1, b); int candidate2=LCS(a, b+1); return max(candidate1, candidate2); } ``` ```cpp=16 int main() { cin>>N4; for(int i=0;i<N4;i++) cin>>C4[i]; cin>>N5; for(int i=0;i<N5;i++) cin>>C5[i]; int ans=LCS(0, 0); cout<<ans<<endl; } ``` 記得加上動態規劃的表格阿! 練習題裡的猴子輸入的是字串,不是陣列喔 <font color="darkgreen"> 【學生練習題】</font> > - [ ] [Green Judge b033: 兩隻猴子 ](http://www.tcgs.tc.edu.tw:1218/ShowProblem?problemid=b033) ## > 上一章:[貪婪演算法](https://hackmd.io/s/rkhigGlTG) > 下一章:[回溯法](https://hackmd.io/s/B1Q0_-whQ) > 回目錄:[國立科學園區實驗中學C++程式語言自學講義](https://hackmd.io/s/B18yT_i5Z)