【C++】競程筆記(DP:狀態跟轉移)
題目範例參考:NTUCPC Guide,此筆記僅為個人學習用途。
非線性遞迴題型
481 . [Tutorial] 別離太遠 續
Problem Source:https://oj.ntucpc.org/problems/481
這題要求的不是方法數,而是「最大值」。
這題的上一題已經知道要怎麼求方法數了,結果就是一個費氏數列,而這次要求的是讓每個人的能力值總和到最大,那該怎麼求呢?
遞迴式長得差不多,先定義 base case:
- a1,n=1
- a1+a2,n=2
剩下的情況可以寫成 max(f(n−1),f(n−2))+an ,因為要最大化能力值,所以從第 n 個的前兩位(編號差不能超過 2)挑出最大的那個。
範例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| #include <bits/stdc++.h>
using namespace std;
using ll = long long;
int main(){ int N; cin >> N; vector <ll> a(N + 1); for (int i = 1; i <= N; ++i) cin >> a[i]; vector <ll> dp(N + 1); if (N >= 1) dp[1] = a[1]; if (N >= 2) dp[2] = a[1] + a[2]; for (int i = 3; i <= N; ++i) dp[i] = a[i] + max(dp[i - 1], dp[i - 2]); cout << dp[N]; }
|
描述動態規劃演算法的形狀
動態規劃演算法會有很重要的三元素:
狀態(State)
狀態是對問題在某一個特定階段的描述或快照(Snapshot)。通常由一個或多個變數組成,用來代表當前子問題的解。
像是在當說「於狀態 i 」 的時候,意思就是已經解決規模為 i 的子問題,並將這結果存在變數當中(應該是陣列的某個元素)。
用數學表示則是用陣列來表示一個狀態: dp[i] 或多維 dp[i][j] 。
用先前爬樓梯的例子來說,假設爬 N 階樓梯,則定義 dp[i] 為「爬到第 i 階樓梯的方法數」,而 dp[i] 就是狀態。當求出 dp[5] 時,就知道爬到第 5 階有幾種走法。
:::info
狀態就是求解某個子問題的過程,會告訴自己現在在哪個階段?答案存在哪?
:::
轉移(Transition)
轉移是狀態之間的「關係式」,如何從一個或多個「已知的小狀態」推導出「當前的大狀態」。
由轉移產生的變化寫成數學式後,也就稱之為轉移式。
一樣從爬樓梯來舉例,爬樓梯的規則是一次只能爬 1 階或 2 階,若要到達第 i 階,最後一步只有兩種可能:
- 從第 i−1 階爬 1 步上來。
- 從第 i−2 階爬 2 步上來。
因此可得到轉移式是 dp[i]=dp[i−1]+dp[i−2]
初始狀態(Initial State / Base Case)
初始狀態(也稱邊界條件)是整個遞迴或填表過程的起點,是最小的子問題,不需要經過計算,能直接知道答案。
初始狀態是很重要的狀態,沒有這東東,遞迴就會無限遞迴下去,然後電腦就會炸開。
同樣以爬樓梯來舉例,由於求出的轉移式是 dp[i]=dp[i−1]+dp[i−2] ,有 i−1 跟 i−2 這兩個因素,可以手動設定初始狀態:
- dp[0]=0
- dp[1]=1
DP 三步法
- 定義狀態
- 找出轉移式
- 定義初始狀態
e.g. 1 : ZJ b589. 超級馬拉松賽
Problem Source:https://zerojudge.tw/ShowProblem?problemid=b589
首先定義狀態。
對於第 i 條路徑,小愛只有兩種選擇:
- 正常跑
- 得 Pi 分。
- 下條路徑 (i+1) 可以正常進行路徑決策。
- 總分累計: Pi+dp[i+1]
- 加速跑
- 得 2×Pi 分。
- 下條路徑 (i+1) 要休息,得 0 分,所以下次能做的路徑決策是 (i+2) 。
- 總分累計: 2×Pi+dp[i+2]
令 dp[i] 是從第 i 條路徑開始,一直跑到最後第 n 條路徑所能獲得的最大總分。
第二個就是找出轉移式,根據前面的定義可以寫出 $$dp[i] = max(dp[i + 1] + P_i, dp[i + 2] + 2 \times P_i)$$
第三個則是定義初始狀態,若當 i>n 時(超出路徑範圍),得分為 0,即 dp[n+1]=0,dp[n+2]=0。
這題採用由後往前推的方法會比較好做,如果用由前面往後推的話,可以剛好跑完第 n 關結束,也可以在第 n−1 關加速直接衝過終點(跑到虛擬的 n+1 關),最後的答案會是 max(dp[n],dp[n+1]) ,要多檢查一步。
範例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| #include <bits/stdc++.h>
using namespace std;
int main(){ int n; while (cin >> n && n != 0){ vector <int> P(n); for (int i = 0; i < n; ++i) cin >> P[i]; vector <int> dp(n + 2, 0); for (int i = n - 1; i >= 0; --i){ int normal = P[i] + dp[i + 1]; int accelerate = 2 * P[i] + dp[i + 2]; dp[i] = max(normal, accelerate); } cout << dp[0] << '\n'; } }
|
Generic Cow Protests
Problem Source:https://www.luogu.com.cn/problem/P1569
- 定義狀態:
- 令 S[i] 為前 i 頭乳牛的理智度總和( S[0]=0,S[i]=a1+a2+⋯+ai )
- 定義 dp[i] 為考慮前 i 頭乳牛,最多能分成的組數。
- 如果前 i 頭乳牛無法被合法分割,則 dp[i]=−∞。
- 找出轉移式:
在這類「將序列切成若干段」的題目有一個常見的轉移招數:那就是枚舉最後一段的長相為何。
by NTUCPC Guide
對於第 i 頭乳牛,得要窮舉上一個分組的結束位置 j (其中 0≤j<i)。
:::info
窮舉 j 是為了決定最後一組的範圍,並利用算好的舊答案推出新答案。
:::
如果從第 j+1 頭到第 i 頭乳牛這一組的理智度總和是非負的話,且前 j 頭乳牛已經有合法的分組(dp[j]=−∞),則可將 j 轉移到 i。
區間和條件:第 j+1 到 i 的和為 S[i]−S[j],而題目要求 S[i]−S[j]≥0,即 S[i]≥S[j]。
由於希望組數是要最多的,因此在所有滿足條件的 j 中取最大值並加 1。
dp[i]=0≤j<imax{dp[j]}+1
限制條件是:
- S[j]≤S[i] (當前小組和非負)
- dp[j]=−∞ (前 j 個可以合法分組)
- 初始狀態:就是 dp[0]=0
範例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| #include <bits/stdc++.h>
using namespace std;
const int INF = -1e9;
int main() { ios::sync_with_stdio(false), cin.tie(NULL);
int n; cin >> n;
vector<int> a(n + 1); for (int i = 1; i <= n; ++i) cin >> a[i];
vector<long long> S(n + 1, 0); for (int i = 1; i <= n; ++i) S[i] = S[i - 1] + a[i];
vector<int> dp(n + 1, INF);
dp[0] = 0;
for (int i = 1; i <= n; ++i) for (int j = 0; j < i; ++j) if (dp[j] != INF && (S[i] - S[j] >= 0)) dp[i] = max(dp[i], dp[j] + 1); if (dp[n] > 0) cout << dp[n] << endl; else cout << "Impossible" << '\n';
return 0; }
|
股票買賣 V
Problem Source:https://www.luogu.com.cn/problem/U425829
- 定義狀態
定義 dp[i] 為:在第 i 天結束時,手上沒有股票的最大累積利潤。
沒有股票會有兩種情況:
- 找出轉移式
要使 dp[i] 最大化,有兩種可能:
- 不賣股票
- 賣
不賣股票的話,狀態會從昨天維持到現在,所以是 dp[i−1] 。
賣的話,代表在之前的某一天 j (j<i) 買入了股票,並在今天 i 賣出。
利潤計算為:(今天股價) - (第 j 天買入價) + (第 j 天買入前的基礎利潤)。
由於有 1 天冷凍期,若在第 j 天買入,則上一筆交易最晚結束在第 j−2 天。
這樣 dp 轉移式就會是 P[i]+max0≤j<i(dp[j−2]−P[j]) 。
由於要最大化,因此得到 $$dp[i] = max(dp[i - 1], P[i] + \max_{0 \le j < i}(dp[j - 2] - P[j]))$$
可以發現每次都要窮舉 j ,會造成 O(n2) ,非常的浪費時間,因此可用一個變數 m 來隨時記錄歷史最佳的買入點,也就是記錄 max(dp[j−2]−P[j]) 的值。
然後可以進一步寫成: $$dp[i] = \max(dp[i-1], P[i] + m)$$
同時計算完 dp[i] 後,要為明天更新 m。如果選在今天(第 i 天)買入,則其價值為 dp[i−2]−P[i]:$$m = \max(m, dp[i-2] - P[i])$$
- 定義初始狀態
dp[0]=0:第 0 天(還沒開始)利潤為 0。
dp[1]=0:第 1 天只能買不能賣,所以手上沒股票的利潤仍為 0。
m:在計算第 2 天以前,唯一的買入機會是第 1 天。第 1 天買入的價值 =dp[−1]−P[1]。 dp[−1]=0,則:
m=−P[1]
:::info
註:在這邊的 dp[−1] 指的是第 0 天的前一天的狀態,就是 Day -1 的狀態。
因為題目有 1 天冷凍期的限制,如果決定在 Day 1 買入股票,根據轉移式,必須在 Day -1 就已經把之前的股票賣掉並結束掉交易,這樣 Day 0 才是冷凍期,Day 1 才能買。
而 dp[−1] 代表開始交易前或完全沒有發生過任何交易的狀態,那利潤當然就是 0。
:::
範例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| #include <iostream> #include <vector> #include <algorithm>
using namespace std;
int main() { ios::sync_with_stdio(false), cin.tie(NULL);
int n; cin >> n;
int p[n]; for (int i = 0; i < n; ++i) cin >> p[i];
long long dp[n + 1] = {0};
long long m = -p[0];
for (int i = 2; i <= n; ++i) { int curr = p[i-1];
dp[i] = max(dp[i-1], curr + m);
m = max(m, dp[i-2] - curr); }
cout << dp[n] << '\n';
return 0; }
|
練習題
Frog 1
Problem Source:https://atcoder.jp/contests/dp/tasks/dp_a
- 定義狀態: dp[i] 是青蛙到達第 i 顆石頭時的最小成本。
- 找出轉移式:
青蛙只有兩種方式可以跳到石頭 i:
- 從石頭 i−1 跳過來,成本:到達石頭 i−1 的最小成本 + 從 i−1 跳到 i 的石頭高度。
- dp[i−1]+∣hi−hi−1∣
- 從石頭 i−2 跳過來,成本:到達石頭 i−2 的最小成本 + 從 i−2 跳到 i 的石頭高度。
- dp[i−2]+∣hi−hi−2∣
由於要找的是最小成本,因此得到轉移式: $$dp[i] = \min(dp[i-1] + |h_i - h_{i-1}|, \quad dp[i-2] + |h_i - h_{i-2}|)$$
轉移式適用於 i≥3 的情況,如果 i=2,因為沒有石頭 0,青蛙只能從石頭 1 跳過來。
- 定義初始狀態:dp[1]=0
範例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| #include <iostream> #include <algorithm> #include <cmath>
using namespace std;
int main(){ ios::sync_with_stdio(false), cin.tie(NULL); int n; cin >> n; int h[n + 1]; for (int i = 1; i <= n; ++i) cin >> h[i]; int dp[n + 1]; dp[1] = 0; for (int i = 2; i <= n; ++i){ int j1 = dp[i - 1] + abs(h[i] - h[i - 1]); if (i > 2){ int j2 = dp[i - 2] + abs(h[i] - h[i - 2]); dp[i] = min(j1, j2); } else{ dp[i] = j1; } } cout << dp[n]; }
|
Frog 2
Problem Source:https://atcoder.jp/contests/dp/tasks/dp_b
- 定義狀態:同上題,dp[i] 是青蛙到達第 i 顆石頭時的最小成本。
- 找出轉移式:
青蛙可從 i−1,i−2,…,i−K 這些位置跳過來(前提是那些位置存在,即編號 ≥1)。
因此,對於每個 i 來說,需要檢查前 K 個可能的起跳點,並取其中的最小值:$$dp[i] = \min_{1 \le j \le K, \ i-j \ge 1} \left( dp[i-j] + |h_i - h_{i-j}| \right)$$
- 定義初始狀態:
- dp[1]=0
- 剩下的可以取一個無限大的數字。
範例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| #include <iostream> #include <algorithm> #include <cmath>
using namespace std;
const int INF = 1e9 + 7;
int main(){ ios::sync_with_stdio(false), cin.tie(NULL); int n, k; cin >> n >> k; int h[n + 1]; for (int i = 1; i <= n; ++i) cin >> h[i]; int dp[n + 1]; fill(dp, dp + n + 1, INF); dp[1] = 0; for (int i = 2; i <= n; ++i){ for (int j = 1; j <= k; ++j){ if (i - j >= 1){ dp[i] = min(dp[i], dp[i - j] + abs(h[i] - h[i - j])); } else{ break; } } }
cout << dp[n]; }
|
Typical Stairs
Problem Source:https://atcoder.jp/contests/abc129/tasks/abc129_c
- 定義狀態: dp[i] 為到第 i 階樓梯的方法總數。
- 找出轉移式:
由於到第 i 階僅能依靠這兩種方式:
- 從第 i−1 階跨 1 步上來。
- 從第 i−2 階跨 2 步上來。
而得到的轉移式會是 dp[i]=dp[i−1]+dp[i−2]
但題目有「損壞的階梯」的限制,如果第 i 階是壞掉的,無法踩上去,所以到達該階的方法數應為 0。
可以考慮兩種情況:
- 如果第 i 階是損壞的:$$dp[i] = 0$$
- 如果第 i 階是完好的:$$dp[i] = (dp[i-1] + dp[i-2]) \pmod{10^9 + 7}$$
- 定義初始狀態:dp[0]=1
範例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| #include <iostream>
using namespace std;
const int MOD = 1000000007;
int main(){ ios_base::sync_with_stdio(false), cin.tie(NULL); int n, m; cin >> n >> m; bool isBroken[n + 1]; fill(isBroken, isBroken + n + 1, false); for (int i = 0; i < m; ++i){ int a; cin >> a; isBroken[a] = true; } int dp[n + 1] = {0}; dp[0] = 1; for (int i = 1; i <= n; ++i){ if (isBroken[i]){ dp[i] = 0; continue; } dp[i] = dp[i - 1]; if (i >= 2){ dp[i] = (dp[i - 1] + dp[i - 2]) % MOD; } } cout << dp[n]; }
|