先亮一下复盘结论:宝石组合是典型的“会者不难”,关键是把“枚举三个宝石”转换成“枚举最大公约数”;拔河则是验证基本功的题,会前缀和的人十分钟写完,不会的人可能把整个下午耗进去。这两题放在 2024 年省赛 B 组里特别有代表性:一个考数学转化,一个考模型翻译。下面我把两题从读题到实现的完整思路、现场踩过的坑、以及考后复盘得到的通用套路一次讲清楚。
1. 赛前第一印象:这两题的“坑味”在哪里
1.1 宝石组合:一眼暴力题?
看到“选三个宝石”这种字眼,任何人第一反应都是枚举。三层循环下去,枚举所有i<j<k,每组的复杂度是 O(n^3)。赛场上我也干过这种事,先花两分钟把暴力写完了,还觉得自己稳了。
真正吃人之处在于数据范围。省赛题不会允许 1e5 这样规模的 n 玩三层循环,暴力只能拿到一部分测试点的分。如果你把大量时间押在“三重循环优化一下就能过”的幻想上,基本就会像我当时一样,一小时后回头发现样例过了、提交却 TLE。
这时要做的不是想什么花哨剪枝,而是把问题本身翻译成数论语言。所谓“三颗宝石的组合价值最大”,本质上是在问:给定一堆数字,从中找三个数使它们的最大公约数最大。这个翻译一旦完成,三层循环就被彻底扔掉了。
1.2 拔河:名字骗人
拔河这题拿到手里,很多人会被生活经验带偏。现实里的拔河是两队自己挑人,看起来像是组合优化,甚至有人想去做搜索、状态压缩,这就走远了。
比赛环境下的拔河,数组是有顺序的,队伍对应的是一段连续区间。正规题面通常描述为“从某个位置切开,左边一队、右边一队,求两边力量总和的差的绝对值最小”。理解成“任意挑人”和“队伍连续”完全是两套做法,前者复杂度几何级上涨,后者用一个前缀和就能轻松解决。所以赛场上第一件事永远是确认题目对“队伍”的定义,不要被汉字带节奏。
2. 宝石组合:别急着排序,先把问题还原成倍数枚举
2.1 题面还原与考点定位
按我记忆以及赛后和几位选手对题的结果,宝石组合的大意是:给定 n 颗宝石,每颗宝石有一个正整数能量值 a[i],要求选出三颗下标不同的宝石,使它们的组合价值最大;如果答案不唯一,按某个规定方式输出其中一种方案。题目里核心的那个组合价值,就是这三颗宝石能量值的最大公约数。
这道题对应的是数论里非常经典的“值域倍数枚举”。它不看某个数字本身被选了几次,而是看所有数字中有多少是某个约数的倍数。你只需要关心“有多少颗宝石的能量值能被 g 整除”,如果这个数量至少有 3,就说明一定存在三颗宝石它们的 gcd 至少是 g。
2.2 核心思路:把“选数”换成“按 g 分批”
假设我们想知道答案是不是 g,或者说最大 gcd 能否达到 g,那条件只有一个:数组中能被 g 整除的元素个数必须大于等于 3。
例如数组[6, 10, 15, 12]。如果 g=3,能被 3 整除的是 6、15、12,有三个,所以 gcd 至少为 3;而 g=4 没有三个,g=5 只有 15 和 10 两个。因此从大到小枚举 g,找到第一个“倍数数量 ≥ 3”的 g 就是最大 gcd。
这里有个容易误解的地方:找到 g 之后怎么确定输出哪三个?直接从原数组里依序遍历,取出前三颗能被 g 整除的宝石就行,因为这三颗的 gcd 一定不小于 g。如果你想要的是“gcd 恰好等于 g”的方案,也没有关系,后续计算会自然保证最终价值,输出前三个合法的就是可构造解。
复杂度上看,设最大能量值是 M,最外层从 M 枚举到 1,内层枚举 g 的倍数,所有枚举量加起来是:
M/1 + M/2 + M/3 + ... + M/M
这是一个调和级数,近似为 M log M。在 M 为 105 或 106 级别时完全能跑。
2.3 完整代码实现
C++17 写法如下,直接从最大能量值倒着枚举,命中即输出并结束。
#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; const int MAXV = 1000000; vector<int> cnt(MAXV + 1, 0); vector<int> a(n + 1); int mx = 0; for (int i = 1; i <= n; ++i) { cin >> a[i]; cnt[a[i]]++; mx = max(mx, a[i]); } for (int g = mx; g >= 1; --g) { long long tot = 0; for (int m = g; m <= mx; m += g) { tot += cnt[m]; } if (tot >= 3) { vector<int> ans; for (int i = 1; i <= n && (int)ans.size() < 3; ++i) { if (a[i] % g == 0) { ans.push_back(a[i]); } } cout << ans[0] << ' ' << ans[1] << ' ' << ans[2] << '\n'; return 0; } } return 0; }注意tot用long long,虽然通常不会爆 int,但养成好习惯能省掉很多边界问题。第二层的倍增枚举写法,相当于把所有“能被 g 整除的数”一次性统计出来,而不是真的去遍历原数组。
如果题目要求在 gcd 最大的同时保证字典序最小,只需要把最后的输出改成按原始下标从小到大的顺序尝试,也就是上面代码中按i = 1到n扫描的效果,天然满足字典序最小。
2.4 当价值函数带 LCM 或者乘积项时怎么办
有些变形题会把宝石组合的价值定义成带最小公倍数或者乘积的式子。看到这种公式别慌,做题思路依然是先按约数枚举。任何“只和约数整除关系有关”的指标,都可以先锁定 gcd 可能的值,再把同余类里的数字单独拎出来比较。
比如某个价值函数在同一组gcd = g的前提下,往往只取决于这三颗宝石本身的大小排序。那在统计到某个 g 的倍数数量达到 3 之后,再去这个“桶”里选最小的三个、最大的三个、或者按字典序挑前三个,就能得到最优解。省赛这种题绝大多数情况是:先缩小搜索域到约数枚举,再处理排序细节。把这一步想明白,比记任何高阶数论公式都管用。
3. 拔河:前缀和加上切分,代码只有 20 行
3.1 题面还原与第一直觉
拔河这道题我按考后多数人的记忆中表述来写:给定 n 个人依次站在一排,每个人有力量 a[i],需要在某两个人中间切一刀,左边是甲队,右边是乙队,两队内部顺序保持原样。求两队总力量差的绝对值的最大值还是最小值?这里是最小值。
第一直觉当然是枚举切点,但很多人的第一版写法会直接对每个切点重新求和,于是写出两层循环,整体 O(n^2)。如果 n 是 1e5,O(n^2) 又挂了。其实求区间和这种操作,正确姿势永远是前缀和,而不是每次现算。
3.2 前缀和版本
设pref[i]表示前 i 个人力量之和。左边队伍从第 1 到第 cut 个人,和就是pref[cut];右边队伍从第 cut+1 到第 n 个人,和是pref[n] - pref[cut]。两队差就是这两个数的绝对差。
C++ 代码:
#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; vector<long long> a(n + 1), pref(n + 1, 0); for (int i = 1; i <= n; ++i) { cin >> a[i]; pref[i] = pref[i - 1] + a[i]; } long long ans = LLONG_MAX; for (int cut = 1; cut < n; ++cut) { long long leftSum = pref[cut]; long long rightSum = pref[n] - pref[cut]; ans = min(ans, llabs(leftSum - rightSum)); } cout << ans << '\n'; return 0; }时间复杂度 O(n),空间 O(n)。这题做到这个程度已经算是完整通过。考场里大部分时间不是花在实现,而是花在“确认题目是不是真的只切一刀”上。
3.3 如果题面变成“两个任意不重叠连续区间”
我在赛后讨论时发现,不少考生把拔河理解成“两边各挑一段连续区间,中间还可以隔人不拉”。如果题面确实是这个版本,那模型就变了,相当于找两个不相交区间的区间和之差的最小值。
对于 n 比较小比如 n ≤ 500 的情况,可以枚举一条分隔线,把分隔线左边的所有子区间和丢进一个有序集合,然后扫描分隔线右边的所有子区间和,用二分查找找最接近的数。这个做法能过部分数据,但复杂度依然是多项式量级,不是最优解。考场上如果发现自己读出来的条件让题目越做越复杂,建议立刻回去再看一眼题面,多半是读偏了。
换句话讲,拔河这道题真正的考点不是你能写多复杂的数据结构,而是你能不能识别出“切开队伍直接前缀和”这个最简单模型。
3.4 拔河的易错点
关于这题我有四个非常实际的提醒:
第一,切点不能取 0 或者 n,否则有一边人数为 0,不符合“两队都有人”的常规约束。所以循环范围是cut = 1到cut = n - 1。
第二,力量值可能是比较大的正整数,也可能包含 0,甚至比赛有时会给负数。前缀和照常维护,但差一定要取绝对值。
第三,必须开 long long。如果 n 是 2e5,每个 a[i] 是 1e9,前缀和轻松到 2e14,int 会直接溢出成负数。
第四,很多人写区间和喜欢写pref[r] - pref[l],这可能来自下标从 0 开始的习惯。下标从 1 开始时正确公式是pref[r] - pref[l-1]。拔河只用单个切点还好,但如果进行区间扩展,这个[-1]错位会带来非常隐蔽的 bug。
4. 现场复盘:三个很冤的错误和一个通用必杀技
4.1 宝石组合:暴力三重循环 TLE
比赛时我最早写的是:
for (int i = 0; i < n; ++i) for (int j = i + 1; j < n; ++j) for (int k = j + 1; k < n; ++k) best = max(best, gcd(a[i], gcd(a[j], a[k])));样例全对,小数据全对,一上大数据就卡死。后来改成值域倍数枚举,时间复杂度直接从 O(n^3) 变成 O(M log M)。这个教训是这样的:看到三重循环先不要急着剪枝,先想清楚问题能不能用数学方式降维。
4.2 拔河:端点公式写错
拔河我犯的错更低级。第一次写前缀和时,我把左右两支队伍的“切点”和“端点”搞混了,导致差值的计算多减了一次。当时的样例设计得刚好两边对称,居然输出了正确答案,迷惑性极强。
所以我现在养成了习惯:凡是区间和公式,先在纸上写pref[r] - pref[l-1],再转成代码。别靠脑内多项式展开。
4.3 没开 long long
宝石组合同样存在求和操作。cnt统计虽然不至于爆,但如果你把价值函数扩展成带 lcm 或乘积的形式,中间结果很容易超出 32 位。现场的第三道坎就是一顿int运算,测试点输出各种奇怪的负数,完全不是算法问题,就是数据类型炸了。
这类省赛题目基本都会在数据范围里埋这种坑。强烈建议所有前缀和、统计数量、gcd 相关中间结果一律用long long,虽然多写几个字母,但能救你一次。
4.4 通用必杀技:暴力先写好,再写优化版,最后对拍
这里分享一个我一直沿用的方法,特别适合比赛刷题和赛后复盘。
拿到题先别急着上最优解,先写一个非常笨但一定对的版本,比如宝石组合的 O(n^3),拔河的 O(n^2)。然后用随机数据把暴力版和优化版放在一起对拍。两边输出不一致,说明优化版哪个边界写错了;两边一致,才有信心交上去。
我在现场之所以能在宝石组合上快速从 TLE 切到 AC,就是因为先用暴力版验证了枚举方向不会错。很多选手过了样例就交,容易被特殊样例骗过去,吃过一次亏之后,我现在总会多留十分钟做随机对拍。
5. 两题连在一起看:蓝桥杯省赛的“脾性”
5.1 第一性原则:先别动算法,动数学
宝石组合指向的是数论里的“倍数枚举”大方向,拔河指向的是数组区间问题里的“前缀和”大方向。这两道题放在一张卷子里,其实是在反复强调一件事:算法题的第一层筛选不是你有没有背过数据结构,而是你能不能把题面翻译成更本质的数学模型。
听到“选三个”不会往三重循环上扎,听到“连续区间和”不会往暴力累加上扎,这样的人往往能节省大量时间。与其执着于“我怎么把枚举优化的更快”,不如想“这个枚举真的有必要吗”。倍数枚举解决的是前者,前缀和解决的是后者。
5.2 对刷题路线的直接建议
如果你也在准备算法竞赛,建议给“值域枚举”和“前缀和”这两个模型各找十道题集中练。值域枚举不只出现在宝石组合,还大量用于求最大公约数、最大因数的组合题;前缀和则是几乎所有区间题的基石,从最大子段和到区间查询都是它的变体。
做题时不用记太多“秒杀技巧”,优先掌握两个能力:一是把题目条件写成数学式子,二是把复杂度从多项式次数里降下来。这两题就是最好的练习材料。
5.3 考场时间分配
我的个人建议是:如果一场比赛里同时出现数论题和区间题,先做自己最有把握拿分的那道。宝石组合的推导过程一旦卡住,很容易陷入“越急越绕”的状态;拔河只要判断清楚切点模型,实现几乎是直线型的。
在真实现场,我给了宝石组合大约二十分钟,给拔河十五分钟,剩余时间全部用来对拍和检查数据范围。不要小看检查这一步,省赛很多人的丢分点根本不是不会做,而是数组开小、忘记 long long、循环边界多一少一。把检查做成固定流程,比多做半道难题更值。
最后再送一个很实用的习惯:每次写完代码,先构造一组 n 等于 1 或 2 的边界数据跑一遍,再构造一组全部数字相等的数据跑一遍。宝石组合里如果有重复数字,排序后的选择顺序很容易出问题;拔河里如果所有人力量相等,答案一眼能看出来,错误版本很难在这个数据上蒙混过关。这些小用例花不了十秒钟,但能挡住绝大多数低级失误。希望下次你和这两类题碰面时,能比我当初踩的坑少一点。