AtCoder Beginner Contest 430 题解
AtCoder Beginner Contest 430 题解
| 项目 | 内容 |
|---|---|
| 比赛时间 | 2025-11-01 |
| 比赛链接 | https://atcoder.jp/contests/abc430 |
| 题目数量 | A–G 共 7 题 |
比赛概览
本场整体难度前低后高:A 是简单条件判断,B 是暴力枚举 + set 去重;C 是经典"前缀和 + 单调双指针"的区间计数;D 的亮点在于"差分更新"(每次插入只重算受影响的 个点);E 用 Z 算法判断字符串循环移位;F 需要推导出"每个整数可放置的格子构成一个连续区间"这一关键定理;G 则是一道较有区分度的 Segment Tree Beats 题。建议重点掌握 C 的双指针思想、D 的差分更新、F 的连续区间定理,以及 G 的"当节点信息无法直接整体更新时下推递归再上拉"的核心套路。
A - Candy Cookie Law
题目链接:https://atcoder.jp/contests/abc430/tasks/abc430_a
题目大意
AtCoder 国有一条奇怪的法律:"拥有 个及以上糖果的人,必须拥有 个及以上饼干。"高桥拥有 个糖果和 个饼干,判断他是否违反这条法律。合法时输出 No,违法时输出 Yes。约束:。
思路
这是一道条件判断题。法律只约束"糖果数 "的情形:此时必须满足"饼干数 ",即 。
因此违法当且仅当:
其余情况(,或 )都不违法。例如样例 3 中 ,触发不了法律,所以合法。
易错点:注意"糖果不足不触发法律"这一逻辑,不要写成" 且 "之类;只有 才算饼干不足。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int A, B, C, D;
cin >> A >> B >> C >> D;
if (C >= A && D < B) cout << "Yes\n";
else cout << "No\n";
return 0;
}官方题解给出的 Python 实现:
A, B, C, D = map(int, input().split())
if (C >= A) and (D < B):
print("Yes")
else:
print("No")复杂度分析
- 时间复杂度:
- 空间复杂度:
B - Count Subgrid
题目链接:https://atcoder.jp/contests/abc430/tasks/abc430_b
题目大意
有一个 行 列的网格,第 行第 列的格子是黑色(#)或白色(.)。从网格中取出一个 行 列的区域,问能得到的不同涂色图案有多少种。约束:。
思路
关键观察: 非常小。所有可能的 区域一共有 个(由左上角 唯一确定),最多 个;每个区域内部只有 个字符。因此直接枚举所有区域,把每个区域的图案整体放入一个 set 去重,最后 set 的大小就是答案。
"统计不同的东西有几个"时,用 set 去重是最直接的做法。具体地:
- 枚举左上角 ,其中 。
- 取出 中每一行的第 到 列,拼成一个 的图案。
- 插入
set。
易错点:C++ 中 set<vector<string>> 天然支持比较,可直接使用;Python 中 list 不可哈希,需改用元组 tuple。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N, M;
cin >> N >> M;
vector<string> S(N);
for (int i = 0; i < N; i++) cin >> S[i];
set<vector<string>> pat;
for (int i = 0; i + M <= N; i++) {
for (int j = 0; j + M <= N; j++) {
vector<string> sub;
for (int ii = i; ii < i + M; ii++) sub.push_back(S[ii].substr(j, M));
pat.insert(sub);
}
}
cout << pat.size() << '\n';
return 0;
}官方题解给出的 Python 实现:
N, M = map(int, input().split())
S = [input() for _ in range(N)]
pat = set()
for i in range(N - M + 1):
for j in range(N - M + 1):
sub = tuple(S[ii][j:j + M] for ii in range(i, i + M))
pat.add(sub)
print(len(pat))复杂度分析
- 时间复杂度:,即 (此处 ,非常小)
- 空间复杂度:,其中 为不同图案数(至多 )
C - Truck Driver
题目链接:https://atcoder.jp/contests/abc430/tasks/abc430_c
题目大意
给定长度为 、仅含 a 和 b 的字符串 ,以及正整数 。求满足以下条件的整数对 的个数:
- ;
- 子串 中
a的个数 ; - 子串 中
b的个数 。
约束:。
思路
第一步:前缀和。 设
则区间 内 a、b 的个数分别为 和 ,可 求出。
第二步:固定 ,看 的合法区间。 固定 时, 增大,区间内 a 和 b 的个数都单调不减,所以两条约束分别给出 的一个下界和一个上界:
a条件:令 为使区间 内含 个a的最小 。则 。b条件:令 为使区间 内含 (即 )个b的最大 。则 。
于是固定 的贡献为
第三步:高效求 。 注意当 增大时, 不减,所以 关于 单调不减; 也不减,所以 关于 单调不减。因此可以用两个只前进不后退的指针 依次扫描,整体 ;也可以用二分查找对每个 求 ,整体 。这里给出 的双指针写法。
边界情况与易错点:
- 可能不存在:若 ,说明从 到末尾的
a总个数都不够。又因该值随 增大而递减,此后所有 都不可能满足,直接跳出循环。 - 条件是"",即 ,别忘了 。
- 指针 可能小于 (此时没有合法的 ,贡献为 0),但 仍需继续为后续 维护;因为 的合法上界单调不减,从当前值继续前进即可。
- 答案最大约 ,要用
long long。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N, A, B;
string S;
cin >> N >> A >> B >> S;
vector<int> preA(N + 1, 0), preB(N + 1, 0);
for (int i = 0; i < N; i++) {
preA[i + 1] = preA[i] + (S[i] == 'a');
preB[i + 1] = preB[i] + (S[i] == 'b');
}
long long ans = 0;
int rA = 1, rB = 1;
for (int l = 1; l <= N; l++) {
// 剩余 a 的数量不足 A,之后所有 l 都不可能满足
if (preA[N] - preA[l - 1] < A) break;
// a_l:使 a 个数 >= A 的最小 r
while (rA <= N && preA[rA] - preA[l - 1] < A) rA++;
// b_l:使 b 个数 <= B-1 的最大 r
while (rB < N && preB[rB + 1] - preB[l - 1] <= B - 1) rB++;
if (rB >= rA) ans += rB - rA + 1;
}
cout << ans << '\n';
return 0;
}若想用二分查找,只需把指针推进换成"对每个 二分求 与 ",思路完全一致,复杂度变为 。
复杂度分析
- 时间复杂度:(双指针);二分写法为
- 空间复杂度:
D - Neighbor Distance
题目链接:https://atcoder.jp/contests/abc430/tasks/abc430_d
题目大意
数轴上,初始时只有 person 站在坐标 。之后 person 依次到达,person 站在坐标 (,且互不相同)。每当 person 到达后,当前有 个人,定义 person 的"最近距离"
输出 。共输出 行。约束:。
思路
关键观察(差分更新):一个点 的最近距离只由它左右两个邻居决定。当新点 插入时,只有以下三类点的邻居关系可能发生变化:
- 的前驱 (原来右边的邻居是 ,现在变成了 );
- 的后继 ;
- 新点 自己。
其余所有点的最近距离都不变。因此我们不需要在每次插入后全量重算,只需维护一个有序集合,并做"减去旧的、加上新的"的局部修正。这种只重算受影响因子的技巧叫做差分更新(differential update),在算法题与启发式比赛中都很常用。
做法(Approach 1):
- 用
std::set<int>维护当前所有坐标(初始包含 )。 - 维护全局答案
res。 - 插入新点 前,在旧集合中找到前驱、后继,把它们的旧最近距离从
res中减去。 - 插入 。
- 对前驱、、后继分别计算新最近距离,加到
res上。 - 输出
res。
其中"最近距离"函数 为 与前驱、后继的距离的较小值(某一侧不存在则忽略)。
为什么正确:插入 后,集合中任意点 的左右邻居都不变,故其最近距离不变;只有这三个点的最近距离可能改变。逐一修正即可。
关于 int 的一个有趣的"Bonus":官方代码用 int 存储答案,看似危险(,每个距离可达 ),但实际不会溢出。设当前坐标为 ,记 ,则
小于 。即"最近距离总和"不超过跨距的两倍。当然,写题解代码时用 long long 更稳妥。
参考代码
#include <bits/stdc++.h>
using namespace std;
int nearest(int x, const set<int>& st) {
auto it = st.find(x);
int res = INT_MAX;
if (it != st.begin()) { // 有前驱
auto jt = it;
--jt;
res = min(res, x - *jt);
}
++it;
if (it != st.end()) res = min(res, *it - x); // 有后继
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N;
cin >> N;
set<int> st;
long long res = 0;
int x;
cin >> x;
st.insert(0);
st.insert(x);
res = 2LL * x; // person 0 和 person 1 互为最近
cout << res << '\n';
for (int i = 1; i < N; i++) {
cin >> x;
vector<int> hit;
auto it = st.lower_bound(x);
if (it != st.end()) hit.push_back(*it); // 后继
if (it != st.begin()) {
--it;
hit.push_back(*it); // 前驱
}
for (int v : hit) res -= nearest(v, st); // 减去旧的
st.insert(x);
hit.push_back(x);
for (int v : hit) res += nearest(v, st); // 加上新的
cout << res << '\n';
}
return 0;
}(另一种 Approach 2:利用"插入只会使最近距离减小"这一性质,直接对前驱、后继维护"当前最近距离"的最小值,同样 。)
复杂度分析
- 时间复杂度:(每次插入做常数次
set查找与插入) - 空间复杂度:
E - Shift String
题目链接:https://atcoder.jp/contests/abc430/tasks/abc430_e
题目大意
给定等长的 0/1 字符串 和 。每次操作把 的第一个字符移到末尾(即循环左移一位)。求最少需要多少次操作才能使 ;如果无论怎么操作都不可能,输出 。有 组数据,。
思路
问题转化:左移 次后, 变成
即 的"循环左移 位"。要求的是满足"循环左移 位后等于 "的最小 ()。因为左移 次回到原串,所以 是多余的。
用 Z 算法判断循环移位:构造字符串
然后对 求 Z 数组。回忆 Z 数组的定义: 是 与 的最长公共前缀长度。
- 的下标 是 ;
- 下标 是 的拷贝,下标 开始的 个字符恰好是 循环左移 位的结果。
因此对 :
从 起从小到大检查,第一个满足的 就是最小操作次数;全部不满足则输出 。
Z 算法可以在 时间内求出整个 Z 数组(维护当前最右的匹配区间 )。总长度 ,完全可行。
易错点:下标 不要写成别的偏移;判断条件是 而不是 ;找到就立刻输出(保证最小 )。
参考代码
#include <bits/stdc++.h>
using namespace std;
vector<int> z_algorithm(const string& s) {
int n = (int)s.size();
vector<int> z(n);
z[0] = n;
int l = 0, r = 0;
for (int i = 1; i < n; i++) {
if (i < r) z[i] = min(r - i, z[i - l]);
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++;
if (i + z[i] > r) {
l = i;
r = i + z[i];
}
}
return z;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
string A, B;
cin >> A >> B;
int n = (int)A.size();
string S = B + A + A;
vector<int> z = z_algorithm(S);
int ans = -1;
for (int k = 0; k < n; k++) {
if (z[n + k] >= n) {
ans = k;
break;
}
}
cout << ans << '\n';
}
return 0;
}复杂度分析
- 时间复杂度:每组 (Z 算法线性),总计
- 空间复杂度:(Z 数组与 )
F - Back and Forth Filling
题目链接:https://atcoder.jp/contests/abc430/tasks/abc430_f
题目大意
有 个格子排成一行,要把整数 各写进恰好一个格子。给定长度为 的字符串 :若 ,要求 写在 的左边;若 ,要求 写在 的右边。设 为"能写在第 个格子里的不同整数的个数",输出 。有 组数据,。
思路
关键定理:对每个整数 , 能出现的格子构成一个连续区间 。求出所有区间的左右端点后,用差分数组累加即可得到每个格子的 。下面推导 。
先把约束写成偏序: 在 左侧; 在 左侧。定义四个"最长连续段"长度(字符串按 编号):
- :以 结尾的最长 段长度(若 则为 );
- :以 开头的最长 段长度(若 则为 );
- :以 结尾的最长 段长度;
- :以 开头的最长 段长度。
则 的可行格子(0 基下标)为
为什么 左侧至少有 个元素(下界):
- 若 ,则 在 右侧。设以 结尾的最长 段为 ,链式推出
这 个数都必须位于 左侧。
- 若 ,则 在 左侧。 段 链式推出
这 个数都必须位于 左侧。
对称地,以 结尾的 段(长度 )与以 开头的 段(长度 )所涉及的元素(分别是 与 )必须位于 右侧。因此 的格子至少为 、至多为 。
为什么中间的每个格子都能达到(充分性):除上述必须位于左侧/右侧的元素外,其余元素与 之间不存在"决定性"约束——它们之间的相邻字符链中至少有一处"转向",因此可以自由分布在 的两侧。更严格地:设 左侧那段最长段在字符 处停下、右侧段在字符 处停下,把整数分为 、(含 )、 三块。每块内部的相对顺序分别由块内约束确定,而跨块的约束只有 与 ;恰好因为这两处是"转向点"(与相邻段的字母相反), 块与 块可以按各自内部顺序自由地插入 的任意空隙,特别是可以把任意个数的 元素放在 的左侧、其余放在右侧。于是 的格子可连续地从 一直取到 。
例:,求 能放的位置。 且 ,故 , 必须位于 左侧; 链式推出 , 也必须在 左侧。共 个元素在左侧,因此 最左可到第 格;而 均可自由选择左右,于是 可取第 格,与公式吻合。
实现:预处理四个数组(:以 结尾的最长 段;:以 开头的最长 段),再对每个 计算 ,用差分数组 统计,最后前缀和输出。注意下标: 的左边相邻字符是 ,右边是 ;代码中统一用 0 基格子下标。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
int N;
string S;
cin >> N >> S; // S 长 N-1,按 1..N-1 编号
vector<int> lp(N + 1, 0), rp(N + 1, 0), ls(N + 1, 0), rs(N + 1, 0);
// lp[i] / rp[i]:以字符 i 结尾的最长 L / R 段长度
for (int i = 1; i < N; i++) {
if (S[i - 1] == 'L') lp[i] = lp[i - 1] + 1;
else rp[i] = rp[i - 1] + 1;
}
// ls[i] / rs[i]:以字符 i 开头的最长 L / R 段长度
for (int i = N - 1; i >= 1; i--) {
if (S[i - 1] == 'L') ls[i] = ls[i + 1] + 1;
else rs[i] = rs[i + 1] + 1;
}
vector<int> diff(N + 1, 0);
for (int v = 1; v <= N; v++) {
int l = rp[v - 1] + ls[v]; // 必须位于 v 左侧的元素数
int r = (N - 1) - lp[v - 1] - rs[v]; // N-1 - 必须位于 v 右侧的元素数
diff[l]++;
diff[r + 1]--;
}
int cur = 0;
for (int i = 0; i < N; i++) {
cur += diff[i];
if (i) cout << ' ';
cout << cur;
}
cout << '\n';
}
return 0;
}复杂度分析
- 时间复杂度:每组
- 空间复杂度:每组
G - Range Set Modifying Query
题目链接:https://atcoder.jp/contests/abc430/tasks/abc430_g
题目大意
有 个集合 ,初始均为空。处理 个查询:
1 L R x:对 中每个集合加入 ;2 L R x:对 中每个集合删除 ;3 L R:输出 内集合大小的最大值,以及达到该最大值的集合个数。
约束:,。
思路
难点:区间加一个元素并不是对"大小数组"做区间加——区间内有些集合已含 (大小不变),有些不含(大小 )。朴素懒标记线段树无法在 内完成这样的更新。
Segment Tree Beats 解法(官方)。核心思想:如果"整块更新"所需的信息无法仅由节点本身直接算出,就把懒标记下推、递归处理子节点,再自底向上重新合并;只要这种情况出现得不频繁,总复杂度就有保证。
每个节点维护:
- :区间内所有集合的并集(用 位位掩码表示);
- :区间内所有集合的交集;
- :区间内集合大小的最大值;
- :达到 的集合个数。
懒标记表示映射 ,在代码里写成保留掩码与新增掩码:
- 加入 : 全 1,;
- 删除 : 全 1 ,。
映射成功的条件:当
时,所有被修改的元素在该区间内要么全部存在、要么全部不存在(是"一致的"),于是可以 整块更新:
- 大小变化量 ;
- 再更新 掩码。
直观地说,就是"从每个集合都有的元素里删去的个数"与"从任何集合都没有的元素里新增的个数"。若条件不满足,则映射失败,执行:
push(k); // 把 k 的旧懒标记下推给子节点
all_apply(2k, f); all_apply(2k+1, f); // 递归地对子节点应用 f
update(k); // 由子节点重新合并出 k这正是 Segment Tree Beats 的关键步骤——失败的节点不再"挂着"懒标记,而是把操作真正下推到能处理的深度,再上拉合并。
复杂度(摊还):每次映射失败,该节点上 严格减小(被触碰的不一致元素在递归中都被解析为一致);而一次查询只会让 个节点的 至多增加 1,且元素总数不超过 。因此映射失败总次数为 ,总复杂度 。
(替代解法,无 Segment Tree Beats):准备 棵支持"区间赋值 "与"区间内 find_first"的懒标记线段树(第 棵记录 是否在 中),再加一棵支持"区间加、区间最大 + 计数"的大小线段树。对一次类型 1/2 查询,只在真正发生变化的最大连续段上做赋值并同步对大小树做区间加。由于相邻位置值不同的"边界"总数有限,真正被操作的段数摊还为 ,总复杂度 。这里给出官方的 Segment Tree Beats 实现。
易错点:掩码只用低 位;ALL = (1ULL<<60)-1;~keep 会把高位置 1,但并集/交集的位掩码高位为 0,所以判定条件 不会误判;叶子节点上 (单元素集合),映射永远不会失败,递归必然终止。
参考代码
#include <bits/stdc++.h>
using namespace std;
using ull = unsigned long long;
const ull ALL = (1ULL << 60) - 1; // 只使用低 60 位
struct Node {
ull O, A; // 并集、交集(60 位掩码)
int mx, cnt; // 最大大小、达到最大值的集合个数
};
struct F {
ull keep, add; // f(x) = (x & keep) | add
};
Node mergeNode(const Node& x, const Node& y) {
Node r;
r.O = x.O | y.O;
r.A = x.A & y.A;
if (x.mx > y.mx) { r.mx = x.mx; r.cnt = x.cnt; }
else if (x.mx < y.mx) { r.mx = y.mx; r.cnt = y.cnt; }
else { r.mx = x.mx; r.cnt = x.cnt + y.cnt; }
return r;
}
// 尝试整块应用 f;成功返回 true,否则返回 false(需下推递归)
bool applyNode(Node& x, const F& f) {
ull nonUniform = x.O ^ x.A; // 区间内"不一致"的元素(A 是 O 的子集)
if (nonUniform & ((~f.keep) | f.add)) return false; // 有不一致的元素被修改
x.mx -= __builtin_popcountll(x.A & ~f.keep); // 每个集合都有的元素被删
x.A &= f.keep;
x.O &= f.keep;
x.mx += __builtin_popcountll((~x.O) & f.add); // 任何集合都没有的元素被加
x.A |= f.add;
x.O |= f.add;
return true;
}
F composeF(const F& g, const F& f) { // 先应用 f,再应用 g
F r;
r.keep = f.keep & g.keep;
r.add = (f.add & g.keep) | g.add;
return r;
}
bool isId(const F& f) { return f.keep == ALL && f.add == 0; }
struct Beats {
int n;
vector<Node> d;
vector<F> lz;
Beats(int N) {
n = 1;
while (n < N) n <<= 1;
d.assign(2 * n, {0ULL, ALL, 0, 0}); // 虚节点/空区间:交为全 1
lz.assign(2 * n, {ALL, 0});
for (int i = 0; i < N; i++) d[n + i] = {0ULL, 0ULL, 0, 1}; // 空集合
for (int i = n - 1; i >= 1; i--) d[i] = mergeNode(d[2 * i], d[2 * i + 1]);
}
void update(int k) { d[k] = mergeNode(d[2 * k], d[2 * k + 1]); }
void all_apply(int k, const F& f) {
if (applyNode(d[k], f)) {
if (k < n) lz[k] = composeF(f, lz[k]);
} else {
push(k); // 下推旧懒标记
all_apply(2 * k, f); // 递归处理子节点
all_apply(2 * k + 1, f);
update(k); // 重新合并
}
}
void push(int k) {
if (isId(lz[k])) return;
all_apply(2 * k, lz[k]);
all_apply(2 * k + 1, lz[k]);
lz[k] = {ALL, 0};
}
void range_apply(int L, int R, const F& f) { range_apply(1, 0, n, L, R, f); }
void range_apply(int k, int l, int r, int L, int R, const F& f) {
if (R <= l || r <= L) return;
if (L <= l && r <= R) { all_apply(k, f); return; }
push(k);
int m = (l + r) / 2;
range_apply(2 * k, l, m, L, R, f);
range_apply(2 * k + 1, m, r, L, R, f);
update(k);
}
Node range_query(int L, int R) { return range_query(1, 0, n, L, R); }
Node range_query(int k, int l, int r, int L, int R) {
if (R <= l || r <= L) return {0ULL, ALL, 0, 0};
if (L <= l && r <= R) return d[k];
push(k);
int m = (l + r) / 2;
return mergeNode(range_query(2 * k, l, m, L, R),
range_query(2 * k + 1, m, r, L, R));
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N, Q;
cin >> N >> Q;
Beats seg(N);
while (Q--) {
int t, L, R;
cin >> t >> L >> R;
--L; // 转成 0 基半开区间 [L-1, R)
if (t == 1) {
int x;
cin >> x;
seg.range_apply(L, R, {ALL, 1ULL << (x - 1)});
} else if (t == 2) {
int x;
cin >> x;
seg.range_apply(L, R, {ALL ^ (1ULL << (x - 1)), 0});
} else {
Node res = seg.range_query(L, R);
cout << res.mx << ' ' << res.cnt << '\n';
}
}
return 0;
}复杂度分析
- 时间复杂度:摊还 ,其中
- 空间复杂度: