AtCoder Beginner Contest 450 ABCDEF 题目解析

A - 3,2,1,GO

  • 预估难度:入门

题意

依次输出 \(N, N-1, \dots, 1\),中间用逗号 , 分隔。

代码

void solve()
{
    int n;
    cin >> n;
    for(int i = n; i >= 1; i--)
    {
        cout << i;
        if(i != 1)
            cout << ",";
    }
}

B - Split Ticketing

  • 预估难度:入门
  • 标签:枚举

题意

\(N\) 个车站 \(1, 2, \dots, N\),按顺序从西到东排成一条直线。

AtCoder 铁路列车从西向东经过这 \(N\) 个车站。

对于满足 \(1 \leq i \lt j \leq N\) 的任意两个整数 \(i, j\),在车站 \(i\) 上车并在车站 \(j\) 下车的费用为 \(C_{i,j}\)

判断是否存在三个整数 \(a, b, c\),使得:

  • \(1 \leq a \lt b \lt c \leq N\)
  • 在车站 \(a\) 上车、在车站 \(b\) 下车,然后再次在车站 \(b\) 上车、在车站 \(c\) 下车的总费用,小于直接在车站 \(a\) 上车并在车站 \(c\) 下车的费用。

思路

\(O(N^3)\) 枚举对应的 \(a, b, c\) 车站,判断是否存在 \(C_{a, b} + C_{b, c} \lt C_{a, c}\) 即可。

代码

int N;
int C[105][105];

void solve()
{
    cin >> N;
    for(int i = 1; i < N; i++) // 上车车站
        for(int j = i + 1; j <= N; j++) // 下车车站
            cin >> C[i][j];
    
    for(int a = 1; a <= N; a++)
        for(int b = a + 1; b <= N; b++)
            for(int c = b + 1; c <= N; c++)
                if(C[a][b] + C[b][c] < C[a][c])
                {
                    cout << "Yes";
                    return;
                }
    cout << "No";
}

C - Puddles

  • 预估难度:普及-
  • 标签:搜索

题意

有一个 \(H\)\(W\) 列的网格。

如果 \(S_{i,j}\)#,则第 \(i\) 行第 \(j\) 列的单元格被涂成黑色;如果 \(S_{i,j}\).,则被涂成白色。

请找出有多少个由白色单元格组成的四连通区域,其被黑色单元格所包围。

思路

如果一个白色连通块没有被黑色单元格包围,那么也就说明从它内部的单元格出发,一定能够走到地图边界。

反过来,我们可以从地图边界上的所有白色单元格出发,先把所有没被包围的连通块都标记掉。之后剩下来的没有被标记过的白色连通块即为答案,宽搜或深搜求解连通块数量即可。

时间复杂度 \(O(H \times W)\)

代码

const int dx[4] = {-1, 1, 0, 0};
const int dy[4] = {0, 0, -1, 1};

int n, m;
char mp[1005][1005];
bool vis[1005][1005]; // 标记每个位置是否被搜过

// 搜索 (x, y) 所在的白色连通块
void dfs(int x, int y)
{
    vis[x][y] = true;
    for(int i = 0; i < 4; i++)
    {
        int px = x + dx[i], py = y + dy[i];
        
        if(px < 1 || py < 1 || px > n || py > m) // 超出边界
            continue;
        if(mp[px][py] != '.') // 不是白色连通块
            continue;
        if(vis[px][py]) // 已搜过
            continue;
        
        dfs(px, py);
    }
}

void solve()
{
    cin >> n >> m;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++)
            cin >> mp[i][j];
    
    // 在地图周围的白色网格所在连通块 没有被黑色包围
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++)
            if(mp[i][j] == '.')
                if(i == 1 || j == 1 || i == n || j == m) 
                    dfs(i, j);
    
    int ans = 0;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++)
            if(mp[i][j] == '.' && !vis[i][j]) // 没被搜过的白色连通块
            {
                dfs(i, j);
                ans++;
            }
    cout << ans;
}

D - Minimize Range

  • 预估难度:普及-
  • 标签:枚举、贪心

题意

给定由 \(N\) 个正整数组成的序列 \(A = (A_1, A_2, \dots, A_N)\) 和一个正整数 \(K\)

你可以对序列 \(A\) 执行以下操作任意次:

  • 选择一个整数 \(i\)\(1 \leq i \leq N\)),并将 \(A_i\) 加上 \(K\)

