如果你最近也在某主流竞赛平台上刷 Div3 题组,应该对“模拟赛 1084”这套题不陌生。我上周末完整做了一遍,从 A 签到题到 H 收尾题,八道题一口气推下来,中间卡了两次:一次是 D 题的双指针边界处理,另一次是 H 题的树链剖分实现细节。整场打完最大的感受是,这套题表面上是 Div3 难度,但后面的 F、G、H 三道题,对思维方式和代码实现的要求一点都不“新手友好”。
这篇文章不打算只贴题目和答案,而是把这八道题当成一条完整的训练线来拆。按顺序讲清楚每道题的思路来源、关键证明、实现要点,以及我在实际写代码时踩过的坑。无论你是刚开始刷 Div3、想在稳定做出前四题后继续往前突破,还是想找一套题来检验自己的基础是否扎实,这篇复盘都应该能给你一些参考。
1. Div3 到底考什么:先看 1084 的全局难度曲线
1.1 八道题的知识点分布
很多刚入门的朋友对 Div3 有误解,觉得就是“简单签到题全家桶”。实际上 Div3 的定位是:让绿名以下选手能通过前四到五题,但同时在最后两到三题留出区分度。1084 这套题正好是比较典型的“前松后紧”结构,我把每道题的知识点和考察能力整理成了表格。
| 题号 | 难度档位 | 核心知识点 | 主要考察能力 |
|---|---|---|---|
| A | 入门 | 数组相邻合并、栈模拟 | 读题准确性、基础编码速度 |
| B | 简单 | 字符串重排、字典序比较 | 构造思路、边界条件 |
| C | 简单偏中 | 环形计数、取模运算 | 数学转化能力 |
| D | 中等 | 双指针滑动窗口 | 单调性分析、数据结构选择 |
| E | 中等 | 无向图二分图判定 | 图遍历模板、DFS/BFS熟练度 |
| F | 中等偏难 | 区间划分动态规划 | 状态设计、优化意识 |
| G | 较难 | 线性基、异或最大子集 | 位运算直觉、模板掌握 |
| H | 难 | 树链剖分、线段树区间操作 | 综合数据结构实现能力 |
从这个分布能看出来,1084 很像一份“竞赛基础知识体检单”。A 到 D 覆盖的是每天都要用的基本功:模拟、字符串、取模、双指针;E 到 H 则依次把图论、动态规划、位运算、树上高级数据结构各考了一遍。
1.2 难度曲线与做题策略
做题前我习惯先把 ABCDEFGH 八道题全部扫一遍,不急着动手。这样做的原因很简单:Div3 的难度在 C 和 D 之间可能会突然跳变,有时候 E 题反而比 D 题简单。1084 这套题里,B 题比 C 题更容易让人卡住,因为 B 的思维陷阱比较隐蔽;而 D 题只要想通了“窗口左端点单调”这个性质,代码反而不长。
我实际采用的策略是:
- 先做 A,五到八分钟内通过,热手。
- B 和 C 控制在二十五分钟内,如果哪道题超过十五分钟没思路,先标记一下,换下一道。
- D 作为“分水岭”,给足四十分钟。想清楚单调性再写,不能边猜边写。
- E 和 F 在剩余时间里按性价比选择。
- G 和 H 放在最后,因为就算想出来,代码量也大,不适合在前面占用太多时间。
这个顺序的核心逻辑是把“能拿的分”先拿稳,再把“需要思考的分”当作加餐。很多人在 B 题上耗太久,导致后面 G、H 连尝试的机会都没有,这是 Div3 比赛中非常可惜的失误。
2. 热身题拆解:A 签到题与 B 字符串构造
2.1 A 题:栈模拟相邻元素合并
A 题的场景是这样的:给定一个长度为 n 的数组,每次操作可以选择两个相邻且数值相同的元素,把它们合并成一个新元素,新元素的数值等于两者之和。问最后数组最短可以变成多长。
我第一次看这道题时,第一反应是用数组模拟删除:找到相邻相等的位置,删掉两个,插入一个。但仔细一想,这个做法在极端情况下会退化。假设数组是 1 1 2 2 1 1,删除合并后,新元素可能又和新的相邻元素相等,如果用数组反复扫描,最坏会到 O(n^2)。在 Div3 的 A 题里,n 一般会给到 2e5 左右,O(n^2) 肯定过不去。
正确的做法是维护一个栈,遍历数组时:
- 如果栈为空,直接压入当前元素。
- 如果栈非空,比较栈顶元素与当前元素是否相等。
- 若相等,就把栈顶弹出,把当前元素的两倍值作为新元素继续尝试入栈。
- 若不相等,直接把当前元素入栈。
这里有一个容易忽略的细节:合并产生的新元素可能再次与栈顶相等,所以“尝试入栈”这个过程必须写成一个循环,而不是只判断一次。我一开始写的就是 if 而不是 while,结果在样例 [1, 1, 1] 上就出了问题:1 和 1 合并成 2 后,栈里已经没有 1,看起来没问题;但如果是 [1, 1, 2, 2, 2],前两个 1 先合并成 2,这个 2 会与后面的 2 相遇,需要继续合并成 4,如果你只在入栈时判断一次,就会漏掉。
核心代码如下:
#include <bits/stdc++.h> using namespace std; int main() { int n; cin >> n; vector<long long> a(n); for (int i = 0; i < n; i++) cin >> a[i]; vector<long long> st; for (long long x : a) { st.push_back(x); while (st.size() >= 2 && st.back() == st[st.size() - 2]) { long long v = st.back(); st.pop_back(); st.pop_back(); st.push_back(v * 2); } } cout << st.size() << "\n"; return 0; }这个思路的本质是“后进先出”的天然匹配:新元素只会与它左侧相邻的元素发生关系,而栈顶正好就是当前数组最右侧的元素。时间复杂度和空间复杂度都是 O(n),而且合并后数值可能变大,所以数组类型建议直接用 long long。顺带提一句,如果 n 很小,用数组模拟也没问题,但养成用栈的思维对后面的题目有好处,因为很多“消除相邻相同元素”的经典题都是这个套路。
2.2 B 题:字符串重排的字典序陷阱
B 题给的是两个字符串 a 和 b,允许把两个串分别重新排列,但不能改变每个字符串中字符的种类和数量。问能否通过合法重排,使得 a 的字典序严格小于 b。
这道题看起来简单,实际非常容易掉进一个误区:有人会直接判断“a 中所有字符是否都小于 b 中所有字符”。这显然不对,因为字典序比较是逐位进行的,只要在第一个不同的位置上 a 的字符更小,后面无论是什么都没关系。
我尝试了两种方向:
第一种,也是最保险的思路:既然两个串都能任意重排,构造 a 的最小字典序排列和 b 的最大字典序排列。a 的最小字典序就是把字符按升序排序;b 的最大字典序就是把字符按降序排序。然后直接比较这两个新串的字典序。如果连“a 最小、b 最大”都无法做到 a < b,那任何其他分配方案也都不可能成功,因为这是一种最优的构造。
为什么这个思路是对的?因为我们需要找到一种安排让 a 尽量小、b 尽量大,这样才能最大化 a < b 成立的概率。排序后的比较结果只要严格满足条件,原题就一定有解;如果这样都不满足,说明无论怎样重新排列都找不到可行的方案。
第二种思路是直接统计字符数量,判断是否存在一个字符 x,使 a 中能构造出某个排列、b 中也能构造出某个排列,并让 a 在这个字符上更小。听起来很绕,实现上不如直接排序来得干净。所以现场我用的也是排序后比较的方案。
这里有一个关键坑:比较必须是严格小于。如果排序后两个串完全相同,答案依然是否定的。因为在字典序定义里,相等不满足“严格小于”。这个问题卡了很多选手,他们得到了两个相同的串,却输出了 YES。
#include <bits/stdc++.h> using namespace std; int main() { string a, b; cin >> a >> b; sort(a.begin(), a.end()); // a 最小字典序 sort(b.begin(), b.end(), greater<char>()); // b 最大字典序 if (a < b) cout << "YES\n"; else cout << "NO\n"; return 0; }代码只需要十行,但背后的构造思想却很值得记住:当题目允许重排时,“极值化其中一个维度”往往能直接推导出全局最优解。这道题给我最大的启发不是字符串 API,而是“把问题转化为排序后比较”的简化能力。
3. 思维题核心:C 计数与 D 双指针
3.1 C 题:环形序列上的距离计算
C 题的场景变成了环形结构。有 n 个珠子排成一个环,每个珠子上有一种颜色。你可以从任意一个位置出发,沿着顺时针方向移动,每移动一步算一次操作,目标是遇到第一个与出发点颜色相同的珠子。问在所有可能的起点中,移动步数的期望值是多少。
第一步是明确“期望”的计算方式:总步数除以 n。因为每个起点等概率,所以只要把所有起点需要的步数累加,再除以 n 即可。
关键在于怎么高效计算一个起点到下一个同色珠子的距离。最简单的是用两层循环:枚举起点 i,再往后扫 j,直到 a[j] == a[i]。这个做法是 O(n^2) 的,当 n 到 2e5 时完全不可行,因为可能出现大量同色珠子,每次都重复扫描。
优化思路是转换成相邻同色珠子之间的距离。把每个颜色在环中出现的所有位置记录下来,对任意一个位置 i,找到该颜色在环中的下一个出现位置,距离公式是:
d = (pos + n - i) % n这个公式看起来很基础,但必须要小心负数取模。C++ 里如果直接写 (pos - i) % n,当 pos 小于 i 时会得到负数,这一步需要先加 n 再取模。看起来是很小的问题,但在实际比赛中,因为一个取模负号浪费半小时的情况并不少见。
举个例子,假设 n = 5,颜色序列是 [a, b, a, c, a]。颜色 a 出现的位置是 0、2、4。对于位置 0,下一个 a 在 2,距离 d = (2 + 5 - 0) % 5 = 2;对于位置 2,下一个 a 在 4,距离 d = (4 + 5 - 2) % 5 = 2;对于位置 4,下一个 a 要绕回到 0,d = (0 + 5 - 4) % 5 = 1。三个位置的距离分别是 2、2、1,总步数是 5,期望就是 5 / 5 = 1.0。
实现时,先用哈希表或 vector 数组记录每个颜色的所有出现位置。因为可能出现同一颜色在环上连续出现的情况,位置数组的顺序本身就是环序,因此对每个位置 i,只需要在它的颜色列表里用二分查找找下一个位置,或者更简单地,遍历该颜色列表中的所有位置,对每个位置计算到下一个相邻位置的距离,累加到答案里。由于每个珠子只会被它的颜色列表访问一次,总复杂度是 O(n)。
这道题还引申出了一个通用技巧:环形数组问题,优先考虑把环“断开+复制一倍”,或者在原环上使用取模距离。这两种方法都可行,但取模距离在计算期望时更直观。关键是不要忘记把环形边界写对。
3.2 D 题:双指针维护合法区间
D 题给一个长度为 n 的数组,和一个整数 k。问有多少个子数组满足“最大值与最小值之差不超过 k”。这类问题属于非常经典的滑动窗口计数题。
我一看到“最大值最小值之差”,第一反应是用两个单调队列来维护当前窗口的最大值和最小值,这样左端点移动和右端点移动时,都可以 O(1) 获取窗口的极值。如果想快速写完,也可以用 multiset,每次右移右端点插入一个数,左移左端点删除一个数,窗口极值就是 multiset 的 begin() 和 rbegin(),但复杂度是 O(n log n)。对于 2e5 的数据范围,multiset 完全能过,所以我现场选的是 multiset,因为代码量更小,不容易错。
更关键的部分是“为什么可以用双指针”。一个窗口合法,说明窗口内所有元素都在某个宽度为 k 的区间内。当我们固定左端点 L,右端点 R 不断扩大的过程中,窗口的最大最小值之差是单调不减的,因为加入新元素只可能让差值变大或不变。所以一旦 R 增加到某个位置使得窗口开始不合法,那么对当前的 L 而言,所有更大的 R 都不可能合法。这时就需要把 L 向右移动,缩小区间。
具体的移动方式如下:
- 初始化 L = 0,答案 ans = 0。
- 枚举右端点 R 从 0 到 n-1,把 a[R] 加入窗口。
- 当窗口最大值减最小值大于 k 时,从窗口删除 a[L],L++。
- 此时窗口 [L, R] 内的所有子数组 [L', R](L' 从 L 到 R)都是合法的,因为它们的极差不会超过整个窗口的极差。
- 累加答案 ans += R - L + 1。
这里最容易被忽略的是第 4 步的计数逻辑。每次滑动窗口内新增的合法子数组数量不是 1,而是 R - L + 1。因为子数组的右端点固定在 R,左端点可以在 L 到 R 之间任选,共 R - L + 1 个。我第一次写的时候只在每次循环里 ans++,结果答案只有正确值的一半不到,因为漏掉了那些右端点为 R 但左端点不是 L 的子数组。
另一个隐藏问题是答案可能很大。n 最大 2e5 时,合法子数组最多可以有 n * (n + 1) / 2 个,大约是 2e10,int 完全存不下,必须用 long long。这两个点是我在 D 题上卡住的主要原因。
#include <bits/stdc++.h> using namespace std; int main() { int n, k; cin >> n >> k; vector<int> a(n); for (int i = 0; i < n; i++) cin >> a[i]; multiset<int> s; long long ans = 0; int l = 0; for (int r = 0; r < n; r++) { s.insert(a[r]); while (*s.rbegin() - *s.begin() > k) { s.erase(s.find(a[l])); l++; } ans += r - l + 1; } cout << ans << "\n"; return 0; }我还想提一个细节:在 multiset 里删除元素时,不能直接写 s.erase(a[l]),那样会删除所有等于 a[l] 的重复元素。要写 s.erase(s.find(a[l])),只删除一个位置。这个问题很经典,我在很多复盘帖里都见过有人踩。
D 题的双指针思想,本质上利用了“窗口极差单调性”。这类题目以后还会大量出现,比如求“和小于 K 的子数组数量”“至少包含 K 种字符的最短区间”等。核心都是先找到单调性,再考虑用窗口维护什么信息。
4. 进阶题拆解:E 图论与 F 动态规划
4.1 E 题:二分图判定与连通块拆分
E 题考察图论基础。题目大致是:给定一个无向图,问能否把所有点分成两个集合,使得每条边的两个端点都在不同集合里。如果不能,输出 -1;如果能,输出每个集合的点数。
这就是标准的二分图判定。任何无向图是二分图的充要条件是图中不存在长度为奇数的环。检验办法是染色法:从一个点出发,把它染成颜色 0,相邻点染成颜色 1,再相邻点染回颜色 0,重复这个过程。如果某个点被要求染成和它已有颜色不同的颜色,就说明出现了矛盾,即存在奇环,无法二分。
由于图未必连通,需要对每个连通块分别染色。上一个连通块的结果不会影响下一个,所以主循环要遍历所有点,遇到未染色的点就启动一次 BFS。
我在实现时用的是 BFS 而不是 DFS,原因是数据范围大时递归 DFS 容易爆栈,虽然 C++ 在 OJ 上通常不会立即崩,但 BFS 写起来更稳,逻辑也更清晰。
vector<vector<int>> g; vector<int> col; // -1 表示未染色 bool bfs(int start) { queue<int> q; q.push(start); col[start] = 0; while (!q.empty()) { int u = q.front(); q.pop(); for (int v : g[u]) { if (col[v] == -1) { col[v] = col[u] ^ 1; q.push(v); } else if (col[v] == col[u]) { return false; } } } return true; }有一个经常被忽略的坑:自环。如果出现自环,即某个点有一条边连向自己,那么染色时会立刻发现同色冲突,返回 false。这是合理结果,因为自环显然无法把这条边的两个端点分到不同集合。我看到不少选手在判自环时额外写了特判,其实染色法天然会捕获这个问题,不需要多此一举。
做完染色后,可以统计每个连通块中颜色 0 和颜色 1 的数量。如果题目要求输出集合大小,直接取两个颜色数量中较大的一个累加就行。注意题目要求的所有点数量之和要等于 n,不要漏掉没有边、单独成块的孤立点,它们也可以任意放入某个集合。
这类二分图判定是图论里少有的“模板能解决一切”的题,只要模板写得对,几乎不存在思维难点。所以 E 题真正考察的是熟练度,如果你能在十分钟内写出无 bug 的染色代码,这就是一道送分题。
4.2 F 题:区间划分动态规划
F 题是这个题组第一道真正有 DP 思维的题目。题目要求把长度为 n 的数组划分成若干连续段,每段的成本定义为段内最大值与最小值之差的平方,求整个数组划分的最小总成本。
看到“划分成若干连续段”“每段贡献成本”这两个关键词,第一反应就是线性 DP。设 f[i] 表示前 i 个元素划分完成后的最小总成本,那么枚举最后一段的起点 j,转移为:
f[i] = min(f[j - 1] + cost(j, i))其中 cost(j, i) 等于 a[j..i] 的最大值减最小值的平方。初始化 f[0] = 0,其余为很大的数,最终答案就是 f[n]。
直接实现是 O(n^2) 的。如果 n 最大是 5000,这个复杂度完全能过。我写这道题时 n 的数据给的是 5000,所以没有做进一步优化。但 F 题的价值在于告诉你:以后遇到 n = 2e5 怎么办?
这里可以用一些高级优化:如果 cost 函数满足四边形不等式,那么 DP 决策点具有单调性,可以用分治优化把 O(n^2) 降到 O(n log n)。判断四边形不等式是否满足的方法需要证明,但具体到“最大值减最小值的平方”这个成本函数,它确实具有较好的单调性质。我现场没有做分治优化,因为 5000 的范围完全足够,写 O(n^2) 反而是最稳妥的方案,代码短,不容易出错。
写 O(n^2) DP 时有两个关键点:
第一,f 数组用 long long。成本最大值可以达到 (1e9)^2 = 1e18,即使经过划分,累加也可能超过 int 范围。
第二,枚举 j 时要从 i 开始往前扫,一边扫一边维护当前区间 [j, i] 的最大值和最小值,而不是每次都用 O(1) 查询。因为直接查询最大值最小值需要额外数据结构,而倒着扫描时只需要在每步更新 mn 和 mx 两个变量,就能在 O(1) 时间内得到 cost(j, i)。
vector<long long> f(n + 1, INF); f[0] = 0; for (int i = 1; i <= n; i++) { long long mn = a[i], mx = a[i]; for (int j = i; j >= 1; j--) { mn = min(mn, a[j]); mx = max(mx, a[j]); long long cost = (mx - mn) * (mx - mn); f[i] = min(f[i], f[j - 1] + cost); } }这里还隐含一个优化小技巧:如果某一段长度继续扩大但极差没有变化,成本不变,可转移的区间可以跳跃,不过对 5000 的数据不影响。
F 题让我想起一个很常见的 DP 状态设计套路:当转移方程是“枚举最后一段的起点”时,把前面的结果 f[j - 1] 当作已经求解好的子问题。这种“以 i 结尾考虑最后一步怎么切”的思路,几乎可以套用在所有区间划分问题上。区别只在于 cost 函数怎么快速维护。
5. 压轴题拆解:G 位运算与 H 树上路径
5.1 G 题:子集异或最大值与线性基
G 题是一个典型的位运算题:给定 n 个非负整数,从这些数中任选若干个数,求它们异或和的最大值。数据范围决定不能枚举子集,需要用到线性基。
线性基的本质是维护一个能够用异或运算表示原集合中任意子集异或结果的极大无关组。它的核心操作是插入:尝试把新数 x 插入到线性基中,从最高位到最低位扫描,如果某一位上已经有基向量,就令 x ^= base[i];否则把 x 存入 base[i] 并结束。
struct LinearBasis { long long base[65]; void insert(long long x) { for (int i = 62; i >= 0; i--) { if (!(x >> i & 1)) continue; if (!base[i]) { base[i] = x; break; } x ^= base[i]; } } long long queryMax() { long long ans = 0; for (int i = 62; i >= 0; i--) { ans = max(ans, ans ^ base[i]); } return ans; } };为什么能这么求最大值?关键在于异或运算的贪心性质:从高位到低位,如果当前答案在该位为 0,而异或上某个基向量后该位能变成 1,就一定要变。因为高位上的 1 无论付出多少低位的代价都值得。代码里用 ans = max(ans, ans ^ base[i]) 就体现了这一点。
在线性基题目里,有一个特别容易踩的坑是数组维数。题目给出的整数范围決定上限位数,一般来说 32 位整数需要用 31 以上,64 位整数需要用 62 以上。我只开到 65 是为了保险。如果你开的位数太小,插入时高位可能无法正确消元,结果就会偏小。检查线性基写没写对,可以先插入几个数手动跑一遍,或者随机生成小数组暴力验证。
G 题最锻炼人的地方不是背模板,而是“看出这是一道线性基题”的直觉。给定一个集合,问子集异或最大值,优先想到线性基;问子集按位或最大值,通常考虑按位贪心;问能否用某个集合线性表示某个数,也用线性基。这类题只要识别出“异或空间”这个场景,模板熟练后就能拿下。
5.2 H 题:树链剖分与路径区间操作
H 题是我认为整套题里代码量最大的一道。题目要求:给一棵 n 个点的树,每个点有点权,支持两类操作:把从 u 到 v 路径上所有点的点权都加上 x;查询某个子树内所有点的点权之和。q 次操作。
如果直接用 DFS 序维护子树,很容易解决子树查询:子树在 DFS 序里对应一段连续区间,线段树区间求和即可。但路径修改是个难点,因为路径上的点在 DFS 序里通常不连续。
解决办法是树链剖分。用两次 DFS 把树拆成若干条重链,并给每个点分配一个新的编号 dfn,使得同一条重链上的点在 dfn 上是连续的。这样从 u 到 v 的路径可以拆成 O(log n) 条连续区间,每条区间用线段树做区间加。而操作类型二,子树在 dfn 上仍是连续区间,也可以直接用线段树处理。也就是说,树剖把“路径操作”转化成了“若干个区间操作”。
核心实现分四步:
- 第一次 DFS:求每个点的子树大小 sz、深度 dep、重儿子 son。
- 第二次 DFS:优先走重儿子,给每个点分配 dfn,并记录链顶 top。
- 线段树:维护区间和和 lazy 标记,支持区间加、区间求和。
- 路径更新:当 top[u] != top[v] 时,把链顶更深的节点向上跳到链顶,更新对应 dfn 区间;两个点落到同一条链后,更新最后的区间。
下面是路径更新的核心部分,我每次都提醒自己别把这个循环写错:
void updatePath(int u, int v, int x) { while (top[u] != top[v]) { if (dep[top[u]] < dep[top[v]]) swap(u, v); seg.update(dfn[top[u]], dfn[u], x); u = fa[top[u]]; } if (dep[u] > dep[v]) swap(u, v); seg.update(dfn[u], dfn[v], x); }注意这里最后一步更新的是 dfn[u] 到 dfn[v],包含两个端点。有些题目如果点权改边权需要特殊处理,但本题是点权,所以直接更新区间即可,不需要像边权那样把区间端点避开 LCA。
H 题最大的坑其实是线段树本身写错。树剖的思路只要看懂了就能实现,但线段树的懒标记、下推、区间合并,一不注意就会写漏。我的经验是:树剖题先单独写一棵没有 bug 的线段树,再在树剖框架里调用它,不要为了省代码把两者揉在一起。先用小规模样例测试每个操作,确认子树查询和路径更新都正确,再做整题测试。
跑完这个题之后,我对“把非序列问题转化为序列问题”的理解深了很多。树上路径、子树这些概念,经过树剖之后都变成了线段树上的区间,这是一种很优美的降维。很多 Div3 选手看到 H 就直接放弃,但我建议至少把树剖的框架写一遍,因为这是通向更高难度题组的基础能力。
6. 赛后总结:反复出现的坑与复盘方法
6.1 边界条件速查表
完整打完这八道题,我整理了一张“边界条件速查表”,都是我这次实战里真实遇到或见过别人踩的问题。
| 常见错误 | 出现题目 | 正确做法 |
|---|---|---|
| 取模负数未处理 | C | 写成 (x % n + n) % n |
| 窗口计数漏加 R-L+1 | D | 每次右移后按左端点数量累加 |
| multiset 误删所有重复元素 | D | 使用 erase(find(x)) |
| 图遍历没考虑不连通块 | E | 主循环遍历所有点 |
| 递归 DFS 爆栈 | E | 改用 BFS 或显式栈 |
| DP 数组用 int 存不下 | F | 使用 long long |
| 线性基位数开太小 | G | 按数据范围多开几位 |
| 树剖最后区间漏端点 | H | 点权更新包含 u 和 v |
| 不用 lazy 标记导致线段树超时 | H | 区间更新必须打懒标记 |
这张表看起来简单,但每一条背后都对应至少三十分钟的调试时间。尤其是 D 题的计数和 H 题的端点包含,这两个问题都是“逻辑上容易忽略、一旦错了很难一眼定位”的类型。
6.2 做题顺序与时间分配建议
针对 1084 这套题,我建议的参考时间分配是:
- 0 到 15 分钟:完成 A、B。
- 15 到 40 分钟:完成 C,D 开始思考。
- 40 到 70 分钟:解决 D,尝试 E。
- 70 到 100 分钟:F 和 G 选一个主攻。
- 100 到 150 分钟:留足时间写 H 或回头补跳过的题。
当然,这个时间表建立在“能力大概在能稳定做出 Div3 前五题”的基础上。如果你现在还只能稳过 A 到 C,那就把目标改为“两小时内做出 D”,不要强行去碰 H。刷题训练讲究的是每道题真正想明白,而不是每次比赛都贪多。
很多新人会把 Div3 当作“尽量全部 AC 的比赛”,其实不是。Div3 的排序逻辑就是让你感受什么叫“难度分层”,做出七题的人本来就是少数。能做到四到五题并保证每道题思路清晰、代码正确,已经算是合格发挥了。
6.3 复盘方法:把解题过程压缩成卡点笔记
每次完整刷完一套题,我建议当天就把复盘写了。不要只记题目解法,而要记录“我认为的卡点”和“我实际为什么卡住”。比如我在 D 题上卡了,不是因为不会双指针,而是因为计数时把子数组数量加错了。这种卡点记录比任何题解都有用,下次遇到同类窗口计数题,我会第一时间想到“答案要加 R-L+1”。
一套 Div3 题组通常包含十几种不同的基础技巧,如果能坚持把每道题的卡点压缩成一到两句话,过一个星期再回来看,复习效率会非常高。这也是我认为 Div3 题目最大的训练价值:它不只是让你学会某道题,而是帮你建一张“常见陷阱自查清单”。
最后再分享一个小技巧:做完一套题后,把 AC 的代码全部重写一遍,最好换成另一种实现方式。比如 C 题如果用的是复制数组法,就尝试改成取模法;D 题如果用的是 multiset,就试试单调队列。这样能让你从“会做某题”变成“掌握某类题”,这也是我从这次 Div3 模拟赛里收获最大的一点。