Skip to main content
  1. Posts/

Atcoder Educational DP Contest 題解

··3609 words
題解 演算法 AtCoder DP
Hyper Hu
Author
Hyper Hu
Building systems. Breaking patterns. Writing in between. Somewhere in this infinite loop of code, silence, and caffeine, I’m still searching for elegance.

我是 dp 菜雞

尚未完成,有時間就會補個題目。巨集們可以在 About 找到,程式碼只放實際有效部分。

Problem A - Frog 1
#

A - Frog 1

定義 \(f_i\) 為走到第 \(i\) 個石頭時的最小成本。由於要走到第 \(i\) 階只能從第 \(i-1\) 或 \(i-2\) 階,因此轉移式如下

$$ f_i = \min (f_{i-1} + |h_i-h_{i-1}|, f_{i-2} + |h_i-h_{i-2}|) $$

AC code

int n, h[100005], dp[100005];

signed main() {
    hyper;
    cin >> n;
    rep1(i,1,n) cin >> h[i];
    dp[1] = 0, dp[2] = abs(h[2] - h[1]);
    rep1(i,3,n) dp[i] = min(dp[i-1]+abs(h[i]-h[i-1]),dp[i-2]+abs(h[i]-h[i-2]));
    cout << dp[n];
}

Problem B - Frog 2
#

B - Frog 2

定義 \(f_i\) 為走到第 \(i\) 個石頭時的最小成本。轉移式如下

$$ f_i = \min_{j\in[1,k]} \{ f_{i-j} + |h_i-h_{i-j}| \} $$

因為有 \(k\) 種可能,直接跑迴圈轉移即可。

AC code

int n, k, h[100005], dp[100005];

signed main() {
    hyper;
    rr(dp,0x3f);
    cin >> n >> k;
    rep1(i,1,n) cin >> h[i];
    dp[1] = 0;
    rep1(i,2,n) rep1(j,1,k) {
        if(i-j >= 0) dp[i] = min(dp[i], dp[i-j]+abs(h[i]-h[i-j]));
    }
    cout << dp[n];
}

Problem C - Vacation
#

C - Vacation

定義一個二維 dp,\(f_{i,j}\) 代表考慮第 \(1\) 到 \(j\) 天,且第 \(j\) 天選擇的是活動 \(i\in\{0,1,2\}\)。轉移式如下

$$ f_{0,j} = \max (f_{1,j-1} + b_j, f_{2,j-1} + c_j) $$

以此類推。

AC code

int n, a[3][MAXN], dp[3][MAXN];

signed main() {
    hyper;
    cin >> n;
    rep1(i,1,n) rep(j,0,3) cin >> a[j][i];
    rep1(i,1,n) {
        dp[0][i] = max(dp[1][i-1]+a[1][i],dp[2][i-1]+a[2][i]);
        dp[1][i] = max(dp[0][i-1]+a[0][i],dp[2][i-1]+a[2][i]);
        dp[2][i] = max(dp[0][i-1]+a[0][i],dp[1][i-1]+a[1][i]);
    }
    cout << max(dp[0][n],max(dp[1][n],dp[2][n]));
}

Problem D - Knapsack 1
#

D - Knapsack 1

沒有任何變化的 01 背包問題,就不寫題解了嘻嘻。

AC code

int n, m, dp[MAXW], w[MAXN], v[MAXN];

signed main() {
    hyper;
    cin >> n >> m;
    rep1(i,1,n) cin >> w[i] >> v[i];
    rep1(i,1,n) {
        for(int j=m; j>=w[i]; --j) {
            dp[j] = max(dp[j], dp[j-w[i]]+v[i]);
        }
    }
    cout << dp[m];
}

Problem E - Knapsack 2
#

E - Knapsack 2

01 背包問題,但 \(W\) 太大了,所以換個方法定義,\(f_i\) 表示要裝總價值 \(i\) 的物品所需的最小容量,然後從價值大到小的開始找答案。

AC code

