【C++】競程筆記(DP:狀態跟轉移)

題目範例參考:NTUCPC Guide,此筆記僅為個人學習用途。

非線性遞迴題型

481 . [Tutorial] 別離太遠 續

Problem Source:https://oj.ntucpc.org/problems/481

這題要求的不是方法數,而是「最大值」。

這題的上一題已經知道要怎麼求方法數了,結果就是一個費氏數列,而這次要求的是讓每個人的能力值總和到最大,那該怎麼求呢?

遞迴式長得差不多,先定義 base case:

  • a1,n=1a_1, n = 1
  • a1+a2,n=2a_1 + a_2, n = 2

剩下的情況可以寫成 max(f(n1),f(n2))+anmax(f(n - 1), f(n - 2)) + a_n ,因為要最大化能力值,所以從第 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)。通常由一個或多個變數組成,用來代表當前子問題的解。

像是在當說「於狀態 ii 」 的時候,意思就是已經解決規模為 ii 的子問題,並將這結果存在變數當中(應該是陣列的某個元素)。

用數學表示則是用陣列來表示一個狀態: dp[i]dp[i] 或多維 dp[i][j]dp[i][j]

用先前爬樓梯的例子來說,假設爬 NN 階樓梯,則定義 dp[i]dp[i] 為「爬到第 ii 階樓梯的方法數」,而 dp[i]dp[i] 就是狀態。當求出 dp[5]dp[5] 時,就知道爬到第 5 階有幾種走法。

:::info
狀態就是求解某個子問題的過程,會告訴自己現在在哪個階段?答案存在哪?
:::

轉移(Transition)

轉移是狀態之間的「關係式」,如何從一個或多個「已知的小狀態」推導出「當前的大狀態」。

由轉移產生的變化寫成數學式後,也就稱之為轉移式。

一樣從爬樓梯來舉例,爬樓梯的規則是一次只能爬 1 階或 2 階,若要到達第 ii 階,最後一步只有兩種可能:

  • 從第 i1i-1 階爬 1 步上來。
  • 從第 i2i-2 階爬 2 步上來。

因此可得到轉移式是 dp[i]=dp[i1]+dp[i2]dp[i] = dp[i - 1] + dp[i - 2]

初始狀態(Initial State / Base Case)

初始狀態(也稱邊界條件)是整個遞迴或填表過程的起點,是最小的子問題,不需要經過計算,能直接知道答案。

初始狀態是很重要的狀態,沒有這東東,遞迴就會無限遞迴下去,然後電腦就會炸開。

同樣以爬樓梯來舉例,由於求出的轉移式是 dp[i]=dp[i1]+dp[i2]dp[i] = dp[i - 1] + dp[i - 2] ,有 i1i - 1i2i - 2 這兩個因素,可以手動設定初始狀態:

  • dp[0]=0dp[0] = 0
  • dp[1]=1dp[1] = 1

DP 三步法

  1. 定義狀態
  2. 找出轉移式
  3. 定義初始狀態

e.g. 1 : ZJ b589. 超級馬拉松賽

Problem Source:https://zerojudge.tw/ShowProblem?problemid=b589

首先定義狀態。

對於第 ii 條路徑,小愛只有兩種選擇:

  1. 正常跑
    • PiP_i 分。
    • 下條路徑 (i+1)(i + 1) 可以正常進行路徑決策。
    • 總分累計: Pi+dp[i+1]P_i + dp[i + 1]
  2. 加速跑
    • 2×Pi2 \times P_i 分。
    • 下條路徑 (i+1)(i + 1) 要休息,得 0 分,所以下次能做的路徑決策是 (i+2)(i + 2)
    • 總分累計: 2×Pi+dp[i+2]2 \times P_i + dp[i + 2]

dp[i]dp[i] 是從第 ii 條路徑開始,一直跑到最後第 nn 條路徑所能獲得的最大總分。

第二個就是找出轉移式,根據前面的定義可以寫出 $$dp[i] = max(dp[i + 1] + P_i, dp[i + 2] + 2 \times P_i)$$

第三個則是定義初始狀態,若當 i>ni > n 時(超出路徑範圍),得分為 0,即 dp[n+1]=0,dp[n+2]=0dp[n+1] = 0, dp[n+2] = 0

