AT_dp 刷题记录
AT_dp 刷题记录
题单:https://www.luogu.com.cn/training/476671。
推荐我最喜欢的一套题解:https://blog.hamayanhamayan.com/entry/2019/01/12/163853。
动态规划具体知识可以看我的笔记:
- 动态规划——区间DP 学习笔记
- 动态规划——数位DP 学习笔记
- 动态规划——状压DP 学习笔记
- 动态规划——树形DP 学习笔记
- 动态规划——矩阵优化DP 学习笔记
- 动态规划——斜率优化DP 学习笔记
- 动态规划——带权二分优化DP 学习笔记
- 动态规划——决策单调性优化DP 学习笔记
AT_dp_a Frog 1
什么时候了还在水橙题。—— AzureHair
记 \(f_i\) 表示到第 \(i\) 个点的最小花费,可以发现 \(f_i\) 只与 \(f_{i-1},f_{i-2}\) 有关。
直接转移即可。代码:
#include <bits/stdc++.h>
using namespace std;
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int n; cin >> n; vector<int> a(n);
for (int &i : a) cin >> i;
vector<int> f(n); f[1] = abs(a[1] - a[0]);
for (int i = 2; i < n; ++i) f[i] = min(f[i - 1] + abs(a[i] - a[i - 1]), f[i - 2] + abs(a[i] - a[i - 2]));
cout << f[n - 1] << endl;
return 0;
}
AT_dp_b Frog 2
什么时候了还在水橙题。—— AzureHair
记 \(f_i\) 表示到第 \(i\) 个点的最小花费,然后向前找最多 \(k\) 步转移即可。代码:
#include <bits/stdc++.h>
using namespace std;
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int n, k; cin >> n >> k;
vector<int> a(n); for (int &i : a) cin >> i;
vector<int> f(n, 0x3f3f3f3f); f[0] = 0;
for (int i = 1; i < n; ++i) for (int j = 1; j <= k && j <= i; ++j) f[i] = min(f[i], f[i - j] + abs(a[i] - a[i - j]));
cout << f[n - 1] << endl;
return 0;
}
AT_dp_c Vacation
可谓是背包鼻祖吧。
设 \(f_{i,k\in\{0/1/2\}}\) 表示考虑前 \(i\) 天,其中第 \(i\) 天选了 \(k\) 时的最大收益。
直接转移即可。代码:
#include <bits/stdc++.h>
using namespace std;
#define rep(i, n) for (int i = 0; i < n; ++i)
constexpr int N = 1e5 + 10;
int n, v[N][3], f[N][3];
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n; rep(i, n) cin >> v[i][0] >> v[i][1] >> v[i][2];
f[0][0] = v[0][0], f[0][1] = v[0][1], f[0][2] = v[0][2];
for (int i = 1; i < n; ++i) {
f[i][0] = max(f[i - 1][1], f[i - 1][2]) + v[i][0];
f[i][1] = max(f[i - 1][0], f[i - 1][2]) + v[i][1];
f[i][2] = max(f[i - 1][0], f[i - 1][1]) + v[i][2];
} cout << *max_element(f[n - 1], f[n - 1] + 3) << endl;
return 0;
}
AT_dp_d Knapsack 1
显然的,01 背包板子。
什么时候了还在水橙题。—— AzureHair
设 \(f_{i,j}\) 表示考虑前 \(i\) 个物品,不超过 \(j\) 的容量的最大价值。
朴素 01 背包代码:
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
#define rep(i, n) for (int i = 0; i < n; ++i)
constexpr int N = 110;
constexpr int M = 1e5 + 10;
int n, W;
int w[N], v[N];
ll f[N][M];
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n >> W; rep(i, n) cin >> w[i] >> v[i];
for (int i = w[0]; i <= W; ++i) f[0][i] = v[0];
for (int i = 1; i < n; ++i) {
for (int j = 0; j < w[i]; ++j) f[i][j] = f[i - 1][j];
for (int j = w[i]; j <= W; ++j) f[i][j] = max(f[i - 1][j], f[i - 1][j - w[i]] + v[i]);
} cout << *max_element(f[n - 1], f[n - 1] + W + 1) << endl;
return 0;
}
滚动数组优化代码:
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
#define rep(i, n) for (int i = 0; i < n; ++i)
constexpr int N = 110;
constexpr int M = 1e5 + 10;
int n, W;
int w[N], v[N];
ll f[M];
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n >> W; rep(i, n) cin >> w[i] >> v[i];
for (int i = w[0]; i <= W; ++i) f[i] = v[0];
for (int i = 1; i < n; ++i) for (int j = W; j >= w[i]; --j) f[j] = max(f[j], f[j - w[i]] + v[i]);
cout << *max_element(f, f + W + 1) << endl;
return 0;
}
AT_dp_e Knapsack 2
显然的,01 背包的变形板子。
注意到 \(w\) 的范围很大,但是 \(v\) 的范围比较小。
考虑动态规划的常见变形技巧。将结果化为状态,将状态化为结果。
设 \(f_{i,j}\) 表示考虑前 \(i\) 个物品,总价值为 \(j\) 的最小容量限制。
朴素背包代码:
#include <bits/stdc++.h>
using namespace std;
#define rep(i, n) for (int i = 0; i < n; ++i)
constexpr int N = 110;
constexpr int M = 1e5 + 10;
int n, s, W;
int w[N], v[N];
int f[N][M];
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n >> W; rep(i, n) cin >> w[i] >> v[i], s += v[i];
fill(f[0] + 1, f[0] + s + 1, 0x3f3f3f3f), f[0][v[0]] = w[0];
for (int i = 1; i < n; ++i) {
for (int j = 0; j < v[i]; ++j) f[i][j] = f[i - 1][j];
for (int j = v[i]; j <= s; ++j) f[i][j] = min(f[i - 1][j], f[i - 1][j - v[i]] + w[i]);
} for (int i = s; ~i; --i) if (f[n - 1][i] <= W) cout << i << endl, exit(0);
return 0;
}
滚动数组优化代码:
#include <bits/stdc++.h>
using namespace std;
#define rep(i, n) for (int i = 0; i < n; ++i)
constexpr int N = 110;
constexpr int M = 1e5 + 10;
int n, s, W;
int w[N], v[N];
int f[M];
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n >> W; rep(i, n) cin >> w[i] >> v[i], s += v[i];
fill(f + 1, f + s + 1, 0x3f3f3f3f), f[v[0]] = w[0];
for (int i = 1; i < n; ++i) for (int j = s; j >= v[i] ; --j) f[j] = min(f[j], f[j - v[i]] + w[i]);
for (int i = s; ~i; --i) if (f[i] <= W) cout << i << endl, exit(0);
return 0;
}
AT_dp_f LCS
啊 LCS 哈哈。板子。啊怎么要输出方案。可怜啊,得多写七八行代码了。
设 \(f_{i,j}\) 表示字符串 \(a\) 的前 \(i\) 个,和字符串 \(b\) 的前 \(j\) 个的最长公共子序列。
然后使用 \(g_{i,j}\) 表示 \(f_{i,j}\) 是从哪里转移来的。
递归(模拟栈)的输出方案即可。代码:
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 3010;
string s, t; int n, m;
int f[N][N], g[N][N];
void print(int x = n, int y = m) {
stack<char> str; while (f[x][y]) {
if (g[x][y] == 1) str.push(s[x]), x = x - 1, y = y - 1;
else if (g[x][y] == 2) x = x - 1; else y = y - 1;
} while (str.size()) putchar(str.top()), str.pop();
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> s >> t; n = s.size(), m = t.size(); s = "#" + s, t = "#" + t;
for (int i = 1; i <= n; ++i) for (int j = 1; j <= m; ++j) {
if (s[i] == t[j]) f[i][j] = f[i - 1][j - 1] + 1, g[i][j] = 1;
else if (f[i - 1][j] > f[i][j - 1]) f[i][j] = f[i - 1][j], g[i][j] = 2;
else f[i][j] = f[i][j - 1], g[i][j] = 3;
} print(n, m);
return 0;
}
AT_dp_g Longest Path
经典的拓扑序上动态规划。先跑出来拓扑序,然后再拓扑序的基础上:
设 \(f_i\) 表示距离节点 \(i\) 最远的节点的距离。对于拓扑序所转移到的每一条边,有 \(f_v=f_u+1\)。
但是需要证明一个东西,即这条路径的出发点一定是一个入度为 \(0\) 的点。
假设我们已经找到了一条形如 \(S_1,S_2,\dots,S_k\) 的路径,其中 \(S_1\) 入度非零。
那么一定存在一个入度为 \(0\) 的点 \(t\),使得 \(t\not\in S\) 且 \(t\) 存在一条不定长的路径 \(t \to S_1\)。
- 由于 \(S_1\) 入度非零,那么一定存在一个祖先 \(t\) 入度为零。
- 而 \(t\) 入度为零,那么一定 \(t\) 不在这个序列上。
- 由于图无环,则 \(t\to S_1\) 的路径上的所有点也一定都不在 \(S\) 中。
那么我们就可以把 \(t\to S_1\) 的路径上的所有点加入这个路径,长度一定更长。
证毕。代码:
#include <bits/stdc++.h>
using namespace std;
#define rep(i, n) for (int i = 0; i < n; ++i)
constexpr int N = 1e5 + 10;
int n, m, in[N];
vector<int> g[N];
int dep[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n >> m; int u, v, ans = 0;
rep(i, m) cin >> u >> v, g[u].push_back(v), ++in[v];
queue<int> q; for (int i = 1; i <= n; ++i) if (!in[i]) q.push(i);
while (q.size()) {
u = q.front(), q.pop(), ans = max(ans, dep[u]);
for (int v : g[u]) if (!--in[v]) dep[v] = dep[u] + 1, q.push(v);
} cout << ans << endl;
return 0;
}
AT_dp_h Grid 1
什么时候了还在水橙题。—— AzureHair
经典小学奥数题。每个点可以从其左、上转移过来,考虑填表发。
设 \(f_{i,j}\) 表示到点 \((i,j)\) 的方案数,有 \(f_{i,j}=f_{i-1,j}+f_{j,i-1}\),对于标为 #
的格子跳过就行了。
代码:
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 1010;
constexpr int mod = 1e9 + 7;
int n, m, f[N][N];
string a[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n >> m; for (int i = 1; i <= n; ++i) cin >> a[i], a[i] = "#" + a[i];
f[0][1] = 1; for (int i = 1; i <= n; ++i) for (int j = 1; j <= m; ++j) if (a[i][j] != '#') f[i][j] = (f[i - 1][j] + f[i][j - 1]) % mod;
cout << f[n][m] << endl;
return 0;
}
AT_dp_i Coins
借着期望 DP 的名头评了个绿。其实很简单滴。
考虑每个硬币会有两种可能性,设第一维 \(i\) 表示考虑前 \(i\) 个硬币。
考虑到要统计正面朝上个数严格大于背面朝上个数的概率。
那么第二维要么记录是否可行(作为值),要么记录正面朝上的个数。
简单想想就知道取第二个。设 \(f_{i,j}\) 表示前 \(i\) 个硬币有 \(j\) 个朝上的概率。
转移分类讨论,有 \(f_{i,j}=f_{i-1,j}\times(1-p_i)+f_{i-1,j-1}\times p_i\)。
朴素代码:
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 3010;
int n; double p[N], f[N][N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n; for (int i = 1; i <= n; ++i) cin >> p[i];
f[0][0] = 1; for (int i = 1; i <= n; ++i) f[i][0] = f[i - 1][0] * (1 - p[i]);
for (int i = 1; i <= n; ++i) for (int j = 1; j <= n; ++j) f[i][j] = f[i - 1][j] * (1 - p[i]) + f[i - 1][j - 1] * p[i];
double ans = 0; for (int i = n + 1 >> 1; i <= n; ++i) ans += f[n][i];
printf("%.10lf\n", ans);
return 0;
}
滚动数组代码:
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 3010;
int n; double p[N], f[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n; for (int i = 1; i <= n; ++i) cin >> p[i];
f[0] = 1; for (int i = 1; i <= n; f[0] = f[0] * (1 - p[i++])) for (int j = n; j; --j) f[j] = f[j] * (1 - p[i]) + f[j - 1] * p[i];
double ans = 0; for (int i = n + 1 >> 1; i <= n; ++i) ans += f[i];
printf("%.10lf\n", ans);
return 0;
}
AT_dp_j Sushi
想吃寿司了。这道题不大简单,而且好像题解区一个写的比一个抽象。
考虑到一共有 \(n\) 个盘子,每个盘子最多有 \(4\) 个状态:一、两、三个寿司、没有寿司。
我们可以把其中三个表示到状态中去,则另一个就可以用数学方法得到了。
设状态 \(f_{i,j,k}\) 表示一、两、三个寿司的盘子数量分别为 \(i,j,k\)。
那么对于状态 \(f_{i,j,k}\),没有寿司的盘子数量就是 \(n-i-j-k\) 了。
考虑转移,我们分讨这一次抽到了哪一类的盘子(因为有相同寿司数量的盘子是等价的):
盘子类型 | 概率 | 操作数 |
---|---|---|
一个寿司 | \(\frac{i}{n}\) | \(f_{i-1,j,k}+1\) |
两个寿司 | \(\frac{j}{n}\) | \(f_{i+1,j-1,k}+1\) |
三个寿司 | \(\frac{k}{n}\) | \(f_{i,j+1,k-1}+1\) |
没有寿司 | \(\frac{n-i-j-k}{n}\) | \(f_{i,j,k}+1\) |
于是我们得到了初步的方程式:
然后逐步化简得:
其中,我们把每个括号内的 \(+1\) 都提出来,合并完恰好就是 \(n\),得:
把所有与 \(f_{i,j,k}\) 有关的式子都移到左边:
把左边 \(f_{i,j,k}\) 的系数除到右边:
到了这一步,其实已经可以进行记忆化搜索了,不过这个式子还是有一些性质的。
首先我们发现只有 \(k\) 这一维,转移的时候是单调的,于是我们考虑将 \(k\) 先遍历。
然后发现在 \(k\) 为同一维度的时候,只有 \(j\) 的转移是单调的,于是考虑将 \(j\) 优先于 \(i\) 遍历。
也就是等价于,将 \(k\) 放到第一维,将 \(j\) 放到第二维,将 \(i\) 放到第一维。
于是就可以直接动态规划了。代码:
#include <bits/stdc++.h>
using namespace std;
#define rep(i, n) for (int i = 0; i <= n; ++i)
constexpr int N = 310;
int n, c[4];
double f[N][N][N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n;
for (int i = 1, t; i <= n; ++i) cin >> t, ++c[t];
rep(k, n) rep(j, n) rep(i, n) if (i | j | k) {
double res = n;
if (i) res += i * f[i - 1][j][k];
if (j) res += j * f[i + 1][j - 1][k];
if (k) res += k * f[i][j + 1][k - 1];
f[i][j][k] = res / (1.0 * i + j + k);
} printf("%.14lf\n", f[c[1]][c[2]][c[3]]);
return 0;
}
AT_dp_k Stones
博弈 DP。我的经典弱项,看着就头晕。
当石子数量为 \(0\) 时先手必败,另外还有两个性质:
- 若当前局势可以转到任何一个先手必败的局势则先手必胜。
此时先手可以通过转到先手必败的局势来获胜。 - 若当前局势无法转到任何一个先手必败的局势则先手必败。
此时先手无论转到哪个状态都会使对手必胜。
一个经典思路,考虑设计状态,用状态值 \(0/1\) 表示此状态下先手是否必胜。
这道题里,状态有且仅有石子个数一个,非常显然,我们将这个设计为状态:
设 \(f_i\) 表示在有 \(i\) 个石子的情况下,是否先手必胜。
根据上面的性质,我们知道,对于 \(f_i\),如果存在一个 \(a_k\) 使得 \(f_{i-a_k}=0\):
那么先手可以取走 \(a_k\) 个石子,先手转移给后手,后手必败,即先手必胜。
很好实现。可以先排序一下,代码:
#include <bits/stdc++.h>
using namespace std;
#define rep(i, n) for (int i = 0; i <= n; ++i)
#define range(x) x.begin(), x.end()
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int n, k; cin >> n >> k;
vector<int> a(n); for (int &i : a) cin >> i;
sort(range(a)); vector<int> f(k + 1);
rep(i, k) for (int j : a) {
if (i - j < 0) break;
if (f[i - j] != 0) continue;
f[i] = 1; break;
} puts(f[k] ? "First" : "Second");
return 0;
}
AT_dp_l Deque
经典区间博弈。考虑和区间 DP 相似的状态设计。设 \(f_{i,j}\) 表示区间 \([i,j]\) 内原先手的最大分差。
需要证明,分数差最大,先手策略最优。
先手和后手取数总和一定,分差越大,则分别最大、最小。因此当前先手策略最优。
考虑转移。区间长度为 \(l\),则已经取走了 \((n-l)\),讨论现在是否是原先手取:
- \((n-l)\) 为偶数,则原先手先取:\(f_{i,j}=\max\{f_{i+1,j}+a_i,f_{i,j-1}+a_j\}\)。
- \((n-l)\) 为奇数,则原后手先取:\(f_{i,j}=\min\{f_{i+1,j}-a_i,f_{i,j-1}-a_j\}\)。
答案就是 \(f_{1,n}\)。代码:
#include <bits/stdc++.h>
using namespace std;
#define rep(i, n) for (int i = 1; i <= n; ++i)
using ll = long long;
constexpr int N = 3e3 + 10;
int n, a[N];
ll f[N][N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n; rep(i, n) cin >> a[i];
rep(l, n) rep(i, n - l + 1) {
int j = i + l - 1;
if (n - l & 1) f[i][j] = min(f[i + 1][j] - a[i], f[i][j - 1] - a[j]);
else f[i][j] = max(f[i + 1][j] + a[i], f[i][j - 1] + a[j]);
} cout << f[1][n] << endl;
return 0;
}
AT_dp_m Candies
经典前缀和优化 DP 的思想。
首先列出朴素的转移方程:
容易发现没一层的转移都是由上一层的连续部分转移来的,因此转化原方程:
因此我们记一个数组记录上一层的前缀和以加速转移:
然后可以滚动数组优化。代码:
#include <bits/stdc++.h>
using namespace std;
#define rep(i, l, n) for (int i = l; i <= n; ++i)
#define range(x) x.begin(), x.end()
constexpr int N = 110;
constexpr int K = 1e5 + 10;
constexpr int mod = 1e9 + 7;
int n, k, a[N];
int f[K], sum[K];
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n >> k; rep(i, 1, n) cin >> a[i];
auto xht = [&] (int l, int r) {
if (l == 0) return sum[r];
else return (sum[r] - sum[l - 1] + mod) % mod;
}; f[0] = 1; rep(i, 1, n) {
sum[0] = f[0]; rep(j, 1, k) sum[j] = (sum[j - 1] + f[j]) % mod;
rep(j, 0, k) f[j] = xht(max(0, j - a[i]), j);
} cout << f[k] << endl;
return 0;
}
AT_dp_n Slimes
经典的区间 DP。石子合并问题。
设 \(f_{i,j}\) 表示合并区间 \([i,j]\) 的最小费用。考虑最后一步合并哪两个数。
每一个数一定表示为一个区间,因此我们枚举合并的右侧的区间的的左端点。
然后转移:\(f_{i,j}=f_{i,k-1}+f_{k,j}+s_j-s_{i-1}\)。
先枚举区间长度 \(l\),然后枚举区间左端点 \(i\),则右端点 \(j=i+l-1\)。
代码:
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
#define rep(i, l, r) for (decltype(r) i = l; i <= r; ++i)
#define min(a, b) ((a) < (b) ? (a) : (b))
#define chmin(a, h) a = min(a, h)
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int n; cin >> n; vector<int> a(n + 1); vector<ll> s(n + 1);
rep (i, 1, n) cin >> a[i], s[i] = s[i - 1] + a[i];
vector<vector<ll>> f(n + 1, vector<ll> (n + 1, 0x3f3f3f3f3f3f3f3f));
rep (i, 1, n) f[i][i] = 0;
rep (l, 1, n) rep (i, 1, n - l + 1) rep (k, i + 1, i + l - 1)
chmin(f[i][i + l - 1], f[i][k - 1] + f[k][i + l - 1] + s[i + l - 1] - s[i - 1]);
cout << f[1][n] << endl;
return 0;
}
AT_dp_o Matching
看起来像二分图(网络流,启动!)但是要求方案数。
考虑到 \(n\le21\),大概率是状压 DP。
设 \(f_S\) 表示集合 \(S\) 中的女孩纸去和男娘交配的方案数。喵。
我们枚举集合 \(S\) 里的每一个女孩纸,然后枚举她的心仪男神。
由于需要每个女孩纸都交配完毕。因此可以考虑简化,我们设 \(t=|S|\)。
然后考虑这个男生 \(t\) 可以和哪个女孩纸交配。酱紫转移很好看捏。
然后把她的男生交配给她。即 \(f_S=\sum_{k\in S}f_{S\setminus k}\)(\(a_{k,t}=1\))。
代码:
#include <bits/stdc++.h>
using namespace std;
#define rep(i, l, r) for (decltype(r) i = (l); i <= (r); ++i)
constexpr int mod = 1e9 + 7;
int popc(int t) {
int c = 0;
while (t) t ^= t & -t, ++c;
return c;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int n; cin >> n;
vector<vector<int>> a(n + 1, vector<int> (n + 1));
rep (i, 1, n) rep (j, 1, n) cin >> a[i][j];
vector<int> f(1 << n); f[0] = 1;
rep (S, 1, 1 << n) {
int t = popc(S);
rep (k, 1, n) if (((S >> k - 1) & 1) && a[t][k]) f[S] = (f[S] + f[S ^ (1 << k - 1)]) % mod;
} cout << f[(1 << n) - 1] << endl;
return 0;
}
AT_dp_p Independent Set
树形 DP 板子题。在树上选出若干个点,不选择相邻的点,求方案数。
考虑树形 DP 经典思路:提节点 \(1\) 为根,记录以 \(u\) 为根的子树的方案数。
我们发现一个状态是否能用与这个状态的根节点的颜色有关。
因此加维度,设 \(f_{u,c}\) 表示以 \(u\) 为根的子树,节点 \(u\) 颜色为 \(c\) 的方案数。
转移:\(f_{u,0}=\prod_{u\in\text{son}_u}(f_{v,0}+f_{v,1})\),\(f_{u,1}=\prod_{u\in\text{son}_u}f_{v,0}\)。
其中 ∏ 表示个个子树形态,每个形态选一个,乘法原理得出上述式子。
考虑一遍 DFS,求解出每个点的信息。则最终答案为 \(f_{1,0}+f_{1,1}\).
代码:
#include <bits/stdc++.h>
using namespace std;
#define rep(i, l, r) for (decltype(r) i = (l); i <= (r); ++i)
constexpr int mod = 1e9 + 7;
constexpr int N = 1e5 + 10;
int n;
vector<int> g[N];
inline void add(int u, int v) { g[u].push_back(v); }
inline void Add(int u, int v) { add(u, v), add(v, u); }
int f[N][2];
int dfs(int u, int rt) {
f[u][0] = f[u][1] = 1;
for (int v : g[u]) if (v != rt) {
f[u][0] = (1ll * f[u][0] * dfs(v, u)) % mod;
f[u][1] = (1ll * f[u][1] * f[v][0]) % mod;
} return (f[u][0] + f[u][1]) % mod;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n; int u, v;
rep (i, 1, n - 1) cin >> u >> v, Add(u, v);
cout << dfs(1, -1) % mod << endl;
return 0;
}
AT_dp_q Flowers
经典的数据结构(线段树/树状数组)优化动态规划。
易得转移方程:\(f_i=\max_{j<i}\{f_j(h_j\le h_i)\}+a_i\)。
可以看出 \(f_i\) 只与 \(i\) 左侧的 \(f\) 的满足 \(h_j\le h_i\) 的最大值有关。
因此考虑树状数组优化转移:树状数组记录最大值,键为 \(h_j\),值为 \(f_j\)。
依次加入每个数,对于每一个 \(h_i\),需要找到当前树状数组中键小于等于 \(h_i\) 的最大值。
然后转移 \(f_i\gets f_j+a_i\) 并更新树状数组中的数据。代码:
#include <bits/stdc++.h>
using namespace std;
#define rep(i, l, r) for (decltype(r) i = (l); i <= (r); ++i)
#define lowbit(x) ((x) & -(x))
using ll = long long;
constexpr int N = 2e5 + 10;
ll s[N];
ll query(int x) {
ll r = 0;
for (; x; x -= lowbit(x)) r = max(r, s[x]);
return r;
}
void modify(int x, ll t) {
for (; x < N; x += lowbit(x)) s[x] = max(s[x], t);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int n; ll ans = 0; cin >> n;
vector<int> h(n + 1), a(n + 1);
rep (i, 1, n) cin >> h[i];
rep (i, 1, n) cin >> a[i];
rep (i, 1, n) {
ll t = query(h[i]) + a[i];
modify(h[i], t);
ans = max(ans, t);
} cout << ans << endl;
return 0;
}
AT_dp_r Walk
矩阵乘法优化 DP 的好题。我们记原式的邻接矩阵为 \(A\)。
设 \(F_k(i,j)\) 表示 \(i \to j\) 的 \(k\) 条边路径数量。
易得:\(F_k(i,j)=\sum_{x=1}^n F_{k-\lambda}(i,x)\times F_\lambda(x,j)\),其中 \(\lambda\) 为小于 \(k\) 的任意正整数。
当然,\(\lambda=1\) 时最好做,即 \(F_k(i,j)=\sum_{x=1}^n F_{k-1}(i,x)\times F_1(x,j)\)。
注意到这就是矩阵乘法的形式,即 \(F_k(i,j)=F_{k-1}\times F_1=F_{k-1}\times A\)。
则 \(F_k(i,j)=A^k\),然后就可以用矩阵乘法做了。代码:
#include <bits/stdc++.h>
using namespace std;
#define rep(i, l, r) for (decltype(r) i = (l); i <= (r); ++i)
using ll = long long;
constexpr int mod = 1e9 + 7;
struct emm {
int n; vector<vector<int>> a;
emm() = default;
emm(int n): n(n) { a.resize(n + 1, vector<int>(n + 1)); }
friend emm operator *(const emm &a, const emm &b) {
int n = a.n; emm r(n);
rep (i, 1, n) rep (k, 1, n) rep (j, 1, n)
(r.a[i][j] += (1ll * a.a[i][k] * b.a[k][j] % mod)) %= mod;
return r;
}
};
emm qpow(emm a, ll k) {
emm r = a; --k;
while (k) {
if (k & 1) r = r * a;
a = a * a, k >>= 1;
} return r;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int n; ll k; cin >> n >> k; emm a(n);
rep (i, 1, n) rep (j, 1, n) cin >> a.a[i][j];
emm q = qpow(a, k); int ans = 0;
rep (i, 1, n) rep (j, 1, n) ans = (ans + q.a[i][j]) % mod;
cout << ans << endl;
return 0;
}
AT_dp_s Digit Sum
经典数位 DP。设 \(f_{\mathit{pos},\mathit{limit},r}\) 表示考虑前 \(i\) 位,是否贴近上限,模 \(d\) 余 \(r\) 的数量。
考虑转移,显然 \(f_{p,l,r}\gets f_{e_p,l\land(k=u),r+k\bmod d}\)。这玩意比较抽象,看代码吧:
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr int N = 1e4 + 10;
constexpr int K = 110;
constexpr int mod = 1e9 + 7;
int n, d;
string k;
int mem[N][K];
int dfs(int pos, int limit, int r) {
if (pos == n) return r == 0;
if (!limit && mem[pos][r] != -1) return mem[pos][r];
int res = 0, up = limit ? k[pos] - '0' : 9;
rep (i, 0, up) res = (res + dfs(pos + 1, limit && (i == up), (r + i) % d)) % mod;
if (!limit) mem[pos][r] = res;
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> k >> d; n = k.size();
memset(mem, -1, sizeof mem);
cout << (dfs(0, 1, 0) - 1 + mod) % mod << endl;
return 0;
}
AT_dp_t Permutation
比较经典的前缀和优化离线转移吧。
设 \(f_{i,j}\) 表示填完前 \(i\) 个数,第 \(i\) 个数为 \(j\) 的方案数,则易得转移方程:
然后考虑前缀和优化,设 \(g_{i,j}=\sum_{k=1}^j f_{i,k}\),则转移:
然后就很简单了。代码:
#include <bits/stdc++.h>
using namespace std;
#define rep(i, l, r) for (decltype(r) i = l; i <= r; ++i)
constexpr int mod = 1e9 + 7;
using ll = long long;
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int n, sum = 0; string s;
cin >> n >> s; s = "#" + s;
vector<int> f(n + 1), g(n + 1);
f[1] = 1; rep (i, 2, n) {
rep (j, 1, i - 1) g[j] = (g[j - 1] + f[j]) % mod;
if (s[i - 1] == '<') rep (j, 1, i) f[j] = g[j - 1];
else rep (j, 1, i) f[j] = (g[i - 1] - g[j - 1] + mod) % mod;
} rep (i, 1, n) sum = (sum + f[i]) % mod;
return cout << sum << endl, 0;
}
AT_dp_u Grouping
根据 \(n\le16\) 可知,状压 DP。
如何设计状态?考虑动态规划最常见的设计方法:将所有信息都记录在状态内。
对于这道题,信息只有两个,谁属于哪个集合,谁不属于哪个集合。
于是考虑设 \(f_S\) 表示对集合 \(S\) 进行划分,可以得到的最大收益。
首先对于集合 \(S\) 一定存在一个基本的收益,即集合内的所有元素同属一个组。
我们将这个信息先记录下来,然后考虑还有什么更优化的可能。
我们可以将集合 \(S\) 分为若干部分,然后加在一起,那么怎么划分呢。
我们分治的考虑,将集合一分为二,再递归考虑,确保每一个集合都被最优化的分割。
转移方程 \(f_S=\max_{T\in S}\{f_T+f_{S\setminus T}\}\)。然后就是基本的状压了,代码:
#include <bits/stdc++.h>
using namespace std;
#define rep(i, l, r) for (decltype(r) i = l; i <= r; ++i)
using ll = long long;
constexpr int N = 20;
int n, a[N][N];
ll f[1 << N];
ll dfs(int s) {
if (f[s] != -1) return f[s];
ll res = 0;
rep (i, 1, n) if ((s >> i - 1) & 1)
rep (j, i + 1, n) if ((s >> j - 1) & 1) res += a[i][j];
for (int i = (s - 1) & s; i; i = (i - 1) & s)
res = max(res, dfs(i) + dfs(s ^ i));
return f[s] = res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n; rep (i, 1, n) rep (j, 1, n) cin >> a[i][j];
memset(f, -1, sizeof f); f[0] = 0;
cout << dfs((1 << n) - 1) << endl;
return 0;
}
AT_dp_v Subtree
换根 DP 板子题。
考虑先设 \(1\) 为根节点,设 \(f_u\) 表示 \(u\) 的子树答案。
非常好转移:\(f_u=\prod(f_v+1)\),其中 \(v\) 表示 \(u\) 的儿子。
然后考虑换根,设 \(g_u\) 表示 \(u\) 向上祖先为子树的答案。
也比较好转移:\(g_u=g_p\times\prod(f_v+1)+1\)。
其中 \(p\) 表示 \(u\) 的父亲,\(v\) 表示 \(u\) 的兄弟。
那么一个节点 \(u\) 的答案最终为 \(f_u\times g_u\)。
但是这个是 \(\mathcal O(n^2)\) 的,考虑优化。
有一个简单的思路:预处理节点 \(u\) 的兄弟的前缀、后缀积。
那么:转移表示为 \(g_u=g_p\times P_u\times S_v+1\)。
没了?没了。注意取模就行了。代码:
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 1e5 + 10;
int n, m;
vector<int> e[N];
int f[N], g[N];
int pre[N], suf[N];
void init(vector<int> &son) {
int p1 = 1, s1 = 1, q = son.size(), t;
for (int i = 0; i < q; ++i) t = son[i], pre[t] = p1, p1 = 1ll * p1 * (f[t] + 1) % m;
for (int i = q - 1; ~i; --i) t = son[i], suf[t] = s1, s1 = 1ll * s1 * (f[t] + 1) % m;
}
void dfs1(int u, int fa) {
f[u] = 1; vector<int> son;
for (int v : e[u]) if (v != fa) dfs1(v, u), f[u] = 1ll * f[u] * (f[v] + 1) % m, son.push_back(v);
init(son);
}
void dfs2(int u, int fa) {
if (fa == -1) g[u] = 1;
else g[u] = ((1ll * g[fa] * pre[u] % m) * suf[u] % m + 1) % m;
for (int v : e[u]) if (v != fa) dfs2(v, u);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int u, v; cin >> n >> m;
for (int i = 0; i < n - 1; ++i) cin >> u >> v, e[u].push_back(v), e[v].push_back(u);
dfs1(1, -1), dfs2(1, -1);
for (int i = 1; i <= n; ++i) cout << 1ll * f[i] * g[i] % m << endl;
return 0;
}
AT_dp_w Intervals
线段树优化转移的经典套路。但是好像很多题解写的很复杂?
设 \(f_r\) 表示考虑前 \(r\) 个字符,且强制第 \(r\) 个字符为 \(1\) 的最大分数。
转移很简单,考虑上一个 \(1\) 的位置 \(l\),那么 \(f_r\) 就是 \(f_{l-1}\) 再加上 \([l,r]\) 的贡献。
也就是 \(f_r=\max\limits_{l=1}^{r-1}\{f_{l-1}+\sum_{k,l\le l_k\le r\le r_k}a_k\}\)。
这个复杂度显然爆炸。然后发现状态设计已经无法优化了,那么优化转移。
考虑计算每个 \(a_k\) 可能存在哪些贡献。显然我们就需要固定区间右端点。
也就是只在枚举到某一个贡献的区间右端点的时候才去考虑其贡献对于其左边的贡献。
之所以处理更新 \(f_r\) 时右端为 \(r\) 的部分,是因为 \(f_0\) 到 \(f_r\) 不再被之前的 \(f\) 更新。
如果对每个元素应用 \(+a\),然后由前一个 \(f\) 更新,则存在 \(+a\) 被处理多次的风险。
那么每一个右端点为 \(r\) 的 \(a_k\) 都应该被其计入贡献,也就是区间加上 \(a_k\) 的贡献。
考虑到我们按照右端点递增处理,因此可以直接将区间 \([l_k,r_k]\) 加入其贡献 \(a_k\)。
考虑需要什么数据结构来维护:
我们需要再一个序列 \(f\) 上区间加、区间求最大值,因此使用线段树。
线段树也不难调,整体来说就是状态转移有点难度,代码:
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr int N = 2e5 + 10;
int n, m;
struct node {
int l, a;
node() = default;
node(int l, int a): l(l), a(a) {}
};
vector<node> q[N];
class segment {
struct emm {
int l, r;
ll s, tag;
};
vector<emm> a;
void build(int k, int l, int r) {
a[k].l = l, a[k].r = r;
if (l == r) return void(a[k].s = a[k].tag = 0);
int mid = l + r >> 1;
build(k * 2, l, mid), build(k * 2 + 1, mid + 1, r);
}
void push_up(int k) {
a[k].s = max(a[k * 2].s, a[k * 2 + 1].s);
}
void action(int k, ll v) {
a[k].tag += v;
a[k].s += v;
}
void push_down(int k) {
if (a[k].tag == 0) return;
action(k * 2, a[k].tag);
action(k * 2 + 1, a[k].tag);
a[k].tag = 0;
}
void modify(int k, int p, int q, ll v) {
int l = a[k].l, r = a[k].r;
if (r < p || l > q) return;
if (l >= p && r <= q) return void(action(k, v));
push_down(k);
modify(k * 2, p, q, v), modify(k * 2 + 1, p, q, v);
push_up(k);
}
ll query(int k, int p, int q) {
int l = a[k].l, r = a[k].r;
if (r < p || l > q) return -1e9;
if (l >= p && r <= q) return a[k].s;
push_down(k);
return max(query(k * 2, p, q), query(k * 2 + 1, p, q));
}
public:
segment(int n) { a.resize(n << 3); build(1, 1, n); }
void add(int l, int r, ll x) { modify(1, l, r, x); }
ll maxx(int l, int r) { return query(1, l, r); }
};
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= m; ++i) {
int l, r, a; cin >> l >> r >> a;
q[r].push_back(node(l, a));
}
segment seg(n);
for (int r = 1; r <= n; ++r) {
ll opt = seg.maxx(1, r);
seg.add(r, r, max(opt, 0ll));
for (node p : q[r]) seg.add(p.l, r, p.a);
}
cout << max(seg.maxx(1, n), 0ll) << endl;
return 0;
}
AT_dp_x Tower
技能:动态规划(背包)、贪心(交换论证)。
每个物品有承重限制和质量。我们猜测一定存在一定的顺序,用于贪心的考虑谁一定在上或在下。
这就是经典的交换论证了(Exchange Arguments),我们考虑相邻的两个物品 \(i,j\):
- 如果 \(i\) 在 \(j\) 的上面,那么这两个物品组成的块,上面还可以放置重 \(\min\{s_i,s_j-w_i\}\) 的物品。
- 如果 \(j\) 在 \(i\) 的上面,那么这两个物品组成的块,上面还可以放置重 \(\min\{s_j,s_i-w_j\}\) 的物品。
我们记 \(f(a,b)=\min\{s_a,s_b-w_a\}\) 表示 \(a\) 在 \(b\) 的上面时的权值。
那么 \(i\) 放在 \(j\) 的上面,有 \(f(i,j)>f(j,i)\),那么我们可以以此作为关键字排序。
我们已经确定了物品的确定性顺序,然后考虑每一个物品是否选择,且满足承重限制。
这就是普通的 01 背包了。设 \(f_i\) 表示当前质量是 \(i\) 的最大价值。
顺序考虑每一个物品。这个物品对答案的贡献可以从 \(f_j\) 转移当且仅当 \(j\leq s_i\)。
我们倒序枚举这个 \(j\) 然后转移 \(f_{j+w_i} \gets f_j+v_i\)。
代码:
#include <bits/stdc++.h>
using namespace std;
#define range(x) x.begin(), x.end()
#define endl '\n'
struct emm {
int w, s, v;
friend bool operator <(const emm &a, const emm &b) {
return min(a.s, b.s - a.w) > min(b.s, a.s - b.w);
}
};
using ll = long long;
constexpr int M = 2e4 + 10;
ll dp[M];
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int n; cin >> n; vector<emm> a(n);
for (auto &[w, s, v] : a) cin >> w >> s >> v;
sort(range(a));
for (auto &[w, s, v] : a) for (int i = s; ~i; --i) dp[i + w] = max(dp[i + w], dp[i] + v);
cout << *max_element(dp, dp + M) << endl;
return 0;
}
AT_dp_y Grid 2
难。首先,我们明确两件事:
从点 \((x_1,y_1)\)(左上)无障碍的走到 \((x_2,y_2)\) 的方案数有 \(\binom{x_2-x_1+y_2-y_1}{x_2-x_1}\)。
我们设函数 \(g(x_1,y_1,x_2,y_2)\) 表示这个方案数,即 \(g(x_1,y_1,x_2,y_2)=\binom{x_2-x_1+y_2-y_1}{x_2-x_1}\)。
那么,从 \((1,1)\) 走到 \((x,y)\) 的方案数有 \(\binom{x+y-2}{x-1}\)。
我们重载函数 \(g(x,y)\) 表示这个方案数,即 \(g(x,y)=\binom{x+y-2}{x-1}\)。
那么我们可以很快得到一个容斥原理的思路:
用 \((1,1)\) 到 \((H,W)\) 的方案,减去到障碍的方案,再加上多减去的,再。。。。。。
复杂度爆炸。我们换思路。既然不能从坐标去考虑障碍,那么我们就从障碍去考虑坐标。
设 \(f_i\) 表示从 \((1,1)\) 走到第 \(i\) 个障碍处的方案数,那么就比较好转移了喵。
易得 \(f_i=g(x_i,y_i)-\sum_{j<i}(f_j\times g(x_j,y_j,x_i,y_i))\)。
然后就只需要线性求逆元,以及预处理逆元求组合数就可以了。代码:
#include <bits/stdc++.h>
using namespace std;
#define range(x) x.begin(), x.end()
#define rep(i, l, r) for (decltype(r) i = l; i <= r; ++i)
#define per(i, r, l) for (decltype(l) i = r; i >= l; --i)
using ll = long long;
constexpr int mod = 1e9 + 7;
constexpr int Q = 2e5 + 10;
int h, w, n;
struct point {
int x, y;
point() = default;
point(int x, int y): x(x), y(y) { }
friend bool operator <(const point &a, const point &b) { return a.x == b.x ? a.y < b.y : a.x < b.x; }
};
vector<point> a;
int s[Q], sv[Q], inv[Q];
inline int qpow(int a, int b) {
int r = 1; for (; b; b >>= 1) {
if (b & 1) r = 1ll * r * a % mod;
a = 1ll * a * a % mod;
} return r;
}
void init() {
int q = h + w; s[0] = 1;
rep (i, 1, q) s[i] = 1ll * s[i - 1] * i % mod;
sv[q] = qpow(s[q], mod - 2);
per (i, q, 1) sv[i - 1] = 1ll * sv[i] * i % mod;
rep (i, 1, q) inv[i] = 1ll * sv[i] * s[i - 1] % mod;
}
inline int comb(int n, int m) { return 1ll * s[n] * sv[m] % mod * sv[n - m] % mod; }
inline int g(point a) { return comb(a.x + a.y - 2, a.x - 1); }
inline int g(point a, point b) { return comb(b.x - a.x + b.y - a.y, b.x - a.x); }
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> h >> w >> n; a.resize(n + 1);
init(); a[0] = point(h, w);
rep (i, 1, n) cin >> a[i].x >> a[i].y;
sort(range(a)); vector<int> f(n + 1);
rep (i, 0, n) { f[i] = g(a[i]);
rep (j, 0, i - 1) if (a[j].y <= a[i].y)
f[i] = (f[i] - 1ll * f[j] * g(a[j], a[i]) % mod + mod) % mod;
} cout << f[n] << endl;
return 0;
}
AT_dp_z Frog 3
斜率优化板子。
首先,暴力的 DP 很好想:设 \(f_i\) 表示到 \(i\) 为止的最小花费。
考虑转移,也很简单:\(f_i=\min_{j=1}^{i-1}\{f_j+(h_i-h_j)^2+c\}\)。
然后就是斜率优化的套路:
当 \(j\) 是从 \(i\) 转移来的时候,有 \(f_i=f_j+(h_i-h_j)^2+c\)。
整理(把所有仅与 \(j\) 有关的项移到左边,其他的放在右边,仅与 \(i\) 有关的再单列),
得 \(f_j+{h_j}^2=2h_ih_j+f_i-{h_i}^2-c\)。
考虑化为一次函数的形式,即设 \(y_j=f_j\),\(x_j=h_j\),
则对应的,\(k_i=2h_i\),\(b_i=f_i-{h_i}^2-c\)。
即得 \(y_j=k_ix_j+b_i\)。然后考虑将 \((x_j,y_j)\) 视为决策点,对于每一个 \(i\) 寻找最优决策点:
考虑到 \(k_i\) 与 \(x_j\) 都是单调递增的,于是用单调队列维护一个下凸壳。
然后按照斜率优化的套路:
弹出队列当且仅当该斜率对于答案无贡献,加入时保证凸壳斜率递增。
然后就很简单了。代码:
#include <bits/stdc++.h>
using namespace std;
#define int ll
#define rep(i, l, r) for (decltype(r) i = l; i <= r; ++i)
using ll = long long;
constexpr int N = 2e5 + 10;
int n, c, h[N];
int q[N], f[N];
inline int X(int j) { return h[j]; }
inline int Y(int j) { return f[j] + h[j] * h[j]; }
inline double K(int i, int j) { return 1.0 * (Y(j) - Y(i)) / (X(j) - X(i)); }
inline int V(int i, int j) { return f[j] + (h[i] - h[j]) * (h[i] - h[j]) + c; }
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cin >> n >> c; rep (i, 1, n) cin >> h[i];
int st = 0, ed = 1;
q[ed++] = 1, f[1] = 0;
rep (i, 2, n) {
while (st + 1 < ed && K(q[st], q[st + 1]) <= 2.0 * h[i]) ++st;
f[i] = V(i, q[st]);
while (st + 1 < ed && K(q[ed - 2], q[ed - 1]) >= K(q[ed - 1], i)) --ed;
q[ed++] = i;
} cout << f[n] << endl;
return 0;
}
后记
这些题整体来说不算太难。只是有的题比较难调。
而且有些题都是板子。对于复习板子很有帮助我感觉。
PS:下面这些话纯纯是为了凑齐文章源码 \(1500\) 行 /cf。
本文来自博客园,作者:RainPPR,转载请注明原文链接:https://www.cnblogs.com/RainPPR/p/18060750/at_dp
如有侵权请联系我(或 2125773894@qq.com)删除。