AtCoder Beginner Contest 428 题解
AtCoder Beginner Contest 428 题解
| 项目 | 内容 |
|---|---|
| 比赛时间 | 2025-10-18 |
| 比赛链接 | https://atcoder.jp/contests/abc428 |
| 题目数量 | A–G 共 7 题 |
比赛概览
本场整体难度偏常规:A、B 为简单签到;C 是经典括号序列的栈判定,属于必会套路;D 是按十进制位数分组、再数平方数的数论计数;E 是树的直径经典应用,难点在于编号取最大的 tie-break 处理;F 利用区间嵌套的不变式构造"块"结构,关键在摊还分析,较有区分度;G 是 Pólya 计数定理 + 卷积 DP 的难题,解法非常优雅。总体梯度平滑,值得重点掌握 C、E、F 的套路,以及 G 的 Pólya 枚举方法。
A - Grandma's Footsteps
题目链接:https://atcoder.jp/contests/abc428/tasks/abc428_a
题目大意
铃响后,高桥重复做两件事:先以每秒 米的速度跑 秒,再静止 秒。问从铃响开始到 秒结束,他一共跑了多少米。输入一行 ,输出总距离(不含单位)。
思路
关键观察:他跑的速度恒定,因此先求出在 秒内他一共"跑"了多少秒,再乘以 即可。
他的动作是一个以 秒为周期的循环:每个周期中前 秒在跑、后 秒静止。把 秒分解为商和余数:
- 完整周期部分:共 个周期,每个周期跑 秒,共 秒;
- 余数部分 秒:若 则全程在跑,贡献 秒;若 则只跑了前 秒,贡献 秒。即 秒。
所以跑步总时长为
答案就是 。
易错点:余数部分取 而非直接取 ;另外所有数不超过 ,用 int 即可,但写成 long long 更稳妥。
(官方题解还给出了逐秒模拟的做法,当 很大时不可行,这里直接采用算术解法。)
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
int S, A, B, X;
cin >> S >> A >> B >> X;
long long run = 1LL * (X / (A + B)) * A + min(X % (A + B), A);
cout << run * S << '\n';
return 0;
}复杂度分析
- 时间复杂度:
- 空间复杂度:
B - Most Frequent Substrings
题目链接:https://atcoder.jp/contests/abc428/tasks/abc428_b
题目大意
给定长度为 的小写字符串 和整数 。定义一个长度为 的串 的出现次数为它在 中作为连续子串出现的次数。求 长子串的最大出现次数 ,并输出所有出现次数恰好为 的 长子串(按字典序升序,空格分隔)。
思路
关键观察:, 长子串一共只有 个位置,数量很小,直接枚举即可。
用关联数组(C++ 的 std::map)对每个长度为 的子串计数:
- 对 ,取出
S.substr(i, K),把memo[t]加 。 - 遍历
memo找到最大计数vmax。 - 再遍历
memo,把所有memo[t] == vmax的键输出。
为什么正确:memo[t] 存的就是 的出现次数,最大值 与所有达到最大值的串都被完整枚举。由于 std::map 按键的字典序(std::string 默认按字典序比较)内部有序,for 遍历自然就是字典序升序,无需额外排序。
易错点:输出两行——第一行最大值,第二行是空格分隔的所有串(末尾也要换行);第二行不能输出多余空格(用 first 标记控制)。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
int N, K;
string S;
cin >> N >> K >> S;
map<string, int> memo;
for (int i = 0; i + K <= N; i++) {
memo[S.substr(i, K)]++;
}
int vmax = 0;
for (auto &[key, val] : memo) vmax = max(vmax, val);
cout << vmax << '\n';
bool first = true;
for (auto &[key, val] : memo) {
if (val == vmax) {
if (!first) cout << ' ';
cout << key;
first = false;
}
}
cout << '\n';
return 0;
}复杂度分析
- 时间复杂度:枚举子串 ,
std::map每次操作 ,其中 为不同子串个数;总体 ,由于 ,可视为 。 - 空间复杂度:。
C - Brackets Stack Query
题目链接:https://atcoder.jp/contests/abc428/tasks/abc428_c
题目大意
初始 为空串。依次处理 个操作,每个操作后判断 是否为"好括号序列"(可以由空串反复删除连续的 () 得到):
1 c:把字符c((或))追加到 末尾;2:删除 的最后一个字符(保证此时 非空)。
每个操作后输出 Yes/No。
思路
问题转化:好括号序列等价于"合法括号序列",它有如下经典的判定方式。
令 ,且对 :
即 是前 个字符的"左括号数减右括号数"(前缀和)。则 是合法括号序列当且仅当:
直观理解:前缀和始终非负,保证任意前缀中左括号不少于右括号(否则会出现 ) 多出来的非法前缀);总前缀和为 ,保证左右括号数量相等。这两个条件合起来正是括号匹配的充要条件。
数据结构:由于操作只有"末尾追加"和"删除末尾",可以用两个栈(实现上用 std::vector)分别维护:
- 栈:存 ;
- 栈:存 ,即前缀最小值。
初始两个栈各含 。追加字符 c 时(新长度 ):
删除末尾时直接对两个栈 pop_back()。每次操作后,判断 A.back() == 0 && B.back() == 0 即得答案。
为什么正确:栈中始终恰好保存当前串完整的前缀信息,追加/删除各只改动栈顶一项, 维护;判定条件即上面的充要条件。, 完全足够。
易错点:判定的是 而非 与 分开看,二者必须同时满足(例如 )( 满足 但不满足 ,不是合法括号序列)。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int Q;
cin >> Q;
vector<int> A(1, 0), B(1, 0); // 前缀和 A、前缀最小值 B
while (Q--) {
int cmd;
cin >> cmd;
if (cmd == 1) {
char c;
cin >> c;
int nv = A.back() + (c == '(' ? 1 : -1);
A.push_back(nv);
B.push_back(min(B.back(), nv));
} else {
A.pop_back();
B.pop_back();
}
cout << (A.back() == 0 && B.back() == 0 ? "Yes" : "No") << '\n';
}
return 0;
}复杂度分析
- 时间复杂度:
- 空间复杂度:(栈深度最大为 )
D - 183184
题目链接:https://atcoder.jp/contests/abc428/tasks/abc428_d
题目大意
对正整数 , 表示把 和 的十进制字符串直接拼接后得到的整数(如 )。给定 组 ,对每组求满足 且 是完全平方数的整数 的个数。
思路
关键转化:令 ,并设 有 位十进制数。拼接就是" 后面跟 ",因此
这样就把"拼接"化成了乘法与加法,问题变为对某个 数区间内的平方数。
按位数分组: 有 位当且仅当 ,即 满足
再结合 ,定义
若 ,该 没有合法的 ;否则 。
计数:此时问题等价于统计区间
内完全平方数的个数。不超过 的完全平方数恰好有 个,因此该区间的平方数个数为
遍历所有 :,所以 从 取到 的十进制位数即可( 时无 位数)。
数值与精度:,(最多 10 位),最大被开方数约为 ,仍在 long long 范围内。浮点开方对 级别的数可能产生 ±1 的误差,因此求 后要作校正:while (r*r > x) r--; while ((r+1)*(r+1) <= x) r++;(, 不溢出)。
易错点: 可能为负,务必与 取 max;每个测试独立计算;开方后要校正。
参考代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
// floor(sqrt(x)),带 ±1 校正
ll isqrt(ll x) {
ll r = (ll)sqrtl((long double)x);
while (r > 0 && r * r > x) r--;
while ((r + 1) * (r + 1) <= x) r++;
return r;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
ll C, D;
cin >> C >> D;
ll ans = 0;
// d10 = 10^{d-1}, p = 10^d
for (ll d10 = 1, p = 10; d10 <= C + D; d10 = p, p *= 10) {
ll L = max(1LL, d10 - C);
ll R = min(D, p - 1 - C);
if (L <= R) {
ll lo = C * p + C + L;
ll hi = C * p + C + R;
ans += isqrt(hi) - isqrt(lo - 1);
}
}
cout << ans << '\n';
}
return 0;
}复杂度分析
- 时间复杂度:每组测试 (位数数量级,最多约 10),总 。
- 空间复杂度:。
E - Farthest Vertex
题目链接:https://atcoder.jp/contests/abc428/tasks/abc428_e
题目大意
给定一棵 个顶点的树。对每个顶点 ,输出与 距离最远的顶点编号;若有多个顶点距离都最远,输出其中编号最大者。距离定义为路径上边的条数。
思路
关键观察 1(直径性质):设 是树的直径(任意一对相距最远的顶点)的两个端点。则对任意顶点 ,与 距离最远的顶点一定是 或 之一,即
证明(反证):若存在某个 同时比 、 离 更远,分析 - 路径与 - 路径的位置关系(考虑两路径分离/相交的情况),可推出 - 距离比直径还长,矛盾。因此"最远距离"完全由直径端点决定。
关键观察 2(编号 tie-break):本题要求"多个最远顶点时取编号最大者",而直径性质只保证了"距离"层面最远点在 中,并没有直接处理编号。官方题解用一个巧妙的"扩展图"来吸收编号:
- 给每个顶点 新增一个复制顶点 ,连一条长度为 (比任何距离差都小的极小正数)的边 -。
在新图中,顶点 到 的距离为 。由于附加项极小,距离的比较等价于先比较 ,相等时再比较编号 。扩展后最远顶点唯一,且根据直径性质,这个唯一最远点必为扩展图直径的端点。因此答案是
即对两个直径端点 ,取二元组 与 中字典序较大者对应的端点。这也等价于:距离不相等时取距离大的端点;距离相等时取编号大的端点。
算法步骤:
- 从任意顶点(如 号)BFS/DFS 求距离,取距离最远的顶点为 (若有多个取编号最大者);
- 从 BFS/DFS,取距离最远的顶点为 (同样编号最大者)—— 即直径端点对;
- 从 BFS/DFS 得到所有点到 的距离;
- 对每个顶点 ,比较 与 的字典序,输出较大者的编号。
为什么编号最大的取法正确:第 1、2 步中"取编号最大者"保证了 恰好是扩展图直径的端点(等价于在原始的直径端点对中取编号和最大的一对),这正是把编号 tie-break 吸收进距离比较所必需的。
易错点: 可达 ,递归 DFS 可能爆栈,应使用 BFS 或显式栈的迭代 DFS;比较二元组时注意距离相等要取大编号。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N;
cin >> N;
vector<vector<int>> g(N);
for (int i = 0; i < N - 1; i++) {
int a, b;
cin >> a >> b;
--a; --b;
g[a].push_back(b);
g[b].push_back(a);
}
auto bfs = [&](int s) {
vector<int> dist(N, -1);
queue<int> q;
dist[s] = 0;
q.push(s);
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : g[u]) {
if (dist[v] != -1) continue;
dist[v] = dist[u] + 1;
q.push(v);
}
}
return dist;
};
auto d0 = bfs(0);
int u = 0;
for (int i = 1; i < N; i++)
if (d0[i] >= d0[u]) u = i; // 距离最大,且编号最大
auto du = bfs(u);
int v = 0;
for (int i = 1; i < N; i++)
if (du[i] >= du[v]) v = i;
auto dv = bfs(v);
for (int i = 0; i < N; i++) {
int ans;
if (du[i] > dv[i]) ans = u;
else if (du[i] < dv[i]) ans = v;
else ans = max(u, v); // 距离相等取大编号
cout << ans + 1 << '\n';
}
return 0;
}官方题解给出的 Python 版本(以下为改为迭代 DFS 的写法,避免标准 Python 递归深度限制):
import sys
def main():
input = sys.stdin.readline
N = int(input())
g = [[] for _ in range(N)]
for _ in range(N - 1):
a, b = map(int, input().split())
a -= 1
b -= 1
g[a].append(b)
g[b].append(a)
def bfs_dist(s):
dist = [-1] * N
dist[s] = 0
stack = [s]
while stack:
c = stack.pop()
for d in g[c]:
if dist[d] == -1:
dist[d] = dist[c] + 1
stack.append(d)
return dist
d0 = bfs_dist(0)
u = max(range(N), key=lambda i: (d0[i], i))
du = bfs_dist(u)
v = max(range(N), key=lambda i: (du[i], i))
dv = bfs_dist(v)
for i in range(N):
print(max((du[i], u), (dv[i], v))[1] + 1)
if __name__ == "__main__":
main()复杂度分析
- 时间复杂度:三次遍历 。
- 空间复杂度:。
F - Pyramid Alignment
题目链接:https://atcoder.jp/contests/abc428/tasks/abc428_f
题目大意
数轴上有 个区间,区间 初始为 ,且 。依次处理 个操作:
1 v:设 为区间 当前的左端点,把编号 的所有区间平移使它们的左端点在 ;2 v:设 为区间 当前的右端点,把编号 的所有区间平移使它们的右端点在 ;3 x:输出当前包含坐标 的区间个数。
思路
关键观察 1(嵌套不变式):任意时刻区间始终是嵌套的:
且每个区间的宽度保持为 。初始显然成立。对操作 1:编号 的区间左端统一设为 ,宽度不变,故它们在编号方向上仍嵌套;而新区间 等于旧区间 (因为 就是旧 ),区间 原本就包含旧区间 ,因此跨边界也嵌套。操作 2 同理(用右端点对齐,左端点随宽度自动确定)。这个不变式是整个题目的基石。
关键观察 2(块结构):编号相邻的区间常常"共享同一端点"。把连续编号、且左端点相同(或右端点相同)的区间合并成一个块,用四元组 表示"编号 的区间满足: 时左端点都为 , 时右端点都为 "。由宽度不变式,块内区间 的坐标完全确定:
初始只有一个块 (所有左端点为 )。用栈(std::vector 当栈用,栈顶存最小编号所在块)维护块的序列。
操作 1/2:以操作 1 为例(操作 2 完全对称)。从栈顶开始弹出块,直到弹出包含编号 的块(满足 ):
- 若 ,把剩余部分 压回栈;
- 由被弹块的性质算出区间 当前的左(右)端点,设为 ;
- 把新区块 (或操作 2 的 )压栈。
被弹出的所有块编号都 ,被新区块完整覆盖,直接丢弃即可。
为什么总复杂度是 (摊还):设块数为 。初始 ;每次操作 1/2 中,除最后一次循环外,每次弹出一个块使 减 ,最后压入至多 2 个新块使 最多加 。 次操作后块数增量至多为 ,而块数非负,故所有操作的总弹出次数 。因此操作 1/2 摊还 。
操作 3(二分):由于区间嵌套,包含点 的区间构成一个后缀:存在 使得区间 不包含它、区间 都包含它,答案即 。又因为所有端点都是整数,区间 包含 当且仅当 。
二分分两层:先在块之间二分找到"最小右端点包含该点"的块(利用该块右端区间是否包含点作为单调谓词),再在该块内部二分找到最小编号。每层 。
易错点:块在栈中的顺序是"编号从大到小"还是"从小到大"要在代码里保持一致;判断包含用 (因为 落在整数格中间);操作 1/2 中新区块的端点要由被弹块的性质推算,不能假设区间 在块内的左右端点形式。
参考代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
struct Block {
int li, ri; // 编号范围(0-indexed)
int g; // -1: 左端点都等于 x;+1: 右端点都等于 x
ll x;
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N;
cin >> N;
vector<ll> w(N);
for (int i = 0; i < N; i++) cin >> w[i];
vector<Block> st; // 栈顶 = 最小编号所在块
st.push_back({0, N - 1, -1, 0});
int Q;
cin >> Q;
while (Q--) {
int type;
cin >> type;
if (type == 1 || type == 2) {
int v;
cin >> v;
--v;
int dir = (type == 1) ? -1 : +1; // 对齐左端点 / 右端点
while (true) {
Block b = st.back();
st.pop_back();
if (b.ri >= v) { // 找到包含 v 的块
if (v + 1 <= b.ri) {
st.push_back({v + 1, b.ri, b.g, b.x});
}
ll fl, fr; // 区间 v 的左右端点
if (b.g < 0) {
fl = b.x;
fr = fl + w[v];
} else {
fr = b.x;
fl = fr - w[v];
}
ll cx = (dir < 0) ? fl : fr;
st.push_back({0, v, dir, cx});
break;
}
}
} else {
ll x;
cin >> x;
// 块间二分:找最大的块下标 ok,使该块最右区间包含 x+1/2
int ok = -1, ng = (int)st.size();
while (ok + 1 < ng) {
int mid = (ok + ng) / 2;
Block &b = st[mid];
ll fl, fr;
if (b.g < 0) {
fl = b.x;
fr = fl + w[b.ri];
} else {
fr = b.x;
fl = fr - w[b.ri];
}
if (fl <= x && x < fr) ok = mid;
else ng = mid;
}
int ans;
if (ok < 0) { // 没有任何区间包含该点
ans = 0;
} else {
Block &b = st[ok];
// 块内二分:找最小编号 lo2 满足区间包含该点
int lo2 = b.ri, hi2 = b.li - 1;
while (hi2 + 1 < lo2) {
int mid = (lo2 + hi2) / 2;
ll fl, fr;
if (b.g < 0) {
fl = b.x;
fr = fl + w[mid];
} else {
fr = b.x;
fl = fr - w[mid];
}
if (fl <= x && x < fr) lo2 = mid;
else hi2 = mid;
}
ans = N - lo2; // 后缀长度
}
cout << ans << '\n';
}
}
return 0;
}复杂度分析
- 时间复杂度:操作 1/2 摊还 (总弹出次数 ),操作 3 为 ;总 。
- 空间复杂度:块数 。
G - Necklace
题目链接:https://atcoder.jp/contests/abc428/tasks/abc428_g
题目大意
有 种宝石,每种无限供应;宝石 的美值为 ,同种宝石不可区分。把若干宝石围成一圈形成项链,两条项链旋转后相同视为同一条(不可翻转)。对每个 ,求美值乘积恰好为 的项链数,对 取模。
思路
关键观察 1(长度上限):每颗宝石美值 ,因此长度为 的项链美值乘积 。要满足乘积 ,必须
所以项链长度至多 18,非常小。
第一步:先数"序列"(线性排列)。令 为长度 、美值乘积为 的宝石序列数, 为美值等于 的宝石种类数。则有转移
即"在乘积为 的序列后追加一颗美值 的宝石"。递推到 即可。复杂度 。
第二步:Pólya 枚举定理数项链。项链把长度 的序列按旋转 个方向合并等价类。由 Burnside 引理(Pólya 的直接推论),长度 的项链数是旋转固定点数的平均:
旋转 把 个位置分成 个循环,每个循环长 。被它固定的配置在每个循环内宝石都相同,因此由长度 的一个序列唯一决定;若该序列的循环内乘积为 ,则总乘积为 。令 ,即 ,固定配置数就是 (当 不是 次完全幂时取 )。
把 按 分组: 的 有 个,于是长度 、乘积 的项链数为
其中 不是整数时该项贡献为 。总答案
实现方式:不要对每个 单独算,而是枚举 后枚举 :对每个 、每个 满足 ,把 加到 上。 用线性筛/简单筛预计算, 用费马小定理。这部分的复杂度是
主要由 的 次加法主导,非常快。
正确性补充:不同长度 的项链是不同对象(珠子数不同),不会重复计数;Pólya 公式已处理旋转等价,且题目明确禁止翻转,所以不除以 2。
另一解法(官方进阶做法):把宝石序列的 Dirichlet 生成函数(DGF) 引入,利用 Pólya 得到项链 DGF 为 ,再借助 DGF 上的"魔法导数"(以 替换 )做到 。该技巧很漂亮但实现复杂,本文参考代码采用上面直观的 Pólya + DP 解法。
参考代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 998244353;
ll modpow(ll a, ll e) {
ll r = 1;
while (e) {
if (e & 1) r = r * a % MOD;
a = a * a % MOD;
e >>= 1;
}
return r;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N, U;
cin >> N >> U;
vector<int> freq(U + 1, 0);
vector<int> vals; // 出现过的美值(需升序,供 DP 内层提前 break)
for (int i = 0; i < N; i++) {
int b;
cin >> b;
if (freq[b]++ == 0) vals.push_back(b);
}
sort(vals.begin(), vals.end());
int V = 0; // V = floor(log2 U)
while ((1LL << (V + 1)) <= U) V++;
// A[k][n]:长度 k、乘积 n 的序列数
vector<vector<ll>> A(V + 1, vector<ll>(U + 1, 0));
A[0][1] = 1;
for (int k = 0; k < V; k++) {
for (int s = 1; s <= U; s++) {
ll av = A[k][s];
if (av == 0) continue;
for (int t : vals) {
if ((ll)s * t > U) break;
A[k + 1][s * t] = (A[k + 1][s * t] + av * freq[t]) % MOD;
}
}
}
// 欧拉函数 phi[1..V]
vector<int> phi(V + 1);
for (int i = 0; i <= V; i++) phi[i] = i;
for (int i = 2; i <= V; i++) {
if (phi[i] == i) { // i 为素数
for (int j = i; j <= V; j += i) phi[j] -= phi[j] / i;
}
}
// 模逆 inv[1..V]
vector<ll> inv(V + 1, 1);
for (int i = 1; i <= V; i++) inv[i] = modpow(i, MOD - 2);
vector<ll> f(U + 1, 0);
for (int L = 1; L <= V; L++) {
for (int d = 1; d <= L; d++) {
if (L % d) continue;
int k = L / d;
ll coeff = phi[d] * inv[L] % MOD;
ll y = 1;
while (true) { // 枚举 y = x^(1/d),满足 y^d <= U
ll p = 1;
for (int e = 0; e < d; e++) {
if (p > U / y) { p = U + 1; break; }
p *= y;
}
if (p > U) break;
if (A[k][y] != 0) {
f[p] = (f[p] + coeff * A[k][y]) % MOD;
}
y++;
}
}
}
for (int x = 2; x <= U; x++) {
if (x > 2) cout << ' ';
cout << f[x];
}
cout << '\n';
return 0;
}复杂度分析
- 时间复杂度:序列 DP 为 ;项链求和为 ;总 。
- 空间复杂度:。