int n, m, dp[MAXW], w[MAXN], v[MAXN];

signed main() {
    hyper;
    cin >> n >> m;
    rep1(i,1,n) cin >> w[i] >> v[i];
    rr(dp,0x3f);
    dp[0] = 0;
    rep1(i,1,n) {
        for(int j=100000; j>=v[i]; --j) {
            dp[j] = min(dp[j], dp[j-v[i]]+w[i]);
        }
    }
    for(int i=100000; i>=0; --i) {
        if(dp[i] <= m) {
            cout << i;
            return 0;
        }
    }
}

Problem F - LCS
#

F - LCS

LCS 板子題,dp 時記一下是從誰轉移過來,最後找答案就沿路回推。

AC code

string s, t;
int dp[3005][3005], pv[3005][3005];

void print(int i, int j) {
    if(i==0 || j==0) return;
    if(pv[i][j]==0) {
        print(i-1, j-1);
        cout << s[i-1];
    }
    else if(pv[i][j]==1) print(i-1, j);
    else print(i, j-1);
}

signed main() {
    hyper;
    cin >> s >> t;
    int ls = s.length(), lt = t.length();
    rep1(i,1,ls) rep1(j,1,lt) {
        if(s[i-1] == t[j-1]) {
            dp[i][j] = dp[i-1][j-1] + 1;
            pv[i][j] = 0;
        }
        else if(dp[i-1][j] >= dp[i][j-1]) {
            dp[i][j] = dp[i-1][j];
            pv[i][j] = 1;
        }
        else {
            dp[i][j] = dp[i][j-1];
            pv[i][j] = 2;
        }
    }
    print(ls, lt);
}

Problem G - Longest Path
#

G - Longest Path

定義 \(f_i\) 表示以 \(i\) 為結尾的最長路徑長,並依照拓樸順序遍歷節點 \(u\),如果存在邊 \(u \to v\),就更新

$$ f_v \leftarrow \max \{ f_v, f_u + 1 \} $$

最後答案為 \(\max_{i \in V} f_i\)

AC code

int n, m, in[MAXN], dp[MAXN];
vector<int> g[MAXN];

signed main() {
    hyper;
    cin >> n >> m;
    rep(i,0,m) {
        int x, y;
        cin >> x >> y;
        g[x].push_back(y);
        in[y]++;
    }
    queue<int> q;
    rep1(i,1,n) if(!in[i]) q.push(i);
    while(!q.empty()) {
        int u = q.front(); q.pop();
        for(int v: g[u]) {
            dp[v] = max(dp[v], dp[u]+1);
            if(!--in[v]) q.push(v);
        }
    }
    cout << *max_element(dp+1, dp+1+n);
}

Problem H - Grid 1
#

H - Grid 1

經典排組題。走到某一格的方法數就是走到他上面的方法數 + 走到他左邊的方法數,而不能走的格子方法數顯然是 \(0\)。定義 \(f_{i,j}\) 是走到座標 \((i,j)\) 格子的方法數,轉移式如下

$$ f_{i,j} = \begin{cases} 0, & (i,j)\text{ is wall} \\ f_{i-1,j}+f_{i,j-1}, & \text{else} \end{cases} $$

AC code

int h, w, dp[MAXN][MAXN];
string a[MAXN];

signed main() {
    hyper;
    cin >> h >> w;
    rep(i,0,h) cin >> a[i];
    rep(i,0,h) dp[i][0] = 0;
    rep(i,0,w) dp[0][i] = 0;
    dp[1][1] = 1;
    rep1(i,1,h) rep1(j,1,w) {
        if(i==1 && j==1) continue;
        if(a[i-1][j-1] == '#') dp[i][j] = 0;
        else dp[i][j] = (dp[i-1][j] + dp[i][j-1]) % MOD;
    }
    cout << dp[h][w];
}

Problem I - Coins
#

I - Coins