求序列 \(A\)最大值与最小值之差可能的最小值。

思路

由于最终要求的答案为最值之差,所以我们只需要关注数字之间的差值而非实际大小。

可以考虑数字间的相对关系,明确“让小的数字变大等同于让大的数字变小”,因此操作也可以理解为每次任选数字减去 \(K\) 或加上 \(K\) 均可,这对最终答案来说没有影响。

既然操作不局限于加法了,不如直接让给定的所有 \(A_i\) 全部通过 \(-K\) 操作降低为最小的非负整数先,即 \(A_i := A_i \bmod K\),这样整个序列的数值范围就都变为 \([0, K-1]\) 之间了。

接下来便可以通过枚举去假设让每个数都成为最终序列中的最小值。假设 \(A_i\) 是序列的最小值,那么对于目前序列而言,我们应该让所有 \(\lt A_i\) 的数都进行一次 \(+K\) 操作,然后取序列中的最大值与 \(A_i\) 求差值即可得到答案。

对于序列最大值,易得以下分类讨论:

  • 如果序列中没有任何 \(\lt A_i\) 的数字,那么 \(A_i\) 也就是原序列的最小值,此时最大值便是原序列的最大值。
  • 如果序列中存在 \(\lt A_i\) 的数字,由于前面的处理中序列内每个整数均在 \([0, K-1]\) 范围内,只要某个数字 \(+K\) 就一定会超过序列中的其它所有数字。因此,此时最大值便是“\(\lt A_i\) 的数字中最大的数字 \(+K\)”。

为了快速地求出 \(\lt A_i\) 的数字中最大的数,可以对 \(A\) 序列进行排序,那么上述分类讨论可以更简单地变为:

  • 假如 \(A_1\) 是最小值,那么最大值就是 \(A_N\),差值即为 \(A_N - A_1\)
  • 假如 \(A_i\) \((2 \le i \le N)\) 是最小值,那么最大值就是 \(A_{i-1} + K\),差值即为 \(A_{i-1} + K - A_i\)

求最小差值即可,时间复杂度 \(O(N\log N)\)

代码

int n, k;
int a[200005];

void solve()
{
    cin >> n >> k;
    for(int i = 1; i <= n; i++)
    {
        cin >> a[i];
        a[i] %= k;
    }
    sort(a + 1, a + n + 1);
    
    int ans = a[n] - a[1];
    for(int i = 2; i <= n; i++)
        ans = min(ans, (a[i - 1] + k) - a[i]);
    cout << ans;
}

E - Fibonacci String

  • 预估难度:普及/提高-
  • 标签:深度优先搜索、前缀和

题意

给定字符串 \(X\)\(Y\)。按如下方式定义字符串序列 \(S_1, S_2, \dots\)

  • \(S_1 = X\)
  • \(S_2 = Y\)
  • 对于 \(i \geq 3\)\(S_i\) 是前后拼接 \(S_{i-1}\)\(S_{i-2}\) 两个字符串所得到的字符串。

对于每个 \(i = 1, 2, \ldots, Q\),回答以下问题。

  • 给定整数 \(L_i, R_i\) 和字符 \(C_i\),求字符 \(C_i\) 在字符串 \(S_{10^{18}}\) 的第 \(L_i\) 个到第 \(R_i\) 个字符中出现的次数。

思路

首先注意到,当 \(i \geq 3\) 时,\(S_i\) 是将 \(S_{i-1}\) 作为其前半部分,再在后面拼上 \(S_{i-2}\) 所得到的。换句话说, \(S_{i-1}\) 一定是是 \(S_i\) 的前缀。扩展出去,\(S_{i-1}\) 一定是后面所有字符串的前缀。

对于该字符串序列中每个字符串的长度,其增长速度类似于斐波那契数列,并且本题询问的下标区间 \(L_i, R_i\)\(10^{18}\) 范围内。根据斐波那契数列 \(F_1 = F_2 = 1, F_i = F_{i-1} + F_{i-2}\) 可得,\(F_{88} \gt 10^{18}\),因此我们完全不需要在意题目中的 \(S_{10^{18}}\) 这一项说明,最多只需要看到字符串序列第 \(88\) 项即可。

如此,我们便可以借助深搜+剪枝来从长度最大的这一项开始倒着解决每个询问。定义数组 \(\text{len}[i]\) 表示第 \(i\) 个字符串的长度,明显:

  • \(\text{len}[1] = |X|\)
  • \(\text{len}[2] = |Y|\)
  • \(\text{len}[i] = \text{len}[i-1] + \text{len}[i-2]\)

