11.8 校内jsy模拟赛总结 + 题解(计数原理 + 状压搜索(复杂度分析)+ trie树 + 构造(逆天题))
时间安排
- \(7:40 - 8:00\) 开题。听说今天这场比较难,就先把前 \(3\) 题看了。 \(T4\) 题面看起来很逆天,就没有仔细看。 \(T1\) 是一道计数, 复杂度应该是 \(O(Tnm)\),或许不能 \(dp\),而是乘法原理啥的。 \(T2\) 数据规模很神秘,但是看题意感觉还挺可做?? \(T3\) 理解题意后感觉非常困难。
- \(8:00 - 9:10\) 做 \(T1\)。直接没有往 \(dp\) 上想,而是考虑从大到小填数,每个数就有若干个位置可以填,每次乘上这样的位置数就是答案。大概想了 \(20min\),\(10min\) 写完,发现过不了较大的样例,而且还大很多。尝试手构一组比较小的数据,发现少了一些情况。加上后就把较大的样例过了。测大样例,为啥第一组询问过了第二组询问没过??开始以为是自己多测没清空,然后尝试把第二组数据提前,发现还是不对。然后就静态查错,找不出来问题。 \(10min\) 后被告知 \(std\) 没清空,所以大样例有问题。难绷,下发正确的大样例后发现都过了。就跳了。
- \(9:10 - 9:40\) 开 \(T2\),发现只会最基础的 \(2^n \times n^3\)状压。以为能过 \(13pts\) 后来发现只能过 \(5pts\)。这部分分给的真幽默。然后发现并不是很会优化状压 \(dp\),就开始思考特殊性质。然后 \(10min\) 想出了链的做法,但是也只有 \(10pts\) o(╥﹏╥)o。尝试树,发现有一种情况不会处理。最后还是只有 \(15pts\),感觉不是很想写就先去看 \(T3\)了。
- \(9:40 - 10:20\) 看 \(T3\),发现 \(n, m\) 较小的时候可以暴力,这样就有高贵的 \(5pts\) 了。然后一点一点扣分,发现 \(A\) 性质可以直接 \(trie\) 树上跑一遍,这样就又能获得 \(5pts\) 了。然后考虑可以跑 \(trie\) 树时处理出来一个 \(cnt_{x, c_1, c_2}\) 数组代表有多少个字符串 \(y\) 和 \(x\) 第一个不同的位置上 \(x\) 是 \(c_1\) 字符, \(y\) 是 \(c_2\) 字符。那么对于 \(B\) 性质就只需要维护一个 \(to\) 数组记录每种字符会变成那种字符,然后每次询问枚举 \(cnt_{x, a, b}\) 就可以在 \(O(k^2)\) 的时间复杂度求出答案。然后接着思考,发现如果说两个字符变成了同一种字符就暴力重构 \(cnt\) 数组,那么这样的合并最多 \(k\) 次,所以复杂度是 \(O(\sum|S_i| \times k^2)\) 的!!计算复杂度发现是 \(3 \times 10^8\),不知道能不能过,而且 \(n = 5 \times 10^6\) 好想还会爆空间。但是 \(n = 10^5\) 还是可以过的,这样有 \(31pts\),加上一些其它的部分分,应该可以到 \(40\) 多分。虽然有点难受但是还是开写了。(噩梦开始)
- \(10:20 - 12:00\) 大概 \(40pts\) 写完了,测样例发现 \(RE\) 了。调过之后发现答案不对,接着调,看不出来。心里纠结到底是继续静态查错还是对拍。然后决定手造一组,造了两组发现 \(WA\)了!!!赶紧开调,发现忘记了字符串可能会被改成相同的。加上之后过了第一个样例。测试第二个,打开文件发现第一页都一样,以为稳了结果后面我全部输出 \(1\),给我人干蒙了。但是这组数据太大了没法调啊!然后就是各种打表,一度红温。最后 \(15min\) 意识到我每次重构的时机不对,但是一直发现不了问题,然后就是特判了一些情况去重构就交了。遗憾立场。
成绩
期望得分: 100 + 0 + 0 + 0 = 100
实际得分:100 + 0 + 13 + 0 = 113
rk 3
成绩也挺幽默,没有人 \(130pts\) 以上,除了第一道题,其他题一般都是个位数。(出题人反思反思)
总结
- \(T3\) 想的就是正解, \(std\) 复杂度也是这个。但是对于我会爆空间的问题,可以通过离线解决。但是还是想吐槽为什么没想到离线但是时间复杂度正确只有 \(31\) 分,如果在多一点选手写起来也会多一点底气。
- 如果这次比赛没有去写 \(T3\) 的大块部分分而是从 \(T2\) 开始一档一档分去写,那么我认为自己是可以写出来 \(150 +\) 的分数的。但是这次 \(T3\) 能想到做法,虽然没调出来但是也算有所慰藉了。只是模拟赛可以去磕一道题,但是正赛还是得把暴力都拿到。
题解
T1.积云生沧海
题意:
\(T\) 组询问,每次给你 \(n, m\),你需要把 \(1 \sim n \times m\) 填到一个 \(n \times m\) 的表格中,使得对于第 \(i(1 \leq i \leq n)\) 行,最大值为 \(a_i\),对于第 \(j(1 \leq j \leq m)\) 列,最大值为 \(b_j\)。问有多少种合法的填数方案。两种方案不同当且某个位置上填的数不同。
\(T \leq 20\), \(max(n, m) \leq 2000\)
分析:
首先可以注意到 \(O(Tnm)\) 就已经 \(8 \times 10^7\) 了,所以每组数据应该只能 \(O(nm)\) 的复杂度。或许不太能够 \(dp\)。
按照套路,可以从大到小填数。那么每次就可以填进若干个位置,对每个数 \(x\) 统计出能填进几个位置,然后乘起来就是答案。
T2.幻梦三千醉
题意:
\(T\) 组询问,每次给你一个 \(n\) 和 一个 \(k\) 以及一个模数 \(mod\)。你需要对满足所有给定限制的排列 \(1 \sim n\) 计数。有 \(m\) 个限制,每个限制形如一个二元组 \((u_i, v_i)\),表示 \(u_i\) 和 \(v_i\) 在排列中位置之差的绝对值不超过 \(k\)。答案对 \(mod\) 取模。
\(n \leq 60\), \(m \leq \frac{n \times (n - 1)}{2}\), \(1 \leq k \leq 2\), \(10^8 \leq mod \leq 10^9\)。
分析:
\(k = 1\) 的情况是简单的,下面只考虑 \(k = 2\) 的情况。
因为只需要判断有限制的两个数之间的距离是大于等于 \(3\),因此有一个比较简单的状压 \(dp\)。
设 \(dp_{S, x, y}\) 表示已经填入了 \(S\) 中的数字,上一个填的数是 \(y\),上上一个填的数是 \(x\) 的方案数。那么转移就是枚举一个不在 \(S\) 中的数 \(a\),判断 \(a\) 填入是否满足限制即可。
这样的复杂度是 \(O(2^n \times n^3)\) 的。然后这个 \(dp\) 是很难优化的。邪恶的出题人只给了 \(5pts\)。
正解感觉挺难想到:实际上面的 \(dp\) 合法的状态数只有 \(n^2 \times 2^7\) 个,直接记忆化搜索即可。
证明:
考虑将每个限制中的 \(u_i\) 和 \(v_i\) 连一条边。那么首先有一个性质:如果存在一个点 \(i\) 的度数超过 \(4\),那么一定无解。因为 \(i\) 不可能同时和 \(5\) 个点的距离都小于等于 \(2\)。
然后对于一个状态 \((S, x, y)\),将 \(S\) 中的点标记成 \(1\), 不在 \(S\) 中点看做 \(0\)。那么如果把所有一个端点为 \(x\)或 \(y\) 的边断开,再把 \(x\) 和 \(y\) 删掉,最多会形成 \(7\) 个联通块。对于每个联通块而言,联通块内的点的 \(0/1\) 状态都是相同的才可能合法。这个比较容易想:如果一个联通块内存在既存在 \(0\) 也存在 \(1\),那么一定至少存在一对相邻的 \(0\) 和 \(1\)。那么这个 \(1\) 一定是在 \(x\) 和 \(y\) 就填进去的,而 \(0\) 一定将会在 \(x\) 和 \(y\) 填过后才可能填入,因此这样的 \(0\) 和 \(1\) 位置之差一定超过了 \(2\)。
所以对于一对数 \((x, y)\),有用的状态 \(S\) 最多只有 \(2^7\) 种。(每个联通块内要么都是 \(0\),要么都是 \(1\))因此直接记忆化搜索 加上 剪枝 就是能过的。
CODE:
// 逆天题
// 首先有一个性质是一个点的度数最多为 4
// 首先有一个暴力状压: f[msk][x][y] 表示已经经过了msk中的点,其中上一次是 y, 上上一次是 x 的方案数
// 然后考虑将 x 和 y 所有连的边断开,再把 x 和 y 删掉,会形成最多 7 个联通块
// 那么每个联通块内的点有没有在 msk 中必须是相同的才合法。 所以最多有 n^2 * 2^7 中状态
// 记忆化搜索 + 剪枝即可
#include<bits/stdc++.h>
#define pb emplace_back
using namespace std;
const int N = 65;
typedef long long LL;
typedef unsigned long long ull;
int T, id;
int n, m, k;
int deg[N];
LL mod;
unordered_map< ull, LL > dp;
vector< int > E[N];
ull H(LL s, int x, int y) {
return ((ull)(s) * 1333331 + x) * 1331 + y;
}
LL dfs(LL S, int x, int y) {
ull o = H(S, x, y);
if(dp.count(o)) return dp[o];
if(S == (1LL << n) - 1) return dp[o] = 1LL;
int u = (k == 1 ? y : x);
if(deg[u] > 1) return dp[o] = 0;
LL res = 0;
for(int i = 1; i <= n; i ++ ) {
if(S >> (i - 1) & 1) continue;
for(auto v : E[i]) deg[v] --;
if(deg[u] == 0) res = (res + dfs(S | (1LL << (i - 1)), y, i)) % mod;
for(auto v : E[i]) deg[v] ++;
}
return dp[o] = res;
}
void solve() {
scanf("%d%d%d%lld", &n, &m, &k, &mod);
dp.clear(); memset(deg, 0, sizeof deg);
for(int i = 1; i <= n; i ++ ) E[i].clear();
for(int i = 1; i <= m; i ++ ) {
int u, v; scanf("%d%d", &u, &v);
E[u].pb(v); E[v].pb(u);
deg[u] ++; deg[v] ++;
}
LL res = dfs(0, 0, 0);
printf("%lld\n", res);
}
int main() {
scanf("%d%d", &T, &id);
while(T -- ) solve();
return 0;
}
T3.清影化梧桐
题意:
给你 \(n,m, k\),代表有 \(n\) 个字符串\(S_i\),\(m\) 次操作, 字符串的字符集为前 \(k\) 个小写字母。
会依次进行 \(m\) 次操作,操作之间不独立。
操作分两种:
1 P,其中 \(P\) 是一个长度为 \(k\) 的小写字母构成的字符串,对于每种字符 \(c\), 会同时把所有字符 \(c\) 变成 \(P_{c}\)。2 x,询问当前编号为 \(x\) 的字符串的排名。排名定义 \(n\) 个字符串中字典序比 \(S_x\) 小的字符串数量加一。
\(n,m,|\sum S_i| \leq 5 \times 10^5\), \(1 \leq k \leq 26\)
分析:
首先可以发现,如果每次 \(1\) 操作 \(P\) 中都没有重复字符,那么就不会有 最初的两种字符变相同 的情况。先来考虑这种情况:
如果两个字符串 \(x,y\) 在最初的时刻是在 \(p\) 位置比出字典序大小的,那么在进行若干次 \(1\) 操作后 \(x,y\) 分出大小关系的位置也一定是在 \(p\)。因为 \(1 \sim p - 1\) 位置上的字符都相同,变换后也一定相同。而 \(p\) 位置上的字符变换后也一定不同。
在这种情况下,我们就只关心对于一个字符串 \(x\),别的字符串第一个和它不一样的位置上是什么字符,以及它自己是什么字符。
因此记 \(cnt_{x, a, b}\) 表示有多少个字符串 \(y\) 满足第一个和 \(x\) 不一样位置上 \(x\) 的字符为 \(a\), \(y\) 的字符为 \(b\)。
那么每次 \(1\) 操作可以更新 \(to_{i}\) 数组表示当前 \(i\) 字符变成了 \(to_{i}\) 字符。然后每次询问 \(x\) 就可遍历 \(cnt_{x, a, b}\),如果 \(to_{a} > to_{b}\) 那么答案加上 \(cnt_{x, a, b}\) 即可。这样复杂度就是 \(O(m \times k^2)\) 的,上限在于每次询问都需要 \(k^2\) 遍历 \(cnt_{x, a, b}\)。
接下来考虑如何求出 \(cnt_{x, a, b}\):
将所有字符串插入 \(trie\) 树中,然后遍历一遍 \(trie\) 树就可以求出所有 \(cnt_{x, a, b}\)。
具体的过程是:
- 首先求出每个节点 \(p\) 的子树中有多少个字符串的结尾,记这个数量为 \(c_{p}\)。
- 然后从 \(p\) 节点往儿子 \(tr_{p, c}\) 递归时,将所有 \(c_{tr_{p, d}}\) 加入暂存数组 \(tmp_{c, d}\) 中。
- 如果遇到了某个节点是某个字符串 \(x\) 的结尾,那么将暂存数组 \(tmp\) 赋值给 \(cnt_{x}\)。
- 回溯的时候将 \(tmp_{c, d}\) 减去\(c_{tr_{p, d}}\)。
代码如下:
for(int i = 0; i < k; i ++ ) {
if(tr[x][i]) { // 有这个儿子才枚举, 这样每个点会被它的k个兄弟枚举一次, 总复杂度是 |∑si|k
for(int j = 0; j < k; j ++ ) {
if(i == j) continue;
tmp[i][j] += c[tr[x][j]];
}
Dfs(tr[x][i]);
for(int j = 0; j < k; j ++ ) {
if(i == j) continue;
tmp[i][j] -= c[tr[x][j]]; // 撤销
}
}
}
虽然是二重循环,但是这个过程的复杂度是是 \(O(\sum|S_i| \times k)\)。具体分析是:对于一个点 \(tr_{x, i}\),只有在往它的子树递归时才会枚举它的 \(k\) 个兄弟。而一共有 \(\sum |S_i|\) 个节点,因此总复杂度就是 \(O(\sum |S_i| \times k)\) 的。但是需要注意如果删掉了代码的第二行的剪枝,那么复杂度就会退化到 \(O(\sum |S_i| \times k^2)\),因为这样会给每个点补充一些空儿子,从而使每个点的儿子数都达到了 \(k\),总点数就是 \(\sum|S_i| \times k\) 的。
然后考虑 \(1\) 操作中的 \(P\) 存在相同字符的情况。发现这样会导致 最初的某些字符变成同一种字符,并且以后无论如何操作,这些字符都是一摸一样的。那么如果两种最初的字符变成同一种,就将它们合并起来,不难发现这样的合并次数最多是 \(k\) 次。每次合并我们都将所有字符串更新,然后暴力重构 \(trie\) 树,重新求一遍 \(cnt\) 数组。这样总复杂度还是 \(O(m \times k^2)\) 的,与原来相同。这个时间复杂度是可以通过的。
但是还有最后一个问题:\(cnt\) 数组开不下。因为没法开一个 \(n \times k^2\) 的数组,这样会 \(MLE\)。
赛场上就这个没想到,然后最多就只有 \(31pts\)了
解决方法也比较简单:离线 处理询问。因为每次重构相当于把时间划分成了若干段,可以每段内的对 \(x\) 的询问挂到 \(trie\) 树上 \(x\) 的末尾节点上,然后和原来一样去求 \(cnt\) 数组。不过和原来不同的是,我们不需要把 \(cnt\) 数组开出来,因为到某个节点上时暂存数组 \(c\) 就等于需要的 \(cnt_{x}\),可以直接在这个节点上把询问的答案都求出来。这样时间复杂度不改变,空间上也不会爆了。
时间复杂度 \(O(m \times k^2)\),空间复杂度 \(O(m \times k)\)。
CODE:
// 如果每次重排的字符集都不相同,那么只需要对于每一个字符串记录有多少个字符串和它在某一个字符不同
// 如果可能相同那么就会把这两种字符合并起来。最多合并 k 次,每次就暴力重构。
#include<bits/stdc++.h>
#define pb emplace_back
using namespace std;
const int N = 5e5 + 10;
const int M = 5e5 + 10;
const int U = 26;
int n, m, k;
bool is[M]; // 是否为重构时刻。
int opt[M], id[M]; // 询问
int ans[M]; // 离线
int l[N], r[N]; // 每个字符串的范围
int tot;
int bin[U]; // 最初为i的字符和谁在一个联通块里
int lto[U];
int to[U][M], now[U]; // now[x] 记录x现在是什么 to[x][i] 表示到第i次操作, x变成什么
char str[M], S[M];
char P[U]; // to[i] 表示 i 号字符变成什么
int tr[M][26], trtot;
int et[N]; // 表示第i个字符的结尾
vector< int > idx[M]; // 是否为结尾
vector< int > qry[M];
int Cnt[M], son; // 有多少个 son 代表前缀子串
int c[26][26];
int Find(int x) {return x == bin[x] ? x : bin[x] = Find(bin[x]);}
int calc(int r) {
int res = 0;
for(int i = 0; i < k; i ++ ) { // x 为 i 号字符, y 为 j 号字符
for(int j = 0; j < k; j ++ ) {
if(i != j) {
if(to[i][r] > to[j][r]) res += c[i][j];
}
}
}
return res + son;
}
void dfs(int x) {
Cnt[x] += (idx[x].size());
for(int i = 0; i < k; i ++ ) {
if(tr[x][i]) {
dfs(tr[x][i]);
Cnt[x] += Cnt[tr[x][i]];
}
}
}
void Dfs(int x) {
if(!idx[x].empty()) {
for(auto r : qry[x]) { // 对每个 v 解决 r 的询问
ans[r] = calc(r) + 1;
}
son += (idx[x].size());
}
for(int i = 0; i < k; i ++ ) {
if(tr[x][i]) { // 有这个儿子才枚举, 这样每个点会被它的k个兄弟枚举一次, 总复杂度是 |∑si|k
for(int j = 0; j < k; j ++ ) {
if(i == j) continue;
c[i][j] += Cnt[tr[x][j]];
}
Dfs(tr[x][i]);
for(int j = 0; j < k; j ++ ) {
if(i == j) continue;
c[i][j] -= Cnt[tr[x][j]]; // 撤销
}
}
}
if(!idx[x].empty()) son -= (idx[x].size());
}
void build(int x) { // 暴力重构
for(int i = 1; i <= tot; i ++ ) {
str[i] = (char)(to[str[i] - 'a'][x] + 'a');
}
for(int i = 1; i <= trtot; i ++ ) {
for(int j = 0; j < k; j ++ ) tr[i][j] = 0;
idx[i].clear();
Cnt[i] = 0;
qry[i].clear();
}
trtot = 1;
for(int i = 1; i <= n; i ++ ) {
int p = 1;
for(int j = l[i]; j <= r[i]; j ++ ) {
if(!tr[p][str[j] - 'a']) tr[p][str[j] - 'a'] = ++ trtot;
p = tr[p][str[j] - 'a'];
}
idx[p].pb(i);
et[i] = p;
}
dfs(1); // 求出每个点的字符串数量
}
int main() {
freopen("firmiana.in","r",stdin);
freopen("firmiana.out","w",stdout);
scanf("%d%d%d", &n, &m, &k);
for(int i = 1; i <= n; i ++ ) {
scanf("%s", S + 1);
int len = strlen(S + 1);
l[i] = tot + 1;
for(int j = 1; j <= len; j ++ ) str[tot + j] = S[j];
tot += len;
r[i] = tot;
}
for(int i = 0; i < k; i ++ ) now[i] = i, to[i][0] = lto[i] = i, bin[i] = i;
is[0] = 1;
for(int i = 1; i <= m; i ++ ) {
scanf("%d", &opt[i]);
if(opt[i] == 1) {
scanf("%s", P + 1);
for(int j = 0; j < k; j ++ ) {
to[j][i] = (P[lto[j] + 1] - 'a');
}
bool f = 1;
for(int j = 0; j < k; j ++ ) {
for(int l = j + 1; l < k; l ++ ) {
if(to[now[j]][i] == to[now[l]][i]) {
int f1 = Find(j), f2 = Find(l);
if(f1 != f2) {
f = 0;
bin[f1] = f2;
}
}
}
}
if(!f) {
is[i] = 1;
for(int j = 0; j < k; j ++ ) now[j] = to[now[j]][i]; // 变成了转变后的样子
for(int i = 0; i < k; i ++ ) lto[i] = i; // 改成原来的
}
else for(int j = 0; j < k; j ++ ) lto[j] = to[j][i];
}
else {
scanf("%d", &id[i]);
for(int j = 0; j < k; j ++ ) to[j][i] = lto[j];
}
}
for(int i = 0; i <= m;) {
build(i); // 按照 i 的 to重建
int j = i + 1;
while(j <= m && !is[j]) {
if(opt[j] == 2) qry[et[id[j]]].pb(j);
j ++;
}
for(int j = 0; j < k; j ++ )
for(int l = 0; l < k; l ++ )
c[j][l] = 0;
son = 0;
Dfs(1); // 求出来每个点的cnt数组
i = j;
}
for(int i = 1; i <= m; i ++ ) {
if(opt[i] == 2) printf("%d\n", ans[i]);
}
return 0;
}
总结:
- 时间复杂度正确,空间复杂度爆炸的情况下可以考虑能不能 离线 以减少空间复杂度。
- 调代码时别红温。
T4.灯火镜中人
题意:
不想说,自己看吧。点我
分析:
性质 + 结论 + 难写。
还没订。

浙公网安备 33010602011771号