機率 dp,跟高中數學題差不多。假設 \(f_{i, j}\) 表示有 \(i\) 個硬幣,其中 \(j\) 個是 head 的機率,那麼它可以從 \(i-1\) 個硬幣推得:

$$ f_{i, j} = f_{i-1, j-1} \times p_i + f_{i-1, j} \times (1 - p_i) $$

也就是考慮第 \(i\) 個是正面還是反面。注意處理邊界就好,時間複雜度是 \(O(n^2)\)。

AC code

int n;
double p[MAXN], dp[MAXN][MAXN];

signed main() {
    hyper;
    cin >> n;
    rep1(i,1,n) cin >> p[i];
    dp[0][0] = 1;
    rep1(i,1,n) {
        dp[i][0] = dp[i-1][0]*(1-p[i]);
        rep1(j,1,i) dp[i][j] = dp[i-1][j-1]*p[i] + dp[i-1][j]*(1-p[i]);
    }
    double sum = 0;
    for(int i=n; i>n-i; --i) sum += dp[n][i];
    cout << fixed << setprecision(10) << sum;
}

Problem J - Sushi
#

J - Sushi

關鍵是盤子裡最多只會有 3 個壽司,而且因為隨機均勻分布,盤子的順序並不重要,所以我們可以用三個維度 \(x, y, z\) 分別表示剩餘 \(1, 2, 3\) 個壽司的盤子數量,空盤子數就是 \(n - x - y - z\)。令 \(f(x,y,z)\) 是盤子在此狀態下時的答案(吃光所需次數的期望值),不難推出:

$$ f(x,y,z) = \frac{1}{x+y+z} \left( n + x f(x-1,y,z) + y f(x+1,y-1,z) + z f(x,y+1,z-1) \right) $$

這邊使用遞迴 + 記憶化搜尋來寫(我也不知道為什麼當初這樣寫,不過這樣不用考慮計算順序),總之複雜度是 \(O(n^3)\) 稍微有點緊。

AC code

int n, cnt[4];
double dp[MAXN][MAXN][MAXN];

double sol(int x, int y, int z) {
    if(x<0 || y<0 || z<0) return 0;
    if(dp[x][y][z] >= 0) return dp[x][y][z];
    return dp[x][y][z] = (n+x*sol(x-1,y,z)+y*sol(x+1,y-1,z)+z*sol(x,y+1,z-1))/(x+y+z);
}

signed main() {
    hyper;
    cin >> n;
    rep1(i,1,n) {
        int x;
        cin >> x;
        cnt[x]++;
    }
    rep1(i,0,n) rep1(j,0,n) rep1(k,0,n) dp[i][j][k] = -1;
    dp[0][0][0] = 0;
    cout << fixed << setprecision(10) << sol(cnt[1],cnt[2],cnt[3]);
}

Problem K - Stones
#

K - Stones

一定必勝或必敗,定義 \(f_i\) 代表目前只有 \(i\) 顆石頭的情況下,先手是否獲勝。只要拿走任一種數量的石頭使得對手接下來必敗就贏了,否則就是輸。轉移式如下

$$ f_i = \begin{cases} 1, & \exists \, a_i : f_{i-a_i} = 0 \\ 0, & else \end{cases} $$

AC code

int n, k, a[105];
bool dp[100005];

signed main() {
    hyper;
    cin >> n >> k;
    rep(i,0,n) cin >> a[i];
    rep1(i,1,k) rep(j,0,n) {
        if(i-a[j] >= 0 && !dp[i-a[j]]) dp[i] = 1;
    }
    cout << (dp[k] ? "First" : "Second");
}

Problem L - Deque
#

L - Deque

又是賽局的題目,因為兩邊都可以拿東西,所以變成區間 dp。可以看出兩邊都是想讓自己分數越高越好,假設 \(f(l, r)\) 是 \([l, r]\) 區間中先手減後手的分數最大值(不一定是 \(X-Y\) 或 \(Y-X\)),轉移時 \(X, Y\) 會互換,所以變成要找最小值,也就是加負號,得到:

