AtCoder Beginner Contest 452 题解
AtCoder Beginner Contest 452 题解
| 项目 | 内容 |
|---|---|
| 比赛时间 | 2026-04-04 |
| 比赛链接 | https://atcoder.jp/contests/abc452 |
| 题目数量 | A–G 共 7 题 |
比赛概览
本场整体难度偏向中低,前四题(A–D)基本是入门与基础算法题:A、B 是直接的模拟/构造,C 考察"预处理 + 逐位判断",D 是一道经典的子序列匹配 DP。E 是一道考察调和级数分组 + 前缀和的数论求和题,思路巧妙值得重点掌握。F 将滑动窗口与树状数组结合求"恰好等于 K 的逆序区间数",是双指针 + 数据结构的典型训练题。G 是本场区分度最高的一题,需要游程编码 + 后缀数组的高阶技巧,建议量力而行。
A - Gothec
题目链接:https://atcoder.jp/contests/abc452/tasks/abc452_a
题目大意
一年中有五个"季节节日"(五节句):1 月 7 日、3 月 3 日、5 月 5 日、7 月 7 日、9 月 9 日。给定月份 和日期 ,判断该日期是否为其中之一,是则输出 Yes,否则输出 No。输入保证 是闰年公历中的合法日期。
思路
本题是纯条件判断。五个节日对应的 分别是 、、、、,直接逐一对 做相等比较即可。
也可以把五个日期放进一个列表/集合,用 (M,D) 是否在列表中判断,逻辑更清晰且不易漏写。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
int m, d;
cin >> m >> d;
if ((m == 1 && d == 7) || (m == 3 && d == 3) || (m == 5 && d == 5) ||
(m == 7 && d == 7) || (m == 9 && d == 9)) {
cout << "Yes\n";
} else {
cout << "No\n";
}
return 0;
}官方题解提供的 Python 写法如下:
m, d = map(int, input().split())
gossekulist = [(1, 7), (3, 3), (5, 5), (7, 7), (9, 9)]
print("Yes" if (m, d) in gossekulist else "No")复杂度分析
- 时间复杂度:
- 空间复杂度:
B - Draw Frame
题目链接:https://atcoder.jp/contests/abc452/tasks/abc452_b
题目大意
有一个 ()的网格。定义"边界格子"为:与其边相邻的格子数至多为 3 的格子(即上、下、左、右四个方向中至少缺一个邻居)。将边界格子涂黑(#),其余格子涂白(.),输出涂色后的网格。
思路
关键观察:在一个 的网格中,普通格子有 4 个边相邻邻居,边上的格子(非角落)有 3 个,角落格子有 2 个。因此"相邻格子数 "当且仅当该格子位于第一行、最后一行、第一列或最后一列,即满足
(注意:只有当 时这个等价关系才成立,比如 时所有格子都在边界上;本题约束保证了 ,因此可以直接使用上述条件。)
于是输出就是"画一个边框":第 1 行和第 行全为 #,其余行的第 1 列和第 列为 #、中间为 .。对每个格子 判断并输出即可。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
int H, W;
cin >> H >> W;
for (int i = 0; i < H; i++) {
for (int j = 0; j < W; j++) {
if (i == 0 || i == H - 1 || j == 0 || j == W - 1) cout << '#';
else cout << '.';
}
cout << '\n';
}
return 0;
}官方题解中给出的 Python 代码缩进有误(每输出一个字符就换行),这里给出修正后的版本:
H, W = map(int, input().split())
for i in range(H):
for j in range(W):
if i == 0 or i == H - 1 or j == 0 or j == W - 1:
print('#', end='')
else:
print('.', end='')
print()复杂度分析
- 时间复杂度:
- 空间复杂度:
C - Fishbones
题目链接:https://atcoder.jp/contests/abc452/tasks/abc452_c
题目大意
有一个由 1 根脊柱和 根肋骨组成的鱼骨架()。脊柱上要写一个长度为 的字符串;第 根肋骨上要写一个长度为 的字符串,并且该字符串的第 个字符必须等于脊柱字符串的第 个字符。所有 个字符串都必须从给定的 个互不相同的候选串 (长度 ,)中选取,可重复使用。对每个 ,回答:是否存在一种合法的赋值方式,使得脊柱字符串恰为 。
思路
关键观察:脊柱上的第 个字符会"固定"第 根肋骨的一个约束——存在一个候选串,其长度为 且第 个字符等于该字符。而每根肋骨的选择彼此独立(候选串可重复使用),因此所有 个约束可以同时满足,当且仅当每个约束单独可以满足。
于是对每个候选串 :
- 若 ,脊柱长度必须是 ,直接回答
No; - 否则对每个位置 ,检查"是否存在长度为 且第 个字符为 的候选串"。全部满足则输出
Yes,否则No。
预处理:直接对每个 逐一扫描候选串是 的,会超时。但候选串长度至多为 10、字符集只有 26 个小写字母,信息量很小。定义布尔数组
( 用 0-indexed 表示)。扫描一遍所有候选串,对每个串的每个字符打标记即可完成预处理。之后每个位置的判断就是 查表。
为什么正确:第 根肋骨的候选串只通过三元组 与脊柱交互,且不同肋骨互不干扰。所以"逐位置可行"⟺"整体可行",不需要搜索组合方式。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N;
cin >> N;
vector<int> A(N), B(N);
for (int i = 0; i < N; i++) cin >> A[i] >> B[i];
int M;
cin >> M;
vector<string> S(M);
for (int i = 0; i < M; i++) cin >> S[i];
// memo[len][pos][c]: is there a candidate string of length len
// whose pos-th character (0-indexed) is ('a'+c)?
bool memo[11][11][26] = {};
for (const auto& s : S) {
int len = (int)s.size();
for (int i = 0; i < len; i++) memo[len][i][s[i] - 'a'] = true;
}
for (const auto& s : S) {
if ((int)s.size() != N) { cout << "No\n"; continue; }
bool ok = true;
for (int i = 0; i < N; i++) {
if (!memo[A[i]][B[i] - 1][s[i] - 'a']) { ok = false; break; }
}
cout << (ok ? "Yes" : "No") << '\n';
}
return 0;
}复杂度分析
- 时间复杂度:预处理 ,其中 ,;查询每串 ,总计 。由于 ,整体为 。
- 空间复杂度:,即常量级。
D - No-Subsequence Substring
题目链接:https://atcoder.jp/contests/abc452/tasks/abc452_d
题目大意
给定字符串 ()和 ()。统计 的非空子串中,不包含 作为(不要求连续的)子序列的个数。两个取自不同位置的子串即使内容相同也视为不同。
思路
关键观察:对任意字符串 ,定义
即 所能匹配到的 的最长前缀长度。那么" 不含完整 作为子序列" ⟺ 。
进一步地, 的变化规律非常简单。设 ( 为追加的单个字符),且 :
- 若 :(完整 仍是子序列);
- 若 且 :;
- 否则:。
为什么 至多增加 1?因为若 是 的子序列且 ,则 必是 的子序列( 要么落在 里,要么用掉最后一个字符 ),这与 矛盾。
DP 设计:从左到右扫描 ,维护数组 ()表示以当前位置结尾的、 的非空子串个数,并额外用"空子串"作为每个新子串的起点状态。处理字符 时:
dp[0]++:表示开启一个新的(当前为空的)子串,起点为当前位置;- 对 从 递减到 :若 (0-indexed,即 1-indexed 的 ),则执行
表示把匹配长度从 推到 ;若 ,则 保持不动(追加该字符不改变匹配长度)。
3. 累加答案 ,即所有以当前位置结尾、(不含完整 )的子串数。
注意两点:
- 必须按递减顺序更新:同一个字符 只能让匹配长度增加 1,若按递增顺序更新,一个 会被同一字符连续推移多次,产生错误;
dp[|T|]从不被清零:已经包含完整 的子串,追加任何字符后仍包含完整 , 恒为 ,它们在更新中自然"滞留"在dp[|T|]。
等价做法:全部非空子串数为 ,减去 (含完整 的子串数)即得答案,两种统计方式完全等价。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string S, T;
cin >> S >> T;
int m = (int)T.size();
vector<long long> dp(m + 1, 0);
long long ans = 0;
for (char c : S) {
dp[0]++; // start a new (empty) substring before appending c
// extend existing substrings by appending c (descending to avoid reuse)
for (int j = m - 1; j >= 0; j--) {
if (T[j] == c) {
dp[j + 1] += dp[j];
dp[j] = 0;
}
}
// substrings ending here that do NOT contain the whole T
for (int j = 0; j < m; j++) ans += dp[j];
}
cout << ans << '\n';
return 0;
}复杂度分析
- 时间复杂度:,可以轻松通过
- 空间复杂度:
E - You WILL Like Sigma Problem
题目链接:https://atcoder.jp/contests/abc452/tasks/abc452_e
题目大意
给定正整数序列 和 (,元素 ),求
思路
直接枚举 是 的,必然超时。需要利用取模的结构。
关键转化:把求和对 单独提出:
对固定的 ,按商 把 分组:满足 的 恰好构成区间
并且在该区间内 。于是每组对总和的贡献为
前缀和加速:预处理两个前缀和
则任意区间 上的 与 都能 求出。
复杂度来源:对固定的 , 的取值范围是 ,所以总的分组数为
(调和级数 的量级),每组 计算,总复杂度 ,足够快。
注意事项:全程在模 下运算,前缀和与中间结果取模,减法时要加模数防止负数。
参考代码
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 998244353;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N, M;
cin >> N >> M;
vector<long long> A(N + 1), B(M + 1);
for (int i = 1; i <= N; i++) cin >> A[i];
for (int i = 1; i <= M; i++) cin >> B[i];
// prefix sums: sumA[i] = sum A[1..i], sumAi[i] = sum A[k]*k (1 <= k <= i)
vector<long long> sumA(N + 1, 0), sumAi(N + 1, 0);
for (int i = 1; i <= N; i++) {
sumA[i] = (sumA[i - 1] + A[i]) % MOD;
sumAi[i] = (sumAi[i - 1] + A[i] * i) % MOD;
}
long long ans = 0;
for (int j = 1; j <= M; j++) {
long long cur = 0;
// i in [j*k, min(j*(k+1)-1, N)] satisfies i mod j = i - j*k
for (int k = 0; (long long)k * j <= N; k++) {
int l = k * j;
int r = (int)min((long long)(k + 1) * j - 1, (long long)N);
long long s2 = (sumAi[r] - sumAi[l] + MOD) % MOD; // sum A_i * i
long long s1 = (sumA[r] - sumA[l] + MOD) % MOD; // sum A_i
cur = (cur + s2 - s1 * (long long)(k * j) % MOD + MOD) % MOD;
}
ans = (ans + cur * B[j]) % MOD;
}
cout << ans << '\n';
return 0;
}复杂度分析
- 时间复杂度:
- 空间复杂度:
F - Interval Inversion Count
题目链接:https://atcoder.jp/contests/abc452/tasks/abc452_f
题目大意
给定 的一个排列 ()和整数 。统计满足 且子段 的逆序对数恰好等于 的区间 的个数。
思路
先转化为"不超过":设 为逆序对数不超过 的区间个数,则答案
恰好是逆序对数等于 的区间数。于是只需高效计算 。
单调性观察:
- 固定左端点 ,随着 增大(在末尾追加元素),逆序对数单调不降;
- 固定 ,左端点 增大(删除最左元素),逆序对数单调不增。
因此对每个 ,满足 的 构成一个从 开始的连续区间,且随着 增大, 的右边界也不会回退——这正好可以用**滑动窗口(双指针)**处理。
窗口维护:用树状数组(Fenwick Tree)维护当前窗口 内各值的出现次数:
- 把 加入窗口时,新增的逆序对数量等于窗口内比 大的元素个数,即 (值域是 ,因为 是排列元素互异,不会算到自己);
- 把 移出窗口时,减少的逆序对数量等于窗口内比 小的元素个数,即 。
算法:对每个 ,先用 while 尽可能把 右移(保证加入后逆序对数仍不超过 ),然后该 对答案的贡献是 (即 都是合法的右端点),最后把 移出窗口继续。
为什么贡献是 而不是 :窗口内当前有效的右端点是 (最后一个已成功加入的下标是 ),共有 个; 本身尚未加入。
边界情况: 时 ,直接返回 0。 最大可达 ,需要用 64 位整数。
参考代码
#include <bits/stdc++.h>
using namespace std;
struct BIT {
int n;
vector<int> bit;
BIT(int n) : n(n), bit(n + 1, 0) {}
void add(int idx, int val) {
for (int i = idx + 1; i <= n; i += i & -i) bit[i] += val;
}
int sumPrefix(int idx) { // sum over [0, idx)
int s = 0;
for (int i = idx; i > 0; i -= i & -i) s += bit[i];
return s;
}
int sum(int l, int r) { // sum over [l, r)
return sumPrefix(r) - sumPrefix(l);
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N;
long long K;
cin >> N >> K;
vector<int> P(N);
for (int i = 0; i < N; i++) {
cin >> P[i];
P[i]--; // 0-indexed value
}
auto count_leq = [&](long long K) -> long long {
if (K < 0) return 0;
BIT bit(N);
long long now = 0; // inversion number of window [l, r)
long long res = 0;
int r = 0;
for (int l = 0; l < N; l++) {
if (r < l) r = l;
while (r < N && now + bit.sum(P[r], N) <= K) {
now += bit.sum(P[r], N); // # elements in window > P[r]
bit.add(P[r], 1);
r++;
}
res += r - l; // all windows [l, r') with l <= r' < r
now -= bit.sum(0, P[l]); // # elements in window < P[l]
bit.add(P[l], -1);
}
return res;
};
cout << count_leq(K) - count_leq(K - 1) << '\n';
return 0;
}复杂度分析
- 时间复杂度:(两次滑动窗口,每次窗口内每个元素进出一次,每次树状数组操作 )
- 空间复杂度:
G - 221 Substring
题目链接:https://atcoder.jp/contests/abc452/tasks/abc452_g
题目大意
称一个正整数序列 为 221 序列,若它的游程编码中每一段"极大连续相等段"的长度恰好等于该段的值。例如 是 221 序列,而 和 不是。给定长度为 ()、元素 的序列 ,统计 的连续子段中出现的互不相同的 221 序列的个数。
思路
本题要同时处理两件麻烦事:子段要连续、序列要按"值去重"。官方做法分三步走。
第一步:游程编码与可行性刻画
对 做游程编码 ,其中 是值 连续出现的长度。设一个连续子段对应的游程区间为 (可能取到首尾块的子段),则该子段是 221 序列当且仅当:
- 中间块 必须完整取用,因此 ;
- 首块 、末块 只取其中 、 个元素,因此只需 、。
的块无论作首、中、尾都不可能出现在 221 子段中,是绝对的"屏障"。
第二步:构造序列 并建立一一对应
把游程区间划分成若干个"极大可用块" :块内除首、尾两个块外,中间全部满足 ;首、尾块只需 ;块与块之间必然隔着 的屏障。划分方式是扫描游程:遇到 直接跳过;遇到 开始一个块,向右扩展——遇到 则纳入继续,遇到 则把它作为当前块的尾块并停止(它同时作为下一个块的起点)。
构造整数序列 :先放一个 ,然后对每个极大可用块,把块内各游程块的值 依次写入 ,块结束追加一个 作为分隔符。值 在这里充当"障碍符"。
关键性质: 中不含 的子数组(值序列)与 的 221 子段一一对应。具体地, 中一段值序列 对应 的子段:取游程块 最右侧的 个元素、中间块全部、游程块 最左侧的 个元素——它们恰好拼成一个连续子段,且每段长度等于值,因而是 221 序列;反过来任何 221 子段显然落在这段构造内。因此原问题转化为:统计 中不含 的互异子数组个数。
第三步:用后缀数组统计互异子数组
设 的长度为 。记后缀数组为 , 为字典序第 与第 个后缀的最长公共前缀长度。再预处理
按字典序依次考察每个后缀。对排名 的后缀(起点 ):任何以 为起点的子数组都是该后缀的前缀。其中长度不超过 的前缀,已经在排名更靠前的后缀中出现过(后缀数组的相邻性质保证 是与所有更靠前后缀的最大公共前缀长度);而长度超过它的前缀都是第一次出现。同时子数组不能含 ,所以可用的前缀长度不超过 。故该后缀贡献的新子数组数为
(排名最前的后缀取 )。对 求和即得答案。
复杂度说明:官方题解用 SA-IS 算法(atcoder::suffix_array)在 内求出后缀数组,从而整体 。下面的参考代码为了自包含,实现了倍增法后缀数组,复杂度为 ,对本体数据量()也足够快。
参考代码
#include <bits/stdc++.h>
using namespace std;
// Doubling suffix array for integer string s (values >= 0), O(n log n), no sentinel
vector<int> suffix_array(const vector<int>& s) {
int n = (int)s.size();
vector<int> sa(n), rnk(n), tmp(n);
// initial rank by compressed first character
{
vector<int> vals = s;
sort(vals.begin(), vals.end());
vals.erase(unique(vals.begin(), vals.end()), vals.end());
for (int i = 0; i < n; i++)
rnk[i] = lower_bound(vals.begin(), vals.end(), s[i]) - vals.begin();
iota(sa.begin(), sa.end(), 0);
stable_sort(sa.begin(), sa.end(), [&](int a, int b) { return rnk[a] < rnk[b]; });
}
for (int k = 1; k < n; k <<= 1) {
int maxr = 0;
for (int i = 0; i < n; i++) maxr = max(maxr, rnk[i]);
// key2[i] = rnk[i+k]+1 if i+k < n else 0 (0 == "empty second half")
vector<int> key2(n);
for (int i = 0; i < n; i++) key2[i] = (i + k < n) ? rnk[i + k] + 1 : 0;
// stable counting sort by key2
vector<int> cnt(maxr + 2, 0);
for (int i = 0; i < n; i++) cnt[key2[i]]++;
for (int i = 1; i < maxr + 2; i++) cnt[i] += cnt[i - 1];
vector<int> sa2(n);
for (int i = n - 1; i >= 0; i--) sa2[--cnt[key2[i]]] = i;
// stable counting sort by key1 = rnk
fill(cnt.begin(), cnt.end(), 0);
for (int i = 0; i < n; i++) cnt[rnk[i]]++;
for (int i = 1; i <= maxr; i++) cnt[i] += cnt[i - 1];
vector<int> sa3(n);
for (int i = n - 1; i >= 0; i--) sa3[--cnt[rnk[sa2[i]]]] = sa2[i];
sa.swap(sa3);
// reassign ranks
tmp[sa[0]] = 0;
for (int i = 1; i < n; i++) {
int a = sa[i - 1], b = sa[i];
int ka = (a + k < n) ? rnk[a + k] : -1;
int kb = (b + k < n) ? rnk[b + k] : -1;
bool same = (rnk[a] == rnk[b] && ka == kb);
tmp[sa[i]] = tmp[sa[i - 1]] + (same ? 0 : 1);
}
rnk.swap(tmp);
if (rnk[sa[n - 1]] == n - 1) break; // all ranks already distinct
}
return sa;
}
// Kasai LCP array: lcp[i] = LCP of sa[i-1] and sa[i]
vector<int> lcp_array(const vector<int>& s, const vector<int>& sa) {
int n = (int)s.size();
vector<int> rank(n);
for (int i = 0; i < n; i++) rank[sa[i]] = i;
vector<int> lcp(n - 1, 0);
int h = 0;
for (int i = 0; i < n; i++) {
int r = rank[i];
if (r == 0) { h = 0; continue; }
int j = sa[r - 1];
while (i + h < n && j + h < n && s[i + h] == s[j + h]) h++;
lcp[r - 1] = h;
if (h) h--;
}
return lcp;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N;
cin >> N;
vector<int> A(N);
for (int i = 0; i < N; i++) cin >> A[i];
// run-length encoding of A
vector<pair<int, int>> lens; // (value, run length)
for (int i = 0; i < N; ) {
int j = i;
while (j < N && A[j] == A[i]) j++;
lens.push_back({A[i], j - i});
i = j;
}
// maximal usable blocks
vector<pair<int, int>> maximals;
{
int idx = 0;
int n = (int)lens.size();
while (idx < n) {
if (lens[idx].first > lens[idx].second) { idx++; continue; }
int jlen = idx + 1;
int jsee = jlen;
while (jlen < n) {
if (lens[jlen].first > lens[jlen].second) {
break; // barrier: not included
} else if (lens[jlen].first < lens[jlen].second) {
jsee = jlen + 1; // included as the last run of the block
break;
} else {
jlen++; jsee++;
}
}
maximals.push_back({idx, jsee});
idx = jlen;
}
}
// build T : [0] ++ block values joined by 0
vector<int> T = {0};
for (auto [li, ri] : maximals) {
for (int i = li; i < ri; i++) T.push_back(lens[i].first);
T.push_back(0);
}
// count distinct subarrays of T that do not contain 0
int m = (int)T.size();
vector<int> sa = suffix_array(T);
vector<int> lcp = lcp_array(T, sa);
vector<int> zf(m + 1, 0);
for (int i = m - 1; i >= 0; i--) {
if (T[i] == 0) zf[i] = 0;
else zf[i] = zf[i + 1] + 1;
}
long long ans = 0;
for (int i = 0; i < m; i++) {
long long l = (i == 0) ? 0 : lcp[i - 1];
long long r = zf[sa[i]];
ans += max(0LL, r - l);
}
cout << ans << '\n';
return 0;
}复杂度分析
- 时间复杂度:游程编码与块构造 ;后缀数组若用 SA-IS 为 ,参考代码用倍增法为 ,其中 。
- 空间复杂度:
总结
本场题目的知识点分布:A、B 是热身题;C 强调"局部可行 ⟺ 整体可行"的转化与查表优化;D 的核心是 状态设计与单字符转移的单调性;E 展示了"按商分组 + 前缀和"处理 求和的经典手法;F 是滑动窗口 + 树状数组求区间逆序数;G 则需要游程编码、障碍符构造与后缀数组统计互异子串的综合能力。建议重点吃透 D、E、F 三题的方法,它们对应的技巧在后续竞赛中复用率很高。