按顺序处理该数组,直到某一项 \(\text{len} \gt 10^{18}\) 为止,记最大这一项为第 \(n\) 项,之后每次询问就从这一项开始往前推。

我们记 \(f(n, l, r, c)\) 表示“\(S_n\) 的下标 \([l, r]\) 范围内有多少个字符 \(c\)”。由于 \(S_n = S_{n-1} + S_{n-2}\),因此我们可以将 \(S_n\) 的下标区间划分成 \(1 \sim \text{len}[n-1]\)\(\text{len}[n-1]+1 \sim \text{len}[n]\) 这两段。分类讨论询问的区间 \([l, r]\) 与这两段下标区间的关系:

  • 如果 \(r \le \text{len}[n-1]\),说明整个问题与后半段的 \(S_{n-2}\) 无关,那么该问题便等价于
    • \(f(n-1, l, r, c)\)
  • 如果 \(l \gt \text{len}[n-1]\),说明整个问题与前半段的 \(S_{n-1}\) 无关,那么该问题便等价于
    • \(f(n-2, l-\text{len}[n-1], r-\text{len}[n-1], c)\)
  • 否则,说明询问跨越了这两段下标区间,该问题与 \(S_{n-1}\)\(S_{n-2}\) 均有关联,此时问题等价于以下两部分
    • \(f(n-1, l, \text{len}[n-1], c)\)
    • \(f(n-2, 1, r - \text{len}[n-1], c)\)

考虑搜索边界,当 \(n=1\) 时等价于直接询问字符串 \(X\) 内部字符出现个数,当 \(n=2\) 时等价于直接询问字符串 \(Y\),那么便可以对这两个字符串开两个前缀和数组,用于快速获取一段区间内某种字符的出现次数。

如果搜索只写到这里,时间复杂度等同于递归实现斐波那契数列求解,最坏情况下可能达到 \(O(2^n)\),这是因为过程中出现了大量的重复子问题。

考虑剪枝。首先发现问题只会在遇到上述分类讨论的第三种情况时,才会因为询问跨越两段区间,而导致单个问题变为多个子问题。但观察此时的询问区间,会发现其从原本的字符串内部,变成了询问两个字符串的前后缀,因此在之后的搜索过程中,一旦两个子问题再遇到跨越两段区间的情况,其中一定会有一个子问题包含整个字符串。

因此我们可以再开一个数组 \(\text{sum}\),用于求解每个字符串中某个字符的出现次数,来将上述包含整个字符串的询问直接解决。这个数组可以方便地跟着 \(\text{len}\) 数组一起递推求得。

这样,我们便可以保证单次询问的时间复杂度是 \(O(n)\) 的。

最终时间复杂度为 \(O((|X| + |Y| + n) \cdot \sigma + Q \cdot n)\),其中 \(\sigma = 26, n \le 88\)

代码

string x, y;
ll len[100]; // len[i] 表示第 i 项的字符串长度

int xpre[10005][26]; // xpre[i][j] 表示字符串 x 的前 i 个位置里出现了多少次字符 j
int ypre[10005][26]; // ypre[i][j] 表示字符串 y 的前 i 个位置里出现了多少次字符 j
ll sum[100][26]; // sum[i][j] 表示第 i 个字符串中出现了多少次字符 j

// 求第 n 个字符串下标 [l, r] 范围内出现了多少次字符 c
ll dfs(int n, ll l, ll r, int c)
{
    if(n == 1)
        return xpre[r][c] - xpre[l-1][c];
    if(n == 2)
        return ypre[r][c] - ypre[l-1][c];
    
    if(l == 1 && r == len[n]) // 询问包含整个字符串 S[n]
        return sum[n][c];
    
    // S[n] = S[n-1] + S[n-2]
    ll mid = len[n - 1];
    if(r <= mid)
        return dfs(n - 1, l, r, c);
    else if(l > len[n - 1])
        return dfs(n - 2, l - mid, r - mid, c);
    else
        return dfs(n - 1, l, mid, c) + dfs(n - 2, 1, r - mid, c);
}

