---
# System prepended metadata

title: 【C++】競程筆記（DP：狀態跟轉移）
tags: [資料結構, 程式設計, C++, DP, 競程, 演算法, 程式語言, CP]

---

# 【C++】競程筆記（DP：狀態跟轉移）

[TOC]

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

## 非線性遞迴題型

481 . [Tutorial] 別離太遠 續

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

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

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

遞迴式長得差不多，先定義 base case：

- $a_1, n = 1$
- $a_1 + a_2, n = 2$

剩下的情況可以寫成 $max(f(n - 1), f(n - 2)) + a_n$ ，因為要最大化能力值，所以從第 n 個的前兩位（編號差不能超過 2）挑出最大的那個。

範例程式碼：

```cpp=
#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 三步法

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

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

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

首先定義狀態。

對於第 $i$ 條路徑，小愛只有兩種選擇：

1. 正常跑
    - 得 $P_i$ 分。
    - 下條路徑 $(i + 1)$ 可以正常進行路徑決策。
    - 總分累計： $P_i + dp[i + 1]$
2. 加速跑
    - 得 $2 \times P_i$ 分。
    - 下條路徑 $(i + 1)$ 要休息，得 0 分，所以下次能做的路徑決策是 $(i + 2)$ 。
    - 總分累計： $2 \times P_i + 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])$ ，要多檢查一步。

範例程式碼：

```cpp=
#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]$ 為前 $i$ 頭乳牛的理智度總和（ $S[0] = 0, S[i] = a_1 + a_2 + \cdots + a_i$ ）
    - 定義 $dp[i]$ 為考慮前 $i$ 頭乳牛，最多能分成的組數。
        - 如果前 $i$ 頭乳牛無法被合法分割，則 $dp[i] = -\infty$。
2. 找出轉移式：

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

對於第 $i$ 頭乳牛，得要窮舉上一個分組的結束位置 $j$ （其中 $0 \le j < i$）。

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

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

區間和條件：第 $j+1$ 到 $i$ 的和為 $S[i] - S[j]$，而題目要求 $S[i] - S[j] \ge 0$，即 $S[i] \ge S[j]$。

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

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

限制條件是：

- $S[j] \le S[i]$ （當前小組和非負）
- $dp[j] \neq -\infty$ （前 $j$ 個可以合法分組）

3. 初始狀態：就是 $dp[0] = 0$

範例程式碼：

```cpp=
#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]$ 為：在第 $i$ 天結束時，手上沒有股票的最大累積利潤。

沒有股票會有兩種情況：

- 今天剛賣掉股票。
- 今天沒動作（昨天沒買股票）。

2. 找出轉移式

要使 $dp[i]$ 最大化，有兩種可能：

1. 不賣股票
2. 賣

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

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

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

由於有 $1$ 天冷凍期，若在第 $j$ 天買入，則上一筆交易最晚結束在第 $j-2$ 天。

這樣 dp 轉移式就會是 $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]))$$

可以發現每次都要窮舉 $j$ ，會造成 $O(n^2)$ ，非常的浪費時間，因此可用一個變數 $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])$$

3. 定義初始狀態

$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。
:::

範例程式碼：

```cpp=
#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]$ 是青蛙到達第 $i$ 顆石頭時的最小成本。
2. 找出轉移式：

青蛙只有兩種方式可以跳到石頭 $i$：

- 從石頭 $i-1$ 跳過來，成本：到達石頭 $i-1$ 的最小成本 + 從 $i-1$ 跳到 $i$ 的石頭高度。
    - $dp[i-1] + |h_i - h_{i-1}|$
- 從石頭 $i-2$ 跳過來，成本：到達石頭 $i-2$ 的最小成本 + 從 $i-2$ 跳到 $i$ 的石頭高度。
    - $dp[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}|)$$

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

3. 定義初始狀態：$dp[1] = 0$

範例程式碼：

```cpp=
#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]$ 是青蛙到達第 $i$ 顆石頭時的最小成本。
2. 找出轉移式：

青蛙可從 $i-1, i-2, \dots, i-K$ 這些位置跳過來（前提是那些位置存在，即編號 $\ge 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)$$

3. 定義初始狀態：
    - $dp[1] = 0$
    - 剩下的可以取一個無限大的數字。

範例程式碼：

```cpp=
#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]$ 為到第 $i$ 階樓梯的方法總數。
2. 找出轉移式：

由於到第 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}$$

3. 定義初始狀態：$dp[0] = 1$

範例程式碼：

```cpp=
#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];
}
```