【题解】牛客 OI 赛前训练营(2023)-普及组题目选解

还有三四天就 CSP 了,比赛还是打打得了。

作为一个退役有一段时间的蒟蒻,发现今年的题目甚至像是比往年的模拟赛更简单,很神奇。

这些题目中,不乏有一些值得复习巩固的好题,于是写这篇博客记录一下。

题目排序:

std::sort(problem + 1, problem + n + 1, [](const Problem x, const Problem y) {
	if (x.contestID == y.contestID) return x.problemID < y.problemID;
	return x.contestID < y.contestID;
});

contestIDD1D6 表示,problemIDT1T4 表示。

D1T4. 括号序列

这题比较仁慈的地方是部分分和特殊性质分给的都很足。尤其特殊性质分,给 40 40 40 分?

对于前 10 % 10\% 10% 的分数,直接暴力每一个括号的可能涂色就好了,复杂度指数级。

对于所有数据,就要拿出应付括号序列的最佳办法:分治。分治的思路是处理 [ 1 , n ] [1,n] [1,n],就要处理 [ 1 , match 1 ] [1, \text{match}_1] [1,match1] 以及 [ match 1 + 1 , n ] [\text{match}_1+1, n] [match1+1,n],其中 match i \text{match}_i matchi 代表与 i i i 配对的右括号的下标。分治内部则枚举区间左右端点染色方案。

需要注意的是,单纯的分治是无法通过的,依然会 TLE。所以还需要加上记忆化。

下面给出核心代码,以供参考。

int dfs(int l, int r, int c1, int c2) {
    if (l > r) return 0;
    if (f[l][r][c1][c2]) return f[l][r][c1][c2];
    if (match[l] == r) {
        for (int i = 0; i <= 2; i++) {
            if (c1==i or c2==i) continue;
            f[l][r][c1][c2] = std::max(f[l][r][c1][c2], dfs(l+1, r-1, i, i) + value[i]);
        }
    } else {
        for (int i = 0; i <= 2; i++) {
            if (c1 == i) continue;
            f[l][r][c1][c2] = std::max(f[l][r][c1][c2], dfs(l+1, match[l]-1, i, i) + dfs(match[l]+1, r, i, c2) + value[i]);
        }
    }
    return f[l][r][c1][c2];
}
for (int i = 0; i <= 2; i++)
    for (int j = 0; j <= 2; j++) 
        ans = std::max(ans, dfs(1, n, i, j));
D3T3. 涂色仪式

六场比赛唯一的数据结构题?

可以将两节点权值为质数的边涂成红边,其它的涂成黑边,这样得到一个森林。取其中的一棵树研究,容易发现只需要每次将叶子节点染成白色,再向根节点推进即可达到染色节点数最大化,即「该树所含节点数 − 1 -1 1」。这个可以使用并查集解决。

PS.《容易发现》只是在事后诸葛… 我一开始以为要隔点染色,然后跑 n n n 遍 dp…

完整代码:

#include <iostream>
#include <cstring>
const int N = 3e5 + 9;
const int P = 2e6 + 9;
int n, w[N];
bool vis[N];
// Euler
int prime[P], pc;
bool notPrime[P];
// disjoint set
int ds[N];
int find(int x) { return ds[x]<0 ? x : (ds[x] = find(ds[x])); }
void merge(int x, int y) {
    x = find(x), y = find(y);
    if (x == y) return ;
    if (ds[x] > ds[y]) std::swap(x, y);
    ds[x] += ds[y], ds[y] = x;
}
bool judge(int x, int y) { return find(x) == find(y); }
void Euler() {
    for (int i = 2; i < P; i++) {
        if (!notPrime[i]) prime[pc++] = i;
        for (int j = 0; j < pc; ++j) {
            if (1ll * i * prime[j] > P) break;
            notPrime[i*prime[j]] = true;
            if (i % prime[j] == 0) break;
        }
    }
}
int main() {
    Euler();
    memset(ds, -1, sizeof ds);
    scanf("%d", &n);
    for (int i = 1; i <= n; i++)
        scanf("%d", w + i);
    for (int i = 1; i < n; i++) {
        int x, y;
        scanf("%d%d", &x, &y);
        if (notPrime[w[x] + w[y]]) continue;
        merge(x, y);
    }
    int ans = 0;
    for (int i = 1; i <= n; i++) {
        int t = find(i);
        if (vis[t]) continue;
        ans += -ds[t] - 1;
        vis[t] = true;
    }
    printf("%d\n", ans);
    return 0;
}
D4T3. 回文树