$$ f(l, r) = \max \{ a_l - f(l+1, r), a_r - f(l, r-1) \} $$

前者表示拿 \(l\),後者表示拿 \(r\),注意 \(l\) 跟 \(r\) 的計算方向是相反的,答案是整個區間,也就是 \(f(1, n)\)。

AC code

int n, a[MAXN], dp[MAXN][MAXN];

signed main() {
    hyper;
    cin >> n;
    rep1(i,1,n) {
        cin >> a[i];
        dp[i][i] = a[i];
    }
    for(int l=n; l>=1; --l) {
        rep1(r,l+1,n) {
            dp[l][r] = max(a[l] - dp[l+1][r], a[r] - dp[l][r-1]);
        }
    }
    cout << dp[1][n];
}

Problem M - Candies
#

M - Candies

設 \(f(i, j)\) 是考慮到第 \(i\) 個小孩,用掉 \(j\) 個糖果時的方法數,則:

$$ f(i, j) = \sum_{k = j - a_{i}}^{j} f(i-1, k) $$

但這樣時間會炸,很容易發現可以用前綴和處理連續區間和,就可以把時間降到 \(O(nk)\)。然後可以用滾動 dp 進一步變成一維空間,好耶。

AC code

int n, k, a[MAXN], dp[MAXK], ps[MAXK];

signed main() {
    hyper;
    cin >> n >> k;
    rep1(i,1,n) cin >> a[i];
    rep1(j,0,k) {
        dp[j] = (j <= a[1]);
        ps[j+1] = ps[j] + dp[j];
    }
    rep1(i,2,n) {
        rep1(j,0,k) dp[j] = (ps[j+1] - ps[max(0,j-a[i])]) % MOD;
        rep1(j,0,k) ps[j+1] = ps[j] + dp[j];
    }
    cout << dp[k];
}

Problem N - Slimes
#

N - Slimes

典型區間 dp。定義 \(f_{i,j}\) 為合併第 \(i\) 到第 \(j\) 隻史萊姆的最小成本。則轉移式如下

$$ f_{i,j} = \min_{i \le k \le j} \left\{ f_{i,k} + f_{k+1,j} + \sum_{n=i}^{j} a_n \right\} $$

其中 \(\sum_{n=i}^{j} a_n\) 可以用前綴和 \(O(1)\) 處理掉,總複雜度 \(O(N^3)\)。

AC code

int n, a[MAXN], dp[MAXN][MAXN], ps[MAXN];

signed main() {
    hyper;
    cin >> n;
    rep1(i,1,n) {
        cin >> a[i];
        dp[i][i] = 0;
        ps[i] = ps[i-1] + a[i];
    }
    rep1(len,2,n) rep1(i,1,n-len+1) {
        int j = i + len - 1;
        dp[i][j] = INF;
        rep(k,i,j) dp[i][j] = min(dp[i][j], dp[i][k]+dp[k+1][j]+ps[j]-ps[i-1]);
    }
    cout << dp[1][n];
}

Problem O - Matching
#

O - Matching

本質上是二分圖完美匹配數量,用狀壓 dp 可以做。考慮 \(f_{i, S}\) 是前 \(i\) 個男生跟女生子集合 \(S\) 的匹配數,如果 \(a_{i,j} = 1\),代表我可以把 \(j\) 加入 \(S\) 轉移過去,轉移變成:

$$ f_{i+1, S} = \sum_{j \in S, a_{i,j} = 1} f_{i, S \setminus \{j\}} $$

答案是 \(f_{N, \{0, 1, \dots, N-1\}}\),時間複雜度是 \(O(2^N \cdot N)\)。

可以參考 k 染色问题、二分图完美匹配数、k 路径问题

AC code

int n, dp[22][1<<21];
bool a[21][21];