void solve()
{
    cin >> x >> y;
    
    // 处理两字符串各字符的前缀数量
    for(int i = 0; i < x.size(); i++)
        for(int j = 0; j < 26; j++)
            xpre[i + 1][j] = xpre[i][j] + (x[i] - 'a' == j);
    for(int i = 0; i < y.size(); i++)
        for(int j = 0; j < 26; j++)
            ypre[i + 1][j] = ypre[i][j] + (y[i] - 'a' == j);
    
    for(int j = 0; j < 26; j++)
    {
        sum[1][j] = xpre[x.size()][j];
        sum[2][j] = ypre[y.size()][j];
    }
    
    int n = 2; // 可能需要用到的字符串序列最大下标
    len[1] = x.size();
    len[2] = y.size();
    while(true)
    {
        n++;
        
        len[n] = len[n - 1] + len[n - 2];
        for(int j = 0; j < 26; j++)
            sum[n][j] = sum[n-1][j] + sum[n-2][j];
        
        if(len[n] > 1e18) // 第 n 个字符串长度超过 1e18,即可结束
            break;
    }
    
    int q;
    cin >> q;
    while(q--)
    {
        ll l, r;
        char c;
        cin >> l >> r >> c;
        cout << dfs(n, l, r, c - 'a') << "\n";
    }
}

F - Strongly Connected 2

  • 预估难度:提高+/省选-
  • 标签:动态规划、线段树

题意

有一个包含 \(N\) 个点和 \(M + N - 1\) 条边的有向图,边和点都有编号。

对于 \(1 \leq i \leq M\),第 \(i\) 条边是从点 \(X_i\) 指向点 \(Y_i\) 的有向边(\(X_i \lt Y_i\));

对于 \(1 \leq i \leq N-1\),第 \(M+i\) 条边是从顶点 \(i+1\) 指向顶点 \(i\) 的有向边(即存在一条从 \(N\)\(1\) 的链)。

现在考虑从第 \(1 \sim M\) 条边中选择一些边并删除(也可以不选),共有 \(2^M\) 种选择方式。

请问有多少种选择方式,使得在删除所选边后,剩余的图是强连通的?输出对 \(998244353\) 取模后的结果。

思路

由于 \(N \rightarrow 1\) 这条链上的 \(N-1\) 条边不可被删除,且另外的 \(M\) 条边起点编号一定小于终点编号。为了保证强连通,就必须要保证剩余的图中有至少一种走法能从点 \(1\) 走到点 \(N\)

首先改变一下题意,求删除边的方案数,等价于求保留边的方案数。

假设有四个整数 \(1 \le a \lt b \lt c \lt d \le N\),且 \(a \rightarrow c\)\(b \rightarrow d\) 存在两条边,由于倒序链的存在,我们可以从编号较大的 \(c\) 走回点 \(b\),因此这样交叉的两条链也能够实现从 \(a \rightarrow d\) 的走法。因此,我们可以尝试去维护所有选择的边能够到达的最右侧顶点的编号。

考虑动态规划。既然需要维护选择的边能到达的最右侧顶点,可以先让每条边按起点 \(X_i\) 从小到大排序,然后按顺序考虑每条边。记 \(\text{dp}[i][j]\) 表示考虑前 \(i\) 条边,有多少种选择方案能够从点 \(1\) 出发,最大能到达点 \(j\)

初始状态 \(\text{dp}[0][0] = 1\)

对于 \(\text{dp}[i][j]\) 与正在看的第 \(i\) 条边 \(X_i \rightarrow Y_i\),讨论如下:

  • 如果 \(j \lt X_i\),此时 \(j\)\(X_i\) 之间存在未被覆盖的部分,我们无法从 \(j\) 直接到达 \(X_i\)。又因为边按起点 \(X_i\) 排序,因此后面的边也无法覆盖这一段,可以当作无解,无需转移。
  • 如果 \(X_i \le j \lt Y_i\),此时选择这条边会导致最大能到达的点变为 \(Y_i\)。为了让最大能到达的点仍然为 \(j\),此时这条边只能不选。
    • \(\text{dp}[i][j] = \text{dp}[i-1][j]\)
  • 如果 \(j = Y_i\),此时我们可以将此前最大覆盖到 \(X_i \sim Y_i\) 之间的所有方案,通过选择这条边,来将其全部变为最大覆盖到 \(j\) 的方案。而如果这条边不选,那么答案只能取在此之前就覆盖到 \(Y_i\) 的方案。
    • \(\text{dp}[i][j] = (\sum\limits_{k=X_i}^{Y_i}\text{dp}[i-1][k]) + \text{dp}[i-1][j]\)
  • 如果 \(j \gt Y_i\),此时这条边选与不选均不影响最大覆盖的位置。
    • \(\text{dp}[i][j] = 2\cdot \text{dp}[i-1][j]\)