这好像也是数据结构诶

但感觉这个树有点水,啥性质都没考。所以就算啥关于树的算法都没学都可以会。

本质思想就是直接模拟就行了。由于题目保证了是完全二叉树,每一次修改操作不会超过 log ⁡ 2 n \log_2n log2n 次,于是时间复杂度其实是 O ( n log ⁡ n ) O(n \log n) O(nlogn),可以通过。就是在码代码的时候得注意细节,调试调了巨 TM 久无比。

void build(int u) {
    if (u > n) return ;
    build(u << 1), build(u << 1 | 1);
    tr[u].cnt[a[u]]++;
    // printf("tr[%d].cnt[%d] = %d\n", u, a[u], tr[u].cnt[a[u]]);
    for (int i = 0; i < 26; i++) {
        tr[u].cnt[i] += tr[u<<1].cnt[i] + tr[u<<1|1].cnt[i];
        // printf("tr[%d].cnt[%d] = %d\n", u, i, tr[u].cnt[i]);
        if (tr[u].cnt[i] & 1) tr[u].odd++;
    }
    // printf("tr[%d].odd = %d\n", u, tr[u].odd);
    if (tr[u].odd <= 1) ans++;
}
void modify(int p, int v, int add) {
    tr[p].cnt[v] += add;
    if (tr[p].cnt[v] & 1) {
        // printf("tr[%d].odd = %d(+1)\n", p, tr[p].odd + 1);
        if (++tr[p].odd == 2)
            ans--;
    } else {
        // printf("tr[%d].odd = %d(-1)\n", p, tr[p].odd - 1);
        if (--tr[p].odd == 1)
            ans++;
    }
}
D4T4. 构造题

