AtCoder Beginner Contest 425 题解
AtCoder Beginner Contest 425 题解
| 项目 | 内容 |
|---|---|
| 比赛时间 | 2025-09-27 |
| 比赛链接 | https://atcoder.jp/contests/abc425 |
| 题目数量 | A–G 共 7 题 |
比赛概览
本场整体难度平缓递进:A、B 是纯入门题;C 是经典的"环形旋转 + 区间和"双指针/前缀和技巧题;D 是一道网格染色过程的模拟,关键观察是"每个格子至多被染黑一次";E 需要把多重组合数在任意模数下用帕斯卡三角预处理求出来;F 是反向删除 + 状态压缩 DP,难点在于如何对相同的字符串序列去重;G 则是按最高位分治的递归题,把字典树(Trie)的思想展开成集合划分的递归式,思维含量最高。其中 E、F、G 三道题较有区分度,值得重点掌握。
A - Sigma Cubes
题目链接:https://atcoder.jp/contests/abc425/tasks/abc425_a
题目大意
给定正整数 (),求 的值。
思路
这是纯粹的入门循环题。因为 至多只有 ,直接用一个 for 循环累加即可:当 为偶数时 ,累加 ;当 为奇数时 ,累减 。这是最直观、最不易出错的做法。
顺便一提,本题还存在一个封闭形式公式:
它可以这样推导:设 ,把相邻两项配对,
而 时 。把两种奇偶情况合并即可得到上面的公式。不过本题数据范围极小,直接循环是最佳选择;公式版本作为验证手段也很有用。注意 时 ,答案用 int 就够,但用 long long 更稳妥。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
int N;
cin >> N;
long long ans = 0;
for (int i = 1; i <= N; i++) {
if (i % 2 == 0) ans += 1LL * i * i * i; // (-1)^i = +1
else ans -= 1LL * i * i * i; // (-1)^i = -1
}
cout << ans << endl;
return 0;
}官方题解还给出了基于封闭公式的一行 Python 实现(利用 Python 的 // 做精确整除,公式值恒为整数):
N = int(input())
print(((-1) ** N * (4 * N**3 + 6 * N**2 - 1) + 1) // 8)复杂度分析
- 时间复杂度:
- 空间复杂度:
B - Find Permutation 2
题目链接:https://atcoder.jp/contests/abc425/tasks/abc425_b
题目大意
给定长度为 的序列 ,每个元素要么是 ,要么是 中的某个整数。问是否存在一个 的排列 ,使得对每个满足 的位置都有 。若存在,输出 Yes 并给出任意一个合法的 ;否则输出 No。。
思路
,所以 ,完全可以直接枚举所有排列。
具体做法:用 std::next_permutation 从 开始依次生成全部 个排列,对每个排列 检查条件是否对全部 成立。条件"若 则 "等价于逻辑表达式" 或 ",写成 a[i] == -1 || p[i] == a[i] 即可,实现上非常简洁。
正确性:任何满足条件的 必然是 的排列,而我们恰好枚举了所有排列,所以只要存在解就一定能枚举到;一旦找到就立即输出并终止程序,找不到则输出 No。若固定值之间存在冲突(例如同一个值出现在两个不同位置),自然不会有排列通过检查,枚举完就会输出 No,无需特判。
易错点:输出任意合法解即可,不需要字典序最小;注意固定值本身都在 内,所以不存在"越界"的固定值。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
cin >> n;
vector<int> a(n);
for (int &x : a) cin >> x;
vector<int> p(n);
iota(p.begin(), p.end(), 1); // p = (1, 2, ..., n)
do {
bool ok = true;
for (int i = 0; i < n; i++) {
if (a[i] != -1 && a[i] != p[i]) ok = false;
}
if (ok) {
cout << "Yes\n";
for (int i = 0; i < n; i++) {
cout << p[i] << " \n"[i + 1 == n];
}
return 0;
}
} while (next_permutation(p.begin(), p.end()));
cout << "No\n";
return 0;
}官方题解的 Python 版本(利用 itertools.permutations):
from itertools import permutations
n = int(input())
a = list(map(int, input().split()))
for p in permutations([i + 1 for i in range(n)]):
ok = True
for i in range(n):
ok &= a[i] == -1 or p[i] == a[i]
if ok:
print("Yes")
print(*p)
break
else:
print("No")复杂度分析
- 时间复杂度:,最坏约 次基本操作
- 空间复杂度:
C - Rotate and Sum Query
题目链接:https://atcoder.jp/contests/abc425/tasks/abc425_c
题目大意
给定长度为 的整数序列 ,按顺序处理 个操作,共两类:
1 c:把 的第一个元素移到末尾,重复 次(即整体左移 位);2 l r:输出当前 。
,。
思路
关键观察:左移操作并不需要真的改动数组。左移 位等价于"把整个数组看作一个环形,起始下标向后移动 "。因此只要维护一个全局偏移量
就能 知道当前数组的起点在原始数组中的位置。
为了能用前缀和回答区间查询,把 复制两份拼成 (长度 )。由于 ,当前数组恰好是 中的连续一段
具体地,当前数组的第 ()个元素就是 (下标从 起)。于是操作 2 l r 的答案就是
其中 是 的前缀和,。由于 ,前缀和数组开 大小即可,不会越界。
为什么正确:所有左移只在"起点位置"上累积,而环形结构保证任何一次操作 1 后数组都仍是 中长度 的连续段;前缀和预处理一次后,区间求和只需两次减法,均为 。
易错点: 需要累积并取模;取模后偏移在 ,不会让下标越出 的范围。另外 可达 ,区间和要用 long long。
参考代码
#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> a(n);
for (auto &x : a) cin >> x;
// B = A + A,pref 为 B 的前缀和
vector<long long> pref(2 * n + 1, 0);
for (int i = 0; i < 2 * n; i++) pref[i + 1] = pref[i] + a[i % n];
long long shift = 0; // 全局左移偏移量(对 n 取模后)
while (q--) {
int t;
cin >> t;
if (t == 1) {
int c;
cin >> c;
shift = (shift + c) % n;
} else {
int l, r;
cin >> l >> r;
// 当前数组第 p 个元素 = B[shift + p - 1]
cout << pref[shift + r] - pref[shift + l - 1] << '\n';
}
}
return 0;
}官方题解给出的是基于后缀和的 Python 实现,思路等价:
import sys
input = sys.stdin.readline
n, q = map(int, input().split())
a = list(map(int, input().split()))
b = a + a
for i in range(2 * n - 1, 0, -1):
b[i - 1] += b[i] # b[i] = 从 i 到末尾的后缀和
rui_c = 0
for _ in range(q):
query = list(map(int, input().split()))
if query[0] == 1:
rui_c = (rui_c + query[1]) % n
else:
l, r = query[1] - 1, query[2]
print(b[l + rui_c] - b[r + rui_c])复杂度分析
- 时间复杂度:
- 空间复杂度:
D - Ulam-Warburton Automaton
题目链接:https://atcoder.jp/contests/abc425/tasks/abc425_d
题目大意
有一个 的网格,初始某些格子为黑色(#)、其余为白色(.)。不断执行以下操作共 次:设 为所有"白色且恰好有 1 个黑色邻居"的格子的集合,把 中所有格子同时染黑。求最终黑色格子的数量。。
思路
关键观察 1:每个格子至多被染黑一次
格子只会由白变黑,不会由黑变白,因此每个格子在整个过程中至多进入一次 。
关键观察 2:第 步被染黑的格子必与第 步被染黑的格子相邻
设格子 在第 步被染黑(即 )。在第 步结束时, 是白色且恰好有一个黑色邻居 。反证:假设 在第 步之前就已经是黑色(即 )。
- 若在第 步结束时 恰好有一个黑色邻居,那么 应该在第 步就被染黑,与" 在第 步才被染黑"矛盾;
- 若在第 步结束时 的黑色邻居数 ,由于黑色集合只增不减,第 步结束时 的黑色邻居数仍 ,这与"第 步结束时 恰好有 1 个黑色邻居"矛盾。
因此 必然在第 步刚被染黑,即 。
算法流程
由观察 2,求 时只需检查 中所有格子的四邻域即可,无需全图扫描。算法如下:
- 初始时扫描整个网格,把所有"白色且恰好有 1 个黑色邻居"的格子放入 (即 )。
- 若 ,过程终止;否则把 中所有格子染黑。
- 遍历 中每个格子的四邻域:若某个邻居当前仍为白色,且恰好有 1 个黑色邻居,则放入新的集合 。
- 令 ,回到第 2 步。
复杂度论证:集合 两两不交(每个格子只被染黑一次),所以 。每轮对 中的每个格子检查常数个邻居,总检查次数是 。由于每步至少染黑一个格子,过程至多 步就会停止,因此操作 次与"一直操作到稳定"完全等价。
实现细节:
- 染黑 之后再去检查邻居,保证"恰好 1 个黑色邻居"用的是最新状态;
- 因为"恰好 1 个黑邻居 + 邻接 "的条件已经保证每个新格子只可能被唯一的 格子发现,理论上不会重复,但稳妥起见还是对 去重;
- 若初始就没有满足条件的白格(例如全白网格),答案就是初始黑格数。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int H, W;
cin >> H >> W;
vector<string> S(H);
for (auto &s : S) cin >> s;
const int dx[4] = {1, 0, -1, 0};
const int dy[4] = {0, 1, 0, -1};
auto in = [&](int x, int y) { return 0 <= x && x < H && 0 <= y && y < W; };
auto cnt = [&](int x, int y) { // 统计 (x, y) 的黑色邻居数
int c = 0;
for (int d = 0; d < 4; d++) {
int nx = x + dx[d], ny = y + dy[d];
if (in(nx, ny) && S[nx][ny] == '#') c++;
}
return c;
};
vector<pair<int, int>> T;
for (int i = 0; i < H; i++)
for (int j = 0; j < W; j++)
if (S[i][j] == '.' && cnt(i, j) == 1) T.push_back({i, j});
while (!T.empty()) {
for (auto [x, y] : T) S[x][y] = '#'; // 同时染黑
vector<pair<int, int>> nT;
for (auto [x, y] : T) {
for (int d = 0; d < 4; d++) {
int nx = x + dx[d], ny = y + dy[d];
if (in(nx, ny) && S[nx][ny] == '.' && cnt(nx, ny) == 1)
nT.push_back({nx, ny});
}
}
sort(nT.begin(), nT.end());
nT.erase(unique(nT.begin(), nT.end()), nT.end());
T = nT;
}
long long ans = 0;
for (int i = 0; i < H; i++)
for (int j = 0; j < W; j++)
if (S[i][j] == '#') ans++;
cout << ans << endl;
return 0;
}官方题解的 Python 实现(不显式去重,逻辑相同):
H, W = map(int, input().split())
S = [list(input()) for i in range(H)]
dxdy = [(1, 0), (0, 1), (-1, 0), (0, -1)]
def in_grid(x, y):
return 0 <= x < H and 0 <= y < W
def count(x, y):
c = 0
for dx, dy in dxdy:
nx, ny = x + dx, y + dy
if in_grid(nx, ny) and S[nx][ny] == "#":
c += 1
return c
for i in range(H * W):
if i == 0:
T = []
for x in range(H):
for y in range(W):
if S[x][y] == "." and count(x, y) == 1:
T.append((x, y))
else:
nT = []
for x, y in T:
for dx, dy in dxdy:
nx, ny = x + dx, y + dy
if in_grid(nx, ny) and S[nx][ny] == "." and count(nx, ny) == 1:
nT.append((nx, ny))
T = nT
if len(T) == 0:
break
for x, y in T:
S[x][y] = "#"
print(sum(row.count("#") for row in S))复杂度分析
- 时间复杂度:(每个格子至多被检查常数次)
- 空间复杂度:
E - Count Sequences 2
题目链接:https://atcoder.jp/contests/abc425/tasks/abc425_e
题目大意
个测试用例,给定正整数 与序列 。统计满足以下条件的正整数序列的个数,结果对给定的(不一定是素数的)模数 取模:
- 序列的每个元素都在 之间;
- 对每个 ,值 在序列中恰好出现 次。
约束:,,各用例 之和 。
思路
问题转化:多重组合数
所有元素之和为 。一个满足条件的序列就是:先决定长度为 ,其中 个位置放 、 个位置放 、……、 个位置放 。这是经典的多重排列(multinomial coefficient)问题,答案是
直接验证:样例 1 第一组 ,,答案为 ,与样例一致。
关键技巧:化为二项式系数的乘积
多重组合数可以写成若干二项式系数的乘积:
(记 ,则 ,逐项相乘后分子分母交错消去,只剩下 。)于是只需从左到右维护前缀和 ,每次乘上 即可。
任意模数下的组合数:帕斯卡三角
不一定是素数,因此不能用"阶乘 + 逆元"预处理组合数。但 ,可以直接用帕斯卡递推在模 意义下预处理 的全部二项式系数:
该递推只涉及加法,对任意模数 都成立,预处理后每个组合数可在 查询。每个测试用例只需 次乘法取模。
正确性:多重排列计数是基本组合结论;帕斯卡递推给出的 在 范围内覆盖了所有需要的查询(前缀和 )。由于 且每个 ,表内元素用 int 存储即可,求和时注意用 long long 转存避免中间溢出。
易错点:表的第二维大小要开足(至少 );ans 初始化为 ;每步 ans = ans * C[s][c] % M 中乘积可能接近 ,必须用 long long。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
long long M;
cin >> T >> M;
const int K = 5005; // 覆盖 sum(C) <= 5000
vector<vector<int>> C(K, vector<int>(K, 0));
C[0][0] = 1;
for (int n = 1; n < K; n++) {
C[n][0] = 1;
for (int k = 1; k <= n; k++) {
long long v = (long long)C[n - 1][k - 1] + C[n - 1][k];
C[n][k] = v % M;
}
}
while (T--) {
int N;
cin >> N;
long long ans = 1, s = 0;
for (int i = 0; i < N; i++) {
long long c;
cin >> c;
s += c; // s = C1 + ... + Ci
ans = ans * C[s][c] % M; // ans *= binom(s, c)
}
cout << ans << "\n";
}
return 0;
}官方题解的 Python 实现:
T, mod = map(int, input().split())
K = 5010
binom = [[0] * K for i in range(K)]
binom[0][0] = 1
for n in range(1, K):
binom[n][0] = 1
for k in range(1, n + 1):
binom[n][k] = (binom[n - 1][k - 1] + binom[n - 1][k]) % mod
for _ in range(T):
N = int(input())
C = list(map(int, input().split()))
ans = 1
s = 0
for c in C:
s += c
ans *= binom[s][c]
ans %= mod
print(ans % mod)复杂度分析
- 时间复杂度:预处理 (),每个测试用例
- 空间复杂度:(二项式系数表)
F - Inserting Process
题目链接:https://atcoder.jp/contests/abc425/tasks/abc425_f
题目大意
给定长度为 ()的小写字母串 。统计满足以下条件的字符串序列 的个数,对 取模:
- 是空串;
- 对 , 是在 的任意位置(含首尾)插入一个任意字符得到的;
- 。
思路
反向思考:从 删字符
把过程倒过来:从 出发,每次删掉一个字符,最终得到空串。用一个下标集合 表示"当前串仍保留的字符在 中的位置"。删除一个字符就等价于从 中删掉一个元素。
关键问题:不同删除方式可能产生同一个字符串序列
例如 :先删第 1 个 a 或先删第 2 个 a,得到的下一层串都是 ab,进而产生完全相同的字符串序列(反向看都是 "" -> "a" -> "ab" -> "aab")。因此直接数"删除方式"会重复计数。
去重规则:连续相同字符"从左往右删"
加上如下规范约束:
若 中相邻的两个位置 满足 ,则禁止删除右边的 。
也就是说,对每一段连续相同的字符,总是先删最左边的一个。
为什么这条规则恰好给出双射:
- 一个删除顺序的逆过程就是一个插入顺序,不同删除顺序产生相同字符串序列,当且仅当它们的差异只发生在"某段连续相同字符内部"(此时删左删右得到的字符串相同)。
- 规定"每段相同字符总是先删最左",就把每一类等价删除方式挑出了唯一代表;反向理解就是"在构造 时,一段连续相同字符中最左的那个最后插入",这为每个字符串序列确定了一个唯一的规范化反向过程。
- 反之,满足该规则的任意删除顺序都对应一个合法字符串序列;不同规则删除顺序在第一次分歧处删除的字符位于不同的段(同段只允许删最左、最左是唯一的),得到的字符串必然不同。
以 为例:满足规则的删除顺序恰好 3 种——先删 b;先删第 1 个 a 再删第 2 个 a;先删第 1 个 a 再删 b(先删第 2 个 a 是被禁止的,因为其左侧剩余字符同为 a),对应答案 3,与样例一致。
状态压缩 DP
设 dp[mask] 表示"当前剩余字符的下标集合为 mask 时,按规范规则删空的方案数"。初始 dp[(1<<N)-1] = 1,答案 dp[0]。
转移:对 mask 中每个为 1 的位置 i,找出 mask 中它左边最近的剩余位置 pre。若 pre 不存在(i 是当前最左剩余字符)或 ,则可以从 mask 删除 i,即
注意 mask ^ (1<<i) < mask 恒成立(清除一个 1 只会让数值变小),所以从大到小枚举 mask 即可保证转移方向正确。
复杂度:状态数 ,每个状态扫 位,总复杂度 ,在 C++ 中很快。模数为 ,累加两次不超过 ,仍小于 int 上限,用"加后判减"即可安全取模。
参考代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
string t;
cin >> n >> t;
const int MOD = 998244353;
vector<int> dp(1 << n, 0);
dp[(1 << n) - 1] = 1; // 初始剩余全部字符
for (int mask = (1 << n) - 1; mask >= 0; mask--) {
if (dp[mask] == 0) continue;
char pre = 0; // 0 不等于任何小写字母,保证最左字符可删
for (int i = 0; i < n; i++) {
if ((mask >> i) & 1) {
if (pre != t[i]) { // 左边无剩余字符,或字符不同 → 允许删除
int &to = dp[mask ^ (1 << i)];
to += dp[mask];
if (to >= MOD) to -= MOD;
}
pre = t[i]; // 更新"左边最近剩余字符"
}
}
}
cout << dp[0] << '\n';
return 0;
}官方题解的 Python 实现:
mod = 998244353
n = int(input())
t = input()
dp = [0] * (1 << n)
dp[(1 << n) - 1] = 1
for bit in range((1 << n) - 1, -1, -1):
dp[bit] %= mod
pre = "?"
for i in range(n):
if (bit >> i) & 1:
if pre != t[i]:
dp[bit ^ (1 << i)] += dp[bit]
pre = t[i]
print(dp[0])复杂度分析
- 时间复杂度:
- 空间复杂度:
G - Sum of Min of XOR
题目链接:https://atcoder.jp/contests/abc425/tasks/abc425_g
题目大意
给定 个非负整数 与正整数 ,求
其中 为按位异或。,,。
思路
定义递归函数
由于 且 ,可以把所有数看成 位以内的二进制。定义
要求 中所有元素都小于 且 。我们要求的就是 。边界: 或 时 (此时所有异或值都为 0)。
按最高位划分集合
设 ,。把所有 按第 位(最高位)分为两类:(高位为 0)与 (高位为 1,此时 )。同时把 分成两组:
- :最高位为 0;
- :最高位为 1,减去 后对齐。
由于异或的每一位互不影响,有下面的基本结论(对 ):
即"异或结果的最高位由 与 的最高位是否相同决定"。由此可得:
- 对 :若 非空,最小值来自 (数值 );若 为空,最小值来自 ,且比 内部的最小值大 ;
- 对 :若 非空,最小值来自 ;若 为空,最小值来自 ,且加上 。
分情况写出递归式
情况 1:。 取遍 ,高低两半各占一半。
- 若 均非空:高位为 0 的一半取 ,高位为 1 的一半取 ,故
- 若 为空:低位一半的最小值都要加 ,共 个数,故
- 若 为空:同理 。
情况 2:。 所有 最高位均为 0,。
- 若 为空:最小值来自 且整体加 ,共 个 ,
- 若 非空: 中的元素不可能成为最小值( 给出的值 ,而 给出的值 ),
情况 3:。 把 分成 (共 个)与 (共 个)两段分别计算后相加。
- 第一段(, 取遍 ): 为空时贡献 ;否则贡献 。
- 第二段(,): 为空时贡献 ;否则贡献 。
为什么这样是高效的:每次调用都把集合按最高位划分成两个不交的子集,递归只落在非空的子集上(空的一侧用公式直接处理,不再把整个集合传下去)。每个递归层处理的总元素数不超过 ,而二进制位数最多约 层,总复杂度 。
边界与易错点:递归前保证 非空(题目保证 );答案可能达到 ,必须用 64 位整数;中间量 也需 long long。
参考代码
#include <bits/stdc++.h>
using namespace std;
// f(A, M, k) = sum_{x=0}^{M-1} min_{a in A} (x XOR a)
// 前置条件:M <= 2^k,A 中所有元素均 < 2^k
long long f(const vector<long long>& A, long long M, int k) {
if (k == 0 || M == 0 || A.empty()) return 0;
long long mid = 1LL << (k - 1);
vector<long long> B0, B1;
for (long long a : A) {
if (a < mid) B0.push_back(a);
else B1.push_back(a - mid); // 减去最高位后对齐
}
if (M == (1LL << k)) { // 情况 1:x 取遍 [0, 2^k)
if (!B0.empty() && !B1.empty())
return f(B0, mid, k - 1) + f(B1, mid, k - 1);
if (B0.empty()) return 2 * f(B1, mid, k - 1) + mid * mid;
return 2 * f(B0, mid, k - 1) + mid * mid;
}
if (M <= mid) { // 情况 2:所有 x 的最高位均为 0
if (B0.empty()) return f(B1, M, k - 1) + M * mid;
return f(B0, M, k - 1);
}
// 情况 3:mid < M < 2^k,分两段
long long ans = 0, rest = M - mid;
if (B0.empty()) ans += f(B1, mid, k - 1) + mid * mid;
else ans += f(B0, mid, k - 1);
if (B1.empty()) ans += f(B0, rest, k - 1) + rest * mid;
else ans += f(B1, rest, k - 1);
return ans;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N;
long long M;
cin >> N >> M;
vector<long long> A(N);
long long mx = 0;
for (int i = 0; i < N; i++) {
cin >> A[i];
mx = max(mx, A[i]);
}
// 取最小的 k 使得 2^k > max(mx, M),满足递归前置条件
int k = 1;
while ((1LL << k) <= max(mx, M)) k++;
cout << f(A, M, k) << '\n';
return 0;
}官方题解的 Python 实现(其巧妙之处在于当某一半为空时直接递归整个集合 ,借助"最高位对异或值的影响可用公式补回"这一事实,与上面的写法等价):
def f(A, M, k):
if k == 0:
return 0
B = [[], []]
for i in A:
B[(i >> (k - 1)) & 1].append(i & ~(1 << (k - 1)))
mid = 1 << (k - 1)
if M == 1 << k:
if B[0] and B[1]:
return f(B[0], mid, k - 1) + f(B[1], mid, k - 1)
return 2 * f(A, mid, k - 1) + mid * mid
ans = f(B[0], min(M, mid), k - 1) if B[0] else f(A, min(M, mid), k - 1) + mid * min(M, mid)
if M > mid:
ans += f(B[1], M - mid, k - 1) if B[1] else f(A, M - mid, k - 1) + mid * (M - mid)
return ans
N, M = map(int, input().split())
A = list(map(int, input().split()))
print(f(A, M, 30))复杂度分析
- 时间复杂度:(约 层递归,每层总工作量 )
- 空间复杂度:(每次递归的划分临时数组)