AtCoder Beginner Contest 421 题解
AtCoder Beginner Contest 421 题解
| 项目 | 内容 |
|---|---|
| 比赛时间 | 2025-08-30 |
| 比赛链接 | https://atcoder.jp/contests/abc421 |
| 题目数量 | A–G 共 7 题 |
比赛概览
本场 ABC421 整体难度适中:A 是纯签到,B 是简单的模拟,C 是经典的最小相邻交换问题,D 把超大的 RLE 移动序列合并成 段分别判断,E 是"掷骰子选保留"的概率 DP,F 是链表结构的插入与区间删除求和,G 是差分转化后的最小费用流。较有区分度的是 D、F、G 三题,其中 G 的建流模型(把"区间加 1"转成差分数组上的"搬球")值得仔细体会。
A - Misdelivery
题目链接:https://atcoder.jp/contests/abc421/tasks/abc421_a
题目大意
大厦有 个房间,第 号房间住着名字为 的人。要把收件人为 的包裹送到 号房间,判断收件地址是否正确,即判断 是否等于 。输入第一行为 ,随后 行是各房间住户的名字,最后一行给出 与 。若 输出 Yes,否则输出 No。
思路
签到题,直接读入并按题意比较即可。注意两点:
- 大多数语言的数组下标从 开始,因此应比较
S[X-1]与Y。 - 字符串比较要比较内容:C++ 的
std::string重载了==,可以直接S[X-1] == Y;而某些语言(如 Java)需要用equals而不能用==比较引用。
由于 、字符串长度 ,没有任何复杂度上的顾虑。题目的"易错点"只有下标偏移与字符串比较方式。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
int N;
cin >> N;
vector<string> S(N);
for (int i = 0; i < N; i++) cin >> S[i];
int X;
string Y;
cin >> X >> Y;
cout << (S[X - 1] == Y ? "Yes" : "No") << '\n';
return 0;
}官方题解还给出了 Python 参考实现:
N = int(input())
S = [input() for _ in range(N)]
X, Y = input().split()
X = int(X)
print("Yes" if S[X - 1] == Y else "No")复杂度分析
- 时间复杂度:读入 个字符串,(字符串总长度为 )。
- 空间复杂度:。
B - Fibonacci Reversed
题目链接:https://atcoder.jp/contests/abc421/tasks/abc421_b
题目大意
对正整数 ,定义 为:把 的十进制表示(无前导零)写成字符串,翻转后按十进制重新解释得到的整数。例如 ,(因为 "01" 解释为 )。
给定正整数 ,定义序列 ,求 。
思路
直接按题意模拟即可:从 循环到 ,每次把 转成字符串、翻转、再转回整数。C++ 中可用 to_string、reverse、stoll 一行完成;Python 中可用 str、切片 [::-1]、int。
关键易错点:答案可能超过 32 位整数。分析数值的增长速度:设 为 的十进制位数。两个数之和的位数至多比较大的那个多 ,而翻转操作不会增加位数(位数至多不变,因为前导零会被去掉),因此
初始 (至多 位),经过 次递推后位数至多 位,远小于 64 位整数的上限,所以用 long long 是安全的。 恒为正整数(翻转后若前导是零,则去掉后仍至少为一位数字),这一点由字符串反转天然满足。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<long long> a(10);
cin >> a[0] >> a[1];
for (int i = 2; i < 10; i++) {
string s = to_string(a[i - 2] + a[i - 1]);
reverse(s.begin(), s.end());
a[i] = stoll(s);
}
cout << a[9] << '\n';
return 0;
}官方题解同时给出的 Python 参考实现:
a = [0] * 10
a[0], a[1] = map(int, input().split())
for i in range(2, 10):
s = str(a[i - 2] + a[i - 1])
a[i] = int(s[::-1])
print(a[9])复杂度分析
- 时间复杂度:仅常数次迭代,每次迭代处理至多十几个字符,。
- 空间复杂度:。
C - Alternated
题目链接:https://atcoder.jp/contests/abc421/tasks/abc421_c
题目大意
给定一个长度为 的字符串 ,其中恰好含有 个 A 和 个 B。一次操作可以交换 中两个相邻的字符。求使得 中不存在相邻相同字符所需的最少操作次数。。
思路
关键观察:含有 个 A、 个 B 且相邻字符两两不同的字符串只有两种:
因此原问题等价于:把 变成目标串 所需的最少相邻交换次数,对这两种 分别计算后取最小值。
接下来考虑把 变成某个 的最少相邻交换次数。设 为 中第 个 A 所在的位置(-indexed), 为目标串中第 个 A 所在的位置。
- 交换两个相同字符是无用的操作,所以只需考虑
A与B之间的交换。任何一次相邻交换都恰好使一个A的位置改变 ,因此从 到 至少需要 次操作。 - 反过来,可以贪心地让每个
A向目标位置移动:对满足 的 按 升序把第 、 位交换(把A向右推),对满足 的 按 降序把第 、 位交换(把A向左推)。每做一次操作, 恰好减少 ,故能恰好用 步完成。
所以最少交换次数就是 (这里的匹配是"第 个 A 对第 个 A"的稳定匹配,这也是相邻交换问题中的经典结论)。对于两种目标串:
- 时,第 个
A应在 ; - 时,第 个
A应在 。
答案为
注意 A 的位置确定了,B 的位置也就确定了(因为有 个 B 填满其余位置),因此只需统计 A 即可。一次扫描即可得到所有 ,时间复杂度 。总和的数量级为 ,需用 long long。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
int N;
string S;
cin >> N >> S;
long long ans = (1LL << 62);
// par = 0 : 目标为 ABAB...,第 k 个 A 应在 2k-1
// par = 1 : 目标为 BABA...,第 k 个 A 应在 2k
for (int par = 0; par < 2; par++) {
long long sum = 0;
int k = 0;
for (int i = 0; i < 2 * N; i++) {
if (S[i] == 'A') {
k++;
long long y = 2LL * k - 1 + par; // 目标位置
sum += llabs((long long)(i + 1) - y); // 当前位置 i+1
}
}
ans = min(ans, sum);
}
cout << ans << '\n';
return 0;
}复杂度分析
- 时间复杂度:对两种目标串各做一次 扫描,。
- 空间复杂度:(只需存储原串)。
D - RLE Moving
题目链接:https://atcoder.jp/contests/abc421/tasks/abc421_d
题目大意
无限网格上,Takahashi 从 、Aoki 从 出发,分别按照长度为 的移动串 (仅含 U/D/L/R)同时移动 步,每步一格。求"恰在一次移动结束后两人位于同一格子"的次数。由于 很大(), 与 以 RLE 形式给出: 由 段 拼接而成( 个 字符), 由 段 拼接而成,且两串总长度都等于 。。
思路
方向约定:本题以"向下、向右"为正方向,因此 使行坐标 , 使行坐标 , 使列坐标 , 使列坐标 。
子问题 (★):两人都朝固定方向移动时,如何计数?设某段时间内 Takahashi 从 出发、每步移动方向向量 ,Aoki 从 出发、方向向量 。走 步后两人的位置分别为
两人重合当且仅当
记 ,,,,则需求解整数 满足
分两类讨论:
- (两人同方向):相对位置不变。若 ,则整段每走一步两人都重合,计数为段长 ;否则整段永不重合。
- 否则:两个一次方程至多确定一个整数 。对每个方程:若系数非零,则 (需满足整除);若系数为零,则要求对应差值也为零(否则无解)。两方程给出的 必须一致,且落在 内( 从 开始,因为初始重合不计),才计 次。
合并时间轴:两人同时移动,用双指针把两人的 RLE 段合并处理。维护两人各自的当前段(方向与剩余长度),每轮取 ,这一段内两人的方向都是常数,直接调用子问题 (★) 计数;然后两人都沿各自方向前进 步,剩余长度减去 ,若某人的剩余长度归零则进入下一段。由于两串总长度都等于 ,两人会同时耗尽所有段,合并的段数为 。
易错点:
- 使行坐标减小、 使行坐标增大,方向向量不能写反;
- 从 开始计数,两人在段起点已经重合不算本段贡献(它已在上一段的末尾被计过);
- 判断整除时直接用取模
diff % alpha == 0(负数取模不影响整除性判断); - 坐标数量级可达 ,计数总和可达 ,一律用
long long。
参考代码
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
ll Rt, Ct, Ra, Ca;
cin >> Rt >> Ct >> Ra >> Ca;
ll N;
int M, L;
cin >> N >> M >> L;
vector<char> sc(M);
vector<ll> sn(M);
for (int i = 0; i < M; i++) cin >> sc[i] >> sn[i];
vector<char> tc(L);
vector<ll> tn(L);
for (int i = 0; i < L; i++) cin >> tc[i] >> tn[i];
// 方向字符 -> (行增量, 列增量)
auto vec = [](char c, ll& dr, ll& dc) {
dr = 0; dc = 0;
if (c == 'U') dr = -1;
else if (c == 'D') dr = 1;
else if (c == 'L') dc = -1;
else dc = 1; // 'R'
};
ll drt, dct, dra, dca;
vec(sc[0], drt, dct);
vec(tc[0], dra, dca);
int pt = 0, pa = 0;
ll remt = sn[0], rema = tn[0];
ll ans = 0;
while (pt < M && pa < L) {
ll len = min(remt, rema);
ll dr = Rt - Ra, dc = Ct - Ca; // 当前行、列差 (Taka - Aoki)
ll ar = drt - dra, ac = dct - dca;
ll cnt = 0;
if (ar == 0 && ac == 0) {
// 同方向:初始重合则整段重合,否则永不重合
if (dr == 0 && dc == 0) cnt = len;
} else {
bool ok = true;
ll t1 = 0; bool have1 = false;
if (ar != 0) {
if (dr % ar != 0) ok = false;
else { t1 = -dr / ar; have1 = true; }
} else {
if (dr != 0) ok = false;
}
ll t2 = 0; bool have2 = false;
if (ac != 0) {
if (dc % ac != 0) ok = false;
else { t2 = -dc / ac; have2 = true; }
} else {
if (dc != 0) ok = false;
}
if (ok && have1 && have2 && t1 != t2) ok = false;
if (ok) {
ll t = have1 ? t1 : t2;
if (t >= 1 && t <= len) cnt = 1;
}
}
ans += cnt;
Rt += drt * len; Ct += dct * len;
Ra += dra * len; Ca += dca * len;
remt -= len; rema -= len;
if (remt == 0) { pt++; if (pt < M) { vec(sc[pt], drt, dct); remt = sn[pt]; } }
if (rema == 0) { pa++; if (pa < L) { vec(tc[pa], dra, dca); rema = tn[pa]; } }
}
cout << ans << '\n';
return 0;
}复杂度分析
- 时间复杂度:合并后段数为 ,每段 判断,总 。
- 空间复杂度:。
E - Yacht
题目链接:https://atcoder.jp/contests/abc421/tasks/abc421_e
题目大意
有 5 枚六面骰子,每枚骰子六个面分别写着 (允许有相同的数),每个面向上的概率均为 。游戏流程:
- 掷全部 5 枚骰子并观察结果,可任意保留若干枚(可为 0);
- 重掷未被保留的骰子,观察结果,可再从新结果中任意保留若干枚(之前保留的始终保留);
- 重掷仍未保留的骰子并观察结果;
- 任选一个数 ,设 5 枚骰子中显示 的有 枚,得分 。
求在最优策略下的期望得分。要求误差 。
思路
这是一个"每次掷完后决定保留哪些骰子"的决策问题,用带记忆化的概率 DP 求解。
设 表示:还剩 次掷骰机会,当前保留的骰子的值构成多重集 时,最优的期望得分。设 为本轮要掷的骰子数。掷 枚骰子得到结果多重集 的概率为 (多项式分布,见下)。分两类:
- (最后一次掷骰):掷完后游戏结束,直接选最优的 ,得分为
因此
- :掷出 后,可以选择再保留 的任意子多重集 (其余继续重掷),应取最优者:
答案即为 。
为什么状态很少:保留的骰子数不超过 5,每枚取值至多 6 种,因此保留多重集 的种类数为 量级;对每个状态,结果多重集 的种类数至多 ,子多重集 至多 种。按官方题解的分析,记忆化递归的总调用次数不超过 ,完全可行。
实现要点:
- 把 中相同的值合并:不同的值记为 ,掷一次得到 的概率为 。
- 掷 枚骰子得到计数为 的结果的概率为多重分布
可用递推方式逐个确定每种值的个数并累乘组合数,避免大数运算。
- 状态用计数向量 ()编码,按 分别记忆化。
- 直接对计数取 即可。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
vector<int> A(6);
for (int i = 0; i < 6; i++) cin >> A[i];
// 不同的值及其出现概率
vector<int> vals;
for (int i = 0; i < 6; i++) {
bool dup = false;
for (int v : vals) if (v == A[i]) dup = true;
if (!dup) vals.push_back(A[i]);
}
int D = (int)vals.size();
vector<double> p(D, 0.0);
for (int i = 0; i < 6; i++)
for (int j = 0; j < D; j++)
if (A[i] == vals[j]) p[j] += 1.0 / 6.0;
map<vector<int>, double> memo[4]; // memo[k][S]
function<double(int, vector<int>)> f = [&](int k, vector<int> S) -> double {
auto it = memo[k].find(S);
if (it != memo[k].end()) return it->second;
int r = 5;
for (int c : S) r -= c;
double res = 0.0;
vector<int> P(D, 0); // 本轮掷骰结果(计数)
// 枚举 r 枚骰子的所有结果多重集 P
function<void(int, int, double)> gen = [&](int j, int remain, double prob) {
if (j == D) {
if (remain != 0) return;
if (k == 1) {
double sc = 0.0;
for (int t = 0; t < D; t++)
sc = max(sc, (double)(S[t] + P[t]) * vals[t]);
res += prob * sc;
} else {
double best = -1e18;
vector<int> T(D, 0); // 本轮额外保留的子多重集
function<void(int)> genT = [&](int jj) {
if (jj == D) {
vector<int> U = S;
for (int t = 0; t < D; t++) U[t] += T[t];
best = max(best, f(k - 1, U));
return;
}
for (int t = 0; t <= P[jj]; t++) {
T[jj] = t;
genT(jj + 1);
}
};
genT(0);
res += prob * best;
}
return;
}
double comb = 1.0; // C(remain, c)
for (int c = 0; c <= remain; c++) {
P[j] = c;
double pw = 1.0;
for (int t = 0; t < c; t++) pw *= p[j];
gen(j + 1, remain - c, prob * comb * pw);
comb = comb * (remain - c) / (c + 1);
}
};
gen(0, r, 1.0);
memo[k][S] = res;
return res;
};
vector<int> empty(D, 0);
double ans = f(3, empty);
cout << fixed << setprecision(10) << ans << '\n';
return 0;
}复杂度分析
- 时间复杂度:状态数 ,每个状态枚举 种结果 种子集,总枚举量 ,可视为常数。
- 空间复杂度:。
F - Erase between X and Y
题目链接:https://atcoder.jp/contests/abc421/tasks/abc421_f
题目大意
初始序列 。按顺序处理 个询问,:
1 x:把当前询问的编号 插到值 在 中出现的位置之后;2 x y:删除 中 与 之间的所有元素(不含 本身),输出被删元素之和,并把 更新为(…, x, y, …)。
保证操作前 都在 中,且处理过程中 中每个值至多出现一次(因此位置唯一)。
思路
因为值互不相同,可以用链表维护序列:记 为值 在 中后一个位置的值,若 在末尾则为 。初始只有值 ,。
类型 1(插入):要把 插到 之后。若插入前 ,则链 … 变为 …,只需更新
其余元素不受影响, 完成。
类型 2(删除):需要找出 谁在前,累加两者之间所有值并输出,然后把它们"缝合"起来。直接判断谁在前需要额外信息,这里采用双指针交替推进:两个指针分别从 与 出发沿 前进,同时累加途经值(分别为 ):
- 若从 出发的指针先到达 ,说明 在 之前,输出 ,并令 ;
- 若从 出发的指针先到达 ,说明 在 之前,输出 ,并令 。
虽然单次删除可能遍历若干节点,但每个被删的元素只会被遍历常数次,而插入总共只有 次、最多产生 个元素,因此所有删除遍历的总和为 ,整体摊还 。
细节:
- 被插入的值就是查询编号 ,范围 ,加上初始的 ,所以
nxt数组开 即可; - 类型 2 中被删元素之和可能很大:最多 个数、每个最大 ,和可达约 ,必须用
long long; - 若 相邻(之间无元素),遍历一步即相遇,输出 ,此时 是"原地缝合",正确。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int Q;
cin >> Q;
vector<int> nxt(Q + 1, -1); // nxt[v] : 值 v 在 A 中后面相邻的值,末尾为 -1
nxt[0] = -1;
for (int i = 1; i <= Q; i++) {
int t;
cin >> t;
if (t == 1) {
int x;
cin >> x;
nxt[i] = nxt[x];
nxt[x] = i;
} else {
int x, y;
cin >> x >> y;
int cx = x, cy = y;
long long sx = 0, sy = 0;
while (true) {
if (nxt[cx] != -1) {
cx = nxt[cx];
if (cx == y) { // x 在前,x 与 y 之间的元素已被累加到 sx
cout << sx << '\n';
nxt[x] = y;
break;
} else {
sx += cx;
}
}
if (nxt[cy] != -1) {
cy = nxt[cy];
if (cy == x) { // y 在前
cout << sy << '\n';
nxt[y] = x;
break;
} else {
sy += cy;
}
}
}
}
}
return 0;
}复杂度分析
- 时间复杂度:插入 ;删除的遍历总代价与被删元素总数同阶,摊还 。
- 空间复杂度:。
G - Increase to make it Increasing
题目链接:https://atcoder.jp/contests/abc421/tasks/abc421_g
题目大意
给定长度 的整数序列 ()和 个区间 。一次操作可任选一个 ,把 全部加 。判断能否通过若干次操作使 单调不减,若能则求最少操作次数,否则输出 。,。
思路
差分转化。定义差分数组 :
一次对区间 的操作,等价于 加 、 减 。而 单调不减等价于 ()且 (恒真),即
球的模型。把差分值想象成 个盒子 里的球:
- ()表示盒子 有 个多余的球;
- ()表示盒子 还需要 个球;
- 盒子 有无限多的多余球(因为 允许任意负)。
一次区间操作 等价于把 个球从盒子 移到盒子 ( 增加、 减少)。目标是用最少的移动次数把多余球送到所有需要球的位置。这正是一个最小费用流问题。
建图。构造图 ,顶点为盒子 再加源点 、汇点 :
- 对每个 ():,容量 ,费用 ;
- 对每个 ():,容量 ,费用 ;
- ,容量 ,费用 (盒子 的无限供给);
- 对每个操作 :,容量 ,费用 (搬 1 个球花 1 次操作)。
记需求总量 。求 到 的最小费用最大流:若最大流 ,说明有些需求无法被满足,输出 ;否则答案为最小费用最大流的费用。
正确性:从 出发的一条流代表"一个多余球经过一系列搬运(每条边是一次区间操作)到达某个需求位置",路径经过的边数即花费的操作次数;最小费用最大流即满足所有需求所需的最少操作总数。反过来,任何一组可行操作方案都对应图上的一个可行流,因此最小费用流给出的正是最优值。费用非负,可用 Dijkstra 实现的最短路增广(successive shortest path)求解。
复杂度:最大流值 ,每次增广为一次 Dijkstra,,总复杂度 。官方题解使用了 atcoder 库的 mcf_graph,下面给出不依赖外部库的自实现版本(思路完全相同)。
参考代码
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll INF = (1LL << 60);
// 最小费用流:所有费用非负,用 Dijkstra + 势能 (primal-dual) 实现
struct MinCostFlow {
struct Edge {
int to, rev;
ll cap, cost;
};
int n;
vector<vector<Edge>> g;
MinCostFlow(int n) : n(n), g(n) {}
void add_edge(int from, int to, ll cap, ll cost) {
g[from].push_back({to, (int)g[to].size(), cap, cost});
g[to].push_back({from, (int)g[from].size() - 1, 0, -cost});
}
// 返回 (最大流, 最小费用),maxf 为流量上限
pair<ll, ll> min_cost_flow(int s, int t, ll maxf = INF) {
ll flow = 0, cost = 0;
vector<ll> h(n, 0), dist(n);
vector<int> prevv(n), preve(n);
while (flow < maxf) {
priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<>> pq;
fill(dist.begin(), dist.end(), INF);
dist[s] = 0;
pq.push({0, s});
while (!pq.empty()) {
auto [d, v] = pq.top();
pq.pop();
if (dist[v] < d) continue;
for (int i = 0; i < (int)g[v].size(); i++) {
Edge &e = g[v][i];
if (e.cap <= 0) continue;
ll nd = dist[v] + e.cost + h[v] - h[e.to];
if (nd < dist[e.to]) {
dist[e.to] = nd;
prevv[e.to] = v;
preve[e.to] = i;
pq.push({nd, e.to});
}
}
}
if (dist[t] == INF) break;
for (int v = 0; v < n; v++)
if (dist[v] < INF) h[v] += dist[v];
ll d = maxf - flow;
for (int v = t; v != s; v = prevv[v])
d = min(d, g[prevv[v]][preve[v]].cap);
flow += d;
cost += d * h[t];
for (int v = t; v != s; v = prevv[v]) {
Edge &e = g[prevv[v]][preve[v]];
e.cap -= d;
g[v][e.rev].cap += d;
}
}
return {flow, cost};
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N, M;
cin >> N >> M;
vector<int> A(N);
for (int i = 0; i < N; i++) cin >> A[i];
MinCostFlow mcf(N + 3);
int S = N + 1, T = N + 2;
ll K = 0; // 总需求
for (int i = 0; i < N; i++) {
int d = A[i] - (i ? A[i - 1] : 0); // d_0 = A_1, d_i = A_{i+1} - A_i
if (d > 0) mcf.add_edge(S, i, d, 0);
else if (d < 0) {
mcf.add_edge(i, T, -d, 0);
K += -d;
}
}
mcf.add_edge(S, N, INF, 0); // 盒子 N 有无限多余的球
for (int i = 0; i < M; i++) {
int L, R;
cin >> L >> R;
--L; // L_i - 1(下标转 0-based)
mcf.add_edge(R, L, INF, 1); // 从盒子 R 搬 1 个球到盒子 L-1,花费 1 次操作
}
auto [flow, cost] = mcf.min_cost_flow(S, T);
if (flow < K) cout << -1 << '\n';
else cout << cost << '\n';
return 0;
}复杂度分析
- 时间复杂度:,其中 ,即 。
- 空间复杂度:。