上述动态规划时间复杂度为 \(O(N \cdot M)\),仍然较高,但我们可以考虑优化掉表示看到第几条线段过的第一维,只维护其中用于表示最大覆盖到的顶点编号的第二维。

那么,对于每次操作,可以简化为:

  • 对下标 \(Y_i\) 位置的值加上 \(\sum\limits_{k=X_i}^{Y_i}\text{dp}[i-1][k]\)
  • 对下标在区间 \([Y_i + 1, N]\) 范围内的值全部 \(\times 2\)

操作涉及单点修改、区间求和与区间修改,考虑带懒惰标记的线段树进行优化即可。

时间复杂度 \(O(M\log M + M\log N)\)

代码

typedef long long ll;

struct edge
{
    int x, y;
    bool operator < (const edge &e) const
    {
        return x < e.x;
    }
};
edge e[200005];

int n, m;

#define ls (p << 1)
#define rs (p << 1 | 1)

struct node
{
    int l, r;
    ll sum; // 区间总和
    ll lazy; // 控制区间乘的懒惰标记
};
node tr[200005 << 2];

// 答案上传
void push_up(int p)
{
    tr[p].sum = (tr[ls].sum + tr[rs].sum) % mod;
}

// 标记下传
void push_down(int p)
{
    if(tr[p].lazy == 1)
        return;
    
    tr[ls].sum = tr[ls].sum * tr[p].lazy % mod;
    tr[rs].sum = tr[rs].sum * tr[p].lazy % mod;
    
    tr[ls].lazy = tr[ls].lazy * tr[p].lazy % mod;
    tr[rs].lazy = tr[rs].lazy * tr[p].lazy % mod;
    
    tr[p].lazy = 1;
}

void build(int l, int r, int p = 1)
{
    tr[p].l = l;
    tr[p].r = r;
    tr[p].lazy = 1;
    if(l == r)
        return;
    int mid = l + r >> 1;
    build(l, mid, ls);
    build(mid + 1, r, rs);
}

// 单点修改,让 pos 位置的值加 add
void updatePoint(int pos, ll add, int p = 1)
{
    if(tr[p].l == tr[p].r) // 叶子结点
    {
        tr[p].sum = (tr[p].sum + add) % mod;
        return;
    }
    push_down(p);
    if(pos <= tr[ls].r)
        updatePoint(pos, add, ls);
    if(pos >= tr[rs].l)
        updatePoint(pos, add, rs);
    push_up(p);
}

// 区间修改,让区间 [l, r] 每个值均 *2
void updateSegment(int l, int r, int p = 1)
{
    if(l <= tr[p].l && tr[p].r <= r)
    {
        tr[p].sum = tr[p].sum * 2 % mod;
        tr[p].lazy = tr[p].lazy * 2 % mod;
        return;
    }
    push_down(p);
    if(l <= tr[ls].r)
        updateSegment(l, r, ls);
    if(r >= tr[rs].l)
        updateSegment(l, r, rs);
    push_up(p);
}

// 区间求和
ll query(int l, int r, int p = 1)
{
    if(l <= tr[p].l && tr[p].r <= r)
        return tr[p].sum;
    push_down(p);
    ll res = 0;
    if(l <= tr[ls].r)
        res += query(l, r, ls);
    if(r >= tr[rs].l)
        res += query(l, r, rs);
    return res % mod;
}

void solve()
{
    cin >> n >> m;
    for(int i = 1; i <= m; i++)
        cin >> e[i].x >> e[i].y;
    sort(e + 1, e + m + 1);
    
    build(1, n);
    
    updatePoint(1, 1); // dp[1] = 1
    
    for(int i = 1; i <= m; i++)
    {
        int &x = e[i].x, &y = e[i].y;
        // 单点修改,y 位置加 [x, y] 的总和
        updatePoint(y, query(x, y));
        // 区间修改,[y+1, n] 全部 *2
        if(y + 1 <= n)
            updateSegment(y + 1, n);
    }
    cout << query(n, n);
}
posted @ 2026-03-21 22:28  StelaYuri  阅读(197)  评论(0)    收藏  举报