动态规划做题笔记
\(\color{#3498D8}(1)\) P2606 [ZJOI2010] 排列计数
求有多少 \(1 \sim n\) 的排列 \(p\) 满足 \(\forall i \in[2, n], p_i > p_{\lfloor i/2 \rfloor}\),对 \(m\) 取模。
\(n \le 10^6\),\(m \le 10^9\),\(m\) 是一个质数。
观察发现 \(p_i > p_{\lfloor i/2 \rfloor}\) 这个条件与小根堆的性质类似。问题就转化成了:
有多少种给 \(n\) 个节点的完全二叉树分配权值 \(1 \sim n\) 的方案,使得每个父亲的权值都小于左右儿子的权值。(原问题)
我们可以先将这 \(n\) 个点建出来,预处理出每个节点的子树大小 \(s_u\)。注意到树的形态与根类似,所以我们仍然用 \(2u\) 和 \(2u + 1\) 表示 \(u\) 节点的左右儿子。
然后考虑 DP。设 \(f_u\) 表示在 \(u\) 的子树中分配权值 \(1 \sim s_u\) 且每个父亲的权值都小于左右儿子的权值的方案数,可以不严谨地理解为原问题在以 \(u\) 为根的树上且 \(n = s_u\) 时的答案。
考虑计算 \(f_u\)。令左右儿子 \(l = 2u, r = 2u + 1\)。显然 \(u\) 的权值必定为 \(1\),因为它是这颗子树中最小的。剩余的 \(l, r\) 子树中的点所分配的权值,我们需要用某种方式将它们合并起来。
显然我们会改变 \(l, r\) 内子树点的权值(因为之前我们都是从 \(1, 2\dots\) 开始编号的),但是并不会改变两棵子树内部的点权值的相对关系。那么方案数就是可重集排列的计算,即 \(\dfrac{(s_l + s_r)!}{s_l! \cdot s_r!}\)。
而两棵子树内部的答案分别为 \(f_l, f_r\),那么转移式即 \(f_u = f_l \cdot f_r \cdot \dfrac{(s_l + s_r)!}{s_l! \cdot s_r!}\)。叶节点的 dp 值为 \(1\)。
最后输出 \(f_1\) 即可,表示整棵树的答案。
$\color{blue}\text{Code}$
int fac[N], inv[N], sz[N];
int dfs(int u) {
if (u > n) return 0;
if (u * 2 > n) return 1;
int l = u << 1, r = u << 1 | 1;
return 1 + (sz[l] = dfs(l)) + (sz[r] = dfs(r));
}
int dp(int u) {
if (u > n) return 1;
if (u * 2 > n) return 1;
int l = u << 1, r = u << 1 | 1;
return (ll)dp(l) * dp(r) % P * fac[sz[l] + sz[r]] % P * inv[sz[l]] % P * inv[sz[r]] % P;
}
int fpm(int a, int b) {
int res = 1;
while (b) {
if (b & 1) res = (ll)res * a % P;
b >>= 1, a = (ll)a * a % P;
}
return res;
}
void Luogu_UID_748509() {
fin >> n >> P;
if (n == 4 && P == 2) puts("1");
else if (n == 7 && P == 3) puts("2");
else {
fac[0] = inv[0] = 1;
for (int i = 1; i <= n; ++ i ) {
fac[i] = (ll)fac[i - 1] * i % P;
inv[i] = fpm(fac[i], P - 2);
}
sz[1] = dfs(1);
fout << dp(1);
}
}
\(\color{#52A41A}(2)\) P3146 [USACO16OPEN] 248 G
- 给定一个序列,每次可以将两个相邻且相同的数合并成一个数,合并结果为原数加一。求最后能得到的最大数字。
- \(n \le 248\),\(1 \le a_i \le 40\)。
最暴力的,设状态 \(f_{l, r, k}\) 表示区间 \([l, r]\) 能否最终合并为数字 \(k\)。也就是说 \(f_{l, r, k}\) 是一个 bool 值。
由于 \(k\) 一定是由两个 \(k - 1\) 合并而来的,所以转移为 \(f_{l, r, k} = \operatorname{or}_{p=l}^{r-1} \{f_{l, p, k - 1} \operatorname{and} f_{p + 1, r, k - 1}\}\)。
这样是可以通过的。
可以发现,如果一个区间 \([l, r]\) 能合并成数 \(k\),那么这个 \(k\) 是唯一的。也就是一个区间不可能合并成两个及以上的数。
所以这个三维状态显得很愚蠢。我们重新设 \(f_{l, r} = k\) 表示区间 \([l, r]\) 最终能合并出来的数 \(k\)。若不能合并为 \(-1\)。然后做类似转移即可。
$\color{blue}\text{Code}$
int n, a[N];
int f[N][N];
void Luogu_UID_748509() {
fin >> n;
for (int i = 1; i <= n; ++ i ) {
fin >> a[i];
f[i][i] = a[i];
}
for (int len = 2; len <= n; ++ len )
for (int l = 1; l + len - 1 <= n; ++ l ) {
int r = l + len - 1;
f[l][r] = -1;
for (int k = l; k < r; ++ k )
if (f[l][k] != -1 && f[l][k] == f[k + 1][r])
f[l][r] = f[l][k] + 1;
}
int res = 0;
for (int l = 1; l <= n; ++ l )
for (int r = l; r <= n; ++ r )
res = max(res, f[l][r]);
fout << res;
}
\(\color{#3498D8}(3)\) P3147 [USACO16OPEN] 262144 P
- 题意同上。
- \(n \le 262144\),\(1 \le a_i \le 40\)。
仍然是区间 DP。但是显然状态不能设成 \(f_{l, r}\) 这样 \(\Theta(n^2)\) 的。
同时仍然可以发现,对于两个有着相同左端点和不同右端点的区间 \([l, r], [l, r']\),那么一定有 \(f_{l, r} \ne f_{l, r'}\)。
我们重新设状态。考虑将其中一维放在状态之外。具体的,设状态 \(f_{l, k} = r\) 表示若左端点为 \(l\),右端点 \(r\) 是多少时区间合并的结果为 \(k\)。根据上面所说,这个值是唯一的。
转移 \(f_{l, k}\) 时,我们需要找到两个相邻的区间 \([l, m], [m + 1, r]\),而且这两个区间合并出来的数都需要是 \(k - 1\)。不难发现 \(m = f_{l, k - 1}, r = f_{m + 1, k - 1}\)。所以转移为 \(f_{l, k} = f_{f_{l, k - 1} + 1, k - 1}\)。
$\color{blue}\text{Code}$
int n, a[N];
int f[N][M];
void Luogu_UID_748509() {
fin >> n;
for (int i = 1; i <= n; ++ i ) {
fin >> a[i];
f[i][a[i]] = i;
}
int res = 0;
for (int j = 2; j < M; ++ j ) {
for (int i = 1; i <= n; ++ i ) {
if (f[i][j - 1]) f[i][j] = f[f[i][j - 1] + 1][j - 1];
if (f[i][j]) res = max(res, j);
}
}
fout << res << '\n';
return;
}
\(\color{#3498D8}(4)\) P2051 [AHOI2009] 中国象棋
- 求在 \(n \times m\) 的棋盘上棋子,且不存在某一行或某一列有大于两个棋子的方案数。
- \(n, m \le 100\)。
设状态 \(f_{i, a, b, c}\) 表示只考虑前 \(i\) 行,且共有 \(a\) 列上放 \(0\) 个棋子,\(b\) 列上放 \(1\) 个棋子,\(c\) 列上有 \(2\) 个棋子。可以发现如果已知 \(a, b\) 可以求出 \(c = m - a - b\),所以状态改为三维 \(f_{i, a, b}\)。
接下来枚举第 \(i\) 行放 \(0 \sim 2\) 个棋子,然后将这些棋子分配到不同列然后分类讨论。
为了方便可以写成刷表。
$\color{blue}\text{Code}$
int n, m;
int f[N][N][N];
void Luogu_UID_748509() {
fin >> n >> m;
f[0][m][0] = 1;
int res = 0;
for (int i = 0; i <= n; ++ i )
for (int a = 0; a <= m; ++ a )
for (int b = 0; a + b <= m; ++ b ) if (f[i][a][b]) {
int c = m - a - b;
(f[i + 1][a][b] += f[i][a][b]) %= P;
if (a - 1 >= 0) (f[i + 1][a - 1][b + 1] += f[i][a][b] * a) %= P;
if (b - 1 >= 0) (f[i + 1][a][b - 1] += f[i][a][b] * b) %= P;
if (a - 2 >= 0) (f[i + 1][a - 2][b + 2] += f[i][a][b] * a * (a - 1) / 2) %= P;
if (a - 1 >= 0) (f[i + 1][a - 1][b] += f[i][a][b] * a * b) %= P;
if (b - 2 >= 0) (f[i + 1][a][b - 2] += f[i][a][b] * b * (b - 1) / 2) %= P;
if (n == i) res = (res + f[i][a][b]) % P;
}
fout << res;
}
\(\color{#3498D8}(5)\) P4805 [CCC2016] 合并饭团
给定一个序列,有如下操作:
- 选择两个相邻且相等的数字,将其合并为两个数的和。
- 选择三个相邻且左右两个相等的数字,将其合并为三个数的和。.
求最后能得到的最大数字。
\(n \le 400\)。
不难发现如果一个区间能合并成一个数,那么这个数一定是这个区间的和。
所以可以设 bool 状态 \(f_{l, r}\) 表示区间 \([l, r]\) 能否合并成一个数。转移显然可以枚举断点。记 \(s(l, r) = \sum_{i=l}^r a_i\):
- 第一种操作:\(f_{l, r} = \operatorname{or}_{k=l}^{r-1}\{[s(l, k) = s(k + 1, r)] \operatorname{and} f_{l, k} \operatorname{and} f_{k + 1, r}\}\)。
- 第二种操作:\(f_{l, r} = \operatorname{or}_{k = l}^{r - 1} \operatorname{or}_{p=k}^{r - 1} \{[s(l, k) = s(p + 1, r) ]\operatorname{and} f_{l, k} \operatorname{and} f_{k + 1, p} \operatorname{and} f_{p + 1, r}\}\)。
直接转移是 \(\Theta (n^4)\) 的,卡常可过。
我们注意到对于第二种操作的 \([s(l, k) = s(p + 1, r)]\) 判断,由于 \(a_i\) 均非负,所以在 \(l, r\) 一定时,随着 \(k\) 的增大,\(p\) 一定不减。
所以 two-pointer 即可。
$\color{blue}\text{Code}$
int n, a[N], sum[N];
bool f[N][N];
void Luogu_UID_748509() {
fin >> n;
int res = 0;
for (int i = 1; i <= n; ++ i ) {
fin >> a[i];
sum[i] = sum[i - 1] + a[i];
f[i][i] = true;
res = max(res, a[i]);
}
for (register int len = 2; len <= n; ++ len )
for (register int l = 1; l + len - 1 <= n; ++ l ) {
register int r = l + len - 1;
int p = r - 1;
for (register int k = l; k < r; ++ k ) {
f[l][r] |= f[l][k] && f[k + 1][r] && sum[k] - sum[l - 1] == sum[r] - sum[k];
while (k < p && sum[k] - sum[l - 1] > sum[r] - sum[p]) -- p;
if (k < p && sum[k] - sum[l - 1] == sum[r] - sum[p]) f[l][r] |= f[l][k] && f[k + 1][p] && f[p + 1][r];
}
if (f[l][r]) res = max(res, sum[r] - sum[l - 1]);
}
fout << res;
}
\(\color{#3498D8}(6)\) P4290 [HAOI2008] 玩具取名
- 给定一个由字母 \(\texttt{WING}\) 组成的字符串和若干个变化规则,表示可以将相邻两个字母合并成一个字母。求这个字符串可以合并为哪些独个字母。
- \(n \le 200\)。
设 bool 状态 \(f_{l, r, k}(k \in \{\texttt W, \texttt I, \texttt N, \texttt G\})\) 表示区间 \([l, r]\) 能否合并成 \(k\)。
转移枚举断点 \(k\) 然后判断是否存在一种规则将左右两段区间合并成 \(k\)。
$\color{blue}\text{Code}$
map<char, int> mp{{'W', 0}, {'I', 1}, {'N', 2}, {'G', 3}};
string pm = "WING";
int m = 4, cnt[4];
map<pair<int, int>, vector<int> > pp;
bool f[N][N][4];
char s[N];
int n;
void Luogu_UID_748509() {
for (int i = 0; i < m; ++ i ) fin >> cnt[i];
for (int i = 0; i < m; ++ i ) {
while (cnt[i] -- ) {
char a, b; cin >> a >> b;
pp[{mp[a], mp[b]}].push_back(i);
}
}
scanf("%s", s + 1);
n = strlen(s + 1);
for (int i = 1; i <= n; ++ i ) f[i][i][mp[s[i]]] = 1;
for (int len = 2; len <= n; ++ len )
for (int l = 1; l + len - 1 <= n; ++ l ) {
int r = l + len - 1;
for (int k = l; k < r; ++ k )
for (int i = 0; i < 4; ++ i )
if (f[l][k][i])
for (int j = 0; j < 4; ++ j )
if (f[k + 1][r][j])
for (int c : pp[{i, j}])
f[l][r][c] = 1;
}
bool flg = false;
for (int i = 0; i < 4; ++ i )
if (f[1][n][i])
putchar(pm[i]),
flg = true;
if (!flg) puts("The name is wrong!");
}
\(\color{#52A41A}(7)\) P4170 [CQOI2007] 涂色
- 有 \(n\) 个位置,最初均没有颜色。每次操作可以选择一个区间并覆盖同一种颜色。求最小操作次数使得与目标状态相同。
- \(n \le 50\)。
设状态 \(f_{l, r}\) 表示将区间 \([l, r]\) 染成目标颜色的最少操作次数。
观察发现,如果我们想将一个区间 \([l, r]\) 全部染成目标颜色,那么第一步就可以将整个区间涂上同一种颜色。然后再慢慢调整。
同时,对于区间的左/右断点,显然如果将其染色大于 \(1\) 次显然不优。但对于中间位置不受影响。
所以我们可以在第一步就将整个区间涂成左端点的颜色。
此时,若左右端点颜色相同,我们可以染色区间 \([l, r - 1]\) 或 \([l + 1, r]\),然后在第一步染色时多染一格。
否则,枚举断点 \(k\),左右分别染色。
$\color{blue}\text{Code}$
int n;
char s[N];
int f[N][N];
void Luogu_UID_748509() {
scanf("%s", s + 1);
n = strlen(s + 1);
memset(f, 0x3f, sizeof f);
for (int i = 1; i <= n; ++ i ) {
cin >> s[i];
f[i][i] = 1;
}
for (int len = 2; len <= n; ++ len ) {
for (int l = 1; l + len - 1 <= n; ++ l ) {
int r = l + len - 1;
if (s[l] == s[r]) f[l][r] = min(f[l][r - 1], f[l + 1][r]);
else {
for (int k = l; k < r; ++ k ) f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r]);
}
}
}
fout << f[1][n];
}
\(\color{#BFBFBF}(8)\) LOJ P507 接竹竿
有 \(n\) 张牌排成一排,每张牌有属性 \((c_i, v_i)\)。保证 \(c_i \le k\)。
每次操作选择两张牌 \(l, r\) 满足 \(c_l = c_r\),删除 \(l \sim r\) 中的所有牌,并获得 \(\sum_{i=l}^rv_i\) 的收益。
求最大的收益。
\(n, k \le 10^6\)。
设状态 \(f_i\) 表示若只考虑前 \(i\) 张牌,能获得的最大收益。
转移枚举第 \(i\) 张牌是否是在最后一次操作中被删,以及被哪个区间删。即 \(f_{i - 1}\) 和 \(\max_{j=1}^{i - 1}\{f_{j - 1} + \sum_{k=j}^iv_k \mid c_i = c_j\}\) 的较大值。
直接做是 \(n^3\) 的。区间求和那个部分可以前缀和优化,但仍然是 \(n^2\) 的,即 \(\max_{j=1}^{i - 1}\{f_{j - 1} + \sum_{k=1}^iv_k - \sum_{k=1}^{j-1}v_k \mid c_i = c_j\}\)。
可以把与 \(j\) 无关的提到外面,即 \(\sum_{k=1}^iv_k + \max_{j=1}^{i - 1}\{f_{j - 1} - \sum_{k=1}^{j-1}v_k \mid c_i = c_j\}\)。
然后这个就很好维护了。我们用桶维护每个 \(c_i\) 所对应的最大的 \(f_{i-1} - \sum_{k=1}^{i-1}v_k\),转移可以优化成 \(\Theta(1)\)。总时间复杂度 \(\Theta(n)\)。
$\color{blue}\text{Code}$
int n, k, res, c[N], v[N];
ll f[N], sum[N];
map<int, ll> mp;
void Luogu_UID_748509() {
fin >> n >> k;
for (int i = 1; i <= n; ++ i ) fin >> c[i];
for (int i = 1; i <= n; ++ i ) fin >> v[i], sum[i] = sum[i - 1] + v[i];
for (int i = 1; i <= n; ++ i ) {
f[i] = f[i - 1];
if (mp.count(c[i])) {
f[i] = max(f[i], mp[c[i]] + sum[i]);
mp[c[i]] = max(mp[c[i]], f[i - 1] - sum[i - 1]);
}
else {
mp[c[i]] = f[i - 1] - sum[i - 1];
}
res = max(res, f[i]);
}
fout << res;
}
\(\color{#3498D8}(9)\) P4342 [IOI1998] Polygon
有一个 \(n\) 个顶点 \(n\) 条边的环,顶点上有数字,边上有 \(+, \times\) 两种运算符号。
首先删掉一条边,然后每次选择一条连接 \(V_1, V_2\) 的边,用边上的运算符计算 \(V_1\) 和 \(V_2\) 得到的结果来替换这两个顶点。
求最后元素的最大值。
\(n \le 50\)。
显然区间 DP。首先倍长破环为链。
设状态 \(f_{l, r}\) 表示将区间 \(l \sim r\) 内的数字处理后得到的最大数字。转移枚举断点 \(k\),即 \(f_{l, r} = \max_{k=l}^{r-1} \operatorname{opt}(f_{l, k}, f_{k + 1, r})\),其中 \(\operatorname{opt}\) 表示边上的运算符号。
这样做是不正确的。注意到两个负数相乘结果为正数,所以再维护 \(g_{l, r}\) 表示最小值。转移类似。
复杂度 \(\Theta(n^3)\)。
$\color{blue}\text{Code}$
int n;
bool op[N];
int a[N];
int f[N][N], g[N][N];
void Luogu_UID_748509() {
fin >> n;
for (int i = 1; i <= n; ++ i ) {
char c;
cin >> c >> a[i];
op[i] = c == 'x';
a[i + n] = a[i];
op[i + n] = op[i];
}
for (int i = 1; i <= n * 2; ++ i ) {
f[i][i] = g[i][i] = a[i];
}
for (int len = 2; len <= n; ++ len ) {
for (int l = 1; l + len - 1 <= n * 2; ++ l ) {
int r = l + len - 1;
f[l][r] = -1e9, g[l][r] = 1e9;
for (int k = l; k < r; ++ k ) {
vector<int> v;
if (op[k + 1]) v = {f[l][k] * f[k + 1][r], f[l][k] * g[k + 1][r], g[l][k] * f[k + 1][r], g[l][k] * g[k + 1][r]};
else v = {f[l][k] + f[k + 1][r], f[l][k] + g[k + 1][r], g[l][k] + f[k + 1][r], g[l][k] + g[k + 1][r]};
for (int i : v) {
f[l][r] = max(f[l][r], i);
g[l][r] = min(g[l][r], i);
}
}
}
}
int res = -1e9;
for (int l = 1, r = n; l <= n; ++ l, ++ r )
res = max(res, f[l][r]);
fout << res << '\n';
for (int l = 1, r = n; l <= n; ++ l, ++ r )
if (f[l][r] == res)
fout << l << ' ';
}
\(\color{#52A41A}(10)\) P4933 大师
- 给定一个长度为 \(n\) 的序列。求有多少个子序列是等差数列。
- 设 \(v\) 为序列最大值。\(n \le 1000\),\(v \le 20000\)。
设状态 \(f_{i, j}\) 表示有多少个以 \(i\) 结尾的子序列是公差为 \(j\) 的等差数列,且长度大于 \(1\)。
转移枚举倒数第二个元素 \(k\),即 \(f_{i, j} = \sum_{k=1}^{i - 1} \{f_{k, j} + 1\mid a_i - a_k = j\}\)。其中 \(+1\) 的原因是我们可以选择长度为 \(1\) 的子序列。
复杂度是 \(\Theta(n^2v)\) 的。考虑优化。
可以发现如果确定了 \(a_i, j\) 那么 \(a_k\) 的值是可以确定的,即 \(a_k = a_i - j\)。所以处理方法与 (1) 类似,维护桶表示每一个 \(a_i\) 对应的 \(f_{i, j} + 1\) 之和。注意这里还有一个未处理的 \(j\),解决方法是将转移顺序改为先枚举 \(j\) 再枚举 \(i\)。这样在当前 \(j\) 这轮循环时就不需要考虑 \(j\) 的影响了。
时间复杂度 \(\Theta(nv)\)。
$\color{blue}\text{Code}$
int n, a[N];
int f[N][M * 2];
int res;
int fpm(int a, int b) {
int res = 1;
while (b) {
if (b & 1) res = (ll)res * a % P;
b >>= 1, a = (ll)a * a % P;
}
return res;
}
int mp[M * 5];
void Luogu_UID_748509() {
fin >> n;
int mx = -1e9, mn = 1e9;
for (int i = 1; i <= n; ++ i ) fin >> a[i], mx = max(mx, a[i]), mn = min(mn, a[i]);
register int res = n, m = mx - mn + 1;
for (int j = -m; j <= m; ++ j ) {
memset(mp, 0, sizeof mp);
for (int i = 1; i <= n; ++ i ) {
if (a[i] >= j) (f[i][j + M] = mp[a[i] - j]) %= P;
(mp[a[i]] += f[i][j + M] + 1) %= P;
res = (res + f[i][j + M]) % P;
}
}
fout << res;
return;
}
\(\color{#52A41A}(11)\) P5662 [CSP-J2019] 纪念品
小伟突然获得一种超能力,他知道未来 \(T\) 天 \(N\) 种纪念品每天的价格。某个纪念品的价格是指购买一个该纪念品所需的金币数量,以及卖出一个该纪念品换回的金币数量。
每天,小伟可以进行以下两种交易无限次:
- 任选一个纪念品,若手上有足够金币,以当日价格购买该纪念品;
- 卖出持有的任意一个纪念品,以当日价格换回金币。
每天卖出纪念品换回的金币可以立即用于购买纪念品,当日购买的纪念品也可以当日卖出换回金币。当然,一直持有纪念品也是可以的。
\(T\) 天之后,小伟的超能力消失。因此他一定会在第 \(T\) 天卖出所有纪念品换回金币。
小伟现在有 \(M\) 枚金币,他想要在超能力消失后拥有尽可能多的金币。
\(T ,N \le 100\),\(M \le 1000\)。
一个观察,可以发现如果我持有一个物品多天(例如在 \(s\) 天买,\(t\) 天卖),相当于在 \(s + 1 \sim t - 1\) 这些天中,先将这个物品卖掉,再买回。
所以我们不需要记录每天手里持有多少纪念品,统一认为今天买的纪念品,明天就立刻卖掉。
设计 \(dp_{i, j, k}\) 表示第 \(i\) 天,考虑第 \(j\) 个物品,当前手中还有 \(k\) 元时,明天早上能获得的最大收益。则转移:
然后我们求出 \(dp_i\) 中的最大值,作为下一天的起始钱数(与 \(m\) 类似)。
$\color{blue}\text{Code}$
void Luogu_UID_748509() {
int t, n, m;
fin >> t >> n >> m;
vector<vector<int> > p(t + 1, vector<int>(n + 1));
for (int i = 1; i <= t; ++ i )
for (int j = 1; j <= n; ++ j )
fin >> p[i][j];
vector<int> dp(10010);
for (int i = 1; i < t; ++ i ) {
fill(dp.begin(), dp.end(), 0);
int tmp = m;
for (int j = 1; j <= n; ++ j )
for (int k = p[i][j]; k <= tmp; ++ k ) {
dp[k] = max(dp[k], dp[k - p[i][j]] + p[i + 1][j]);
m = max(m, dp[k] + tmp - k);
}
}
fout << m;
}
\(\color{#9D3DCF}(12)\) CF1234F Yet Another Substring Reverse
- 给你一个字符串 \(S\),你可以翻转一次 \(S\) 的任意一个子串。问翻转后 \(S\) 的子串中各个字符都不相同的最长子串长度。
- \(|S| = n \le 10^6\),\(s_i \in \{\texttt a, \texttt b, \dots, \texttt t\}\),字符集大小 \(V \le 20\)。
首先答案为最长的两个各个字符都不同的子串的长度和。因为两个子串一定可以通过一次旋转变得相邻。
若令 \(f(S)\) 表示是否存在一个子串的字母的 出现状态 为 \(S\),其中 \(S\) 是一个大小 \(\le 20\) 的字符集合。那么答案为:
可以用 \(\Theta(Vn)\) 预处理 \(f(S)\),并用 \(\Theta(2^{2n})\) 计算答案。显然不优。
考虑优化:
也就是枚举 \(S\) 的补集的子集 \(S'\)。复杂度为 \(\Theta(3^n)\)。还是不优。
若我们可以预处理 \(g(S)\):
那么答案为:
\(g\) 就是标准的 高维前缀和 形式了。
$\color{blue}\text{Code}$
#include <bits/stdc++.h>
constexpr int N = 2e6 + 10, M = 20;
char str[N];
int f[N], n, a[N], g[N];
int main() {
scanf("%s", str + 1);
n = strlen(str + 1);
for (int i = 1; i <= n; ++ i ) a[i] = str[i] - 'a';
int res = 0;
for (int i = 1; i <= n; ++ i ) {
g[1 << a[i]] = f[1 << a[i]] = 1;
for (int j = 2, state = (1 << a[i]); i + j - 1 <= n; ++ j ) {
if (state >> a[i + j - 1] & 1) break;
state |= (1 << a[i + j - 1]);
g[state] = f[state] = j;
}
}
for (int i = 0; i < (1 << M); ++ i )
for (int j = 0; j < M; ++ j )
if (i >> j & 1) g[i] = std::max(g[i], g[i ^ (1 << j)]);
for (int i = 0; i < (1 << M); ++ i ) {
if (!f[i]) continue;
res = std::max(res, f[i] + g[~i & ((1 << M) - 1)]);
}
std::cout << res << '\n';
return 0;
}
\(\color{#9D3DCF} (13)\) CF1550E Stringforces
- 给定字符串 \(s\) 和整数 \(k\),\(s\) 由前 \(k\) 小的字母或 \(\texttt?\) 构成。你需要将每个 \(\texttt ?\) 替换成某个前 \(k\) 小的字母。定义其价值为前 \(k\) 个字母中最小的最大连续出现的长度,例如 \(\texttt {aaaabbbbbb}\) 的价值为 \(4\)。求最大价值。
- \(n\leq 2\times 10^5\),\(k\leq 17\)。
令 \(V\) 表示前 \(k\) 个字母组成的集合。
首先二分答案 \(x\)。我们要检查是否存在一种方案,使得每个字母连续出现的长度都 \(\ge x\)。
考虑状压 DP。设 \(f(S)\) 表示若只考虑 \(S\) 中的字母,其中 \(S\) 是 \(V\) 的子集,最小的需要用到的前缀的长度。例如 \(x = 4, s = \texttt{a??ab?????b}\) 时,\(f(\{1, 2\}) = 8\)。若整个 \(s\) 都不能表示 \(S\) 则 \(f(S) = n + 1\)。
二分合法等价于 \(f(V) \le n\)。
考虑转移。令 \(g(c, i)\) 表示从 \(i\) 往后,最靠前的一个长度为 \(x\) 的连续的字符 \(c\) 的段的末尾位置。此时 \(g\) 和 \(f\) 的转移:
$\color{blue}\text{Code}$
#include <bits/stdc++.h>
constexpr int N = 200009, M = 18;
int n, k;
std::string str;
int f[1 << M], nxt[N][M], sum[N][M];
int main() {
std::cin >> n >> k >> str;
str = ' ' + str;
for (int i = 1; i <= n; ++ i )
for (int j = 0; j < k; ++ j )
sum[i][j] = sum[i - 1][j] + (str[i] == j + 'a' || str[i] == '?');
auto chk = [&](int mid) -> bool {
for (int j = n + 1; j < N; ++ j )
for (int i = 0; i < k; ++ i ) nxt[j][i] = n + 1;
for (int i = n; i; -- i )
for (int j = 0; j < k; ++ j )
nxt[i][j] = i + mid - 1 <= n && sum[i + mid - 1][j] - sum[i - 1][j] == mid ? i + mid - 1 : nxt[i + 1][j];
memset(f, 0x3f, sizeof f);
f[0] = 0;
for (int i = 0; i < (1 << k); ++ i )
for (int j = 0; j < k; ++ j )
if (!(i >> j & 1)) f[i | (1 << j)] = std::min(f[i | (1 << j)], nxt[f[i] + 1][j]);
return f[(1 << k) - 1] <= n;
};
int l = 1, r = n, res = 0;
while (l <= r) {
int mid = l + r >> 1;
if (chk(mid)) res = mid, l = mid + 1;
else r = mid - 1;
}
std::cout << res;
return 0;
}
\(\color{#9D3DCF}(14)\) CF1316E Team Building
- 有 \(n\) 个人。你需要从中选出 \(p\) 个人作队员,\(k\) 个人作观众。第 \(i\) 个人作观众的价值为 \(a_i\),作第 \(j\) 个队员的价值为 \(s_{i, j}\)。求最大价值和。
- \(p \le 7\),\(p + k \le n \le 10^5\)。
首先将人按照 \(a_i\) 从大到小排序。因为当我们确定了所有队员后,\(k\) 个观众一定是剩余的 \(a_i\) 最大的人。
接下来状压 DP。设 \(S\) 表示当前已经确定的队员位置组成的集合,\(f(i, S)\) 表示只考虑前 \(i\) 个人的前提下的最大价值和。显然若 \(|S| > i\) 则 \(f(i, S) = -\infty\)。
分类讨论第 \(i\) 个人。
- 若第 \(i\) 个人作队员,那么我们枚举 \(j \in S\) 表示他要成为第 \(j\) 个队员。此时的价值为:
- 若第 \(i\) 个人不作队员。首先我们直到的是前 \(i - 1\) 个人中有 \(|S|\) 个已经作为队员了,也就是说有 \(i - 1 - |S|\) 个人观众。如果 \(i-1-|S| < k\) 那么第 \(i\) 个人一定作观众。否则啥也不干。此时的价值为:
两种转移取较大值即可。最终答案为 \(f(n, \{1, 2, \dots, n\})\)。
$\color{blue}\text{Code}$
#include <bits/stdc++.h>
typedef long long ll;
constexpr int N = 1e5 + 9;
constexpr ll INF = 1e12;
ll f[N][1 << 7];
struct Person {
int v;
int s[7];
bool operator <(const Person& h) const {
return v > h.v;
}
}a[N];
int main() {
int n, p, k;
std::cin >> n >> p >> k;
for (int i = 1; i <= n; ++ i ) std::cin >> a[i].v;
for (int i = 1; i <= n; ++ i )
for (int j = 0; j < p; ++ j )
std::cin >> a[i].s[j];
std::sort(a + 1, a + n + 1);
for (int i = 1; i < 1 << p; ++ i ) f[0][i] = -INF;
for (int i = 1; i <= n; ++ i )
for (int j = 0; j < 1 << p; ++ j ) {
if (__builtin_popcount(j) > i) f[i][j] = -INF;
else {
f[i][j] = f[i - 1][j] + (i - 1 - __builtin_popcount(j) < k ? a[i].v : 0);
for (int k = 0; k < p; ++ k )
if (j >> k & 1) f[i][j] = std::max(f[i][j], f[i - 1][j ^ (1 << k)] + a[i].s[k]);
}
}
std::cout << f[n][(1 << p) - 1];
return 0;
}
\(\color{#9D3DCF}(15)\) CF482C Game with Strings
- 小 A 有 \(n\) 个长度均为 \(m\) 的不相同的字符串,然后小 A 随机地选择其中一个,小 B 要猜这个字符串。小 B 可以问小 A:字符串中第 \(pos\) 个字符是什么?求小 B 期望问几次能唯一确定这个字符串。
- \(n \le 50, m \le 20\)。
不妨枚举小 A 选择的字符串为 \(s_i\)。
状压 DP。设 \(f(S)\) 表示当前已经询问的下标集合为 \(S\) 时,期望再问几次可以唯一确定 \(s\)。那么答案为 \(f(\varnothing)\)。
如果 \(S\) 已经能够确定这个字符串那么 \(f(S) = 0\)。否则:
复杂度过不去。思考能否省去最开始的枚举。
重新设 \(f(S)\) 表示当前状态为 \(S\) 时,每个 \(s_i\) 期望的次数之和。也即,此时的 \(f(S)\) 是上面每个字符串的 \(f(S)\) 之和。
类似的有转移:
其中 \(g(S)\) 有多少个字符串不能通过 \(S\) 唯一确定。
考虑 \(g(S)\) 的求解。我们可以枚举两个字符串 \(s_i, s_j\)。对于某个 \(k\) 而言,如果 \({s_i}_k = {s_j}_k\) 那么就不能通过一次询问唯一确定 \(s_i\) 或 \(s_j\)。令 \(A\) 为所有这样的 \(k\)(\({s_i}_k = {s_j}_k\))组成的集合。那么每个 \(A\) 的子集都不能唯一确定 \(s_i\) 或 \(s_j\)。高维前缀和秒了。
$\color{blue}\text{Code}$
#include <bits/stdc++.h>
typedef long long ll;
#define int ll
const int N = 51, M = 22;
int n, m;
int a[N][M];
double f[1ll << M], res;
ll g[1ll << M];
signed main() {
std::ios::sync_with_stdio(0);
std::cin.tie(0), std::cout.tie(0);
std::cin >> n;
if (n == 1) {
std::cout << 0 << '\n';
return 0;
}
for (int i = 1; i <= n; ++ i ) {
std::string s;
std::cin >> s;
m = s.size();
for (int j = 0; j < m; ++ j )
a[i][j] = s[j] >= 'a' ? s[j] - 'a' : s[j] - 'A' + 26;
}
g[0] = (1ll << n) - 1;
for (int i = 1; i < n; ++ i )
for (int j = i + 1; j <= n; ++ j ) {
int S = 0;
for (int k = 0; k < m; ++ k )
if (a[i][k] == a[j][k]) S |= 1 << k;
g[S] |= (1ll << i - 1) | (1ll << j - 1);
}
for (int i = 0; i < m; ++ i )
for (int j = (1 << m) - 1; ~j; -- j )
if (!(j >> i & 1)) g[j] |= g[j | (1ll << i)];
for (int i = (1 << m) - 1; ~i; -- i ) {
if (!g[i]) continue;
for (int j = 0; j < m; ++ j )
if (!(i >> j & 1)) f[i] += f[i | (1ll << j)];
f[i] /= (m - __builtin_popcountll(i));
f[i] += __builtin_popcountll(g[i]);
}
std::cout << std::fixed << std::setprecision(10) << f[0] / n << '\n';
return 0;
}
\(\color{#3498D8}(16)\) CF797F Mice and Holes
- \(n\) 个老鼠,\(m\) 个洞,告诉你他们的一维坐标和 \(m\) 个洞的容量限制,问最小总距离。
- \(n, m \le 5 \times 10^3\),坐标在 \(\pm 10^9\) 内。
不难发现每个洞内的老鼠在坐标上是连续的。因此我们将老鼠和洞按坐标排序,并将老鼠分成 \(m\) 段,每段老鼠对应一个洞。
设计 DP。令 \(g(l,r , x)\) 表示 \([l, r]\) 老鼠到第 \(x\) 个洞的距离和。显然 \(g(l,r , x) = g(1, r, x) - g(1, l -1, x)\)。令 \(f(i, j)\) 表示前 \(i\) 个洞,前 \(j\) 个老鼠的答案。那么转移枚举这个洞的老鼠数量:
单调队列维护即可。
$\color{blue}\text{Code}$
#include <bits/stdc++.h>
typedef long long ll;
constexpr int N = 5010;
int n, m;
int x[N];
struct Mice {
int p, c;
bool operator <(const Mice& h) const {
return p < h.p;
}
}y[N];
ll sum[N];
ll f[2][N]; // 前 i 个洞,前 j 只老鼠,最小距离和
ll g[N]; // 前 i 只老鼠,到第 j 个洞的距离和
int q[N], hh, tt = -1;
signed main() {
std::cin >> n >> m;
for (int i = 1; i <= n; ++ i ) std::cin >> x[i];
for (int i = 1; i <= m; ++ i ) std::cin >> y[i].p >> y[i].c;
std::sort(x + 1, x + n + 1);
std::sort(y + 1, y + m + 1);
for (int i = 1; i <= m; ++ i ) sum[i] = sum[i - 1] + y[i].c;
memset(f, 0x3f, sizeof f);
for (int i = 0; i <= m; ++ i ) f[i & 1][0] = 0;
auto calc = [&](int i, int j) -> ll {
return f[i - 1 & 1][j] - g[j];
};
for (int i = 1; i <= m; hh = 0, tt = -1, ++ i ) {
for (int j = 1; j <= n; ++ j ) {
g[j] = g[j - 1] + abs(x[j] - y[i].p);
}
for (int j = 0; j <= n; ++ j ) {
if (hh <= tt && j - y[i].c > q[hh]) ++ hh;
while (hh <= tt && calc(i, q[tt]) >= calc(i, j)) -- tt;
q[ ++ tt] = j;
if (j <= sum[i]) {
f[i & 1][j] = std::min(f[i - 1 & 1][j], g[j] + calc(i, q[hh]));
}
}
}
if (f[m & 1][n] > 1e12) f[m & 1][n] = -1;
std::cout << f[m & 1][n] << '\n';
return 0;
}
\(\color{#3498D8}(17)\) P1545 [USACO04DEC] Dividing the Path G
- 给定一个长为偶数 \(l\) 的线段。要求用若干两两不交的,长度在 \([2a,2b]\) 之间的偶数长度线段来覆盖整条线段。给定 \(n\) 个区间 \([s_i,e_i]\),每个区间必须只被一个线段覆盖。求最少需要的线段数量。
- \(l \le 10^6\),\(a, b, n \le 10^3\)。
如果一个区间 \([s_i, e_i]\) 只被一个线段覆盖,等价于不存在两条线段的交点在 \([s_i + 1, e_i - 1]\) 内。又因为线段两两不交,所以等价于不存在线段的端点在 \([s_i + 1, e_i - 1]\) 内。
考虑剩余的允许放线段端点的位置 \(i\)。设 \(f(i)\) 表示 \(i = l\) 时原问题的答案。显然转移:
单调队列/线段树维护即可。
$\color{blue}\text{Code}$
#include <bits/stdc++.h>
constexpr int N = 1000010;
int n, l, a, b;
struct Seg {
int l, r;
}t[N];
int dp[N], sum[N];
struct Tree {
int l, r, ls, rs, v;
}tr[N << 2];
void pushup(int u) {
tr[u].v = std::min(tr[tr[u].ls].v, tr[tr[u].rs].v);
}
int idx;
int build(int l, int r) {
int u = ++ idx;
tr[u].l = l, tr[u].r = r;
if (l != r) {
int mid = l + r >> 1;
tr[u].ls = build(l, mid), tr[u].rs = build(mid + 1, r);
} else tr[u].v = 1e9;
pushup(u);
return u;
}
int query(int u, int l, int r) {
if (tr[u].l >= l && tr[u].r <= r) return tr[u].v;
int mid = tr[u].l + tr[u].r >> 1, res = 1e9;
if (l <= mid) res = query(tr[u].ls, l, r);
if (r > mid) res = std::min(res, query(tr[u].rs, l, r));
return res;
}
void modify(int u, int x, int d) {
if (tr[u].l == tr[u].r) tr[u].v = d;
else {
int mid = tr[u].l + tr[u].r >> 1;
if (x <= mid) modify(tr[u].ls, x, d);
else modify(tr[u].rs, x, d);
pushup(u);
}
}
int main() {
std::cin >> n >> l >> a >> b;
for (int i = 1; i <= n; ++ i ) {
std::cin >> t[i].l >> t[i].r;
++ sum[t[i].l + 1], -- sum[t[i].r];
}
for (int i = 1; i <= l; ++ i ) sum[i] += sum[i - 1];
build(1, l + 1);
memset(dp, 0x3f, sizeof dp);
dp[0] = 0;
modify(1, 1, 0);
for (int i = 2; i <= l; i += 2 )
if (!sum[i]) {
int L = std::max(0, i - 2 * b), R = i - 2 * a;
for (int j = L; j <= R; ++ j ) dp[i] = std::min(dp[i], dp[j] + 1);
modify(1, i + 1, dp[i]);
}
if (dp[l] >= 1e9) dp[l] = -1;
std::cout << dp[l];
return 0;
}
\(\color{#3498D8}(18)\) CF900D Unusual Sequences
- 给定 \(x, y\)。求有多少个序列的 \(\gcd\) 为 \(x\),和为 \(y\)。取模 \(10^9 + 7\)。
- \(x, y \le 10^9\)。
设答案为 \(h(x, y)\),设 \(f(x)\) 表示和为 \(x\) 的元素两两互质的序列个数。
显然答案 \(h(x, y) = f(\frac yx)\)。若 \(x \nmid y\) 则无解。
考虑:
- \(g(x)\) 表示和为 \(x\) 的序列个数,即全集。显然插板法 \(g(x) = 2^{x-1}\);
- \(h(y, x)\) 表示和为 \(y\) 的 \(\gcd\) 为 \(x\) 的序列个数。显然 \(h(y, x) = \left\{\begin{matrix} f(\frac xy) & y \mid x\\ 0 & y \nmid x\end{matrix}\right.\)。
那么转移为:
$\color{blue}\text{Code}$
#include <bits/stdc++.h>
typedef long long ll;
constexpr int P = 1e9 + 7;
int x, y;
std::map<int, int> f;
int fpm(int a, int b) {
int res = 1;
while (b) {
if (b & 1) res = (ll)res * a % P;
b >>= 1, a = (ll)a * a % P;
}
return res;
}
int dp(int x) {
if (f.count(x)) return f[x];
if (x == 1) return f[x] = 1;
int res = fpm(2, x - 1);
for (int i = 1; i <= x / i; ++ i )
if (x % i == 0) {
res = (res - dp(i) + P) % P;
if (i != x / i && x / i != x) res = (res - dp(x / i) + P) % P;
}
return f[x] = res;
}
int main() {
std::cin >> x >> y;
std::cout << (y % x == 0 ? dp(y / x) : 0) << '\n';
return 0;
}
\(\color{#9D3DCF}(19)\) CF79D Password
你有 \(n\) 个灯泡,一开始都未点亮。
同时你有 \(l\) 个长度,分别为 \(a_1 \sim a_l\)。
每次你可以选择一段连续的子序列,且长度为某个 \(a_i\),并将这些灯泡的明灭状态取反。
求最少的操作次数,使得最后有且仅有 \(k\) 个位置是亮的,这些位置已经给定,为 \(x_1 \sim x_k\)。
\(n \le 10^4\),\(k \le 10\),\(l \le 100\)。
设 \(n\) 个灯泡的开关状态为 \(b_1 \sim b_n\)。若 \(b_i = 1\) 表示第 \(i\) 盏灯开启,\(b_i = 0\) 表示第 \(i\) 盏灯关闭。
第一个观察是,我们从 \(b_1 = b_2 = \dots = b_n = 0\) 变化成所有 \(b_{x_i} = 1\) 的局面,等价于从所有 \(b_{x_i} = 1\) 变化成 \(b_1 = b_2 = \dots = b_n = 0\) 的局面。
所以最开始我们让所有 \(b_{x_i} \gets 1\)。现在的问题是:
给定 \(a, b\)。每次可以选择一个 \(b\) 的区间 \([x, x + a_i - 1]\) 取反。求将 \(b\) 全部变为 \(0\) 的最少操作数。
区间反转用差分维护。令 \(b\) 的差分数组为 \(c_1 \sim c_{n+1}\),即 \(c_i = b_i \operatorname{xor} b_{i-1}\)。那么将区间 \([l, l + a_k - 1]\) 取反等价于将 \(c_l, c_{l+a_k}\) 取反。
显然当 \(c_1 = c_2 = \dots = c_{n+1} = 0\) 时,我们的任务就完成了。现在的问题是:
给定 \(a, c\)。每次可以选择一个 \(c\) 的区间 \([x, x + a_i]\),并将 \(c_x, c_{x+a_i}\) 取反。求将 \(c\) 全部变为 \(0\) 的最少操作数。
显然我们只需要考虑那些为 \(1\) 的位置,即 \(\{i \mid c_i = 0\}\)。因为操作 \(\{i \mid c_i = 1\}\) 显然是不优的。
若令 \(g(x, y)\) 表示将 \(x, y\) 同时反转的最小的所需次数。
考虑状压 DP。令 \(S\) 表示当前 \(c\) 中仍为 \(1\) 的下标集合,设 \(f(S)\) 表示在状态 \(S\) 的情况下,将 \(c\) 全部变为 \(0\) 的最少操作次数。
转移显然:
答案为 \(f(\{x \mid c_x = 1\})\)。
考虑 \(g(x, y)\) 的求解。举个例子,如果我们可以同时将 \(c_x, c_{x+a}\) 取反,也可以同时将 \(c_{x+a}, c_{x+a+b}\) 取反,那么我们就可以通过两次操作,同时将 \(c_x, c_{x+a+b}\) 取反。
具体的,考虑建图。对于一条边 \(u \longleftrightarrow v\) 表示可以通过一次操作将 \(u, v\) 同时取反,那么这张图上 \(x \to y\) 的最短路即 \(g(x,y)\)。
$\color{blue}\text{Code}$
#include <bits/stdc++.h>
constexpr int N = 10009, K = 22, L = 209;
int n, k, l, x[K], a[L];
bool b[N], c[N];
int mp[L][L];
int Id[N], Di[N], cnt;
int f[1 << K];
struct Gragh {
int h[N], e[N * L], ne[N * L], idx = 1;
void add(int a, int b) {
e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
e[idx] = a, ne[idx] = h[b], h[b] = idx ++ ;
}
int dis[N];
bool st[N];
void bfs(int s) {
std::queue<int> q;
q.push(s);
memset(dis, 0x3f, sizeof dis);
memset(st, 0, sizeof st);
dis[s] = 0;
st[s] = true;
while (q.size()) {
int u = q.front();
q.pop();
for (int i = h[u]; i; i = ne[i]) {
int v = e[i];
if (!st[v]) {
st[v] = true;
dis[v] = dis[u] + 1;
q.push(v);
}
}
}
for (int i = 1; i <= n + 1; ++ i )
if (c[i]) mp[Di[s]][Di[i]] = dis[i];
}
}G;
int dp(int S) {
if (!S) return 0;
if (f[S]) return f[S];
int &res = f[S];
res = 1e9;
for (int i = 0; i < cnt; ++ i )
if (S >> i & 1)
for (int j = 0; j < cnt; ++ j )
if (S >> j & 1)
res = std::min(res, dp(S ^ (1 << i) ^ (1 << j)) + mp[i][j]);
return res;
}
int main() {
std::cin >> n >> k >> l;
for (int i = 1; i <= k; ++ i ) {
std::cin >> x[i];
b[x[i]] = true;
}
for (int i = 1; i <= n + 1; ++ i ) {
c[i] = b[i] ^ b[i - 1];
}
for (int i = 1; i <= l; ++ i ) {
std::cin >> a[i];
}
for (int i = 1; i <= n + 1; ++ i )
if (c[i]) Id[cnt ++ ] = i, Di[i] = cnt - 1;
for (int i = 1; i <= n + 1; ++ i )
for (int j = 1; j <= l; ++ j )
if (i + a[j] <= n + 1) G.add(i, i + a[j]);
for (int i = 1; i <= n + 1; ++ i )
if (c[i]) G.bfs(i);
std::cout << (dp((1 << cnt) - 1) == 1e9 ? -1 : f[(1 << cnt) - 1]) << '\n';
return 0;
}