哇塞久违的构造题(

上一次做构造题还是在 atcoder 上打玄学交互题,到现在都没过。

不过这题的构造思路还是比较简单的。总共 n n n 个数,还互不相等,那么顺序对和逆序对之和显然是 ( n 2 ) n \choose 2 (2n)。那么我只需要考虑顺序对的数量达到 ( n 2 ) / 2 {n \choose 2} / 2 (2n)/2 即可。又因为要求字典序最小,则应该让前面的数尽可能小,即取 1 , 2 , 3 , … 1,2,3, \dots 1,2,3,。这些数会产生 n − 1 , n − 2 , n − 3 , … n-1,n-2,n-3,\dots n1,n2,n3, 组顺序对。直到顺序对数量将爆未爆的时候(即 ( n 2 ) / 2 − ( n − 1 ) − ( n − 2 ) − ( n − 3 ) − . . . {n \choose 2} / 2 - (n-1) - (n-2) - (n-3) - ... (2n)/2(n1)(n2)(n3)... 减到负数的前一个),记录下当前还差多少顺序对(记作 k k k),在序列后面在加一个 n − k n-k nk 即可。

而对于剩下的数,由于不能产生新的顺序对,则将其降序排列即可。

LL target = combination(n, 2) / 2;
for (int i = 1; n-i <= target; i++) {
    ans[++c] = i;
    target -= (n - i);
}
int t;
if (target == 0) t = 0;
else ans[++c] = t = n - target;
for (int i = n; c <= n; i--) {
    if (i == t) i--;
    ans[++c] = i;
}
D5T3. 学习除法

这几套题为啥都跟加减乘除过不去呢?

要求的是要除以多少使得它最后结果小于等于某个数并且花费最小。

发现 x , y x, y x,y 都巨小无比,那不如直接求出对一个数除以至少 k k k 所需要的花费最小值。可以直接 dp。

启示:离线问询可以先预处理出答案再 O ( 1 ) O(1) O(1) 问询。

#include <iostream>
#include <cstring>
using LL = long long;
const int N = 1e5 + 9;
int n, q, a[N];
LL f[N];
int main() {
    memset(f, 0x3f, sizeof f);
    scanf("%d%d", &n, &q);
    for (int i = 1; i <= n; i++)
        scanf("%d", a + i);
    for (int i = n-2; i; i--)
        f[i] = std::min((LL)a[i], f[i+1]);
    f[n] = a[n];
    for (int i = 1; i < N; i++)
        for (int j = 2; 1ll * i * j < N; j++)
            f[i*j] = std::min(f[i*j], f[i] + f[j]);
    for (int i = N - 2; i; i--)
        f[i] = std::min(f[i], f[i+1]);
    // for (int i = 1; i < N; i++)
    //     printf("%d ", f[i]);
    while (q--) {
        int x, y;
        scanf("%d%d", &x, &y);
        if (x <= y) {
            puts("0");
            continue;
        }
        printf("%lld\n", f[x/(y+1)+1]);
    }
    return 0;
}

还看到了一种做法,可以将此处的 dp 用完全背包模型实现,挺巧妙的。

D6T3. 学习加法

显然,易知,同理,dfs 之。

从最后一位开始 dfs,当前这个位置上可能要进位也可能不要,这不仅取决于上一位是否能够进位,还取决于是否记得进位,所以再带一个参数 c a r r y carry carry 表示上一位到当前位是否有进位。以此类推,dfs 即可。需要记忆化。

LL dfs(int bit, int carry) {
    if (bit>sa.size() and bit>sb.size())
        return 1;
    if (f[bit][carry])
        return f[bit][carry];
    int cur = a[bit] + b[bit] + carry;
    LL ret = dfs(bit+1, 0);
    if (cur >= 10) ret += dfs(bit+1, 1);
    return f[bit][carry] = ret % MOD;
}

还有一种解法。可以从排列组合的角度考虑。从样例二中其实就已经可以发现, 9 9 9 在这道题目中是非常特殊的。因为两位之和大于 9 9 9 就必定可以进位,所以选法为 2 2 2;小于 9 9 9 则不可能进位,所以选法为 1 1 1。但等于 9 9 9 是否需要向下一位进位,是取决于上一位中有没有进位的。如果上一位记得进位,那 9 9 9 才有可能接着进位;如果忘了,那接下来的 9 9 9 也不可能进位。而对于下一位而言,如果下一位还是 9 9 9,那又要看当前位 9 9 9 的脸色。所以,一串连续的 9 9 9,任意一个地方断掉,都可以产生一种选法。若有 n n n 9 9 9 后面连带一个大于 9 9 9 的,那这一组数能产生的选法就是 n + 2 n + 2 n+2。最后,根据乘法原理,乘起来就是答案。

#include <iostream>
using LL = long long;
const int N = 1e5 + 9;
const int MOD = 1e9 + 7;
std::string sa, sb;
int a[N], b[N];
int main() {
    std::cin >> sa >> sb;
    for (int i = sa.size()-1, j = 1; i >= 0; i--, j++)
        a[j] = sa[i] - '0';
    for (int i = sb.size()-1, j = 1; i >= 0; i--, j++)
        b[j] = sb[i] - '0';
    int bit = std::max(sa.size(), sb.size());
    LL ans = 1;
    for (int i = 1; i <= bit; i++) {
        if (a[i] + b[i] <= 9) continue;
        int cnt = 2;
        while (a[i+1]+b[i+1]==9 and i+1<=bit)
            cnt++, i++;
        (ans *= cnt) %= MOD;
    }
    printf("%lld", ans);
    return 0;
}
D6T4. 嘤嘤的子串权值和

非常巧妙的题目!

暴力去做的话,枚举四个字符,时间复杂度 O ( n 4 ) O(n^4) O(n4) 巨大无比,期望得分 10 10 10 分。

有一种更加聪明的做法。容易发现 abba \texttt{abba} abba 实际上是两个 a \texttt{a} a 夹着两个 b \texttt{b} b,所以只需要枚举两端点 a \texttt{a} a,再计算出两端点间夹了多少个 b \texttt{b} b,乘一下得到答案。离线统计区间内 b \texttt{b} b 的数量可以用前缀和,于是整体时间复杂度就到了 O ( n 2 ) O(n^2) O(n2),期望得分 30 30 30 分。

// O(n^2): mathematical, 30pts
#include <iostream>
#include <cstring>
using LL = long long;
const int N = 5e6 + 9;
const int MOD = 1e9 + 7;
char s[N];
int pre[N];
LL combination(int n, int m) {
    if (n < m) return 0;
    LL ans = 1;
    for (int i = n, j = 1; j <= m; i--, j++)
        ans = ans * i / j % MOD;
    return ans;
}
int main() {
    scanf("%s", s + 1);
    int n = strlen(s + 1);
    for (int i = 1; i <= n; i++) {
        pre[i] = pre[i-1];
        if (s[i] == 'b')
            pre[i]++;
    }
    LL ans = 0;
    for (int i = 1; i <= n; i++) {
        if (s[i] == 'a') {
            for (int j = i+1; j <= n; j++) {
                if (s[j] == 'a') {
//                     printf("%d ~ %d: %d\n", i, j, pre[j-1]-pre[i]);
                    (ans += i * (n-j+1) * combination(pre[j-1] - pre[i], 2)) %= MOD;
                }
            }
        }
    }
    printf("%lld\n", ans);
    return 0;
}

继续优化。

刚才是固定 a \texttt{a} a 的位置,计算 b \texttt{b} b 的组合数,当然也可以反过来,固定 b \texttt{b} b 的位置,计算 a \texttt{a} a 的组合数。记两个固定的 b \texttt{b} b 的位置为 l , r ( l < r ) l, r(l<r) l,r(l<r),左边的 b \texttt{b} b 的左侧所有的 a \texttt{a} a 的下标为 l 1 , l 2 , … , l i l_1,l_2,\dots,l_i l1,l2,,li,右侧为 r 1 , r 2 , … , r j r_1,r_2,\dots,r_j r1,r2,,rj

为了方便研究(懒得打字),假设 i = j = 2 i = j = 2 i=j=2。则组合数为:

l 1 × ( n − r 1 + 1 ) + l 1 × ( n − r 2 + 1 ) + l 2 × ( n − r 1 + 1 ) + l 2 × ( n − r 2 + 1 ) l_1 \times (n-r1+1) + l_1 \times (n-r2+1) + l_2 \times (n-r1+1) + l_2 \times (n-r2+1) l1×(nr1+1)+l1×(nr2+1)+l2×(nr1+1)+l2×(nr2+1)

可被化简为:
( l 1 + l 2 ) × [ ( n − r 1 + 1 ) + ( n − r 2 + 1 ) ] (l_1 + l_2) \times [(n-r1+1) + (n-r2+1)] (l1+l2)×[(nr1+1)+(nr2+1)]
即:所有 b \texttt{b} b 左侧的 a \texttt{a} a 的所有下标的和乘上所有右侧的 a \texttt{a} a 的所有下标的和。两者分别可用前缀和与后缀和实现。于是整体时间复杂度为 O ( n 2 ) O(n^2) O(n2)

O ( n 2 ) O(n^2) O(n2) 优化到了 O ( n 2 ) O(n^2) O(n2)

看似没啥进展(事实上,从时间上也是有优化的,算法常数变得非常小),但是此时,我们实际上得到了一个对于每一组 b \texttt{b} b 的贡献的通项公式。

为什么时间复杂度没有降低?是因为我们仍然是靠枚举两个字符然后在让另外两个字符去匹配它。如果我们能只枚举一个字符,那时间复杂度就可以进一步被优化了。那就需要我们去推出对于单个的 b \texttt{b} b 作为 abba \texttt{abba} abba 的第二个字符时作出的贡献的通项公式。其实非常简单,因为当 b \texttt{b} b 作为第二个字符时,能够作为第三个字符的 b \texttt{b} b 一定在整个字符串的右侧所以贡献就是所有右侧的 b b b 的贡献加起来,即对于上面的通项公式中的后缀和部分再求后缀和。这样,时间复杂度终于被压缩到了 O ( n ) O(n) O(n)。至此,算法的分析结束。

#include <iostream>
#include <cstring>
using LL = long long;
const int N = 5e6 + 9;
const int MOD = 1e9 + 7;
LL pre[N], suf[N], sufb[N];
char s[N];
int main() {
    scanf("%s", s + 1);
    int n = strlen(s + 1);
    for (int i = 1; i <= n; i++) {
        pre[i] = pre[i-1];
        if (s[i] == 'a')
            (pre[i] += i) %= MOD;
    }
    for (int i = n; i; i--) {
        suf[i] += suf[i+1];
        if (s[i] == 'a')
            (suf[i] += n - i + 1) %= MOD;
        sufb[i] += sufb[i+1];
        if (s[i] == 'b')
            (sufb[i] += suf[i]) %= MOD;
    }
    LL ans = 0;
    for (int i = 1; i <= n; i++)
        if (s[i] == 'b')
            (ans += pre[i] * sufb[i+1]) %= MOD;
    printf("%lld\n", ans);
    return 0;
}

这题真的太牛逼了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值