昨晚这场 Codeforces Round 1086 (Div. 2) 打完,我最大的感受是:前四题的难度梯度做得太标准了。A 题是纯模式识别,B 题考观察力,C 题考按位拆贡献,D1 考枚举优化。没有那种让人卡死的题,但每一道都对"代码实现细心程度"有要求。这篇文章我就按比赛时的思考顺序,把 A 到 D1 的完整思路、推导过程和踩坑记录全部写清楚。如果你正在练 Div.2 的 A-D1 阶段,这篇应该能帮你少走几条弯路。
1. 先聊这场比赛的定位:A-D1 才是 Div.2 的拿分基本盘
很多朋友打 Div.2 的习惯是死磕 E、F,觉得 A-D1 都是"水题",不值得花精力复盘。我打过这么多场之后反而觉得,Div.2 的 E、F 往往是给高端选手准备的表演曲目,而 A-D1 才是大多数人的真实战场。这场 Round 1086 尤其典型:A 是签到题但存在一个容易被忽视的细节,B 是那种"看着像模拟、实际上有结论"的题,C 需要按位拆贡献,D1 则是标准的 O(n^2) 枚举优化。四道题没有一道考冷门算法,全是基础功。
所以这篇题解我不打算只贴代码,而是把每道题"我当时是怎么想到这个思路的"以及"哪里容易写错"都拆开讲。毕竟代码网上随便一搜就有,但解题的思维路径和踩坑经验才是真正值钱的东西。
下面按我比赛时的 AC 顺序,从 A 题开始。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. A 题:Polycarp and Snakes——网格判定题要先把"合法"定义清楚
2.1 我一开始读题想复杂了
先说我的第一反应。看到 "Snakes" 这个词,我下意识以为这题要模拟蛇的覆盖顺序,类似于"后面的蛇会盖住前面的蛇"那种逻辑。如果真是这样,就得考虑每条蛇的绘制顺序、覆盖关系,直接变成一道 C 题甚至 D 题。但这是 A 题,不可能那么复杂。
所以我重新读了一遍题面,发现关键信息是:最终网格里每种可见的颜色必须本身就是一条完整的蛇,不存在"被覆盖后只剩一半"的情况。也就是说,题目要我们判断的只是两件事:
- 每种出现过的大写字母,它的所有格子是否恰好形成一条水平或垂直的连续线段。
- 出现过的颜色编号(A=1, B=2, ..., Z=26)是否恰好是
1..k,其中k是出现过的颜色总数。
想通这一点,代码结构就清爽了。
2.2 判定逻辑的三步走
第一步,全图扫描。对每个非 . 的格子,更新该颜色的四个边界值:最小行、最大行、最小列、最大列,同时记录出现次数。
第二步,逐颜色检查线段性。对于颜色 c,如果它合法,必须满足以下三种情况之一:
- 只有一个格子:行和列的边界都相同,显然合法。
- 水平线段:所有格子都在同一行,那么
出现次数 == 最大列 - 最小列 + 1。 - 垂直线段:所有格子都在同一列,那么
出现次数 == 最大行 - 最小行 + 1。
如果出现次数不等于这个区间长度,说明这个颜色的格子中间有空缺,或者有分叉,直接判非法。
第三步,检查颜色编号连续性。因为题目要求出现过的颜色必须是最小的 k 个,所以只要判断"出现过的最大颜色编号是否等于 k-1"即可。这个细节最容易漏:如果只出现了 B 和 C,颜色总数 k=2,但最大编号是 2(C),不满足 1..2 的要求。
2.3 参考代码与两个容易踩的坑
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n, m;
cin >> n >> m;
vector<string> g(n);
for (int i = 0; i < n; i++) cin >> g[i];
int minR[26], maxR[26], minC[26], maxC[26], cnt[26];
memset(minR, 0x3f, sizeof(minR));
memset(minC, 0x3f, sizeof(minC));
memset(maxR, -1, sizeof(maxR));
memset(maxC, -1, sizeof(maxC));
memset(cnt, 0, sizeof(cnt));
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (g[i][j] == '.') continue;
int c = g[i][j] - 'A';
cnt[c]++;
minR[c] = min(minR[c], i);
maxR[c] = max(maxR[c], i);
minC[c] = min(minC[c], j);
maxC[c] = max(maxC[c], j);
}
}
int k = 0, mx = -1;
for (int c = 0; c < 26; c++) {
if (cnt[c] > 0) {
k++;
mx = c;
}
}
if (mx != k - 1) {
cout << -1 << '\n';
return 0;
}
for (int c = 0; c < 26; c++) {
if (cnt[c] == 0) continue;
bool row = (minR[c] == maxR[c]);
bool col = (minC[c] == maxC[c]);
if (row && col) continue;
if (row && cnt[c] == maxC[c] - minC[c] + 1) continue;
if (col && cnt[c] == maxR[c] - minR[c] + 1) continue;
cout << -1 << '\n';
return 0;
}
cout << k << '\n';
return 0;
}
这里有两个坑我比赛时差点踩进去:
第一个是 memset 初始化的边界值。minR/minC 用 0x3f 初始化,maxR/maxC 用 -1 初始化,这样在后续更新时不会出错。如果初始化反了,单格颜色会直接判错。
第二个是颜色连续性判断。我一开始写的是"检查 1..k 之间有没有缺号",需要开 bool 数组逐个检查。后来发现直接比较 mx 和 k-1 更快,因为颜色是从 A 开始连续编号的,只要最大编号等于总数减一,就说明没有任何缺号。这个优化虽然小,但在 A 题里能省下不少书写时间。
3. B 题:删除相邻不同字符——结论题如何严谨验证
3.1 从模拟中发现的"不变量"
B 题题意很直白:给你一个 01 串,每次可以删除一对相邻且不同的字符,然后剩余部分拼接,问最短能剩下多长。
我第一反应是直接用栈模拟:遍历字符串,如果栈顶和当前字符不同,就弹出栈顶(相当于删掉一对),否则入栈。这个做法本身能 AC,但问题是它只给出了答案,没有解释"为什么"。比赛时我多想了想,发现这道题有个更漂亮的结论:
答案就是字符串中 0 的数量与 1 的数量的差的绝对值。
原因很简单。每次删除一个 0 和一个 1,所以 cnt0 - cnt1 这个值在整个过程中始终保持不变。既然最终串里必须全是 0 或全是 1,那么最终长度至少是 |cnt0 - cnt1|。这个下界一定能达到吗?能。只要当前串里同时存在 0 和 1,那就一定存在相邻的不同字符对。这个结论可以从"扫描字符串,找到任意一个 0 和 1 的交界位置"来证明。所以我们可以一直删,直到串里只剩下一种字符。
3.2 为什么栈模拟能工作,但不是最优思维
栈模拟本质上就是在维护这个删除过程。每次遇到栈顶和当前字符不同,就说明这一对可以删除;相同则暂时保留。这个做法的时间复杂度是 O(n),也完全能过题。
但我个人建议在做这种题时,还是先找"不变量"再写代码。原因有两个:
- 结论题的正确性更容易验证。你可以在纸上推导在不变量约束下,最终状态长什么样,而不是依赖模拟代码的偶然正确。
- 结论题往往能把 O(n) 的模拟降成 O(1) 的计算,虽然 B 题 n 不大,但这种思维在后续题目里会节省大量时间。
3.3 参考实现与一个延展方向
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n;
string s;
cin >> n >> s;
int c0 = 0;
for (char ch : s) {
if (ch == '0') c0++;
}
cout << abs(c0 - (n - c0)) << '\n';
return 0;
}
如果题目改一下,要求输出剩下字符串的任意一种可能,那栈模拟就有用了:遍历时维护一个栈,碰到与栈顶不同的字符就弹栈,否则入栈。最后栈里剩下的就是最终串。这个延展可以作为配套练习,原理和结论题完全一致。
4. C 题:三数异或贡献——按位拆贡献的通用套路
4.1 为什么直接枚举三元组不行
C 题要求所有三元组 (i, j, k) 中,(a[i] xor a[j]) + (a[j] xor a[k]) + (a[i] xor a[k]) 的和。看到 n 的范围在 1e5 级别,第一反应就是:O(n^3) 枚举三元组肯定不行,O(n^2) 也不太行。这种"三个元素 + 位运算 + 求和"的组合,十有八九要按位拆贡献。
4.2 按位拆贡献的完整推导
按位拆贡献的核心思路是:异或运算每一位独立,普通加法虽然会有进位,但我们可以先计算每一位对最终总和的贡献,再乘以该位的权值(2 的幂),最后累加。
固定一个二进制位 b,设数组中第 b 位为 1 的数有 x 个,第 b 位为 0 的数有 y = n - x 个。现在从 n 个数里选出三个数,考虑它们在第 b 位上的值组合:
- 三个数第 b 位全部相同(全 0 或全 1)时,任意两个数在第 b 位异或的结果都是 0,所以这一位对三个异或对的和贡献为 0。
- 三个数第 b 位不全相同时,必然是一对 0 一对 1 的样子。不妨设选出的三个数的第 b 位是
(1, 0, 0),那么三个异或对的结果分别是1, 1, 0,和为 2。如果是(1, 1, 0),同理也是 2。
所以,第 b 位对总和的贡献等于:从 x 个 1 中选 1 个、从 y 个 0 中选 2 个的组合数,加上从 x 个 1 中选 2 个、从 y 个 0 中选 1 个的组合数,再乘以 2。
写成公式就是:
code复制贡献(b) = 2 * (C(x,1)*C(y,2) + C(x,2)*C(y,1)) * 2^b
对所有 b 从 0 到 60 累加即可。
4.3 组合数预处理与取模细节
因为只需要 C(n,1) 和 C(n,2),可以直接用公式算,但我在实现时选择用一个 C[n][3] 表预处理,这样代码更通用,后续如果题目改成四元组也能快速复用。
cpp复制#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 1e9 + 7;
ll C[100005][3];
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n;
cin >> n;
vector<ll> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
// 预处理组合数 C[i][0..2]
for (int i = 0; i <= n; i++) {
C[i][0] = 1;
for (int j = 1; j <= 2 && j <= i; j++) {
C[i][j] = (C[i-1][j-1] + C[i-1][j]) % MOD;
}
}
ll ans = 0;
for (int b = 0; b < 60; b++) {
int x = 0;
for (ll v : a) {
if ((v >> b) & 1) x++;
}
int y = n - x;
ll ways = 0;
if (x >= 1 && y >= 2) ways = (ways + C[x][1] * C[y][2]) % MOD;
if (x >= 2 && y >= 1) ways = (ways + C[x][2] * C[y][1]) % MOD;
ways = ways * 2 % MOD;
ans = (ans + ways * ((1LL << b) % MOD)) % MOD;
}
cout << ans << '\n';
return 0;
}
这里有一个常见的坑:当 x < 1 或 y < 2 时,C[x][1] 或 C[y][2] 可能访问到未初始化的下标,所以要加 if 判断。很多选手第一次写这类组合贡献题,忘了判边界,结果本地跑通过、交上去 WA,就是因为这里。
5. D1 题:k=4 的选择问题——枚举中间两元素是家常便饭
5.1 暴力为什么不行
D1 题面可以概括为:给定数组 a,选四个下标严格递增的元素作为子序列,最大化:
code复制|a[i1] - a[i2]| + |a[i2] - a[i3]| + |a[i3] - a[i4]|
一看就是经典的"选择问题"。n 在 5000 左右,如果枚举四个下标,O(n^4) 直接爆炸。但 D1 版本有个特殊限制:k=4,固定选择了 4 个数。这个限制给了我们枚举中间两个元素的可能。
5.2 枚举 j,k 加前缀后缀最值
设四个选择位置为 i < j < k < l。观察权值公式,它由三部分组成:|a[i]-a[j]|、|a[j]-a[k]|、|a[k]-a[l]|。
如果我们固定了中间的 j 和 k,那么左侧的 |a[i]-a[j]| 只和 i、j 有关,右侧的 |a[k]-a[l]| 只和 k、l 有关,两部分完全独立。因此可以枚举 j 和 k,然后分别从左侧和右侧选出最优的 i 和 l。
左侧怎么快速找最优 i?由于是绝对值,|a[i]-a[j]| 最大,等价于在 [1, j-1] 中找与 a[j] 差值最大的数。只要维护前缀最大值和前缀最小值,max(abs(preMax[j-1]-a[j]), abs(preMin[j-1]-a[j])) 就是最优解。右侧同理,维护后缀最大值和后缀最小值。
这样总复杂度 O(n^2),枚举所有 j,k 对。不要小看这个套路,它是很多"选 k 个数"类题目的基础:固定中间点,然后左右独立最优。
5.3 参考代码与边界处理
cpp复制#include <bits/stdc++.h>
using namespace std;
using ll = long long;
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n;
cin >> n;
vector<ll> a(n + 1);
for (int i = 1; i <= n; i++) cin >> a[i];
const ll INF = 4e18;
vector<ll> preMax(n + 2, -INF), preMin(n + 2, INF);
vector<ll> sufMax(n + 2, -INF), sufMin(n + 2, INF);
for (int i = 1; i <= n; i++) {
preMax[i] = max(preMax[i-1], a[i]);
preMin[i] = min(preMin[i-1], a[i]);
}
for (int i = n; i >= 1; i--) {
sufMax[i] = max(sufMax[i+1], a[i]);
sufMin[i] = min(sufMin[i+1], a[i]);
}
ll ans = 0;
for (int j = 1; j <= n; j++) {
for (int k = j + 1; k <= n; k++) {
if (j < 2 || k > n - 1) continue;
ll left = max(abs(preMax[j-1] - a[j]), abs(preMin[j-1] - a[j]));
ll right = max(abs(sufMax[k+1] - a[k]), abs(sufMin[k+1] - a[k]));
ans = max(ans, left + abs(a[j] - a[k]) + right);
}
}
cout << ans << '\n';
return 0;
}
边界处理是这题最容易翻车的地方。j 必须至少是 2,因为左侧要留出至少一个数;k 最多是 n-1,因为右侧要留出至少一个数。如果题目保证 n >= 4,这个条件天然满足一部分,但写上判断更稳妥。
5.4 从 D1 到 D2:一般化 k 的思考方向
D1 用枚举中间点是因为 k 固定为 4。如果 D2 把 k 变成任意值,枚举中间点的思路就不够了,一般会考虑动态规划:
设 dp[i][j] 表示前 i 个数中选了 j 个数,且第 j 个数放在位置 i 时的最大权值。转移时枚举上一个选择的位置 t,dp[i][j] = max(dp[t][j-1] + |a[t]-a[i]|)。这个做法的复杂度是 O(n^2 k),对某些数据范围可能不够,需要再优化。但不管怎么优化,D1 的"左右独立"思想都是理解 D2 动态规划的基础。
6. 复盘:这四道题教会我的事
打完整场,我最大的收获不是 AC 了四道题,而是注意到了几个可以复用到后续比赛的通用思维模式。
第一,网格题拿到手先别急着模拟,先把"合法状态"用数学语言定义清楚。A 题就是典型:只要把"每种颜色必须形成一条连续线段"这个定义想清楚,代码就不会乱。
第二,结论题不要只满足于"模拟能过"。B 题如果只写栈模拟,虽然也是 O(n),但你损失了一次练习"寻找不变量"的机会。这种寻找不变量的能力,在遇到更难的构造题时会非常有用。
第三,位运算求和题,按位拆贡献是刻在 DNA 里的第一反应。C 题如果不用按位拆,你会发现自己陷入组合爆炸的泥潭。这是 Div.2 里反复出现的套路,值得专门练熟。
第四,D1 这类"选 k 个数"问题,k 很小的时候优先想到枚举中间点,配合前缀/后缀最值优化。这个套路出现的频率比想象中高得多。
我自己的习惯是每场 Div.2 结束后,把 A-D1 的题解思路浓缩成两三句话写在笔记里。比如这场的笔记就是:"网格线段性判定注意颜色编号连续性;01串删除看差;三者位运算按位拆;k=4选择枚举中间点加前缀后缀最值。"下次遇到类似题,先扫一眼笔记,解题速度会快很多。
希望这篇题解能帮到正在刷 Div.2 的朋友。如果你有什么更好的思路,或者在哪道题上踩了不一样的坑,欢迎在评论区一起讨论。