signed main() {
    hyper;
    cin >> n;
    rep(i,0,n) rep(j,0,n) cin >> a[i][j];
    dp[0][0] = 1;
    rep(i,0,n) rep(t,0,1<<n) {
        if(dp[i][t] == 0) continue;
        rep(j,0,n) if(!(t&(1<<j)) && a[i][j]) {
            dp[i+1][t|(1<<j)] = (dp[i+1][t|(1<<j)] + dp[i][t]) % MOD;
        }
    }
    cout << dp[n][(1<<n)-1];
}

Problem P - Independent Set
#

P - Independent Set

樹上 dp 題,把節點是黑或白的方法數分開討論,定義 \(f_{u, c}, c \in \{0, 1\}\) 表示節點顏色,是根為 \(u\) 的子樹在 \(u\) 顏色是 \(c\) 時的答案,轉移時就可以分顏色討論,在 dfs 時順便把小孩的答案乘上去,複雜度 \(O(N)\)。

AC code

int n, dp[MN][2];
vector<int> e[MN];

void dfs(int u, int p) {
    dp[u][0] = dp[u][1] = 1;
    if(u != 1 && e[u].size() == 1) return;
    for(int v: e[u]) {
        if(v == p) continue;
        dfs(v, u);
        dp[u][0] = (dp[u][0] * (dp[v][0] + dp[v][1])) % MOD;
        dp[u][1] = (dp[u][1] * dp[v][0]) % MOD;
    }
}

signed main() {
    hyper;
    cin >> n;
    rep(i,0,n-1) {
        int x, y;
        cin >> x >> y;
        e[x].push_back(y);
        e[y].push_back(x);
    }
    dfs(1, 0);
    cout << (dp[1][0] + dp[1][1]) % MOD;
}

Problem Q - Flowers
#

Q - Flowers

看起來是帶權重的 LIS,定義 \(f_i\) 是選擇的最後一朵是第 \(i\) 朵花的答案,因此只能從高度比目前小的轉移過來,轉移式是:

$$ f_i = \max_{t < i, h_t < h_i} f_t + a_i $$

而答案是 \(\sum f_i\),但這樣複雜度會變 \(O(N^2)\) 不會過。我們把高度排序當作 index,就變成單點查詢、區間最大值的問題,簡單用個 BIT(或線段樹)加速就過了,時間複雜度變成 \(O(N \log N)\),好耶。

AC code

struct Fenwick {
    vector<int> v;
    int sz;
    Fenwick(int n) {
        v.resize(n+1, 0);
        sz = n;
    }
    int lowbit(int x) { return x&-x; }
    void modify(int p, int x) {
        for(int i=p; i<=sz; i+=lowbit(i)) v[i] = max(v[i], x);
    }
    int query(int p) {
        int mx = -INF;
        for(int i=p; i>0; i-=lowbit(i)) mx = max(mx, v[i]);
        return mx;
    }
};

Fenwick bit(MN);

signed main() {
    hyper;
    int n;
    vi h(MN), a(MN);
    cin >> n;
    rep1(i,1,n) cin >> h[i];
    rep1(i,1,n) cin >> a[i];

    vi tmp(h), idx(MN);
    sort(tmp.begin()+1, tmp.begin()+1+n);
    rep1(i,1,n) idx[tmp[i]] = i;

    int ans = -INF;
    rep1(i,1,n) {
        int t = max(bit.query(idx[h[i]]-1), 0) + a[i];
        bit.modify(idx[h[i]], t);
        ans = max(ans, t);
    }
    cout << ans << '\n';
}

Problem R - Walk
#

R - Walk

這題完全在考矩陣乘法,設 \(f(u, v, t)\) 表示從 \(u\) 到 \(v\) 走 \(t\) 步的方法數,則 \(f(u, v, 2t) = \sum_{k \in V} f(u, k, t) \times f(k, v, t)\),所以用矩陣快速冪算出 \(a^K\) 然後把所有 entries 相加就是答案ㄌ。時間複雜度是 \(O(N^3 \log K)\)。

AC code

