--- tags: FD Training --- # 動態規劃(Dynamic Programming) --- ## 核心概念 - 分治:將大問題分解成小問題,計算出小問題的答案後再合併出大問題的解 - 以空間換取時間:由於小問題的答案會被多次使用,因此將小問題的答案全部記錄下來 ---- ### 範例 費氏數列:$f(n)=f(n-1)+f(n-2)$ 分治:將$f(n)$分成先計算$f(n-1),f(n-2)$,再相加合併出$f(n)$ $f(n)=f(n-1)+f(n-2)\\=f(n-2)+f(n-3)+f(n-4)+f(n-3)+...$ 很明顯的一個子問題可能會被詢問非常多次,因此用陣列紀錄所有$f(i\le n)$的解 ---- ```cpp int f(int n) { if(n<2) return n; return f(n-1)+f(n-2); } ``` ```cpp int dp[1000005]={}; int f(int n) { if(n<2) return n; if(dp[n]) return dp[n]; return dp[n]=f(n-1)+f(n-2); } ``` $n=45:$後者約0ms,前者約5s $n=1000000:$後者約0ms,前者至少200000年(?) 實際上後者複雜度$O(n)$,前者是$O(\varphi^n)$(其中$\varphi$是黃金比例) --- ## DP的時間複雜度 簡單的:狀態數\*轉移複雜度 難的:自己通靈 狀態數:計算最終答案總共需要用到的子問題數量 轉移複雜度:計算任一個子問題的複雜度 ---- ### DP轉移式 - 轉移式是用來表達DP的數學式子,類似遞迴數列的式子 - 主要是描述如何用小問題的解計算出大問題的解 ---- ### 範例 - $f(n)=f(n-1)+f(n-2)$ - $dp[i]=\max\limits_{0\le j\lt i,i-j<=k}\{dp[j]+a[i]\}$ - $dp[i][j]=\max({dp[i-1][j],dp[i-1][j-w[i]]+c[i]})$ - $dp[p][n]=\min\limits_{0\le i\lt n}\{\max(dp[p-1][i],a[n]-a[i+1])\}$ - $dp[i][j]=\begin{cases}dp[i-1][j-1]+1,s[i]=t[j]\\max(dp[i-1][j],dp[i][j-1]),s[i]\ne t[j]\end{cases}$ ---- ### DP轉移式的重要性 - 清楚且完整的表達DP - 從轉移式中可以看出遞迴計算大問題所需要的子狀態與計算方式 - 大多DP可以從轉移式看出總狀態數與轉移複雜度 - 列出轉移式後,照著打就可以輕鬆的寫出DP的程式了(尤其top-down) - 容易推導dp優化 ---- ### DP複雜度計算 - ex: $f(n)=f(n-1)+f(n-2)$ 狀態數:$O(n)$,轉移複雜度:$O(1)\Rightarrow$總複雜度$O(n)$ - ex: $dp[i]=\max\limits_{0\le j\lt i,i-j<=k}\{dp[j]+a[i]\}$ 狀態數:$O(n)$,轉移複雜度:$O(k)\Rightarrow$總複雜度$O(nk)=O(n^2)$ ---- - ex: $dp[i][j]=\max({dp[i-1][j],dp[i-1][j-w[i]]+c[i]})$ 狀態數:$O(nw)$,轉移複雜度:$O(1)\Rightarrow$總複雜度$O(nw)$ - ex: $dp[p][n]=\min\limits_{0\le i\lt n}\{\max(dp[p-1][i],a[n]-a[i+1])\}$ 狀態數:$O(pn)$,轉移複雜度:$O(n)\Rightarrow$總複雜度$O(pn^2)$ - ex: $dp[i][j]=\begin{cases}dp[i-1][j-1]+1,s[i]=t[j]\\max(dp[i-1][j],dp[i][j-1]),s[i]\ne t[j]\end{cases}$ 狀態數:$O(n^2)$,轉移複雜度:$O(1)\Rightarrow$總複雜度$O(n^2)$ ---- ### 如何設計DP? 1. 設計狀態,先決定好要計算的東西(實際意義)與其參數 2. 試著將任一狀態的答案用子狀態來表達(當然也要想清楚正確性) 3. 列出轉移式(將2.的結果清楚寫下來) 4. 確定其複雜度是否是好的 5. DP優化(?) --- ## DP實作 ---- ### Top-Down - 由上而下計算,通常以遞迴方式實作 - 一開始所有狀態皆未計算(基本狀態除外) - 當某個狀態被呼叫時,若該狀態已計算過就從記錄中直接回傳答案,反之則須遞迴計算,並記錄答案 - ex: ```cpp int dp[1000005]={}; int f(int n) { if(n<2) return n; if(dp[n]) return dp[n]; return dp[n]=f(n-1)+f(n-2); } ``` ---- ### Bottom-Up - 由下而上計算,通常以迴圈方式實作 - 找出一種"好的"計算順序,依此順序一次計算完所有狀態 - "好的計算順序"指的是當你要計算某個狀態時,其需要用到的子狀態答案皆已計算完畢 - ex: ```cpp int dp[1000005]={}; dp[0]=0,dp[1]=1; for(int i=2;i<=1000000;i++) dp[i]=dp[i-1]+dp[i-2]; ``` ---- ### Top-Down v.s. Bottom-Up - 理論上兩者時間複雜度相同,但前者用遞迴實作通常常數較大 - 對於top-down,若一開始直接呼叫很大的狀態可能會stack overflow - bottom-up需要找出好的計算順序,對於有些DP不易找到 - top-down只會計算該次呼叫所需要用到的所有子狀態,而bottom-up一次記算完所有子狀態 - 對於狀態很疏鬆的DP,bottom-up可能會計算很多冗餘的狀態 ---- - 如果狀態緊密且容易找出計算順序就用bottom-up(常數小,沒有RE風險) - 反之才用top-down - 大多DP都是由小計算到大因此容易找順序,除了少數DP有很奇怪的轉移式 - 大多DP狀態都是滿的,通常是暴搜的記憶化搜索才會是疏鬆的狀態 --- ## DP優化 ---- ### 時間上的優化 - 有非常多的種類,分別用在不同的情況 - 單調隊列優化、斜率優化、四邊形優化、分治優化、矩陣快速冪、多項式乘法、aliens優化... - 又難又難,沒有要教 ---- ### 空間上的優化 #### 滾動DP ---- - 再次拿費氏數列來舉例 - $f(n)=f(n-1)+f(n-2)$ - 可以看出計算每一項皆只需保留前兩項的答案 - 若用bottom-up計算,只需要三個變數交替使用$cur,pre,ppre$ ```cpp int cur,pre=1,ppre=0; for(int i=2;i<=n;i++) cur=pre+ppre,ppre=pre,pre=cur; ``` ---- ### 滾動DP - 用來節省空間,常常可以節省一個維度 - 不是所有DP都可以滾動 - 用於bottom-up,單次計算 - 從轉移式中看計算每一項所需要用到的子狀態是否有跟著遞增(算到後面,前面的狀態就不會再被使用) ---- ### 舉例 - $f(n)=f(n-1)+f(n-2)$ 只需要保留前兩項 - $dp[i][j]=\max({dp[i-1][j],dp[i-1][j-w[i]]+c[i]})$ 只需保留前一列(計算dp[i][...]時只需用到dp[i-1][...]) - $dp[i]=\max\limits_{0\le j\lt i,i-j<=k}\{dp[j]+a[i]\}$ 只需保留前$k$項(計算dp[i]時只需用到dp[i-k]...dp[i-1]) - $dp[i]=\max\limits_{0\le j\lt i,i-j>=k}\{dp[j]+a[i]\}$ 無法滾動(計算dp[i]時需用到dp[0]...dp[i-k],永遠不會有"過期"的元素) --- ## 經典DP題 ---- ### 最大子陣列和(最大區間和) 給定一個數列$a_0,a_1,...,a_{n-1}$,求出區間$l,r$使得區間和$a_l+...+a_r$最大 解法: 令$dp[i]$代表以$a[i]$為結尾最大的區間和 轉移式:$dp[i]=max(dp[i-1]+a[i],a[i])=max(dp[i-1],0)+a[i]$ 狀態數$O(n)$,轉移複雜度$O(1)$,總複雜度$O(n)$ 此DP可以滾動 ---- ### 最長共同子序列(LCS) Longest Common Subsequence 給定兩個字串$s,t$,求$s與t$的最長共同子序列 解法: 令$dp[i][j]$代表字串前綴$s_{0...i}$與$t_{0...j}$的LCS 轉移式:$dp[i][j]=\begin{cases}dp[i-1][j-1]+1,s[i]=t[j]\\max(dp[i-1][j],dp[i][j-1]),s[i]\ne t[j]\end{cases}$ 邊界條件:$dp[i][-1]=dp[-1][j]=0$ 狀態數$O(n^2)$,轉移複雜度$O(1)$,總複雜度$O(n^2)$ 通常為了方便會把$dp$陣列平移,且此DP可以滾動 ---- ## 矩陣鏈乘積 給定$n$個矩陣$M_i$的大小$r[i]\times c[i]$,求計算所有矩陣相乘$M_1M_2...M_n$最少需要用到多少次乘法(保證所有$c[i]=r[i+1]$)(已知$a\times b$與$b\times c$的矩陣相乘需要$a\times b\times c$次乘法,並變成$a\times c$的矩陣) 解法: 令$dp[l][r]$代表$M_l...M_r$最少需要幾次乘法 轉移式:$dp[l][r]=\min\limits_{l\le i\lt r}\{dp[l][i]+dp[i+1][r]+r[l]\cdot c[l]\cdot c[r]\}$ 邊界條件:$dp[i][i]=0$ 狀態數$O(n^2)$,轉移複雜度$O(n)$,總複雜度$O(n^3)$ ---- ### 0/1背包問題(knapsack) 有$N$個物品,第$i$個物品重量$w_i$,價值$p_i$,現在有一個耐重$W$的背包,你可以選一些物品放進去,每個物品只能選或不選(不能取部分),求最大價值。 解法: 令$dp[i][j]$為考慮前$i$個物品,總重量$\le j$的最大價值 轉移式:$dp[i][j]=\begin{cases}max(dp[i-1][j],dp[i-1][j-w_i]+p_i),j\ge w_i\\dp[i-1][j],j\lt w_i\end{cases}$ 狀態數$O(NW)$,轉移複雜度$O(1)$,總複雜度$O(NW)$ ---- 可以發現此DP轉移所需的子狀態為左上角或正上方的狀態,因此若改變$j$的計算順序(由大計算到小)則可以滾動成只用一個陣列,其中陣列中$>j$的項是已計算完第$i$項(也就是等同$dp[i][...]$),而$\le j$的項是尚未計算(也就是等同$dp[i-1][...]$ 滾動後轉移式:$dp[j]=\begin{cases}max(dp[j],dp[j-w_i]+p_i),j\ge w_i\\dp[j],j\lt w_i\end{cases}$ ---- 除此之外若計算順序是$j$由小到大則變成是解"**無限背包問題**",也就是上述問題,但每個物品都有無限多個 轉移式:$dp[i][j]=\max\limits_{w_i\cdot k\le j}\{dp[i-1][j-w_i\cdot k]+p_i\cdot k\}\\=\begin{cases}max(dp[i-1][j],dp[i][j-w_i]+p_i),j\ge w_i\\dp[i-1][j],j\lt w_i\end{cases}$ 狀態數$O(NW)$,轉移複雜度$O(1)$,總複雜度$O(NW)$ ---- 若每項物品的規定改成最多能選$c_i$個則稱作"**多重背包問題**" 基本作法是把第$i$個物品複製$c_i$次做0/1背包問題,複雜度$O(NWC)$ 進階做法是把第$i$個物品分堆成$1,2,4,8,...$(總和$=c_i$),根據二進制可以知道這樣的分堆可以湊出任何數量,因此將所有物品分堆後再做0/1背包問題,複雜度$O(NW\lg C)$ 最難的做法是有DP優化可以做到$O(NW)$,超出範圍 --- ## 總結 DP是個博大精深的演算法,初學者剛學演算法很快就會碰到DP,然而難的DP也可以難到很可怕 :poop: 也是因為DP不論從難度還是類型來看,變化都非常多,所以幾乎所有的程式設計競賽DP都扮演很重要的角色(北二區學科能力競賽更是連續出了好幾年DP),也因此好好學會DP非常的重要! DP不算是一個制式的演算法,而是一種概念,因此它的用途/形式多變想學好它不是很容易,要能在在看到題目時有辦法快速辨別能不能DP/怎麼DP等只能靠多看題目多練習(把各種類型的DP都看很多次自然而然就會熟悉模式了(O)) :poop: 記得設計DP的步驟: 設狀態$\Rightarrow$列轉移$\Rightarrow$算複雜度$\Rightarrow$考慮優化$\Rightarrow$實作出來XD