AtCoder Beginner Contest 426 题解
AtCoder Beginner Contest 426 题解
| 项目 | 内容 |
|---|---|
| 比赛时间 | 2025-10-04 |
| 比赛链接 | https://atcoder.jp/contests/abc426 |
| 题目数量 | A–G 共 7 题 |
比赛概览
本场 A–C 延续了 ABC 一贯的入门节奏:A 是简单的版本字符串比较,B 是计数题,C 考察一个"最老版本指针单调推进"的关键观察,从而把看似暴力的过程压到 。D 是一道非常巧妙的组合计数题,核心在于"从未被操作过的字符在原串中必须连成一段",进而转化为求最长连续相同字符段。E 是几何题,把两个人的相对运动拆成两条线段,把最小距离归结为"点到线段的最短距离"。F 与 G 是本场最有区分度的两道题:F 需要用懒标记线段树维护"售罄事件"(每个商品至多售罄一次,故总复杂度带一个 也可接受),G 需要用基于序列的分治配合 0/1 背包 DP 回答区间背包查询。建议重点掌握 D、F、G 三题的处理手法。
A - OS Versions
题目链接:https://atcoder.jp/contests/abc426/tasks/abc426_a
题目大意
某操作系统的版本按时间先后为 Ocelot、Serval、Lynx。给定版本 与版本 ,判断 是否与 相同或比 更新。输入一行两个字符串,输出 Yes 或 No。
思路
- 关键观察:版本之间的先后关系只有三种,直接按时间顺序给每个版本编号即可把字符串比较转化为整数比较。例如令
则 不早于 等价于编号满足 。
- 也可以直接枚举 种情况:其中答案为
No的只有 严格旧于 的 种组合,即 、、,其余情况(包括 )都输出Yes。 - 需要注意相等时(如
Ocelot Ocelot)答案也是Yes,不要漏掉。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
map<string, int> ver = {
{"Ocelot", 1},
{"Serval", 2},
{"Lynx", 3}
};
string x, y;
cin >> x >> y;
cout << (ver[x] >= ver[y] ? "Yes" : "No") << '\n';
return 0;
}复杂度分析
- 时间复杂度:。
- 空间复杂度:。
B - The Odd One Out
题目链接:https://atcoder.jp/contests/abc426/tasks/abc426_b
题目大意
给定一个长度至少为 的小写字母字符串 。 中恰好包含两种字符,且恰好有一个字符与其他字符不同(即它出现一次)。输出这个"与众不同"的字符。
思路
- 方法一(计数):统计每种字符出现的次数,输出出现次数恰好为 的字符即可。由于 ,用
map或长度 的数组都很容易。题目保证恰有一个字符出现一次,因此答案唯一。 - 方法二(排序,利用结构):把 排序得到 。因为只有两种字符且一种只出现一次,排序后这个唯一的字符必然出现在两端:
- 若 ,说明 的第一个字符就是只出现一次的那个;
- 否则前两个相同,答案就是最后一个字符 。
这个方法无需统计频次,代码更短。
- 边界情况:长度至少为 ,所以 至少有 个字符,
S'.back()一定存在。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
string s;
cin >> s;
map<char, int> cnt;
for (char c : s) cnt[c]++;
for (auto [c, k] : cnt) {
if (k == 1) {
cout << c << '\n';
return 0;
}
}
return 0;
}复杂度分析
- 时间复杂度:(用数组则为 ,)。
- 空间复杂度:。
C - Upgrade Required
题目链接:https://atcoder.jp/contests/abc426/tasks/abc426_c
题目大意
有 个版本(编号 ,数字越小越旧)和 台电脑,初始时第 台电脑的版本为 。依次进行 次操作,第 次操作为:把所有当前版本不超过 的电脑升级到版本 (保证 ),并输出本次被升级的电脑数量。
思路
- 关键观察:当前存在的版本集合始终是一段连续区间。设 为当前所有电脑中的最旧版本,则任意时刻,各电脑的版本集合恰好是 。
- 用归纳法证明:初始版本集合为 ,。假设某次操作前版本集合为 。考虑操作 :
- 若 ,则没有任何电脑的版本 ,版本集合不变;
- 否则,版本在 内的电脑(恰好是版本集合的一个连续前缀)全部升到 。由于 ,升级后这些电脑的版本都是 ,而版本 的电脑保持不动,新的版本集合为 ,即新的最旧版本为 。
- 用归纳法证明:初始版本集合为 ,。假设某次操作前版本集合为 。考虑操作 :
- 算法:维护当前最旧版本 与计数数组 (当前版本恰为 的电脑数)。对每次操作 :
- 若 ,本次没有电脑被升级,输出 ;
- 否则令 ,反复执行
while (O <= X):把版本 的所有电脑(数量 )计入 ,并转移到版本 ,即 ,然后 ;最后输出 。
- 为什么正确:由于版本集合是连续区间,本次升级的电脑数量恰好是 ,上述循环精确累加了这一数量并把它们迁移到 。
- 为什么高效:指针 从 开始只增不减,最多增加到 ,因此所有操作中内层循环的总执行次数不超过 ,总复杂度为 。这是本问题的关键——不能对每个操作重新扫描区间。
- 易错点: 中的旧值在转移后不必清零,因为 已经越过这些位置、今后不会再访问;同时 保证 落在仍然"活跃"的版本区间内, 不会被清掉。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, q;
cin >> n >> q;
vector<long long> cnt(n + 2, 0);
for (int i = 1; i <= n; ++i) cnt[i] = 1;
int o = 1; // 当前最旧版本
while (q--) {
int x, y;
cin >> x >> y;
long long res = 0;
while (o <= x) {
res += cnt[o];
cnt[y] += cnt[o];
++o;
}
cout << res << '\n';
}
return 0;
}复杂度分析
- 时间复杂度:。
- 空间复杂度:。
D - Pop and Insert
题目链接:https://atcoder.jp/contests/abc426/tasks/abc426_d
题目大意
给定长度为 的 01 字符串 。一次操作:删除第一个或最后一个字符,翻转(0 变 1、1 变 0),再把它插回任意位置。求使 中所有字符相同所需的最少操作次数。共 组测试,所有 之和不超过 。
思路
先考虑把最终所有字符都变成 0 的情况(变成 1 完全对称)。
- 下界分析:
- 原串中每个
1都要被翻转奇数次才能变成0,因此每个1至少被操作 次。 - 原串中每个
0要翻转偶数次才能保持为0,因此它要么不操作,要么至少操作 次。 - 关键观察:若初始位于第 位和第 位()的字符从未被操作,则它们之间任何字符都无法被操作。因为一个字符要被操作,必须先把它左边(从前面弹出)或右边(从后面弹出)的所有字符全部弹出;而第 位(或第 位)的字符从未被弹出,会永远挡在中间。因此从未被操作的字符在原串中必须构成一段连续区间。
- 从未被操作的字符都必须是
0,所以它们只能取某一段连续的0,长度至多为最长连续0段的长度 。 - 设 为
0的个数、 为1的个数()。则除最长0段之外的 个0各自至少操作 次,所有 个1各自至少操作 次,故下界为
- 原串中每个
- 上界(构造,说明下界可达到):把最长的一段连续
0(记为保护块 )保护起来,永不操作它。- 处理 左侧:反复弹出当前串的第一个字符并翻转——由于弹出后插回,操作过的字符不会挡路,每次弹出时串首必然仍是 左侧未处理的字符。若翻转后是
0(原本是1),插回串首即完成;若翻转后是1(原本是0),插到串尾,等待第二次翻转。于是 左侧每个字符恰好被操作 次。 - 处理串尾:此时串尾的字符要么是第 1 步从左侧插过来的
1,要么是 右侧尚未处理的字符。反复弹出串尾字符并翻转:若翻转为0,插回串首(完成);若翻转为1(即 右侧原本为0的字符第一次被翻转),插回串尾,使其紧接着被再次弹出、完成第二次翻转。 - 最终所有字符都变成
0。统计操作次数: 之外的每个字符都至少被弹出 次,共 次;其中原本为0的( 个)额外多弹出 次。总数为正好等于下界。
- 处理 左侧:反复弹出当前串的第一个字符并翻转——由于弹出后插回,操作过的字符不会挡路,每次弹出时串首必然仍是 左侧未处理的字符。若翻转后是
- 因此目标为全
0的答案是 。对称地,目标为全1的答案是 ( 为最长连续1段的长度)。最终答案取两者较小者: - 实现:一次扫描统计
0、1的个数与各自的最长连续段长度即可,注意段长度的统计要按字符分段扫。
参考代码
#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;
vector<int> cnt(2, 0), mx(2, 0);
for (int i = 0; i < n; ) {
int j = i;
while (j < n && s[j] == s[i]) ++j;
int c = s[i] - '0';
cnt[c] += j - i;
mx[c] = max(mx[c], j - i);
i = j;
}
int ans = 2 * n;
for (int c = 0; c < 2; ++c) {
ans = min(ans, 2 * (cnt[c] - mx[c]) + cnt[1 - c]);
}
cout << ans << '\n';
}
return 0;
}复杂度分析
- 时间复杂度:每组 ,总计 。
- 空间复杂度:。
E - Closest Moment
题目链接:https://atcoder.jp/contests/abc426/tasks/abc426_e
题目大意
高桥从 走向 ,青木从 走向 。两人同时出发,速度均为 ,到达各自终点后停下(停下的时刻可以不同)。求两人距离的最小值,包含出发时刻和双方都停下之后的时刻。共 组测试。
思路
- 问题转化:设 、 为高桥的起点与终点,、 为青木的起点与终点;令 、 分别为两人的行走时长。两人对称,不妨设 (否则交换两人的角色)。
- 只需要考虑 : 时刻之后两人都已停下,距离恒定。
- 把时间分成两段:设 为时刻 (青木到达终点)时高桥所在的位置。则:
- 阶段 1,:高桥从 走向 ,青木从 走向 ,两人同时出发、同时到达。
- 阶段 2,:高桥从 走向 ,青木停在 。
- 相对运动是分段线性的:记相对位置 $R(t) = $ 高桥位置 青木位置。在阶段 1 中,两人的位置都是关于 的线性函数,故 也从 线性变化到 ,即 恰好是连接 与 两点的整条线段。在阶段 2 中, 从 线性变化到 ,即另一条线段。
- 最小距离 点到线段的最短距离:两人的距离为 ,因此答案就是两条线段分别到原点的最短距离中的较小者。
- 点到线段距离的计算:设线段端点 ,其上点 ,。最小化
这是关于 的凸二次函数,无约束最优点为 ,把它截断到 后代入即可(当 时线段退化为点,直接取 )。这给出精确的 做法;官方题解使用三分法(约 次迭代)也可以,精度同样足够。
- 精度:坐标绝对值 ,用
double即可,按题目要求输出 15 位小数。
参考代码
#include <bits/stdc++.h>
using namespace std;
struct Pt { double x, y; };
// 原点 (0,0) 到线段 PQ 的最短距离
double dist_seg_to_origin(Pt p, Pt q) {
Pt v = {q.x - p.x, q.y - p.y};
double len2 = v.x * v.x + v.y * v.y;
double t = 0;
if (len2 > 0) t = -(p.x * v.x + p.y * v.y) / len2;
t = max(0.0, min(1.0, t));
double x = p.x + t * v.x;
double y = p.y + t * v.y;
return sqrt(x * x + y * y);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
cout << fixed << setprecision(15);
while (T--) {
Pt a, b, c, d;
cin >> a.x >> a.y >> b.x >> b.y;
cin >> c.x >> c.y >> d.x >> d.y;
double p = hypot(b.x - a.x, b.y - a.y); // 高桥行走时间
double q = hypot(d.x - c.x, d.y - c.y); // 青木行走时间
if (p < q) { // 保证 p >= q
swap(a, c);
swap(b, d);
swap(p, q);
}
// E:时刻 q 时高桥的位置
Pt e = {a.x + (b.x - a.x) * q / p,
a.y + (b.y - a.y) * q / p};
// 阶段 1:相对位置从 (a-c) 到 (e-d);阶段 2:从 (e-d) 到 (b-d)
Pt r1 = {e.x - d.x, e.y - d.y};
double ans = dist_seg_to_origin({a.x - c.x, a.y - c.y}, r1);
ans = min(ans, dist_seg_to_origin(r1, {b.x - d.x, b.y - d.y}));
cout << ans << '\n';
}
return 0;
}复杂度分析
- 时间复杂度:每组 ,总计 。
- 空间复杂度:。
F - Clearance
题目链接:https://atcoder.jp/contests/abc426/tasks/abc426_f
题目大意
商店有 种商品,第 种商品的库存为 。依次处理 笔订单,第 笔订单为:购买商品 各 件,若某商品库存不足 件,则买走其全部剩余库存。输出每笔订单实际买到商品的总件数。库存只会减少。
思路
- 每个商品的三种状态:库存充足、本单内售罄(剩余不足 )、已售罄(剩余 ,之后不再参与)。关键是"售罄事件":某商品在某笔订单中被买空,此后它永远不会再被真正购买(即便出现在后续订单里,也只能买 件)。
- 算法:对订单 :
- 先假设区间 内每个"还没售罄"的商品都能买 件,得初值
- 把 内所有库存都减去 (区间减)。
- 区间内可能有些商品库存变成负数,它们在本单售罄了。设某商品当前库存为 (即它原本只剩 件),实际只买走 件,而我们按 件计了数,故应从答案中修正 (即 减去 )。修正后把这个商品的库存设为一个极大的数 ,并把它从"未售罄商品数"中删除,保证它今后不会再触发售罄事件。
- 步骤 3 可能要反复执行(一个订单中可能有多个商品同时售罄),直到区间内最小库存 。
- 先假设区间 内每个"还没售罄"的商品都能买 件,得初值
- 数据结构:
- 用懒标记线段树维护区间加与区间最小值,并支持单点赋值;用另一个求和结构(线段树或树状数组)维护每个位置是否还"未售罄"(初始全为 ,售罄后置 ),用于计算"区间内未售罄商品数"。
- 找到区间内负值位置:需要"寻找 内最左的负库存"。用线段树自带的
max_right(或自定义一个"找第一个负值"的递归函数)实现:谓词 具有单调性——一旦某个前缀的最小值 ,再向右扩展只会让最小值更小,仍然 ,因此"首个使前缀最小值变负的位置"可以二分定位。
- 复杂度论证:每个商品售罄一次后就被置为 ;而所有订单累计减去的总量不超过 ,故每个商品至多售罄一次,整个过程中 while 循环的迭代总数不超过 。因此总复杂度为 。
- 数值范围:,答案与中间量都要用
long long。
参考代码
以下实现不依赖 AtCoder Library,使用自写的懒标记线段树(区间加、区间最小值、单点赋值、查询区间内第一个负值)与树状数组(单点减 1、区间和)。
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll BIG = (ll)4e18;
// 区间加、区间最小值、单点赋值、查询 [ql, n) 内第一个负值位置
struct SegTree {
int n;
vector<ll> mn, lazy;
SegTree(const vector<ll>& a) {
int sz = (int)a.size();
n = 1;
while (n < sz) n <<= 1;
mn.assign(2 * n, BIG);
lazy.assign(2 * n, 0);
for (int i = 0; i < sz; ++i) mn[n + i] = a[i];
for (int i = n - 1; i >= 1; --i) mn[i] = min(mn[i << 1], mn[i << 1 | 1]);
}
void apply(int v, ll x) { mn[v] += x; lazy[v] += x; }
void push(int v) {
if (lazy[v]) {
apply(v << 1, lazy[v]);
apply(v << 1 | 1, lazy[v]);
lazy[v] = 0;
}
}
void add(int v, int nl, int nr, int ql, int qr, ll x) {
if (qr <= nl || nr <= ql) return;
if (ql <= nl && nr <= qr) { apply(v, x); return; }
push(v);
int mid = (nl + nr) >> 1;
add(v << 1, nl, mid, ql, qr, x);
add(v << 1 | 1, mid, nr, ql, qr, x);
mn[v] = min(mn[v << 1], mn[v << 1 | 1]);
}
ll prod(int v, int nl, int nr, int ql, int qr) {
if (qr <= nl || nr <= ql) return BIG;
if (ql <= nl && nr <= qr) return mn[v];
push(v);
int mid = (nl + nr) >> 1;
return min(prod(v << 1, nl, mid, ql, qr),
prod(v << 1 | 1, mid, nr, ql, qr));
}
void setval(int v, int nl, int nr, int idx, ll x) {
if (nr - nl == 1) { mn[v] = x; lazy[v] = 0; return; }
push(v);
int mid = (nl + nr) >> 1;
if (idx < mid) setval(v << 1, nl, mid, idx, x);
else setval(v << 1 | 1, mid, nr, idx, x);
mn[v] = min(mn[v << 1], mn[v << 1 | 1]);
}
// 返回 [ql, n) 中最左边的负值位置,保证存在
int first_negative(int v, int nl, int nr, int ql) {
if (nr <= ql || mn[v] >= 0) return -1;
if (nr - nl == 1) return nl;
push(v);
int mid = (nl + nr) >> 1;
int r = first_negative(v << 1, nl, mid, ql);
if (r != -1) return r;
return first_negative(v << 1 | 1, mid, nr, ql);
}
};
// 树状数组:单点加、区间和
struct BIT {
int n;
vector<ll> bit;
BIT(int n_) : n(n_), bit(n_ + 1, 0) {}
void add(int i, ll x) { for (++i; i <= n; i += i & -i) bit[i] += x; }
ll sum(int i) { ll r = 0; for (; i > 0; i -= i & -i) r += bit[i]; return r; } // [0, i)
ll range(int l, int r) { return sum(r) - sum(l); }
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<ll> a(n);
for (int i = 0; i < n; ++i) cin >> a[i];
SegTree seg(a);
BIT bit(n);
for (int i = 0; i < n; ++i) bit.add(i, 1); // 初始全部未售罄
int q;
cin >> q;
while (q--) {
int l, r;
ll k;
cin >> l >> r >> k;
--l; // 转为 0 下标左闭右开 [l, r)
ll res = bit.range(l, r) * k;
seg.add(1, 0, seg.n, l, r, -k);
while (seg.prod(1, 0, seg.n, l, r) < 0) {
int pos = seg.first_negative(1, 0, seg.n, l);
res += seg.prod(1, 0, seg.n, pos, pos + 1); // 修正:实际只买了剩余的 s 件
seg.setval(1, 0, seg.n, pos, BIG); // 售罄后置为极大值
bit.add(pos, -1); // 不再计入"未售罄"
}
cout << res << '\n';
}
return 0;
}复杂度分析
- 时间复杂度:。
- 空间复杂度:。
G - Range Knapsack Query
题目链接:https://atcoder.jp/contests/abc426/tasks/abc426_g
题目大意
有 件物品,第 件的重量为 、价值为 。有 个查询,第 个查询给出 :从物品 中选若干件(可以一件不选),使总重量不超过 ,求能得到的最大总价值。其中 ,。
思路
- 为什么不能对每个查询单独做背包:单个查询的 0/1 背包复杂度 , 个查询会到 ,不可行。
- 序列分治(CDQ 式):定义递归函数 处理"查询区间完全落在 内"的所有查询。取 :
- 查询区间完全在左半边 内 递归进 ;
- 完全在右半边 内 递归进 ;
- 跨过 的查询()在本层直接回答。
- 跨 查询的回答:预处理两类 DP:
- ():从物品 中选、总重量不超过 的最大价值;
- ():从物品 中选、总重量不超过 的最大价值。
- 从空背包 (全 )出发递推: 再放入物品 ( 从 递减到 ); 再放入物品 ( 从 递增到 )。放物品即标准 0/1 背包转移
- 对跨 的查询 ,左右两部分物品不相交,选择可以按重量切分:任何合法方案在左侧用掉重量 、右侧用掉重量 ,故答案为
这是因为"不超过容量"型的背包 DP 关于容量是单调的,右侧取容量 一定不差于取 ;反之任取一个 ,左右两部分各自的最优选择互不影响、可以同时取,构成合法方案。这就是背包可拼接的性质。
- 正确性:每个查询要么在某层作为"跨 "查询被回答,要么最终落到长度恰为 的区间(单物品)在递归边界直接判断 是否取它。
- 复杂度:每个递归节点中,DP 填充对区间内每个物品做一次 转移,一层所有节点合计 ,层数 ;每个查询只在一层被回答一次,代价 。其中 为容量上限 ()。总复杂度
- 实现细节:DP 表大小为 的
long long(容量 ,取 留余量);递归时传"属于本节点的查询下标列表",避免每次扫描全部查询。
参考代码
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int K = 510;
struct Query { int l, r, c; };
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<int> w(n), v(n);
for (int i = 0; i < n; ++i) cin >> w[i] >> v[i];
int q;
cin >> q;
vector<Query> query(q);
for (int i = 0; i < q; ++i) {
cin >> query[i].l >> query[i].r >> query[i].c;
--query[i].l; // 转为 0 下标左闭右开 [l, r)
}
vector<ll> ans(q, 0);
vector<vector<ll>> dp(n + 1, vector<ll>(K, 0));
// 把物品 i 加入背包:pre -> nx
auto upd = [&](const vector<ll>& pre, vector<ll>& nx, int i) {
for (int j = 0; j < K; ++j) {
nx[j] = pre[j];
if (j >= w[i]) nx[j] = max(nx[j], pre[j - w[i]] + v[i]);
}
};
function<void(int, int, const vector<int>&)> f =
[&](int l, int r, const vector<int>& qid) {
if (l + 1 == r) { // 区间只含物品 l
for (int id : qid)
if (query[id].c >= w[l]) ans[id] = v[l];
return;
}
int m = (l + r) >> 1;
fill(dp[m].begin(), dp[m].end(), 0); // 空背包
for (int i = m - 1; i >= l; --i) upd(dp[i + 1], dp[i], i); // dp[i]: 物品 [i, m)
for (int i = m + 1; i <= r; ++i) upd(dp[i - 1], dp[i], i - 1); // dp[i]: 物品 [m, i)
vector<int> ql, qr;
for (int id : qid) {
int L = query[id].l, R = query[id].r, C = query[id].c;
if (R <= m) {
ql.push_back(id);
} else if (L >= m) {
qr.push_back(id);
} else {
// 跨 m 的查询:左侧 [L, m) 与右侧 [m, R) 拼接
for (int j = 0; j <= C; ++j)
ans[id] = max(ans[id], dp[L][j] + dp[R][C - j]);
}
}
f(l, m, ql);
f(m, r, qr);
};
vector<int> qid(q);
iota(qid.begin(), qid.end(), 0);
f(0, n, qid);
for (int i = 0; i < q; ++i) cout << ans[i] << '\n';
return 0;
}复杂度分析
- 时间复杂度:,其中 。
- 空间复杂度:。