mat operator*(const mat &a, const mat &b) {
    int n = a.size();
    mat c(n, vi(n, 0));
    rep(i,0,n) rep(j,0,n) rep(k,0,n)
        c[i][j] = (c[i][j] + a[i][k] * b[k][j]) % MOD;
    return c;
}

mat operator^(mat a, int k) {
    int n = a.size();
    mat b(n, vi(n, 0));
    rep(i,0,n) b[i][i] = 1;
    while(k) {
        if(k & 1) b = b * a;
        a = a * a;
        k >>= 1;
    }
    return b;
}

signed main() {
    hyper;
    int n, k;
    cin >> n >> k;
    mat a(n, vi(n));
    rep(i,0,n) rep(j,0,n) cin >> a[i][j];
    mat b = a ^ k;
    int sum = 0;
    rep(i,0,n) rep(j,0,n) sum = (sum + b[i][j]) % MOD;
    cout << sum << '\n';
}

Problem S - Digit Sum
#

S - Digit Sum

數位 dp 是我之前沒有碰過的題型,所以看 OI Wiki 研究了一下。

要記錄三個東西,目前位數、總和(取模)、以及前面所有位數是否達到上限(lim)。lim 是一個布林值,舉例來說,\(k\) 是 \(54321\),假設我考慮了前三位是 \(543\),那此時 lim 是 true,表示第四位只能填 \(0 \sim 2\);否則假設前三位是 \(522\) 沒有達到上限,那 lim 就是 false,第四位可以隨便填。

轉移的部分看 code 應該就很清楚了,數位 dp 轉移關係比較複雜,用記憶化搜索比較好寫。記得最後答案要減一因為會把 0 也算進去。

AC code

string k;
int d, n;
int dp[10005][105][2];

int sol(int pos, int sum, int lim) {
    if(pos == n) return sum == 0;
    int& ret = dp[pos][sum][lim];
    if(~ret) return ret;
    ret = 0;
    int up = lim ? k[pos] - '0' : 9;
    rep1(i,0,up) ret += sol(pos+1, (sum+i)%d, lim&&(i==up));
    return ret %= MOD;
}

signed main() {
    hyper;
    rr(dp, -1);
    cin >> k >> d;
    n = k.size();
    cout << (sol(0, 0, 1) - 1 + MOD) % MOD << '\n';
}

Problem T - Permutation
#

T - Permutation

這題我覺得有點 tricky,首先最直接想到的是目前處理到的位置跟最後一位數字,這兩個是要記錄的狀態。因此我們定義 \(f(i, j)\) 是長度 \(i\) 且最後一位為 \(j\) 的方法數(用 \(1 \sim i\) 的正整數排列)。關鍵在於我們可以從 \(i-1\) 轉移過來,因為我們可以調整數字,只要不影響大小順序。例如原先是 \((1, 3, 2)\) 而第四位想要是 \(2\) 的話,只要把前三位數都 \(+1\) 就可以了,也就是變成 \((1, 4, 3, 2)\)。考慮 \(>\) 或 \(<\) 能讓上一個數字是多少,我們可以得到轉移式:

$$ f(i, j) = \begin{cases} \sum_{k=1}^{j} f(i-1, k), \quad (<) \\ \sum_{k=j}^{i-1} f(i-1, k), \quad (>) \end{cases} $$

然後簡單用前綴和處理,記得取模。

AC code

signed main() {
    hyper;
    int n;
    string s;
    cin >> n >> s;
    mat dp(n+1, vi(n+1, 0));
    dp[1][1] = 1;
    rep1(i,2,n) {
        vi ps(i+1, 0);
        rep1(j,1,i-1) ps[j] = (ps[j-1] + dp[i-1][j]) % MOD;
        rep1(j,1,i) {
            if(s[i-2] == '<') dp[i][j] = ps[j-1];
            else dp[i][j] = (ps[i-1] - ps[j-1] + MOD) % MOD;
        }
    }
    int ans = 0;
    rep1(i,1,n) ans = (ans + dp[n][i]) % MOD;
    cout << ans << '\n';
}