# 第四章 :基礎動態規劃 動態規劃最重要的就是練習,而且練習動態規劃的CP值很高,因為很多大型考試大部分都有超過一題。 動態規劃簡稱 $DP$ $(Dynamic-Programing)$ ,也被稱為「記憶化遞迴」,它的核心想法就是$$不要重複計算,把之前算過的紀錄下來,以空間換取時間。$$ $DP$ 與分治的概念很像,都是把問題由小而大的計算,先把問題劃分成子問題再合併子問題的答案變成最後的答案,總共有3個步驟 : > 1. 定義子問題。 > 2. 找到問題與子問題之間的關係。 > 3. 找到計算子問題的順序,來避免重複計算重複的。 ## 例題 : 費氏數列 > 費氏數列第$0$項定義為$0$,第$1, 2$項都定義為$1$,求費氏數列的第$n$項$\bmod10^9+7$。 > $1 \le n \le 10^6$ 如果我們以遞迴關係式表示: > $$ > F_i = > \begin{cases} > 1 & \text{if } i=1 \text{ or } 2\\ > F_{i-1} + F_{i-2} & \text{otherwise} > \end{cases} > $$ 換成程式就變成 ```cpp= #include<bits/stdc++.h> using namespace std; const int mod=1e9+7; int f(int n) { if(n<3) return 1; return f(n-1)+f(n-2)%mod; } ``` 根據我們在遞迴上的,在 $n=5$ 時可以畫成下圖,可以發現它在 $n=3$ 時重複計算了,時間複雜度其實不是 $O(n)$,而是可怕的 $O(1.6^n)$,因此根據 $DP$ 的想法,我們可以把已經計算過的紀錄下來,就可以避免多餘的計算。 ![image](https://hackmd.io/_uploads/HyCzi1Z8yg.png) 所以我們可以用陣列紀錄費氏數列的每一項,也就是「記憶化遞迴」的精隨,避免重複計算,程式碼如下。 ```cpp= #include<bits/stdc++.h> using namespace std; #define int long long const int mod=1e9+7; int dp[1000010]; int f(int n) { if(n<3) return 1; if(dp[n]!=-1) return dp[n]; return dp[n]=(f(n-1)+f(n-2))%mod; } signed main() { int n; cin>>n; for(int i=0;i<=n;i++) dp[i]=-1; cout<<f(n); } ``` ## $Top-down$ && $Bottom-up$ 這樣由上而下做 $DP$,計算到哪一項再去看是否計算過的方法又被稱為 $Top-down$ 的 $DP$ ,與之相對的便是 $Bottom-up$ 的 $DP$。 $Bottom-up$ 在中國被稱為「嚴格位置依賴」,也就是如果要計算 $DP$ 中的任何一項,它所需要的每一項都需要被計算過,像是如果要把上面的程式碼轉成 $Bottom-up$ 的 $DP$ 可以轉成下面的程式碼。 ```cpp= #include<bits/stdc++.h> using namespace std; #define int long long const int mod=1e9+7; int dp[1000010]; signed main() { int n; cin>>n; dp[1]=dp[2]=1; for(int i=3;i<=n;i++) { dp[i]=(dp[i-1]+dp[i-2])%mod;//確定都計算過了,根據遞迴式。 } cout<<dp[n]; } ``` $9\sim 12$行的程式跟遞迴式幾乎一樣只是改成使用迴圈而不是遞迴,還有$n \sim 1$改成由$1\sim n$ ### 實作比較 #### $Top-down$ * 找到遞迴式就可以直接實作。 * 通常使用遞迴,速度相對慢,而且要注意遞迴的深度。 #### $Bottom-up$ * 要確定所有問題計算前,它的子問題已經計算好了。 * 通常使用迴圈,速度相對快。 **通常使用$Bottom-up$** ## 名詞介紹 ### 狀態 一個狀態就是用一些參數決定一個子問題,定狀態就是璇則子問題需要紀錄哪些參數。 ### 轉移 轉移指的就是狀態與狀態間的關係,與遞迴的方向相反,由小問題貢獻到大問題 e.g 從 $dp_{i-2}$ 和 $dp_{i-1}$ 轉移到 $dp_i$。 ### $xDyD$ $n$ 是資料量。 $xDyD$ 的意思就是總共有 $n^x$ 個狀態,每個狀態會需要 $n^y$ 個子狀態。 e.g 費氏數列 $f_i=f_{i-1}+f_{i-2}$,總共有 $n$ 個狀態,每個狀態需要 $2$ 個子狀態,所以就是 $1D0D$ ,一個 $xDyD$ 的 $DP$ 的時間複雜度是 $O(n^{x+y})$ ## $Bottom-up$的實作 ### pull * 就是用「拉」的 去找每個狀態需要由哪些狀態轉移 e.g. ```cpp= dp[1]=1, dp[2]=1; for(int i=3;i<=n;i++) { dp[i]=dp[i-1]+dp[i-2]; } ``` ### push * 就是用「推」的 去找每個狀態可以轉移到哪些狀態 e.g. ```cpp= dp[1]=1, dp[2]=1; for(int i=1;i<=n;i++) { if(i==1) dp[i+1]+=dp[i]; else { dp[i+1]+=dp[i]; dp[i+2]+=dp[i]; } } ``` ## 例題 [vacation](https://judge.cchs.chc.edu.tw/ShowProblem?problemid=a560) [原題](https://atcoder.jp/contests/dp/tasks/dp_c) >Kzzz巨砲今年當上國手了,保送台大的他覺得暑假好無聊,於是他決定去游泳,跟朋友出門或為了之後的ICPC RK.1繼續刷題,暑假總共有$N$天,每天都不能做跟昨天一樣的事,每天做每一件事都有對應的快樂度$a_i,b_i,c_i$,請輸出在暑假結束他能獲得的最大快樂度。 $1 \le N \le 10^5$ 我們可以發現如果第$i$天選擇要游泳那第$i-1$天就不能游泳,也就是第$i-1$天只能刷題或跟朋友出門所以我們可以定義 > $x_i$ 為第 $i$ 天游泳所能獲得的最大快樂度,$x_1=a_1$。 > $y_i$ 為第 $i$ 天跟朋友出門所能獲得的最大快樂度,$y_1=b_1$。 > $z_i$ 為第 $i$ 天刷題所能獲得的最大快樂度,$z_1=c_1$。 這題的遞迴式就可以列成這樣 : > $x_i=\max(y_{i-1}, z_{i-1})+a_i$ > $y_i=\max(x_{i-1}, z_{i-1})+b_i$ > $z_i=\max(x_{i-1}, y_{i-1})+c_i$ :::spoiler AC-CODE ```cpp= #include<bits/stdc++.h> using namespace std; signed main() { ios_base::sync_with_stdio(0), cin.tie(0); int n; cin>>n; vector<vector<int>> dp(3, vector<int>(100010, 0)); cin>>dp[0][1]>>dp[1][1]>>dp[2][1]; for(int i=2;i<=n;i++) { cin>>dp[0][i]>>dp[1][i]>>dp[2][i]; dp[0][i]+=max(dp[1][i-1], dp[2][i-1]); //不能由自己這項轉移 dp[1][i]+=max(dp[0][i-1], dp[2][i-1]); //不能由自己這項轉移 dp[2][i]+=max(dp[0][i-1], dp[1][i-1]); //不能由自己這項轉移 } cout<<max(dp[0][n], max(dp[1][n], dp[2][n])); //最後還要看是選哪個比較好 } ``` ::: ## 常見的$DP$題目 ### 求方法數 題目通常會給你一些限制,問你到第$n$項的方法有幾種,跟機率類似。 ### 找最佳解 給你一些限制,問你在滿足這些限制的情況最多可以獲得多少分數。 ## 例題 [atcoder DP contest H](https://atcoder.jp/contests/dp/tasks/dp_h) > 有一個由 $H$ 行和 $W$ 列組成的網格。我們用 $(i, j)$ 表示第 $i$ 行(從上往下數)和第 $j$ 列(從左往右數)的方格。 對於每個 $i$ 和 $j$ $(1\le i\le H, 1 \le j\le W)$,方格 $(i, j)$ 由一個字元 $a_{i,j}$ 描述。如果 $a_{i,j}$ 是 `.`,則方格 $(i, j)$ 是空方格;如果 $a_{i,j}$ 是 `#`,則方格 $(i, j)$ 是牆壁方格。保證方格 $(1,1)$ 和 $(H,W)$ 都是空方格。 太郎將從方格 $(1,1)$ 開始,通過不斷向右或向下移動至相鄰的空方格,最終到達方格 $(H,W)$。 請找出從方格 $(1,1)$ 到方格 $(H,W)$ 的所有可能路徑數量。由於答案可能非常大,請以 $\bmod(10^9 + 7)$ 後輸出結果。 > $1\le W, H \le 1000$ > $a_{i, j}=$ `.` 或 `#` > ![image](https://hackmd.io/_uploads/B1BvJxSUJl.png) 我們可以定義 $DP_{i, j}$ 是走到第 $i$ 行第 $j$ 列的方法數$\bmod 10^9+7$,而且 $DP_{1, 1}=1$,因為是從這格出發,由於走到第 $i$ 行第 $j$ 列之後只能往右或往下走,也就是說 $DP_{i, j}$ 應該要從它左邊和上面的格子轉移,因為只有這兩格可以走到第 $i$ 行第 $j$ 列,所以可以推出轉移式是 > $DP_{1, 1}$=1 > $DP_{0, 1\sim W}=0,DP_{1\sim H, 0}=0$ 因為根本到不了。 > $DP_{i, j}=(DP_{i-1, j}+DP_{i, j-1}) \bmod 10^9+7$ 但是如果$a_{i,j}$ 是 `#` 那這格根本到不了所以不能用 $DP_{i, j}$ 來轉移。完整的轉移式應該要是這樣 > $DP_{1, 1}$=1 > $DP_{0, 1\sim W}=0,DP_{1\sim H, 0}=0$ 因為根本到不了。 > $if a_{i,j} \not=$ # $DP_{i, j}=(DP_{i-1, j}+DP_{i, j-1}) \bmod 10^9+7$ > $else$ $DP_{i, j}=0$ :::spoiler AC-CODE ```cpp= #include<bits/stdc++.h> using namespace std; signed main() { int n, m, mod=1e9+7; cin>>n>>m; vector<vector<int>> dp(1010, vector<int> (1010, 0)); //一開始先初始化為0 vector<vector<char>> adj(1010, vector<char> (1010, ' ')); for(int i=1;i<=n;i++) { for(int j=1;j<=m;j++) { cin>>adj[i][j]; } } dp[1][1]=1; for(int i=1;i<=n;i++) { for(int j=1;j<=m;j++) { if(adj[i][j]=='.') dp[i][j]+=(dp[i][j-1]+dp[i-1][j])%mod; //可以走才轉移 } } cout<<dp[n][m]; } ``` ::: 這題是 $2D0D$ 的 $DP$,因為總共有 $n^2$ 個狀態,每個狀態需要兩個子狀態來轉移,時間複雜度是 $O(n^2)$。 ## 例題 LIS(最長遞增子序列) > 有一個陣列 $a[1], a[2], a[3], ...a[N-1], a[N]$,選擇一個他的子序列,子序列需要滿足除了第一項外,每一項都必須大於前項,求最長的子序列長度。 > * 子序列定義 > > 子序列是選擇原陣列的一些元素,在不改變選擇的元素的相對位置的情況所形成的序列。 > * 限制 > > $1 \le N \le 2000$ > > e.g. `4 7 6` 是`4 8 7 6 3`的子序列但`4 3 6`不是。 > ![image](https://hackmd.io/_uploads/HkNesgBI1l.png) 如果把狀態定為 $DP_i$ 為到第 $i$ 項所能得到的最長子序列長度,感覺是對的但仔細想想又許多問題,可以發現 $DP_i$ 不一定會包含 $a_i$,但到 $DP_j$ 的時候,需要保證用來轉移狀態的 LIS 的最後一項必須小於 $a_j$,可是根據狀態的定義,我們無法確定用來轉移的 LIS 所選的最後一項是甚麼,所以我們無法透過這個狀態來做轉移。 那應該怎麼轉移呢 ? :::spoiler 真的想不到再打開 ### 把狀態定為 $DP_i$ 為,必須選擇 $a_i$ 當LIS的最後一項,而且到第 $i$ 項所能得到的最長子序列長度。 轉移到 $DP_j$ 的時候,可以由滿足 $i<j$ 且 $a_i<a_j$ 的任意一項 $DP_i$ 轉移,非常顯然一定是由 LIS 長度最長的 $DP_i$ 轉移 ::: ::: spoiler AC-CODE ```cpp= #include<bits/stdc++.h> using namespace std; int main() { int n; cin>>n; int a[2010], dp[2010]; for(int i=1;i<=n;i++) { dp[i]=1; cin>>a[i]; } int ans=1; for(int i=2;i<=n;i++) { for(int j=1;j<i;j++) { if(a[i]>a[j]) { dp[i]=max(dp[i], dp[j]+1); ans=max(ans, dp[i]); } } } cout<<ans; } ``` ::: 這題是 $1D1D$ 的 $DP$ 因為總共有 $N$ 個狀態,每個狀態最多可能由 $N-1$ 個狀態來轉移,時間複雜度是 $O(n^2)$,這題可以優化到 $O(n \log n)$ 但這並不再這一章節的內容,如果有興趣的可以想看看。 ## 例題 [LCS](https://atcoder.jp/contests/dp/tasks/dp_f) > 給定字串 $s$ 和 $t$,找出它們的一個最長共同子序列。 > * 注意事項 > >字串 $x$ 的子序列是通過從 $x$ 中移除零個或多個字元,並按原順序連接剩餘字元得到的字串。 >* 限制條件 > >$s$ 和 $t$ 是僅由小寫英文字母組成的字串。 >$1 \leq |s|, |t| \leq 3000$。 > >範例 : abcde abdff $\rightarrow$ LCS長度 : 3 很直覺的想法是 $dp_{i, j}$ 是 $s$ 的前 $i$ 個字元和 $t$ 的前 $j$ 個字元的 LCS長度。 但是如果 $s_i==t_j$ 那 LCS 長度應該要 +1,但是不知道該怎麼轉移。 如果 $s_i==t_j\ \text{AND } s_i==t_{j+1}$ ,LCS 的長度很明顯不能 +2 ,因為不能選擇 $s_i$ 兩次,所以如果 $s_i==t_j$ 那 $dp_{i, j}=dp_{i-1,j-1}+1$,這樣就可以保證不會選到重覆的。 如果 $s_i\not=t_j$,那 $dp_{i, j}=\max(dp_{i-1,j},dp_{i,j-1})$,因為 $dp_{i-1,j}$, $dp_{i,j-1}$ 根據定義是能轉移到的最長 LCS。 圖 from [Yui Huang 演算法學習筆記](https://yuihuang.com/dp-lcs/) ![image](https://hackmd.io/_uploads/Bk8PXJYLyl.png) ::: spoiler AC-CODE ```cpp= #include<bits/stdc++.h> using namespace std; signed main() { string s, t; cin>>s>>t; s=" "+s; //方便實作 t=" "+t; //方便實作 vector<vector<int>> dp(1010, vector<int>(1010, 0)); for(int i=1, l=s.size();i<l;i++) { for(int j=1, len=t.size();j<len;j++) { if(s[i]==t[j]) dp[i][j]=dp[i-1][j-1]+1; else dp[i][j]=max(dp[i-1][j], dp[i][j-1]); } } cout<<dp[s.size()-1][t.size()-1]; } ``` ::: ## 例題 [01背包](https://atcoder.jp/contests/dp/tasks/dp_d) >給一個容量為 $M$ 的背包,還有 $N$ 個物品,第 $i$ 個物品的體積是 $a_i$ ,價值是 $b_i$,選擇一些物品放到背包中。求在總體積不超過背包容量,所能獲得的最大價值。 > > * 限制條件 > > $1 \le M \le 100$, $1 \le N \le 10^5$, $1 \le a_i, b_i \le 10^7$ ### 暴力法 每一項都選或不選,再判斷總體積小於等於 $M$ 的最大價值,時間複雜度是 $O(2^N)$。 似乎就算剪枝也不太可行。 ### DP 把狀態定義為 $DP_{i, W}$ 為只選擇前 $i$ 個物品且總體積小於等於 $W$ 所能獲得的最大價值。 如果選了第 $i$ 項那 $DP_{i, W}=DP_{i-1, W-a_i}+b_i$,如果不選第 $i$ 項那 $DP_{i, W}=DP_{i-1, W}$。 > $$ > DP_{i, w} = \max (DP_{i-1, W-a_i}+b_i, DP_{i, W}=DP_{i-1, W}) > $$ 時間複雜度是 $O(MN)$。 * 實作 > 初始值是 0(不選任何東西)。 > 當 $w<a_i$ 時不能選第 𝑖 個物品。 :::spoiler AC-CODE ```cpp= #include<bits/stdc++.h> using namespace std; // 可以不用開long long 因為100*10^7<2^32-1 signed main() { int m, n; //m是容量, n是個數 cin>>n>>m; vector<vector<int>> dp(110, vector<int>(10010, 0)); vector<int> a(n+1, 0), b(n+1, 0); for(int i=1;i<=n;i++) cin>>a[i]>>b[i]; for(int i=1;i<=n;i++) { for(int j=0;j<=m;j++) { if(j>=a[i]) { dp[i][j]=max(dp[i-1][j], dp[i-1][j-a[i]]+b[i]); } else dp[i][j]=dp[i-1][j]; } } } ``` ::: 其實背包問題還有無限背包還有有限背包,但筆者想多介紹一些不同思路的 $DP$,如果有興趣的可以自己上網找看看。 ## 例題 旅行推銷員問題 >有 $N$ 棟房子,編號是 $0 \sim N-1$,房子 $i$ 和房子 $j$ 的距離是 $a[i][j]$,有一個推銷員要從房子 $0$ 出發,經過每間房子恰好一次再回到房子 $0$,請問他最短要走多遠? > > * 限制條件 > > $2 \le N \le 17$, $0 \le a[i][j] \le 10^7$, $a[i][j]=a[j][i]$, $a[i][i]=a[i][i]$ ### 枚舉法 排列 $0 \sim N-1$ 的所有可能,時間複雜度是 $O(N!)$,$17! \approx 3.5 \times 10^{14}$。 顯然不符合所求。 ### DP $DP_{i,[vis_{N-1}][vis_{N-2}][vis_{N-3}]...[vis_0]}$ 為目前在 $i$,$vis_j$ 是 $0 \text{ or } 1$,表示是否去過 $j$。 * 實作 >實作中,帶著一個18維的陣列轉移似乎有點太難了,所以要使用狀態壓縮。 >可以發現,因為只要記錄每個房子是否去過,所以要用二進位用一個`int`表達。 ### 狀態壓縮 $$DP_{i,[vis_{N-1}][vis_{N-2}][vis_{N-3}]...[vis_0]}\rightarrow DP_{i, [vis_{N-1}vis_{N-2}vis_{N-3}...vis_0]}$$ e.g. $$DP_{4, [1][0][1][0]} \rightarrow DP_{4, [{(1010)}_2]}$$ #### 位元運算 設已經走過的二進位集合為 $S={(1010)_2}$,$S$ 代表已經去過了第1間和第3間。 > $S \&(1<<j)$ (即判斷 $S$ 在二進制下從右數來第 $j+1$ 位是否為1) > 用於檢查點 $j$ 是否在集合內。 > $\text{if }S \&(1<<J)==1$,代表點 $J$ 已經去過。 > $\text{else}$ 點 $J$ 沒有去過。 >e.g. >$S=$<font color="#ff0000">$1010$</font> >$1<<3$ $=$ <font color="#ff0000">$1$</font>$000$ >$S\&1<<3=1$ >$S-(1<<3)=0010 \rightarrow可以用減法把點3移除集合$ > $(1<<N)-1$ 就是去過所有點的集合。 :::spoiler AC-CODE 可能需要理解一下 ```cpp= #include<bits/stdc++.h> using namespace std; const int INF=1e9; signed main() { int n, ans=1e9; vector<vector<int>> a(17, vector<int>(17, 0)), dp(131072, vector<int>(17, INF)); for(int i=0;i<n;i++) { for(int j=0;j<n;j++) { cin>>a[i][j]; } } dp[1][0]=0; //從2^0出發,目前在第0間,距離設定為0。 for(int i=0;i<(1<<n);i++) { //枚舉所有可能的集合。 for(int j=0;j<n;j++) { //枚舉目前可能在哪一間。 if(i&(1<<j)) { //要點 j 有在 目前去過的集合中。 for(int k=0;k<n;k++) { if((i&(1<<k))&&j!=k) { //可以枚舉上一間房子去哪裡,計算最短距離。 dp[i][j]=min(dp[i][j], dp[i-(1<<j)][k]+a[j][k]); } } } } } for(int i=1;i<n;i++) ans=min(ans, dp[(1<<n)-1][i]+a[i][0]); //枚舉所有可能的答案(最後在哪一間房子的總距離+那一間房子到點0的距離) cout<<ans; } ::: 時間複雜度由 $O(N!)$ 壓縮到 $O(N^22^N)$ ## 例題 [$\text{Slimes}$](https://atcoder.jp/contests/dp/tasks/dp_n) > 有 $N$ 個史萊姆排成一排,第 $i$ 隻史萊姆的大小是 $a[i]$ 。每次挑選兩隻相鄰的史萊姆結合在一起,大小變成兩隻史萊姆的大小相加,而花費也是兩隻史萊姆的大小相加,其他史萊姆的相對順序不變,經過 $N-1$ 次結合,所有的史萊姆會被合在一起,求最小的花費。 > * 限制條件 > > $1 \le N \le 400, 1\le a[i] \le 10^9$ > e.g. > `1|2|3` $\rightarrow$ `1|5` $\rightarrow$ `6` $cost=2+3+1+5=11$ >`1|2|3` $\rightarrow$ `3|3` $\rightarrow$ `6` $cost=1+2+3+3=9$ 我們可以觀察到在任何時候「史萊姆一定是由一個連續的區段所組成,而且其大小為這個區間的和。」以及「如果要使用區間 $[L, R]$ 合成史萊姆。一定是由區間 $[L, K]$ 和區間 $[K+1, R]$ 組成。」 所以可以把子問題定義成「把陣列的子區間合成成一個最大史萊姆的最小花費。」 ### DP 定義$DP_{L, R}$為把第 $L$ 隻到第 $R$ 隻的史萊姆都合成的最小總花費。 $$ DP_{L, R}=\\ \begin{cases} 0 \text{ if L=R}\\ DP_{L, K}+DP_{K+1, R}+\sum_{i=L}^Ra[i] \text{ otherwise} \end{cases} $$ * 計算$\sum_{i=L}^Ra[i]$ > 使用前綴和$b[i]=b[i-1]+a[i]$。$\sum_{i=L}^Ra[i]=b[R]-b[L-1]$ 可以 $O(1)$ 計算區間和。 * 實作 實作上需要注意如果由 $1\sim N$ 枚舉左右界進行轉移是不行的,因為計算 $dp_{L, R}$時 $dp_{L+1, R}$ 還沒計算。 :::spoiler AC-CODE ```cpp= #include<bits/stdc++.h> using nmaespace std; #define int long long const int INF=2e18; vector<int> a(410, 0), b(410, 0); vector<vector<int>> dp(410, vector<int>(410, INF)) signed main() { int n; cin>>n; for(int i=1;i<=n;i++) cin>>a[i]; for(int i=1;i<=n;i++) b[i]=b[i-1]+a[i]; for(int i=1;i<n;i++) { for(int j=1;j+i<=n;j++) { int l=i, r=j+i; for(int k=l;k<r;k++) { dp[l][r]=min(dp[l][r], dp[l][k]+dp[k+1][r]+b[r]-b[l-1]); } } } cout<<dp[1][n]; } ``` ::: ## 練習題 $DP$ 的練習方法真的只能多練,本章部分例題來自[Atcoder Educational DP Contest](https://atcoder.jp/contests/dp/tasks)。如果想要練習也可以來這看看。 也可以去 [$\text{Codeforces}$](https://codeforces.com/problemset?order=BY_RATING_ASC&tags=dp) 戳戳看一些 $DP$ 題目