\(\color{blue}(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, j, k} = \max(dp_{i, j - 1, k}, dp_{i, j - 1, k + p_{i, j}} + p_{i + 1, j}) \]然后我们求出 \(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;
}