--- tags: Programming Contest --- # Educational DP Contest 題解(N ~ V) ## 心得 後半 13 題,比前半 13 題難上不少,讓我慢慢寫。 ## N. [Slimes](https://atcoder.jp/contests/dp/tasks/dp_n) 給定長度為 $N$ 的序列 $A$,不斷重覆:「合併 $A$ 中的相鄰兩項 $x, y$ 為 $x + y$,花費成本 $x + y$」,直到 $A$ 只剩一個元素。請問在所有合併方法中,總成本最小能是多少? 設 $f(i, j)$ 為合併 $A$ 的半開區間 $[i, j)$ 為一個元素所需的最小成本。 如同 CLRS 中矩陣乘法的最小成本,我們採用 Divide & Conquer 的技巧。 我們枚舉分割點 $k$ 將 $[i, j)$ 拆成 $[i, k), [k, j)$,此時所需成本有三項: 1. 合併左區間為一個元素的成本 $f(i, k)$ 2. 合併右區間為一個元素的成本 $f(k, j)$ 3. 合併「左區間合併後的元素」 與「右區間合併後的元素」的成本 設 $S_{[a, b)}$ 為 $A_a + A_{a + 1} + \cdots + A_{b - 1}$,轉移方程可以寫成: $$ f(i, j) = \min_{i \lt k \lt j} \left( f(i, k) + f(k, j) + (S_{[i, k)} + S_{[k, j)}) \right) $$ $S_{[a, b)}$ 可以透過前綴和預處理,在轉移時就能 $O(1)$ 算出。實作採用 top-down dp。 ```cpp #include <iostream> #include <vector> #include <algorithm> using namespace std; using ll = long long; const ll INF = 1e18; int N; ll A[400]; ll P[400]; ll dp[400][400 + 1]; ll sum(int i, int j) { return P[j - 1] - ((i >= 1) ? P[i - 1] : 0); } ll f(int i, int j) { // [i, j) if (dp[i][j] != INF) { return dp[i][j]; } if (j - i == 1) { return 0; } ll ret = INF; for (int k = i + 1; k < j; k++) { ll val = f(i, k) + f(k, j) + sum(i, k) + sum(k, j); ret = min(ret, val); } dp[i][j] = ret; return ret; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> N; for (int i = 0; i < N; i++) { cin >> A[i]; } P[0] = A[0]; for (int i = 1; i < N; i++) { P[i] = P[i - 1] + A[i]; } for (int i = 0; i < N; i++) { for (int j = i; j <= N; j++) { dp[i][j] = INF; } } cout << f(0, N) << endl; return 0; } ``` ## O. [Matching](https://atcoder.jp/contests/dp/tasks/dp_o) 給定 NxN 的矩陣 $A_{i, j}$ 代表 man $i$ 能否與 woman $j$ 匹配,限定一個人最多只能有一個匹配,問有多少種匹配方式? 設 $dp[i, S]$ 為 $[0, i$) men 與 $S$ women 的匹配方法數,其中 $S$ 是集合。題目所求即為 $dp[N, \{0, 1, ..., N\}]$。 在狀態 $dp[i, S]$ 時,枚舉 men $i$ 能與哪些 woman 配對,假設是 $(i, j)$,我們得到: $$ dp[i, S] \to dp[i + 1, S \cup \{j\}] $$ 當然此 $j$ 必需不在 $S$ 中。 ```python N = int(input()) M = 10 ** 9 + 7 A = [list(map(int, input().split())) for _ in range(N)] dp = [[0] * (1 << N) for _ in range(N + 1)] dp[0][0] = 1 for i in range(N): for s in range(1 << N): if dp[i][s] != 0: # improve speed for j in range(N): if (s & (1 << j)) == 0 and A[i][j]: dp[i + 1][s | (1 << j)] += dp[i][s] dp[i + 1][s | (1 << j)] %= M print(dp[N][(1 << N) - 1]) ``` ## P. [Independent Set](https://atcoder.jp/contests/dp/tasks/dp_p) 給定一棵大小為 $N$ 的 tree,可以將每個節點染成白色或黑色,但要求不可以有相鄰的兩個節點都為黑色,問染色方法數? 設 $f(u, c)$ 為以 $u$ 為 root 的子樹的染色方法數,且 $u$ 是染成 $c$ (白色 = 1,黑色 = 0)。 若 u 為黑色,則其 children 必需染成白色;若 u 為白色,則其 children 是什麼顏色都可以。根據這個想法,我們得到轉移: $$ \begin{align} f(u, 0) &= \prod_{v \in child(u)} f(v, 1) \\ f(u, 1) &= \prod_{v \in child(u)} (f(v, 0) + f(v, 1)) \end{align} $$ 計算順序為葉子先算,再慢慢往上,直到算到 root。或我們可以用 top-down 實現。 ```cpp #include <atcoder/modint> #include <iostream> #include <vector> using namespace std; using mint = atcoder::modint1000000007; const int MAX_N = 1e5; int N; mint dp[MAX_N][2]; vector<int> adj[MAX_N]; mint f(int u, int c, int p) { if (dp[u][c] != 0) { return dp[u][c]; } mint res = 1; for (auto v : adj[u]) { if (v != p) { if (c == 0) { res *= f(v, 1, u); } else { res *= f(v, 0, u) + f(v, 1, u); } } } dp[u][c] = res; return res; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> N; for (int i = 0; i < N - 1; i++) { int u, v; cin >> u >> v; u--; v--; adj[u].push_back(v); adj[v].push_back(u); } for (int u = 0; u < N; u++) { dp[u][0] = dp[u][1] = 0; } mint ans = f(0, 0, -1) + f(0, 1, -1); cout << ans.val() << endl; return 0; } ``` ## Q. [Flowers](https://atcoder.jp/contests/dp/tasks/dp_q) 這題是 [Weighted LIS](https://amoshyc.github.io/CPsolution/template/dp/wlis.html)。WLIS 的問題是給定二個長度為 N 的序列 A 與B,對於 A 中的每個 increasing sequence $A_{x_1}, A_{x_2}, \cdots, A_{x_l}$,都可以算出一個值 $\sum_{i=1}^l B_{x_i}$。問所有 IS 中,此值最大是多少? 設 dp[i] = maximum total value of all possible IS ending at $A_i$。枚舉各個 IS 的前一項的位置,我們得轉移: $$ dp[i] = max\left(0, \max_{j \in [0, i), A_j < A_i} dp[j] \right) + B_i $$ 此轉移會 TLE。一個常見的優化方式是將 dp 值與他們對應的 A $(A_j, dp[j])$ 存進一個容器 $C$ 中。對此容器詢問 $A_i$,他可以回答容器裡所有 A 值小於 $A_i$ 的 dp 值中,最大是多少,即 $$ \max_{(A_j, dp[j]) \in C, A_j < A_i} dp[j] $$ 此容器可以用線段樹實現,將 A 值當作 index,線段樹存 dp 值,我們邊計算 dp 值邊更新線段樹。 此題的 $h$ 就是 WLIS 的 $A$,$a$ 是 WLIS 的 $B$。 ```cpp #include <atcoder/segtree> #include <iostream> #include <vector> using namespace std; using ll = long long; const ll INF = 1e18; ll op(ll a, ll b) { return max(a, b); } ll e() { return -INF; } using SegTree = atcoder::segtree<ll, op, e>; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int N; cin >> N; vector<ll> h(N); vector<ll> a(N); for (int i = 0; i < N; i++) { cin >> h[i]; } for (int i = 0; i < N; i++) { cin >> a[i]; } vector<ll> dp(N); auto seg = SegTree(N + 10); dp[0] = a[0]; seg.set(h[0], dp[0]); for (int i = 1; i < N; i++) { ll prev = seg.prod(0, h[i]); dp[i] = max(a[i], prev + a[i]); if (dp[i] > seg.get(h[i])) { seg.set(h[i], dp[i]); } } ll ans = *max_element(dp.begin(), dp.end()); cout << ans << endl; return 0; } ``` ## R. [Walk](https://atcoder.jp/contests/dp/tasks/dp_r) 離散數學告訴我們,$A^K$ 即為任兩點之間長度為 $K$ 的 walk 數。 以下也給出 dp 的想法:設 $dp[l, u, v]$ 為 $u$ 到 $v$ 有多少條長度為 $l$ 的 walks。 考慮這些 walks 的最後一條邊 $(x, v)$,我們得到轉移: $$ \begin{align} dp[l, u, v] &= \sum_{A_{x, v} = 1} dp[l - 1, u, x] \\ &= \sum_{x = 1}^N dp[l - 1, u, x] \cdot A_{x, v} \end{align} $$ 而這就是矩陣乘法 $dp[l] = dp[l - 1] A$,又因為 $dp[1] = A$,我們可以歸納出 $dp[l] = A^l$。 ```cpp #include <atcoder/modint> #include <iostream> #include <vector> using namespace std; using ll = long long; using mint = atcoder::modint1000000007; using vec = vector<mint>; using mat = vector<vec>; mat matmul(mat A, mat B) { int n = A.size(); int m = B[0].size(); int l = B.size(); mat C(n, vec(m, 0)); for (int r = 0; r < n; r++) { for (int c = 0; c < m; c++) { for (int k = 0; k < l; k++) { C[r][c] += A[r][k] * B[k][c]; } } } return C; } mat matpow(mat A, ll k) { int n = A.size(); auto base = A; auto res = mat(n, vec(n, 0)); for (int i = 0; i < n; i++) { res[i][i] = 1; } while (k) { if (k & 1) { res = matmul(res, base); } base = matmul(base, base); k >>= 1; } return res; } int main() { ios::sync_with_stdio(false); cin.tie(0); ll N, K; cin >> N >> K; auto A = mat(N, vec(N, 0)); for (int r = 0; r < N; r++) { for (int c = 0; c < N; c++) { int e; cin >> e; A[r][c] = e; } } auto W = matpow(A, K); mint ans = 0; for (int r = 0; r < N; r++) { for (int c = 0; c < N; c++) { ans += W[r][c]; } } cout << ans.val() << endl; return 0; } ``` ## S. [Digit Sum](https://atcoder.jp/contests/dp/tasks/dp_s) 問整數 $1$ ~ $K$ 之間,有多少個整數 $X$ 滿足:digit sum 是 $D$ 的倍數。 花了不少時間,試了各種解法,終於將這題與[另一題](https://hackmd.io/@amoshyc/BkKpztrtP#F-Valid-payments)的邏輯統一,希望能成為我 digit dp 的模版。 讓我們將 $K, X$ 表示成陣列,以 $K=2345$ 為例: $$ \begin{array}{r} &k_3 &k_2 &k_1 &k_0 \\ &2 &3 &4 &5 \\ &x_3 &x_2 &x_1 &x_0 \end{array} $$ 設 $dp[i, s, f]$ = number of ways to fill $x_{i - 1} \cdots x_1x_0$ such that 1. digit sum $(\sum_{j=0}^{i-1} x_j) \bmod D = s$ 2. $(f = 0): x_{i-1} \cdots x_1 x_0 \le k_{i-1} \cdots k_1 k_0$ $(f = 1): x_{i-1} \cdots x_1 x_0 \gt k_{i-1} \cdots k_1 k_0$ 以 $K = 2345$ 為例,$dp[2, 0, 0]$ 代表二位數中有多少數小於或等於 45 且 digit sum $\bmod D = 0$;$dp[2, 0, 1]$ 代表二位數中有多少數大於 $45$ 且 digit sum $\bmod D = 0$;而 $(dp[2, 0, 0] + dp[2, 0, 1])$ 代表所有二位數中有多少個數的 digit sum $\bmod D = 0$。 枚舉 $x_i$ 的所有可能的值 $d (0 \le d \le 9)$,我們有以下遞推: $$ dp[i, s, f] \to dp[i + 1, (s + d) \bmod D, f'] $$ 其中 $f'$ 代表新的數字 $x_i x_{i-1} \cdots x_1 x_0$ 是不是大於 $k_i k_{i-1} \cdots k_1 k_0$。思考一下可以發現,只有當 $(d \gt k_i) \lor (d = k_i \land f = 1)$ 時 $f'$ 會是 1。題目所求為 $dp[N, 0, 0] - 1$。最後的 $-1$ 是因為 dp 會把 $X=0$ 也算是答案,但題目問得是 $1$ ~ $K$。 ```python K = [int(x) for x in reversed(input())] N = len(K) D = int(input()) M = 10 ** 9 + 7 # dp[i, s, f] = #ways to fill x[i - 1]...x[1]x[0] such that # 1. digit sum mod D = s # 2. x[i - 1]...x[1]x[0] <= k[i - 1]...k[1]k[0] (f = 0) or not (f = 1) # By filling the digit x[i] with d (d = 0, 1, ..., 9): # dp[i, s, f] -> dp[i + 1, (s + d) % D, f'] dp = [[[0, 0] for _ in range(D)] for _ in range(N + 1)] dp[0][0][0] = 1 for i in range(N): for s in range(D): for f in range(2): for d in range(10): new_s = (s + d) % D new_f = int((d > K[i]) or (d == K[i] and f == 1)) dp[i + 1][new_s][new_f] += dp[i][s][f] dp[i + 1][new_s][new_f] %= M print((dp[N][0][0] - 1 + M) % M) # subtract the 0 ``` ## T. [Permutation](https://atcoder.jp/contests/dp/tasks/dp_t) 問有多少個 $(1, 2, ..., N)$ 的 permutation `P` 滿足 `S` 描述的不等式?`S[i] = '<'` 代表 `P[i - 1]` 要小於 `P[i]`;`S[i] = '>'` 代表 `P[i - 1]` 要大於 `P[i]`。 網路上的題解我沒一個看得懂,直到看了大神 [Errichto](https://atcoder.jp/users/Errichto) 的[影片](https://www.youtube.com/watch?v=FAQxdm0bTaw&t=12912s)才終於想通:換數字! 設 $dp[i, j]$ = $(1, 2, ..., i)$ 有多少個 permutation 滿足 `S[:(i - 1)]`,且最後一項是 $j$。 以 $i = 4$ 為例,且不等式是 `>`,我們能用的數字是 1, 2, 3, 4。如果 $P_3$ = 2,即我們現在是狀態 $dp[i, 2]$,則 $P_0$, $P_1$, $P_2$ 能用的數字是 1, 3, 4,且 $P_2$ 必需為 3 或 4。這裡找不到重複子問題。 $$ \begin{array}{r} &P_0 &P_1 &P_2 & &P_3 \\ &\text{_} &\text{_} &\text{_} &\gt &2 \\ \end{array} $$ 但若是我們想像將數字 3 換成 2,將 4 換成 3,則變成 $P_0$, $P_1$, $P_2$ 能用的數字是 $1, 2, 3$,且 $P_2$ 必需為 2 或 3,這就變成之前的狀態 $dp[i - 1, 2]$ 與 $dp[i - 1, 3]$。我們可以這樣轉換數字是因為在此題中,這種轉換不影響結果。 以公式描述,若 $P_i = j$ 且不等式是 `>` 時,$P_{i - 1}$ 可以填 $j, j + 1, \cdots, i - 1$,即 $$ dp[i, j] = \sum_{k = j}^{i - 1} dp[i - 1, k] $$ 注意包含了 $j$。同理,若 $P_i = j$ 且不等式是 `<` 時,$P_{i - 1}$ 可以填的數字是 $1, 2, \cdots, j - 1$。 $$ dp[i, j] = \sum_{k = 1}^{j - 1} dp[i - 1, k] $$ 這樣的 dp 是 $O(N^3)$,但我們可以透過對 $dp[i - 1]$ 建立前綴和,將之降為 $O(N^2)$: ```python N = int(input()) S = input() M = 10 ** 9 + 7 # dp[i, j] = number of permutation of (1, 2, ..., i) that satisfies s[:i - 1] and ends with j dp = [[0 for _ in range(N + 1)] for _ in range(N + 1)] dp[1][1] = 1 for i in range(2, N + 1): pref = [0 for _ in range(N + 1)] pref[0] = dp[i - 1][0] for j in range(1, N + 1): pref[j] = (pref[j - 1] + dp[i - 1][j]) % M for j in range(1, N + 1): if S[i - 2] == '<': dp[i][j] = pref[j - 1] else: dp[i][j] = (pref[i - 1] - pref[j - 1] + M) % M # for j in range(1, N + 1): # if S[i - 2] == '<': # for k in range(1, j): # dp[i][j] += dp[i - 1][k] # dp[i][j] %= M # else: # for k in range(j, i): # dp[i][j] += dp[i - 1][k] # dp[i][j] %= M print(sum(dp[N][j] for j in range(1, N + 1)) % M) ``` ## U. [Grouping](https://atcoder.jp/contests/dp/tasks/dp_u) 給定 $N \times N$ 矩陣 $A$,$A_{i, j}$ 代表將 Rabbit $i$ 與 Rabbit $j$ 分在同一組時的得分。現將 $N$ 隻 Rabbits 分成一或多組,請最大化得分的總和。 設 $dp[S]$ 代表將 Rabbit 集合 $S$ 分成一或多組的得分。S 可能是一或多個組,讓我們枚舉其中的一組 $T$ 的所有可能,得到: $$ dp[S] = \max_{\forall T \subset S} \left(dp[S - T] + score[T] \right) $$ 其中 $score[T]$ 可以預先計算好。實作上,枚舉子集合的方法可參[這裡](https://cp-algorithms.com/algebra/all-submasks.html),整體時間 $O(2^N N^2) + O(3^N)$。 ```python N = int(input()) M = 10 ** 9 + 7 A = [[int(x) for x in input().split()] for _ in range(N)] score = [0 for _ in range(1 << N)] for s in range(1 << N): for i in range(N): if s & (1 << i): for j in range(i + 1, N): if s & (1 << j): score[s] += A[i][j] dp = [0 for _ in range(1 << N)] for s in range(1 << N): t = s while t: dp[s] = max(dp[s], dp[s - t] + score[t]) t = (t - 1) & s print(dp[(1 << N) - 1]) ``` ## V. [Subtree](https://atcoder.jp/contests/dp/tasks/dp_v) 給定一棵樹,將每個節點塗成黑色或白色,且滿足:任何黑色節點可以只經由黑色節點到達其他黑色節點。問對每個節點 $v$ 來說,若 $v$ 是黑色的,整棵樹有多少種塗法? 若只要考慮樹根 $root$ 是黑色時的塗法,那這題我們會做。可以想像,若是合法的塗法,黑色的節點都會在樹根的部份,不會出現 parent 是白色的黑色節點,因為不滿足題意。 設 $dp[u]$ 代表樹根是 $root$、且 $u$ 塗成黑色時,子樹 $u$ 的方法數。考慮 $u$ 的 children 要塗成白色或黑色,我們得到以下轉移: $$ dp[u] = \prod_{v \in child(u)} (1 + dp[v]) $$ 其中的 $1$ 代表節點 $v$ 是塗成白色的方法數;$dp[v]$ 是 $v$ 是黑色時,子樹 $v$ 的方法數。但這個 dp 只能告訴我們 $root$ 的答案,我們當然都可以將每個節點都當 $root$ 跑一次 dp,求出該節點的答案,但明顯會 TLE。 事實上,我們可以透過一些組合,將非 $root$ 節點的答案求出,不用真的每個節點都當 $root$ 跑一次 dp。如下圖,這是以節點 0 為 $root$ 的 dp: ```graphviz graph { node [fontname="Arial" ]; size = "3!"; 0 [color="red" xlabel="16"]; 1 [color="red" xlabel="1"]; 2 [color="red" xlabel="1"]; 3 [color="black" xlabel="3"]; 4 [color="blue" xlabel="2"]; 5 [color="blue" xlabel="1"]; 0 -- {1, 2, 3}; 3 -- {4}; 4 -- {5}; } ``` 對於非 $root$ 的節點 3,我們可以將之想成是 $root$,而紅色部份是節點 3 除了子樹 4(藍色部份)以外的另一個子樹,而紅色部份的方法數是: $$ \prod_{v \in child(0), v \ne 3} (1 + dp[v]) = (1 + dp[1]) (1 + dp[2]) = 4 $$ 由此我們就能算出若節點 3 是 $root$ 時,他的答案(也是 dp 值)是 $(1 + red)(1 + blue) = (1 + 4)(1 + 2) = 15$。同理其他非 $root$ 節點 $u$,也是將 $u$ 的 parent 視為 $u$ 的另一個子樹,以此來求出將 $u$ 視為 $root$ 時的答案。 實作上,使用兩次 DFS 並搭配 prefix, suffix product 來高速求積,使用 pypy 會有三個 testcases [TLE](https://atcoder.jp/contests/dp/submissions/19095888),因此使用 C++。 ```cpp #include <iostream> #include <vector> #define sz(x) (int(x.size())) using namespace std; using ll = long long; const int MAX_N = 100000; ll N, mod; ll dp[MAX_N]; ll ans[MAX_N]; vector<int> adj[MAX_N]; ll build(int u, int p) { dp[u] = 1; for (int v: adj[u]) { if (v != p) { dp[u] *= (1 + build(v, u)); dp[u] %= mod; } } return dp[u]; } void solve(int u, int p, ll top) { vector<ll> branches; for (int v : adj[u]) { if (v == p) { branches.push_back(1 + top); } else { branches.push_back(1 + dp[v]); } } if (sz(branches) == 0) { ans[u] = 1; return; } vector<ll> pref(sz(branches), 1); vector<ll> suff(sz(branches), 1); pref.front() = branches.front() % mod; for (int i = 1; i < sz(branches); i++) { pref[i] = (pref[i - 1] * branches[i]) % mod; } suff.back() = branches.back() % mod; for (int i = sz(branches) - 2; i >= 0; i--) { suff[i] = (suff[i + 1] * branches[i]) % mod; } ans[u] = pref.back(); for (int i = 0; i < sz(adj[u]); i++) { int v = adj[u][i]; if (v != p) { ll prod1 = ((i > 0) ? pref[i - 1]: 1); ll prod2 = ((i != sz(branches) - 1) ? suff[i + 1]: 1); solve(v, u, (prod1 * prod2) % mod); } } } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> N >> mod; for (int i = 0; i < N - 1; i++) { int u, v; cin >> u >> v; u--; v--; adj[u].push_back(v); adj[v].push_back(u); } build(0, -1); solve(0, -1, 0); for (int i = 0; i < N; i++) { cout << ans[i] << "\n"; } return 0; } ``` :::success [< 回到所有題解](https://hackmd.io/@amoshyc/HkWWepaPv/) :::