AtCoder Beginner Contest 419 ABCDEF 题目解析
A - AtCoder Language
题意
给定一个字符串,如果是 red、blue、green 的其中一种,请将其分别替换为 SSS、FFF、MMM 输出,否则请输出 Unknown。
代码
void solve()
{
string s;
cin >> s;
if(s == "red")
cout << "SSS";
else if(s == "blue")
cout << "FFF";
else if(s == "green")
cout << "MMM";
else
cout << "Unknown";
}
B - Get Min
题意
有一个空袋子。
总共有 \(Q\) 次操作,每次操作可能是往袋子中放进一个编号为 \(x\) 的小球,也可能是取出当前袋子中编号最小的小球,并输出它的编号。
取球操作不会在没有球时给出。
思路
数据范围很小,做法有很多种。
可以是每当多一个新数字,就往数组中加一个数字再重新暴力排序。
也可以借助 priority_queue 或者 multiset 等关系型容器来进行维护。
下面的代码以二叉堆/优先队列为例。可以直接定义小根堆,或者以存储相反数的形式完成最小数的维护。
代码
void solve()
{
priority_queue<int> q;
int Q;
cin >> Q;
while(Q--)
{
int op;
cin >> op;
if(op == 1)
{
int x;
cin >> x;
q.push(-x);
}
else
{
cout << -q.top() << "\n";
q.pop();
}
}
}
C - King's Summit
题意
在一张 \(10^9 \times 10^9\) 的网格内有 \(N\) 个人,每个人都有一个初始坐标。
每个人每秒钟可以往周围八个方向的任意一个方向走一步。
问至少需要多少秒,才能让所有人最后都走到同一个网格内。
思路
首先我们假设最终的网格坐标是 \((p, q)\)。
如果原坐标为 \((x, y)\) 的这个人想走到 \((p, q)\) 这个位置:
- 当 \(x \ne p\) 且 \(y \ne q\) 时,肯定是沿着对角线走能够尽快地接近目标点
- 当 \(x = p\) 或 \(y=q\) 时,此时只能沿着直线前往目标点
所以从 \((x, y)\) 走到 \((p, q)\) 需要的时间取决于 \(\max(|x-p|, |y-q|)\)。
本题需要让所有人都走到目标点 \((p, q)\),因此答案取决于需要的时间最长的那个人。所以我们在选择 \((p, q)\) 这个目标点的坐标时,需要尽可能保证 \(\max(|x-p|, |y-q|)\) 越小越好。
由于 \(p\) 只和 \(x\) 方向坐标有关,\(q\) 只和 \(y\) 方向坐标有关,因此我们可以把两个方向的坐标分开考虑。对于 \(x\) 方向而言,明显耗时最长的人要么是 \(x\) 坐标最小的那个人,要么是 \(x\) 坐标最大的那个人,为了让他们俩走到目标点的最大时间最短,我们只能够选择这两人的坐标中点。\(y\) 方向同理。
得出目标点坐标后,取一遍所有人的时间最大值即可。
代码
int n, x[200005], y[200005];
void solve()
{
cin >> n;
int minx = 2e9, maxx = -1;
int miny = 2e9, maxy = -1;
for(int i = 1; i <= n; i++)
{
cin >> x[i] >> y[i];
minx = min(minx, x[i]);
maxx = max(maxx, x[i]);
miny = min(miny, y[i]);
maxy = max(maxy, y[i]);
}
int midx = (minx + maxx) / 2; // 找中点,作为目标点的坐标
int midy = (miny + maxy) / 2;
int ans = 0;
for(int i = 1; i <= n; i++)
ans = max(ans, max(abs(x[i] - midx), abs(y[i] - midy)));
cout << ans << "\n";
}
D - Substr Swap
题意
给定两个长度为 \(N\) 的字符串 \(S\) 和 \(T\) 以及 \(M\) 次操作。
每次操作给定两个正整数 \(L, R\),表示将两个字符串中下标在 \([L, R]\) 区间内的每个字符两两对应进行交换。
问 \(M\) 次操作后,\(S\) 字符串是什么。
思路
由于交换是两个字符串中的对应下标位置进行交换的,很明显交换 \(2\) 次和没交换是一样的。
所以我们可以考虑去统计每个位置被交换的次数。
每次给定的区间内的所有位置都会被交换一次,因此需要实现区间 \(+1\) 的操作。考虑差分数组 + 前缀和。
最后对于每个位置,如果交换次数为奇数,说明这个位置的字符应该取 \(T\) 字符串内的对应字符;如果是偶数,则说明不变。
代码
int n, m;
char s[500005], t[500005];
int cnt[500005]; // cnt[i] 表示 i 这个位置的字符被交换了多少次
void solve()
{
cin >> n >> m;
cin >> (s + 1);
cin >> (t + 1);
while(m--)
{
int l, r;
cin >> l >> r;
cnt[l]++;
cnt[r + 1]--; // 差分,[l, r] 区间内交换次数 +1
}
for(int i = 1; i <= n; i++)
{
cnt[i] += cnt[i - 1];
if(cnt[i] % 2 == 1) // 交换次数为奇数,说明第 i 个位置的字符为 t[i]
s[i] = t[i];
}
cout << (s + 1) << "\n";
}
E - Subarray Sum Divisibility
题意
给定一个长度为 \(N\) 的整数序列 \(A_1, A_2, \dots, A_N\)。
你可以重复执行以下操作,直到整个序列中的每一个长度为 \(L\) 的连续子序列的总和均是 \(M\) 的倍数:
- 选择序列中的某个整数,将其 \(+1\)。
问满足条件的最少操作次数。
思路
首先以滑动窗口的视角考虑本题。
对于以 \(i\) 为左端点的长度为 \(L\) 的连续子序列 \([i, i+L-1]\) 而言,假如我们已经保证这段连续子序列的总和是 \(M\) 的倍数了。
考虑让这个滑动窗口右移一个位置,变为 \([i+1, i+L]\) 这段连续子序列。按照题意,我们得保证这段连续子序列的总和也是 \(M\) 的倍数。
但对于前后两个区间而言,我们可以在前一个区间的基础上加上 \(A_{i+L}\) 这个数字,再减去 \(A_i\) 这个数字,就可以得到后一个区间的总和。
既然两个区间总和均为 \(M\) 的倍数,明显 \(A_{i+L} - A_{i} \equiv 0\ (\bmod M)\) 成立。换言之:
对于整个序列中的每一个数字 \(A_i\),所有与当前数字距离为 \(L\) 的倍数的其它数字都应当与 \(A_i\) 是同余的。
我们可以把所有距离为 \(L\) 倍数的数字进行分组,总共可以分出 \(L\) 组数字出来。
我们以每组数字中的最小下标暂时作为该组编号,记 f[i][j] 表示把第 \(i\) 组中的所有数字调整为“除以 \(M\) 的余数为 \(j\)”的情况所需要的最少操作次数。
对于第 \(i\) 组内的每个数字 \(x\),为了将 \(x\) 每次 \(+1\) 直到其除以 \(M\) 的余数为 \(j\),我们需要的操作次数为:
- 如果 \(x \le j\),次数为 \(j - x\)
- 如果 \(x \gt j\),次数为 \(j + M - x\)
综上,次数可以直接通过 \((j + M - x) \bmod M\) 进行计算。该数组可以在 \(O(N\cdot M)\) 的时间复杂度内预处理出。
现在,对于上面分出来的每组数字,组内数字已经可以保证同余了,但是“每组最终的结果相加需要是 \(M\) 的倍数”这个条件我们还没有保证(也就是“任意一个长度为 \(L\) 的子序列总和是 \(M\) 的倍数”这个条件)。
接下来考虑动态规划,我们可以让每组数字都选择一个数值作为该组的变化目标,目的是让每一组选择出来的目标数值之和是 \(M\) 的倍数。
记 dp[i][j] 表示考虑到第 \(i\) 组过,且前 \(i\) 组数字所选择的目标数值总和(除以 \(M\) 的余数)为 \(j\) 时,所需要的最少操作次数。在此基础上再枚举第 \(i\) 组数字所挑选的目标数值为 \(k\),那么就可以得到状态转移方程为:
注意其中 \(j - k\) 需要对 \(M\) 取余,可写作 \((j - k + M) \bmod M\) 的形式。
时间复杂度 \(O(L\cdot M^2)\)。
代码
int n, m, l;
int a[505];
int f[505][505]; // f[i][j] 表示把第 i 组数字全部转为 %M = j 的情况所需要的最少操作次数
int dp[505][505]; // dp[i][j] 表示前 i 组数字总和 %M = j 的情况所需要的最少操作次数
void solve()
{
cin >> n >> m >> l;
for(int i = 1; i <= n; i++)
cin >> a[i];
for(int i = 1; i <= l; i++) // 对于第 i 组数字
for(int j = 0; j < m; j++) // 考虑将这组数字全部转为 %M = j 的情况
for(int k = i; k <= n; k += l) // 对于该组内的每个数字 a[k]
f[i][j] += (j - a[k] + m) % m;
memset(dp, 0x3f, sizeof dp);
dp[0][0] = 0;
for(int i = 1; i <= l; i++) // 考虑到第 i 组数字过
for(int j = 0; j < m; j++) // 前 i 组数字挑选的总和 %M = j
{
dp[i][j] = 1e9;
for(int k = 0; k < m; k++) // 第 i 组数字挑选 k 作为目标
dp[i][j] = min(dp[i][j], dp[i-1][(j-k+m)%m] + f[i][k]);
}
cout << dp[l][0];
}
F - All Included
题意
给定 \(N\) 个由小写英文字母组成的字符串 \(S_1, S_2, \dots, S_N\) 以及一个整数 \(L\)。
问有多少种仅由小写英文字母组成且长度为 \(L\) 的字符串满足 \(S_1, S_2, \dots, S_N\) 的每个字符串均是它的子串?
输出数量,对 \(998244353\) 取模。
思路
考虑计数DP,记 dp[i][s][j] 表示对于所有长度为 \(i\) 的字符串,这些字符已经能够拼出的子串集合为 \(s\)(二进制表示),且目前最后一段字符的状态为 \(j\) 时的方案总数。然后考虑从小到大推出每一种长度字符串的方案总数。
重点是“最后一段字符的状态为 \(j\)”不好在数组中直接描述出来,因为我们现在是一个字符一个字符慢慢把整个字符串拼出来的,有可能现在的“最后一段字符”什么含义也没有,但为了能够拼出题目中给定的某个字符串,当前的“最后一段字符”也有可能会是 \(S_1, S_2, \dots, S_N\) 这些字符串中的某一个字符串的某个前缀。
但除此之外,我们还需要考虑到在拼字符串的过程中,可能会有多个字符串以相同前缀、相同后缀、公共前后缀等形式出现,甚至某个字符串的前缀可以是另一个字符串的中间某一子串,这对于我们的匹配过程其实不好处理。因此我们可以考虑把题目给定的字符串全部放进字典树内。
在实际转移的过程中,如果我们当前的目的只是把某个给定的字符串拼出来,那么在字典树上的走法就是直直地往下走,直到走到某个字符串末尾所代表的叶子结点为止,表示我们拼出了该字符串。对于上文提到的”多个字符串存在相同前缀“这一情况,这是比较好处理的。
但过程中还有可能出现以下这些情况:
- 在某个字符串拼完后,存在其它字符串是当前字符串的后缀(即上文提及的”相同后缀“),此时相当于我们一次性拼出了多个字符串,但在字典树上我们无法处理公共后缀的情况。
- 在某个字符串拼完后,当前字符串的后缀是另一个字符串的前缀(即上文提及的”公共前后缀“情况),我们可以根据这一特点节省答案长度,直接接着继续拼另一个字符串。但如果只是普通字典树的话,我们只能够判断多个字符串间是否存在公共前缀,而无法实现对后缀的判断。
- 在某个字符串拼到一半后,又转而去拼另一个字符串的前缀的情况(即上文提及的”某个字符串的前缀可以是另一个字符串的中间某一子串“情况)。此时就相当于是在字典树上的当前字符串匹配过程中发生了失配。
所以本题我们需要在字典树的基础上,再实现类似于KMP算法失配时往前跳跃的功能,所以该部分需要在字典树上构建 AC 自动机。
在构建出 AC 自动机,得到字典树上每个结点失配时需要跳到的下一个结点之后,我们每拼上一个新字符,便可以直接视作在字典树上向后跳跃即可。
回到最开始的动态规划状态,此时我们便可以把 dp[i][s][j] 看作是对于所有长度为 \(i\) 的字符串,这些字符已经能够拼出的子串集合为 \(s\)(二进制表示),且目前最后一个字符在字典树上的结点编号为 \(j\) 时的方案总数。
枚举这三维状态,然后再枚举要拼的第 \(i\) 个字符是什么,便能够得知接下来往当前结点的哪个方向走,以及走到下一个点之后能够拼出的字符串集合,于是便能够在树上实现向后转移答案。
最后注意上面讨论的第一种情况,也就是如果存在某个字符串是另一个字符串的后缀,那么我们在字典树上拼出较长的字符串时,也代表着所有后缀都拼出来了。这一步我们可以根据 AC 自动机的 fail 指针,把每个结点 fail 指向的结点所表示的终点状态进行下传即可。
总时间复杂度 \(O(L\cdot M\cdot 2^N\cdot k)\),其中 \(k = 26\)。
代码
const int mod = 998244353;
int trie[100][26], status[100], fail[100], tot = 1;
// trie 存储字典树
// status 表示当前结点是哪些字符串的末尾
// fail 指向字典树上当前结点发生失配时需要跳到的结点
// 将字符串 s 插入字典树,当前字符串的状态编号为 sta
void insert(string s, int sta)
{
int p = 1; // 当前结点编号
for(char c : s)
{
int id = c - 'a';
if(trie[p][id] == 0) // 不存在结点则创建新结点
trie[p][id] = ++tot;
p = trie[p][id];
}
status[p] |= sta; // 字典树终点存储其表示的字符串
}
// 构建 AC 自动机
void build()
{
queue<int> q;
for(int i = 0; i < 26; i++)
{
if(trie[1][i] != 0)
{
int v = trie[1][i];
fail[v] = 1; // 第一个字符就匹配失败,fail 指向根结点
q.push(v);
}
else
trie[1][i] = 1; // 直接失配回到根结点
}
while(!q.empty())
{
int u = q.front();
q.pop();
for(int i = 0; i < 26; i++)
{
if(trie[u][i] != 0) // 如果 u 存在 i 方向的儿子结点
{
int v = trie[u][i];
// 如果下个点 v 发生失配,可以通过 u 失配后跳到的结点再往下走相同字符 i 得到 fail 指针
fail[v] = trie[fail[u]][i];
q.push(v);
}
else // 如果 u 不存在 i 方向的儿子结点
{
// 之后的匹配过程中如果往 i 方向跳,直接视作失配即可,从当前失配点出发继续往 i 方向跳
trie[u][i] = trie[fail[u]][i];
}
}
// 当一个字符串是另一个字符串的后缀时,对于字典树来说,fail 指针会指向某一后缀
// 此时可以把 fail 指针带着的标记在字典树上进行下传
status[u] |= status[fail[u]];
}
}
int n, l;
int dp[105][85][260];
// dp[i][j][k] 表示当前考虑到第 i 个字符
// 最后一个字符停留在字典树上第 j 个点
// 且已经拼出的字符串集合为 k 时的方案数
void solve()
{
cin >> n >> l;
for(int i = 1; i <= n; i++)
{
string s;
cin >> s;
insert(s, 1 << (i - 1));
}
build();
dp[0][1][0] = 1;
for(int i = 1; i <= l; i++) // 考虑到第 i 个字符过
{
for(int j = 1; j <= tot; j++) // 上一个字符如果停留在字典树上的 j 位置
{
for(int k = 0; k < (1 << n); k++) // 前 i-1 个字符已经拼得的子串集合
{
for(int c = 0; c < 26; c++) // 第 i 个字符如果放 c
{
int v = trie[j][c]; // 当前字符在字典树上的位置
int s = k | status[v]; // 当前字符串可以拼出的子串集合
dp[i][v][s] = (dp[i][v][s] + dp[i - 1][j][k]) % mod;
}
}
}
}
int ans = 0;
for(int j = 1; j <= tot; j++) // 枚举最后一个字符在字典树上的位置
ans = (ans + dp[l][j][(1 << n) - 1]) % mod;
cout << ans << "\n";
}

浙公网安备 33010602011771号