1. 比赛概览与选题策略
1.1 牛客周赛是给谁准备的
牛客周赛这个系列,我基本从 Round 100 前后就开始跟着打。它不像 ICPC、蓝桥杯那种正赛那么严肃,但也不像随便一个网站的小练习那么没氛围。平台每周固定时间放出四道题,难度从易到难排布,基本上覆盖了“签到题、思维题、板子题、压轴题”四种典型形态。Round 135 延续了这套节奏,所以对于正在准备校招笔试、想要保持算法手感、或者刚开始打竞赛的新手来说,它其实是个性价比很高的练兵场。
我见过不少人纠结“要不要打周赛”,觉得排名不靠前没意义。但从我的实际体验看,周赛最大的价值反而在于“时间盒”和“反馈感”。你花两小时坐在电脑前,目标不是拿高名次,而是逼自己在有限时间内完成读题、建模、编码、调试这一整套流程。这种完整闭环在平日里刷题是很难复刻的,因为刷题时你很容易卡住就直接打开题解,失去现场解决问题的压力感。Round 135 这场我打到一半就明显感觉到,平台上很多参赛者跟我状态类似:前三题写得快,第四题开始暴露问题。
1.2 赛题的难度分布与整体印象
按我的记忆整理,Round 135 的四道题大致是这样一种节奏:第一题属于看完题面就能动手的签到题,但里面埋了一个输出格式的小坑;第二题和第三题是常见的枚举、贪心或者简单数据结构题,考验的是能不能快速把模型抽出来;第四题则明显上强度,需要一点数学推导再加一点优化意识。整体难度曲线比上周的 LeetCode 周赛 430 要更“竞赛化”一些,也就是说题面更直白,不太绕弯,但数据范围往往会逼你放弃朴素写法。
第四题就是热搜里提到的“区间次方和”。这道题我印象很深,因为现场很多人在它身上卡了很久。倒不是说思路有多难,而是“次方”这个操作天然自带爆炸趋势,如果第一时间没有反应过来要做降幂和离线处理,很容易一头扎进快速幂的暴力循环里,等到超时才回头。我周围几个打这场周赛的朋友,赛后交流时也一致认为第四题是分水岭,搞懂它以后,再看牛客周赛里其他类似的区间查询题,思路会清晰很多。
1.3 赛前目标与做题策略
我个人的习惯是,开赛前先给自己定一个低标和高标。低标是“前三题尽量不罚时”,高标是“第四题至少写出一个能过部分数据的版本”。这种目标不是为了面子,而是为了避免比赛中出现“贪多嚼不烂”的情况。第一题和第三题之间难度差距并不大,很多人喜欢按顺序硬推,结果卡在第三题上,第四题连题目都没读。我更喜欢速读四题,先判断出每一题的题型和大致复杂度要求,然后从软柿子开始捏。
Round 135 我采用的策略是先花五分钟把四道题扫一遍,把第一题和第四题优先看。第一题用来热身找手感,第四题先放进脑子里慢慢发酵。这样等我写完第三题回头处理第四题时,已经拥有了一段时间的“潜意识思考”,往往比盯着屏幕死磕更有效率。这个策略在多次周赛里都帮我节省了宝贵时间,尤其是像“区间次方和”这种需要灵光一闪的题目,提前预读比现场现想从容得多。
2. 重点题拆解:区间次方和
2.1 题目印象与数据范围
先说我对第四题题面的记忆。大意是这样的:给定一个长度为 n 的数组 a,数组元素的值都在 1 到 100 之间,接下来有 q 次询问,每次询问给出一段区间 [l, r] 和一个很大的整数 k,要求计算区间内所有元素的 k 次方之和,并对一个质数模数 M 取模。模数我印象里是 998244353,这是竞赛中很常见的 NTT 友好质数。
数据范围方面,n 和 q 都能到 10 的 5 次方级别,k 则是一个 64 位整数都装得下但足够让暴力快速幂吃瘪的数字。这组约束一出来,其实已经暗示了两件事:第一,你不能每次询问都对区间内每个数单独做一次快速幂;第二,k 很大,说明需要借助费马小定理或者欧拉降幂把指数压缩。这里有一点值得反复强调的是,数组元素值域只有 100,这是整道题最关键的突破口。很多人在大范围区间查询题里习惯性往线段树、树状数组方向想,却忽略了这个 100 的存在。
看到“值域极小、区间极大”的组合,我头脑里冒出的第一反应不是数据结构,而是“按值域统计”。因为任意一个数的 k 次方,只取决于这个数本身和 k,和它出现在哪个位置无关。那么区间内的答案就可以写成对 1 到 100 的每个可能值 v,统计 v 在区间内出现的次数,乘以 v 的 k 次方,最后求和。这个思路属于典型的“转区间查询为值域聚合”,很多区间统计题都能套用。
2.2 为什么朴素做法一定超时
部分选手看到这道题的第一反应是:每次询问直接遍历区间,把 l 到 r 里的每个元素都用快速幂算一下 k 次方再累加。这个做法的复杂度是 O(q × 区间长度 × log k),最坏情况 q 和 n 都是 10 的 5 次方,区间长度也是 10 的 5 次方,那总操作量直接奔着 10 的 15 次方去了,哪怕只有百分之一的常数优化也是不可能跑完的。
还有人可能会想:那我用线段树维护区间和,每次修改某个位置的值,查询时区间合并,这样行不行?这里的问题在于,查询操作不是普通加法,它要求对每个元素单独做幂运算。线段树能快速合并的是“已经算好的结果”,但你不可能预先把每个元素在所有可能的 k 下的幂都存下来,因为 k 的范围太大了。线段树维护区间的 sum(a[i]^k) 只有在 k 固定时才有意义,一旦 k 随询问变化,懒标记和合并逻辑就全乱套。
所以这道题真正的难点不在于“区间查询”这个动作,而在于“幂运算”和“变化的 k”。你必须找到一个办法,把大指数 k 先降下来,再把区间查询转化成可以预处理的统计问题。想通了这一点,后面的代码其实很朴素。
2.3 解法核心:值域压缩加上前缀计数
既然数组元素只可能是 1 到 100 这 100 种值,我可以先做一个二维前缀频次表。pref[v][i] 表示数组前 i 个位置中,值恰好为 v 的元素个数。这个表是静态的,因为题目并没有要求修改数组。预处理的复杂度是 O(100 × n),内存上如果 n 是 10 的 5 次方,那么开 101 × (n+1) 的 int 数组大约 40 MB,在牛客的评测环境里完全可接受。
每次询问给定 l、r、k,我先用费马小定理把指数降下来。因为 M 是质数,且所有 a[i] 都在 1 到 100 之间,和 M 互质,所以 a[i]^k 和 a[i]^(k mod (M-1)) 在模 M 意义下相等。记 e = k % (M-1),接下来只需要求每个可能值 v 的 v^e,乘以区间内 v 的出现次数,累加即可。
这里有一个可以优化的点:不要对每组询问里的每个 v 都现场跑一次快速幂。如果两个询问的 e 相同,那么它们需要的 100 个幂结果是完全一样的。所以可以把所有询问离线读进来,按照 e 分组,同一个组只计算一次 v^e 的幂表。这样能把大量重复的快速幂计算省掉。实际效果取决于 e 的重复程度,但就算最坏情况 e 全部不同,这个版本的常数也比“每查询 100 次快速幂”要稳定得多。
2.4 模数不是质数时怎么办
现场有朋友问过我,如果题目换成一个合数模数,比如 10 的 9 次方加 7 的平方之类,费马小定理会不会失效?答案是会。费马小定理要求模数必须是质数,而且底数不能是模数的倍数。如果模数变成了合数,就需要使用扩展欧拉定理。扩展欧拉定理的公式是:当指数 k 大于等于 phi(M) 时,a^k 模 M 等于 a^(k mod phi(M) + phi(M)) 模 M。注意这里有个“加 phi(M)”的步骤,很多第一次接触欧拉降幂的人会漏掉。
为什么必须加上 phi(M)?因为当底数 a 和模数 M 不互质时,直接只取 k mod phi(M) 会丢失 a 的某些质因子带来的周期影响。加上一个完整的 phi(M) 能保证指数的“周期性”部分和“非互质”部分都被保留下来。所以在写通用模板时,我通常不会只写费马小定理版本,而是封装一个“智能降幂”函数:先算 phi(M),然后判断 k 是否大于等于 phi(M),如果大于等于就返回 k % phi(M) + phi(M),否则直接用原 k。
回到这道题,如果模数不是质数,那我上面的代码里就要用扩展欧拉定理来把 k 转化成 e,其他地方逻辑不变。但要注意,如果底数 v 和模数不互质,快速幂里依然要小心结果可能为 0 的情况,这是正常的。总之,降幂是这类题目的第一道门,把它做对了,后面反而轻松。
2.5 实战代码与优化点
下面这段代码是我按记忆整理的现场版本,做了一点离线分组优化,核心逻辑应该足够复现。
#include <bits/stdc++.h> using namespace std; const long long MOD = 998244353LL; long long qpow(long long a, long long b) { long long res = 1; a %= MOD; while (b > 0) { if (b & 1) res = res * a % MOD; a = a * a % MOD; b >>= 1; } return res; } struct Query { int l, r, id; long long k, e; }; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, q; cin >> n >> q; vector<vector<int>> pref(101, vector<int>(n + 1, 0)); for (int i = 1; i <= n; i++) { for (int v = 1; v <= 100; v++) { pref[v][i] = pref[v][i - 1]; } int x; cin >> x; if (x >= 1 && x <= 100) { pref[x][i]++; } } vector<Query> queries(q); for (int i = 0; i < q; i++) { cin >> queries[i].l >> queries[i].r >> queries[i].k; queries[i].e = queries[i].k % (MOD - 1); queries[i].id = i; } sort(queries.begin(), queries.end(), [](const Query& a, const Query& b) { return a.e < b.e; }); vector<long long> ans(q); vector<long long> power(101, 0); long long curE = -1; for (auto &qu : queries) { if (qu.e != curE) { curE = qu.e; for (int v = 1; v <= 100; v++) { power[v] = qpow(v, curE); } } long long sum = 0; for (int v = 1; v <= 100; v++) { long long cnt = pref[v][qu.r] - pref[v][qu.l - 1]; if (cnt) { sum = (sum + cnt % MOD * power[v]) % MOD; } } ans[qu.id] = sum; } for (int i = 0; i < q; i++) { cout << ans[i] << '\n'; } return 0; }这段代码的时间复杂度主要取决于不同 e 的数量。每个不同的 e 最多做 100 次快速幂,每次快速幂约 log(MOD) 次乘法,大约 30 次。如果 e 的去重效果好,整体很快;如果 e 全不相同,最坏会退化到 10 的 7 次方量级的快速幂调用,这在极限数据里可能会被卡。现场还可以进一步用一个叫“指数分块”的技巧救场:预先把每个 v 的 0 到 B 次幂存一张表,再把 v 的 B 倍间隔次幂存另一张表,查询时把 e 拆成高位和低位,两次查表相乘即可。这个技巧本质上是用空间换时间,把快速幂彻底从查询路径上拿掉。
写这段代码时最容易踩的坑有两个。第一个是忘记 k 是 long long,求 e 的时候用 int 截断造成负数或者溢出,调半天才发现是这里的问题。第二个是前缀表的下标,pref[v][i] 代表“前 i 个元素”里的次数,查询区间是左闭右闭 [l, r],那么 cnt 必须写成 pref[v][r] - pref[v][l-1],写成 pref[v][r] - pref[v][l] 会让答案差一个位置,样例数据短时不容易暴露。
3. 其余赛题复盘与横向对比
3.1 A题:看似简单但容易罚时的点
第一题我在 Round 135 里用了八分钟左右才通过,原因不是题目难,而是我一开始没注意输出格式的细节。这类签到题经常会让选手输出一个浮点数或者特定精度的字符串,如果你的 printf 少写了一个换行,或者精度比要求少了一位,评测结果就会是 WA。说实话,算法题里因为输出格式被判错,是最让人恼火的罚时来源。
我的教训是:做完签到题以后,不要着急提交,先把输出语句和题目要求逐字核对一遍。特别是那些要求“每个结果占一行”或者“答案之间保留两个空格”的题,直接复制样例输出对比是最稳妥的。很多竞赛老手之所以罚时少,并不是他们手速比你快多少,而是他们习惯在提交前花十秒钟做这个检查动作。
3.2 B题和C题的常见套路
B题和C题我放在一起说,是因为它们的解法思路比较相似。B题我印象里是一个需要处理“前缀最值”的模拟题,C题则涉及一个比较明显的贪心,需要证明贪心策略的正确性。做这类题时,我最常用的方法是先写一个朴素枚举版本,故意让它跑在数据范围较小的测试点上,用来验证自己的记忆化或贪心版本是否和暴力结果一致。
贪心题容易出错的地方在于想当然。你以为的“每次都取最大”不一定是最优的,可能题目里还藏着某个后效性条件,让局部最优不等于全局最优。我在比赛里经常用穷举小规模数据来检验这个性质:如果 n 小到可以枚举所有方案,那就用全排列暴力求出真正的最优答案,再去和贪心策略的结果对比。这招在赛场上虽然费一点时间,但能避免错误思路带来的无意义罚时。
3.3 D题压轴考察的真正能力
回到第四题,我认为它考察的已经不单纯是某个算法模板,而是一种“先限制复杂度模型,再匹配工具”的综合能力。看到区间查询,第一反应是数据结构;看到大指数,第一反应是降幂;看到值域只有 100,第一反应是桶计数。当这三种反应同时出现时,你必须把它们拼装在一起,形成“离线 + 降幂 + 前缀计数”的完整方案。这个过程很像搭积木,每一块都简单,难的是在有限时间内识别出该用哪几块。
平时训练如果只刷标签题,比如“线段树题”“数论题”,很容易形成思维惯性。但周赛压轴题故意把多个标签融合到一起,让你没有办法靠单一模板秒杀。这也是我为什么建议大家在周赛结束以后,不要只看题解,而是自己把第四题重新实现一遍,写的过程中你会真正体会到“为什么前缀计数能替代线段树”“为什么离线分组能减少重复计算”这些关键问题。
3.4 和 LeetCode 周赛 430 的对比
上周我也打了 LeetCode 周赛 430,牛客周赛和它的差别确实值得聊一下。LeetCode 周赛的题目通常更偏“工程化思维”,题目背景喜欢包装成实际业务场景,比如任务调度、路径规划、数据流统计,数据范围相对友好,很多时候 O(n log n) 甚至 O(n^2) 都能过。而牛客周赛的风格更接近算法竞赛的原始形态,数据范围更大,边界条件更刁钻,数学题含量也明显更高。
拿区间次方和这道题来说,它在 LeetCode 周赛里出现的概率不是没有,但数据范围大概率会被压到比较小,让你可以用带缓存的暴力甚至裸快速幂过掉。牛客这边则不同,它更愿意把“优化”真正作为通过门槛。所以如果一个人能稳定处理牛客周赛的第四题,再回头打 LeetCode 周赛的第三题第四题,往往会觉得轻松不少。反过来,习惯 LeetCode 节奏的选手去打牛客周赛,容易在第一场就因为 TLE 受挫,这很正常,不是水平问题,只是平台侧重点不同。
4. 现场实操与排查技巧
4.1 读题顺序与时间分配
我打周赛的经验是,前五分钟一定不要碰键盘,先用眼睛把四道题全部扫完。这个动作有两个好处:一是可以提前发现有没有“水题”,判断出今晚上分的主要来源;二是给大脑一个后台任务,让它在你写前几题的时候自动思考难题。Round 135 的第四题,我就是在写第一题的过程中突然想通值域压缩这个点的。如果我只盯着第一题顺序往下做,恐怕要等到卡壳才去读第四题,思路启动就晚了大半场。
时间分配上,我的原则是每道题设一个心理警戒线。签到题十五分钟内必须交;第二题第三题三十分钟内解决;第四题如果真的卡到比赛结束前二十分钟还没头绪,就转为“写暴力争取部分分”模式。很多新手容易在一道题上死磕两小时,追求“完美解题”的爽感,却忘了周赛的目标是在有限时间内拿尽量多的分。部分分也是分,哪怕暴力只能过 30% 的数据,也远比空提交要好。
4.2 取模与快速幂的那些细节
降幂和取模是一对容易出错的组合。代码里我习惯先把所有输入都读成 long long,再统一转成合适的类型。k % (MOD - 1) 这个操作看着简单,但如果 k 是从键盘直接读入到 int,一个大数就会变成负数或者乱码,整个 e 就废了。另一个容易踩的坑是快速幂内部乘法溢出。MOD 是 998244353,两个 long long 相乘大约 10 的 18 次方,没有超出 long long 范围,但如果你把 MOD 换成更大的质数,比如 10 的 18 次方级别的数,那就必须考虑用 __int128 或者快速乘来避免溢出。我写模板时会把乘法单独抽出来,方便以后换模数。
还有一个小细节,就是减法取模。计算 cnt 的时候我用的是 pref[v][r] - pref[v][l-1],这两个前缀和都是非负的,所以 cnt 不会为负。但如果在更复杂的题目里出现了减法,记得一定要写成 (a - b + MOD) % MOD,不要写成 (a - b) % MOD,因为 C++ 对负数的取模结果不是我们期望的数学含义。
4.3 高频报错速查表
赛后我整理过一份比赛现场常见的报错速查表,很多问题在 Round 135 的讨论区里也能看到对应反馈。整理成表格方便大家直接查阅:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 测试样例通过了,大数据超时 | 复杂度模型不对,或者快速幂调用次数过多 | 检查是否使用离线分组,是否每个询问都重复计算幂表 |
| 答案总是偏大或偏小 | 前缀表下标用错,或者指数没有降幂 | 手写小数组跑一遍,对比 pref[v][r] - pref[v][l] 与 pref[v][r] - pref[v][l-1] |
| 直接编译报错 | 数组大小是变量,没有用 vector | 改成 vector<vector > 或 new 动态数组 |
| 结果出现负数 | 减法取模没加 MOD | 统一使用函数封装减法取模 |
| 内存溢出 | 二维前缀表开超了 | 把 int 换成 short 或用离线扫描,按值域依次处理 |
这张表不能替代自己的调试,但它能帮你快速定位到最常见的失败原因。尤其是前缀表下标问题和指数降幂问题,我在不少比赛中反复遇到,值得养成条件反射式的自查习惯。
表格之外,我再分享一个独家排错技巧:写完代码后,先构造一个 n 极小、q 极小的数据,比如一个只有 5 个元素的数组,然后手推一遍所有答案,再用代码输出对照。这一步虽然原始,但能过滤掉大约七成的低级错误。千万别依赖“看起来样例过了就交”,周赛的平台对正确性格外较真,一次 WA 可能就影响你本场排名几十个名次。
5. 赛后复盘与后续训练
5.1 一场比赛怎么复盘才有效果
不少选手打完比赛对完题解就算结束了,第二天再问他第四题为什么这么做,已经说不出核心思路。我自己的复盘习惯是三步走:第一步,把四道题全部重新实现一遍,不看题解,先尝试自己推导;第二步,把自己的代码和平台上的高分解法对比,记录两者在常数优化和代码简洁度上的差距;第三步,总结出本场用到的所有套路关键词,比如“值域压缩”“欧拉降幂”“离线分组”“前缀计数”,然后写进自己的套路本里。
这套流程看起来费时间,但效果非常好。我过去几个月通过这种方式,把牛客周赛里常出现的“区间查询 + 统计”类题目归纳成一个稳定的思维框架。以后再遇到新题,我不用每次从零开始建模,而是先去匹配已有的套路,再针对特殊条件做修改。这样解题速度和准确率都提升得很快。
5.2 牛客周赛和 LeetCode 周赛搭配训练
我现在每周固定打两场比赛,一场牛客周赛,一场 LeetCode 周赛。牛客周赛锻炼数据结构和数学能力,LeetCode 周赛锻炼对题面的抽象能力和工程思维。两者形成互补,对面试和竞赛都有帮助。如果时间有限,我建议优先补弱项:算法基础不牢固就多刷牛客,面试主导就多打 LeetCode,但千万不要只打一种。
这里还要提一个容易被忽略的点:每次比赛结束后的当日,我会抽时间读一下排行榜靠前选手的代码。牛客周赛的成绩页允许查看代码,里面能看到很多非常精简的写法。你可能会发现同样的思路,别人用位运算优化了常数,或者用滚动数组压缩了内存。这种“读源码”的过程比看题解更真实,因为它展示了现场条件下你也能做到的水平。
5.3 一个小技巧:区间查询转值域统计
最后分享一个我在第四题里印象很深的通用技巧:当区间查询里的元素数量很多、但元素可能取值很少时,优先考虑值域统计。具体步骤是:先开一个“每个值出现多少次”的桶,然后用前缀和把桶的累计次数存下来,回答查询时枚举值域而不是枚举区间。这个技巧在“区间内每个数平方和”“区间内每个数出现次数众数”“区间内最大出现次数”等题目里同样适用。
我常拿它和“字典序”作类比:区间查询就像你在人群里找长得像的人,如果直接一个个看过去,代价随人群规模线性增长;但如果你先按身高把所有排队的人分成若干组,再来问某一队里有多少人达标,那每次查询只需要数几下。这里的“身高分组”就是值域压缩,“排队”就是前缀计数。
5.4 一点个人体会
我在实际训练中发现,牛客周赛这类平台的题目质量虽然参差不齐,但恰好因为参差不齐,反而能更全面地暴露问题。比如 Round 135 的第四题,第一次差点让我放弃,但搞懂以后再看其他区间统计题,脑子里会自动多出一个“值域压缩”的备选方案。这种从痛苦到通透的过程,正是打周赛最值得珍惜的部分。
每次被一道题折磨过,我都会顺手把它记录到一个“错题本”里,标注当时的错误思路、正确思路和代码教训。三个月下来,这个本子成了我最值钱的资料,比任何付费课程都有针对性。如果你也愿意每周稳定打一场周赛,并且坚持赛后复盘,我相信半年后回看这段经历,一定会感谢那个没有放弃的自己。毕竟,算法能力的提升不是靠某一天的爆发,而是靠一次一次比赛里踩过的坑和想通的点,慢慢堆出来的。