這題採用由後往前推的方法會比較好做,如果用由前面往後推的話,可以剛好跑完第 nn 關結束,也可以在第 n1n-1 關加速直接衝過終點(跑到虛擬的 n+1n+1 關),最後的答案會是 max(dp[n],dp[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

  1. 定義狀態:
    • S[i]S[i] 為前 ii 頭乳牛的理智度總和( S[0]=0,S[i]=a1+a2++aiS[0] = 0, S[i] = a_1 + a_2 + \cdots + a_i
    • 定義 dp[i]dp[i] 為考慮前 ii 頭乳牛,最多能分成的組數。
      • 如果前 ii 頭乳牛無法被合法分割,則 dp[i]=dp[i] = -\infty
  2. 找出轉移式:

在這類「將序列切成若干段」的題目有一個常見的轉移招數:那就是枚舉最後一段的長相為何。
by NTUCPC Guide

對於第 ii 頭乳牛,得要窮舉上一個分組的結束位置 jj (其中 0j<i0 \le j < i)。

:::info
窮舉 jj 是為了決定最後一組的範圍,並利用算好的舊答案推出新答案。
:::

如果從第 j+1j+1 頭到第 ii 頭乳牛這一組的理智度總和是非負的話,且前 jj 頭乳牛已經有合法的分組(dp[j]dp[j] \neq -\infty),則可將 jj 轉移到 ii

區間和條件:第 j+1j+1ii 的和為 S[i]S[j]S[i] - S[j],而題目要求 S[i]S[j]0S[i] - S[j] \ge 0,即 S[i]S[j]S[i] \ge S[j]

由於希望組數是要最多的,因此在所有滿足條件的 jj 中取最大值並加 1。

dp[i]=max0j<i{dp[j]}+1dp[i] = \max_{0 \le j < i} \{ dp[j] \} + 1

限制條件是:

  • S[j]S[i]S[j] \le S[i] (當前小組和非負)
  • dp[j]dp[j] \neq -\infty (前 jj 個可以合法分組)
  1. 初始狀態:就是 dp[0]=0dp[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)
// 窮舉上一個分組的結束點 j
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

  1. 定義狀態

定義 dp[i]dp[i] 為:在第 ii 天結束時,手上沒有股票的最大累積利潤。

沒有股票會有兩種情況:

  • 今天剛賣掉股票。
  • 今天沒動作(昨天沒買股票)。
  1. 找出轉移式

要使 dp[i]dp[i] 最大化,有兩種可能:

  1. 不賣股票

不賣股票的話,狀態會從昨天維持到現在,所以是 dp[i1]dp[i - 1]

賣的話,代表在之前的某一天 jj (j<i)(j < i) 買入了股票,並在今天 ii 賣出。

利潤計算為:(今天股價) - (第 j 天買入價) + (第 j 天買入前的基礎利潤)

由於有 11 天冷凍期,若在第 jj 天買入,則上一筆交易最晚結束在第 j2j-2 天。

這樣 dp 轉移式就會是 P[i]+max0j<i(dp[j2]P[j])P[i] + \max_{0 \le 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]))$$

可以發現每次都要窮舉 jj ,會造成 O(n2)O(n^2) ,非常的浪費時間,因此可用一個變數 mm 來隨時記錄歷史最佳的買入點,也就是記錄 max(dp[j2]P[j])\max(dp[j-2] - P[j]) 的值。

然後可以進一步寫成: $$dp[i] = \max(dp[i-1], P[i] + m)$$

同時計算完 dp[i]dp[i] 後,要為明天更新 mm。如果選在今天(第 ii 天)買入,則其價值為 dp[i2]P[i]dp[i-2] - P[i]:$$m = \max(m, dp[i-2] - P[i])$$

  1. 定義初始狀態

dp[0]=0dp[0] = 0:第 0 天(還沒開始)利潤為 0。

dp[1]=0dp[1] = 0:第 1 天只能買不能賣,所以手上沒股票的利潤仍為 0。

mm:在計算第 2 天以前,唯一的買入機會是第 1 天。第 1 天買入的價值 =dp[1]P[1]= dp[-1] - P[1]dp[1]=0dp[-1]=0,則:

m=P[1]m = -P[1]

:::info
註:在這邊的 dp[1]dp[-1] 指的是第 0 天的前一天的狀態,就是 Day -1 的狀態。

因為題目有 1 天冷凍期的限制,如果決定在 Day 1 買入股票,根據轉移式,必須在 Day -1 就已經把之前的股票賣掉並結束掉交易,這樣 Day 0 才是冷凍期,Day 1 才能買。

dp[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

  1. 定義狀態: dp[i]dp[i] 是青蛙到達第 ii 顆石頭時的最小成本。
  2. 找出轉移式:

青蛙只有兩種方式可以跳到石頭 ii

  • 從石頭 i1i-1 跳過來,成本:到達石頭 i1i-1 的最小成本 + 從 i1i-1 跳到 ii 的石頭高度。
    • dp[i1]+hihi1dp[i-1] + |h_i - h_{i-1}|
  • 從石頭 i2i-2 跳過來,成本:到達石頭 i2i-2 的最小成本 + 從 i2i-2 跳到 ii 的石頭高度。
    • dp[i2]+hihi2dp[i-2] + |h_i - h_{i-2}|

由於要找的是最小成本,因此得到轉移式: $$dp[i] = \min(dp[i-1] + |h_i - h_{i-1}|, \quad dp[i-2] + |h_i - h_{i-2}|)$$

轉移式適用於 i3i \ge 3 的情況,如果 i=2i = 2,因為沒有石頭 0,青蛙只能從石頭 1 跳過來。

  1. 定義初始狀態:dp[1]=0dp[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

  1. 定義狀態:同上題,dp[i]dp[i] 是青蛙到達第 ii 顆石頭時的最小成本。
  2. 找出轉移式:

青蛙可從 i1,i2,,iKi-1, i-2, \dots, i-K 這些位置跳過來(前提是那些位置存在,即編號 1\ge 1)。

因此,對於每個 ii 來說,需要檢查前 KK 個可能的起跳點,並取其中的最小值:$$dp[i] = \min_{1 \le j \le K, \ i-j \ge 1} \left( dp[i-j] + |h_i - h_{i-j}| \right)$$

  1. 定義初始狀態:
    • dp[1]=0dp[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

  1. 定義狀態: dp[i]dp[i] 為到第 ii 階樓梯的方法總數。
  2. 找出轉移式:

由於到第 i 階僅能依靠這兩種方式:

  • 從第 i1i-1 階跨 1 步上來。
  • 從第 i2i-2 階跨 2 步上來。

而得到的轉移式會是 dp[i]=dp[i1]+dp[i2]dp[i] = dp[i - 1] + dp[i - 2]

但題目有「損壞的階梯」的限制,如果第 ii 階是壞掉的,無法踩上去,所以到達該階的方法數應為 0。

可以考慮兩種情況:

  • 如果第 ii 階是損壞的:$$dp[i] = 0$$
  • 如果第 ii 階是完好的:$$dp[i] = (dp[i-1] + dp[i-2]) \pmod{10^9 + 7}$$
  1. 定義初始狀態:dp[0]=1dp[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];
}