# **CSES DP詳細解法** **動機**:在考APCS的時候,我發現自己對動態規劃不了解、熟練度不足,因此常陷入無從下手、時間不夠的困境。為了加強我這方面的能力,我決定到cses練習刷題,並製作自己的詳解。雖然網路上已有其他人製作詳解,但我發現有些寫的比較簡潔,讓新手需花比較多時間理解,因此本詳解會注重在圖解部分,並詳細說明步驟。 **感謝**:謝謝tmting39的講義,Anthony Ngene的youtubequ頻道提供的解題方法及教學。以及台中一中資訊讀書會、電研提供的教學。 # 1.Dice Combination [https://cses.fi/problemset/task/1633](https://) :::spoiler 解法: x的組合數會是x-1,x-2,...x-6加起來。以下圖為例: ![1](https://hackmd.io/_uploads/BJyNff0ayx.png) 可以得知4有以下共8種解法: 1+1+1+1/2+1+1/1+2+1/3+1/1+1+2/2+2/1+3/4 定義0有1種解法,那從1開始: dp[1]=dp[0]; dp[2]=dp[0]+dp[1]; dp[3]=dp[0]+dp[1]+dp[2] ... dp[8]=dp[2]+dp[3]+dp[4]+dp[5]+dp[6]+dp[7] dp[x]=dp[x-6]+dp[x-5]....+dp[x-1] 另外要記住若x<6的話要注意不要減到變負數 |0|1|2|3|4|5|6|7| |-|-|-|-|-|-|-|-| |1|1|2|4|8|16|32|63| ``` 程式碼施工中請稍候 ``` ::: # 2.Minimizing Coins [https://cses.fi/problemset/task/1634](https://) :::spoiler 解法: 對於x來講,他的最少硬幣數dp[x]=min(dp[x-c1],dp[x-c2],dp[x-c3]....)+1 以11 {1,5,7}舉例 dp[0]=0 dp[1]=dp[1-1]+1 dp[2]=dp[2-1]+1 ... dp[5]=min(dp[5-1],dp[5-5])+1 |0|1|2|3|4|5|6|7|8|9|10|11| |-|-|-|-|-|-|-|-|-|-|-|- |0|1|2|3|4|1|2|1|2|3|2|3 ``` 程式碼施工中請稍候 ``` ::: # 3.Coin Combinations I [https://cses.fi/problemset/task/1635](https://) :::spoiler 解法: 這題跟第一題差不多,只是第一題限定由1~6組成,而這題教你輸入硬幣,以8 {2,3,5}為例: ![3](https://hackmd.io/_uploads/HybNPMATkl.png) 共有6種解法,分別是: 2+2+2+2/3+3+2/3+2+3/2+3+3/5+3/3+5 定義0有一種解法 dp[1]=0 dp[2]=dp[0] dp[3]=dp[1]+dp[0] ... dp[9]=dp[7]+dp[6]+dp[4] dp[x]=dp[x-c1]+dp[x-c2]+dp[x-c3]... 一樣要小心邊界 |0|1|2|3|4|5|6|7|8| |-|-|-|-|-|-|-|-|- |1|0|1|1|1|3|2|5|6 ``` 程式碼施工中請稍候 ``` ::: # 4.Coin Combinations II https://cses.fi/problemset/task/1636 :::spoiler 解法: 這題跟上面1、3題不太一樣,由於加的順序不同不能重複算到,因此我們要分開討論,先從幣值最小的硬幣開始討論方法數,接者往下看,討論是否使用新硬幣。拿以下的dp表格做講解,以9,{2,3,5}為例: | | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |-|-|-|-|-|-|-|-|-|-|-| | 2 | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | | 3 | 1 | 0 | 1 | 1 | 1 | 1 | 2 | 1 | 2 | 2 | | 5 | 1 | 0 | 1 | 1 | 1 | 2 | 2 | 2 | 3 | 3 | dp[i][j]=dp[i-1][j]「不使用新硬幣」+dp[i][j-c]「使用新硬幣」 舉例:dp[1][6]=dp[0][6]+dp[1][3] 分別有2+2+2/3+3兩種可能 dp[2][9]=dp[1][9]+dp[2][4] 分別有2+2+2+3/3+3+3/2+2+5三種可能 ``` 程式碼施工中請稍候 ``` ::: # 5.Removing Digits https://cses.fi/problemset/task/1637 :::spoiler 解法: 這題其實可以直接減,用python的字串處理方便很多 ``` a=input() b=0 while a!=0: b=b+1 a=str(a) a=list(a) c=sorted(a) c.reverse() maxn=c[0] a=''.join(a); a=int(a) a=a-int(maxn) print(b) ``` 另外c++解法未來補上 ::: # 6.Grid Paths https://cses.fi/problemset/task/1638 :::spoiler 解法: 其實跟數學寫排列組合時遇到的那種題目差不多 ![6](https://hackmd.io/_uploads/S16JLPCpyx.png) dp[i][j]=dp[i-1][j]+dp[i][j-1] trap的部分當作0 ``` 程式碼施工中 ``` ::: # 7.Book Shop https://cses.fi/problemset/task/1158 :::spoiler 解法: 就是經典的0/1背包問題,開dp表格討論當前物品要選擇還是丟棄,直接上表格當範例,以題目的測資來看 | | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |-|-|-|-|-|-|-|-|-|-|-|-| | (3,1) | 0 | 0 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | | (4,5) | 0 | 0 | 0 | 1 | 5 | 5 | 5 | 6 | 6 | 6 | 6 | | (5,8) | 0 | 0 | 0 | 1 | 5 | 8 | 8 | 8 | 9 | 13 | 13 | | (8,12) | 0 | 0 | 0 | 1 | 5 | 8 | 8 | 8 | 12 | 13 | 13 | dp[i][j]=max(dp[i-1][j] (不選這個,繼承上方的),dp[i-1][j-hi]+si(選這個,加上價值)) ``` #include<iostream> #include<utility> #include<algorithm> using namespace std; int main() int n; int x; cin>>n>>x; pair<int,int> moneypage[n]; int total[n+1][x+1]; for(int k=0;k<n;k++){ cin>>moneypage[k].first; } for(int k=0;k<n;k++){ cin>>moneypage[k].second; } sort(moneypage,moneypage+n); for(int i=0;i<n+1;i++){ for(int j=0;j<x+1;j++){ total[i][j]=0; } } for(int i=1;i<n+1;i++){ for(int j=1;j<x+1;j++){ if(j-moneypage[i-1].first>=0){ total[i][j]=max(total[i-1][j],total[i-1][j-moneypage[i-1].first]+moneypage[i-1].second); }else{ total[i][j]=total[i-1][j]; } } } cout<<total[n][x]; ``` ::: # 8.Array Description https://cses.fi/problemset/task/1746 :::spoiler 解法: 直接開表格紀錄,以題中測資為例: | | 2 | 0 | 2 | |-|-|-|-| | 1 | 0 | 1 | 2 | | 2 | 1 | 1 | 3 | | 3 | 0 | 1 | 2 | | 4 | 0 | 0 | 1 | | 5 | 0 | 0 | 0 | dp[i][j]代表那一格的方法數,可以繼承給dp[i+1][j+1]、dp[i][j] 、dp[i-1][j] 記得注意邊界和取餘數 ``` 施工中 ``` ::: # 9.Counting Towers https://cses.fi/problemset/task/2413 :::spoiler 解法 對於每個2x1的格子,有兩種結果,中間有一條線的和沒有線的 ![9-1](https://hackmd.io/_uploads/H1VZjDyCyg.png) 而這兩種分別會衍伸處下列幾種 ![9-2](https://hackmd.io/_uploads/SJzYJdyC1g.png) 也就是說,中間沒線的會衍伸出兩種中間沒線的和一種中間有線的 中間有線的會衍伸出一種中間沒線的和四種中間有線的 若沒線是x,有線是y x'=2x+y y'=x+4y ``` 程式碼施工中 ``` ::: # 10.Edit Distance https://cses.fi/problemset/task/1639 :::spoiler 解法: 這題和LCS蠻像的,但不太一樣。我們先開dp表格 dp[i][j]代表第一個字串長度i轉移到第二個字串長度j時所需要的步數。我們考慮三種可能的操作: dp[i-1][j] + 1:刪除 A[i-1](讓 A 縮短) dp[i][j-1] + 1:插入 B[j-1](等於在 A 加上這個字元) dp[i-1][j-1] + 1:若 A[i-1] != B[j-1] 則替換 dp[i-1][j-1] 否則不需替換 ::: # 11.Rectangle Cutting https://cses.fi/problemset/task/1744 :::spoiler 解法: 首先注意這題不能貪心 一開始的直覺可能會是直接長邊減短邊,但這樣會出事。舉例來說,500x499若直接切成499x499和1x499,會多切非常多刀。因此這題我們只能窮舉。 開一個dp表格,dp[i][j]代表i x j時的刀數。對於一個長方形,找出所有切割方式的最小值 ![11](https://hackmd.io/_uploads/ByXuZelCJx.png) ::: # 12.Money Sums https://cses.fi/problemset/task/1745 :::spoiler 解法: 這題如果直接窮舉會TLE,因此要想辦法避免重複計算 方法是這樣,以題中測資{4,2,5,2}為例 開一個set一剛開始有{0} 接著每一個硬幣都加上去丟進set裡 4 {0,4} 2 {0,4,0+2,4+2} 5 {0,4,2,6,0+5,4+5,2+5,6+5} 2 {0,4,2,6,5,9,7,11,0+2,4+2,6+2,5+2,9+2,,7+2,11+2} 因為set裡不會重複而且自動排序,所以最後會剩 {0,2,4,5,6,7,8,9,11,13} 將0排除掉就可以輸出了 ``` 施工中 ``` ::: # 13.Removal Game https://cses.fi/problemset/task/1097 :::spoiler 解法: 開個dp表格,dp[i][j]代表陣列剩下第i項到第j項時,先手的人可獲得的最大值 當我們在 a[i...j] 中做選擇時,有兩個選項: 1.拿走a[i],剩下的是a[i+1...j],對手會拿那一段中他能拿到的最大值(也就是 dp[i+1][j]) 2.拿走a[j],剩下的是a[i...j-1],對手拿的是dp[i][j-1] 所以我們希望讓對手拿到的最小(因為剩下的就是我們的分數): dp[i][j]=sum(i,j)-min(dp[i+1][j],dp[i][j-1]) ::: # 14.Two Sets II https://cses.fi/problemset/task/1093 :::spoiler 解法: 兩set內加起來相等,代表一個set內的值加起來是[(1+n)n/2]/2 接著就跟之前寫過的題目很像啦,給你硬幣,問目標數的組合有幾種。記得要除2,因為分成兩個set。 ::: # 15.Increasing Subsequence https://cses.fi/problemset/task/1145 :::spoiler 解法: 這就是個標準的LIS(longest increasing subsequence)問題。 我們開一個vector紀錄目前的LIS,若當前數字比LIS的結尾還大,則可進入LIS,否則取代vector中大於等於此數的最小值(注意,由於這題只要求長度,固可如此操作) 舉例{7 3 5 3 6 2 9 8} {7} {3} 7是大於等於3的最小值,故取代 {3,5}5>3,故加上 {3,3} {3,3,6} {2,3,6} {2,3,6,9} {2,3,6,8} 實際上真正的LIS是{3,5,6,9}或{3,5,6,8}但長度是相同的 證明:我們只有在數字大於vector結尾時才會加入新數字,因此能維持至今為止的正確長度,而取代的意義是我們希望當前LIS的結尾越小越好,這樣我們能夠更容易加長LIS。 ``` #include <bits/stdc++.h> using namespace std; int main() { int n; cin >> n; // 讀入數列長度 int a[n]; // 原始數列 vector<int> temp; // 用來記錄 LIS 的中間狀態 for (int i = 0; i < n; i++) { cin >> a[i]; // 讀入數列中的每個數字 } temp.push_back(a[0]); // 初始化:把第一個數放進 temp // 從第二個數開始處理 for (int i = 1; i < n; i++) { if (a[i] > temp.back()) { // 如果 a[i] 比目前 LIS 的最後一個數還大,表示可以延長 LIS temp.push_back(a[i]); } else { // 否則,在 temp 中找第一個大於等於 a[i] 的數,將它取代為 a[i] // 這樣可以保證相同長度的 LIS,其結尾數越小越好,方便未來延長 *lower_bound(temp.begin(), temp.end(), a[i]) = a[i]; } } // 輸出 LIS 的長度(temp 的大小就是最長遞增子序列長度) cout << temp.size(); } ``` ::: # 16.Projects https://cses.fi/problemset/task/1140 :::spoiler 這題我們要先將資料離散化,避免記憶體爆掉。接著我們知道一個project的結束時間越早越好,這樣我們能夠快點做新的任務。 因此我們要照結束時間排序。然後我們可以開一個一維dp陣列紀錄我們當前的最好得分。記住,project做完才有得分,所以我們在project做完之前只會繼承左邊分數。而當project做完了已後,我們看當前得分比較高(不做這個任務),還是這個任務開頭的時間時的得分+這個任務的得分比較高。也就是dp[任務結束時間]=max(dp[任務結束時間(不選擇任務)],dp[任務開始時間]+任務得分) ::: # 17.Elevator Rides https://cses.fi/problemset/task/1653 :::spoiler 解法: 這題要用位元dp。我們把1當作挑這個物品,0當作不挑。例如0110就是挑第二和第三個東西。對於一個新的人,如果他塞得進目前的電梯,那電梯數就不用更新,並且更新當前電梯載重。如果塞不下,就開新的電梯。 那位元dp要如何窮舉呢? ![17](https://hackmd.io/_uploads/H1RQX31R1e.png) 四個人都進電梯,這個狀態能是由123人已在電梯,考慮第4人的狀態,又或者是124人在電梯,考慮第三人的狀態 註:這篇程式碼參考自https://hackmd.io/hTQrRJDaQ2C2X9UouWengw?view#%E4%BD%8D%E5%85%83dp 另外,解釋一下a<<b 的意思是a x 2^b ``` #include <bits/stdc++.h> using namespace std; int main() { int n, maxWeight; cin >> n >> maxWeight; // 輸入人數與電梯最大承重 vector<int> weight(n); // 每個人的體重 for (int i = 0; i < n; i++) { cin >> weight[i]; } // dp[mask] 表示當前 (已上電梯的人組合)最少需要幾趟電梯 // last[mask] 表示目前這趟電梯上已經載的總重量 vector<int> dp(1 << n), last(1 << n); dp[0] = 1; // 初始狀態:沒人搭乘,預設 1 趟空電梯 last[0] = 0; // 現在這趟電梯還沒載人 for (int mask = 1; mask < (1 << n); mask++) { dp[mask] = INT_MAX; last[mask] = INT_MAX; for (int j = 0; j < n; j++) { if (mask & (1 << j)) { // 如果第 j 個人在這個狀態中已經被安排搭電梯 int prevMask = mask ^ (1 << j); // 嘗試從未安排第 j 人的狀態轉移過來 // 嘗試把第 j 人放進當前這一趟電梯 if (last[prevMask] + weight[j] <= maxWeight) { if (dp[prevMask] < dp[mask] || (dp[prevMask] == dp[mask] && last[prevMask] + weight[j] < last[mask])) { dp[mask] = dp[prevMask]; last[mask] = last[prevMask] + weight[j]; } } else { // 超重了,需要另開一趟電梯給他 if (dp[prevMask] + 1 < dp[mask] || (dp[prevMask] + 1 == dp[mask] && weight[j] < last[mask])) { dp[mask] = dp[prevMask] + 1; last[mask] = weight[j]; // 新的那趟電梯只有這個人 } } } } } cout << dp[(1 << n) - 1] << endl; // 所有人都上去時的最少電梯趟數 return 0; } ``` ::: # 18.Counting Tilings https://cses.fi/problemset/task/2181 :::spoiler 解法: 我們先看圖解 ![16](https://hackmd.io/_uploads/ryzMOUZ0yx.png) 我們從第一欄開始,每一格可能是放橫的(占用右邊那格),也可能是放直的(占用下面那格)。因為可能占用到第二欄,我們開一個bitmask紀錄方便我們轉移。我們把第一欄都填完後,就可以去看下一欄。塗黑的部分代表已經確定填完的,我們可以不用管他,接著塗第二欄,一樣有可能放左或放右。直到我們填到最後一欄,最後一欄只能填直的,不然會超出邊界。 註:程式碼參考至https://www.youtube.com/watch?v=byM079e5gmU ,大家也可以看他的影片圖解,促進理解 ``` #include <bits/stdc++.h> using namespace std; // tilings[i][mask] 表示:已經填好前 i 欄,目前第 i 欄的狀態為 mask 的填法數 int tilings[1001][1 << 10]; // 最多 1000 欄、2^10 種 mask 狀態(因為 n ≤ 10) int n, m; const int MOD = 1e9 + 7; // 防止整數溢位,對結果取模 // 遞迴嘗試如何從 curr_mask 填到 next_mask void fill_column(int column, int idx, int curr_mask, int next_mask) { // 如果已經填完第 column 欄的所有 row(idx == n) if (idx == n) { // 將這種填法數量加到下一欄的 next_mask 狀態中 tilings[column + 1][next_mask] = (tilings[column + 1][next_mask] + tilings[column][curr_mask]) % MOD; return; } // 如果 curr_mask 的第 idx 位是 1,表示這個位置已經被填過 if (curr_mask & (1 << idx)) { // 跳過這格,處理下一格 fill_column(column, idx + 1, curr_mask, next_mask); } else { // 嘗試在第 idx 列直放一個骨牌(往下一欄延伸) fill_column(column, idx + 1, curr_mask, next_mask | (1 << idx)); // 嘗試橫放骨牌:需確保 idx + 1 也尚未填 if ((idx + 1 < n) && !(curr_mask & (1 << (idx + 1)))) { // 橫放骨牌會佔據 idx 和 idx + 1,更新 next_mask 不需變 fill_column(column, idx + 2, curr_mask, next_mask); } } } int main() { cin >> n >> m; // 讀入棋盤大小:n 行 m 列 tilings[0][0] = 1; // 初始狀態:第 0 欄為空,填法數為 1 // 從第 0 列處理到第 m-1 列 for (int column = 0; column < m; column++) { // 遍歷當前欄的所有可能狀態 for (int mask = 0; mask < (1 << n); mask++) { // 只有填法數大於 0 的狀態才需要進行遞迴轉移 if (tilings[column][mask] > 0) { fill_column(column, 0, mask, 0); // 嘗試從當前 mask 生成下一欄的狀態 } } } // 最後輸出填滿 m 列且最後一欄狀態為 0(全填滿)的方式總數 cout << tilings[m][0]; } ``` ::: # 19.Counting Numbers https://cses.fi/problemset/task/2220 :::spoiler 解法: 這題若直接一個一個一個檢查會超時,因此我們要分開討論 0是1種 對於一個一位數,符合規則的有1~9 共9種 對於一個二位數,開頭不可以是0,而第二位可以是0~9但不能和第一位一樣,因此共有9x9種 同理可知,對於n位數,共有9的n次方種。 拿題目來看,我們可以把321拆成幾個部分: 0,1~9 ,10~99 ,100~299 ,300~319 ,320~321 所以最高位數減一(3-1)乘上後面位數的種數(9^2),這裡是(100~299)。接著加上小於當前的位數(9x9+9+1) 接著看還沒討論完的,由於2小於3我們不用排除跟前面一樣的,可以直接加上2x9 (300~319 ),最後加上2(320~321) 由於題目是a和b之間的數,因此我們可以分別算出a-1的數和b的數再相減求出答案 這題額外要注意的是,求次方時不要使用<cmath> 裡的pow(i,j)。這個函式回傳的資料型態是double,而double超過一定位數(通常是15~17 ),會失去精準度,因此建議自己寫次方函式 ``` 施工中 ``` :::