AtCoder Beginner Contest 419 ABCDEF 题目解析

A - AtCoder Language

题意

给定一个字符串,如果是 redbluegreen 的其中一种,请将其分别替换为 SSSFFFMMM 输出,否则请输出 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 \equiv A_{i+L}\ (\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\),那么就可以得到状态转移方程为:

\[dp[i][j] = \min_{k = 0} ^ M (dp[i-1][j-k] + f[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";
}
posted @ 2025-08-16 23:25  StelaYuri  阅读(108)  评论(0)    收藏  举报