1. 一道"算乘方"的题,凭什么当CSP-J 2022的T1
先说一下这道题的来历。P8813是洛谷上对CSP-J 2022年第二轮认证入门级第一题的收录题号。题目描述非常朴素:给定正整数a和b(数据范围是1到10^9),计算a^b的值,但如果结果大于10^9,就输出-1。
光看这个描述,十个选手有九个第一反应都是:"这不就是循环乘b次吗?"然后顺手写完,样例一测,过了,心里还挺美,觉得自己5分钟解决T1,省下大把时间去做后面的题。结果一到评测机上,TLE的TLE,WA的WA,CSP-J第二轮的第一题直接把一批人送走了。
我后来带学生复盘这题时反复强调一句话:**CSP-J的T1从来不是给你送分的,是给你上眼药的。**它考察的根本不是"你会不会算乘方",而是你有没有以下几点意识:
- 看到10^9这种数据规模,能不能立刻想到暴力循环不可行;
- 能不能意识到浮点函数(pow)在这种边界判定题里是个陷阱;
- 知不知道有符号整数溢出会带来什么后果;
- 有没有对a=1这种特殊边界做单独处理。
这几点放在一起,就是一道"伪签到题"。表面上是入门级第一题,实际在考选手的边界意识和数据规模敏感度。很多平时刷难题刷得飞起的选手,反而容易在这道题上栽跟头——因为太简单了,简单到让人放松警惕。
这篇文章我就把这道题从题目拆解、错误写法、正解思路到考场策略完整讲一遍。不管你是准备CSP-J/S入门级的选手,还是带学生的信息学教练,或者只是想把"为什么这么简单的题会错"搞清楚,这篇文章应该都能给你一些参考。
2. 最容易翻车的三种写法:暴力循环、pow函数、无防守累乘
先说结论:这道题的错误代码千奇百怪,但归纳下来,绝大多数翻车选手写的是下面三种"看起来很有道理"的版本。
2.1 暴力循环b次:TLE是必然的
#include <iostream> using namespace std; int main() { long long a, b; cin >> a >> b; long long ans = 1; for (long long i = 1; i <= b; i++) { ans *= a; } if (ans > 1000000000LL) cout << -1 << endl; else cout << ans << endl; return 0; }这段代码的错误是双重的。
第一,b最大可以到10^9,循环10亿次,即使每次只做一次乘法和一次比较,在评测机上也会跑到一秒以上甚至直接超时。CSPJ的时限通常比较紧,T1虽然简单,但评测环境不会因为你写的是"笨办法"就网开一面。
第二,就算b没那么大,这个写法也没有在乘的过程中判断是否超限。当a=2、b=60时,2^60约等于1.15×10^18,还在long long上限(约9.22×10^18)之内,看起来没事。但a=2、b=63时就已经超过long long上限了。更不用说a=10、b=20这种组合,乘积是个天文数字,long long根本装不下。有符号整数溢出在C++里属于未定义行为,结果可能是负数、可能是乱值,你后面拿这个值去和10^9比较,逻辑全是乱的。
2.2 用pow函数:精度坑你没商量
#include <iostream> #include <cmath> using namespace std; int main() { long long a, b; cin >> a >> b; double r = pow(a, b); if (r > 1000000000.0) cout << -1 << endl; else cout << (long long)r << endl; return 0; }这是另一种高频错误写法。选手的思路很直接:"C++自带pow函数,我干嘛还要自己写循环?"问题是,pow是浮点函数,它返回的是double类型,而double在表示大整数时是有精度损失的。
double的有效数字大约是15到16位十进制位,而a^b的结果可能在10^9以上。当结果接近10^9这个判定阈值时,哪怕有一点点舍入误差,都可能让"大于"和"不大于"的判断翻转。比如实际结果是1000000001,因为浮点误差算成了999999999.9999,你输出的是结果而不是-1,就WA了。
还有一种情况是强转:(long long)r在结果刚好是整数附近时,如果r因为精度问题变成536870911.9999,强转后就成了536870911,比正确答案少1。这种边界处的随机误差,是最让人抓狂的。
我见过不少选手本地测试样例全过,一交上去WA掉好几个点,死活查不出原因,最后才发现是pow函数的锅。所以,**凡是涉及整数精确比较的题目,尽量不要用浮点函数。**这不是pow不好,是浮点数在"精确整数判定"场景下天生不适合。
2.3 无防守累乘:等乘完再判断已经晚了
#include <iostream> using namespace std; int main() { long long a, b; cin >> a >> b; long long ans = 1; for (long long i = 1; i <= b; i++) { ans *= a; } if (ans > 1000000000LL) cout << -1 << endl; else cout << ans << endl; return 0; }这段和第一种看起来很像,核心问题也相似——循环体里没有提前判断,非要等整个循环算完再比较。但这里有个更隐蔽的问题:当ans在循环途中已经超过long long上限时,溢出就发生了。等循环结束,ans里面存的那个值,既不是真实结果,也不是"溢出后的确定值",而是undefined behavior,你拿它做什么判断都没有意义。
有些选手会辩解:"我测试了a=2、b=29,结果是536870912,对的呀;a=2、b=30,结果是-1,也对的呀。"这是因为你没测过a=10、b=20这种组合。10^20已经远超long long能表示的范围,循环途中就烂掉了,你后面的比较完全是拿垃圾数据在做决策。
所以,正确写法的核心只有一句话:**不要在乘完之后判断,要在每一次乘的时候判断。**一旦发现当前结果即将超过10^9,立刻输出-1并结束程序。这既能避免溢出,又能省掉后面所有无意义的计算。
| 错误写法 | 核心问题 | 典型后果 |
|---|---|---|
| 暴力循环b次 | 循环次数达到10^9量级 | TLE超时 |
| 使用pow(a,b) | 浮点精度不足,边界判定不可靠 | 边界处WA |
| 无防守累乘 | 循环内不判断,溢出后才比较 | 溢出/UB导致WA |
3. 正解的关键不是算得快,而是"该停就停"
这道题的正解思路,恰恰和很多人的第一直觉相反:**它根本不要求你把a^b整个算出来,它只要求你判断结果是否超过10^9。**一旦结果超过这个阈值,后面的计算在数学上仍然是存在的,但在这道题的任务里已经没有任何意义了,直接输出-1退出即可。
3.1 为什么"边乘边判断"不会超时
这是整个正解最核心的逻辑。很多人担心:万一b是10^9,我总不能乘10^9次吧?其实根本不需要乘那么多次。题目给出了一个隐藏的"加速条件":当a≥2时,从1开始连乘,最多乘大约30次就会超过10^9。
来算一下:2^30 = 1073741824,刚好大于10^9。也就是说,**只要a≥2,无论b有多大,乘积在不超过30次乘法之后必然超过阈值。**循环在30次以内就会触发"输出-1并返回"的分支,根本走不到第10^9次。
所以这个算法的实际复杂度是O(log_2(10^9)),约等于30次操作。听起来很玄乎,但本质上是"借助阈值来截断循环"。你不需要快速幂,不需要任何花哨的优化,只需要在循环里加一行判断,就同时解决了超时和溢出两个问题。
3.2 唯一的例外:a=1的时候要单独处理
如果a=1,那么1^b永远等于1,不管b多大都不可能超过10^9。但是,如果代码不特判a=1,循环条件i <= b会把循环真正执行10^9次——因为每次乘积都是1,永远不会触发"超过阈值"的分支,也就永远不会提前退出。
这就是我在前面说的"隐蔽的TLE坑"。很多选手写出了"边乘边判断"的正解,但忘了处理a=1,结果在大数据点超时了。你说冤不冤?思路全对,就差一个if。
所以正确流程是:
- 读入a、b;
- 如果a==1,直接输出1,结束;
- 否则用ans=1开始连乘,每乘一次判断ans是否大于10^9;
- 大于就输出-1并结束,否则继续;
- 循环正常结束(说明b次乘完都没超阈值),输出ans。
3.3 等号问题:超过和等于完全是两码事
还有一个很多人忽略的细节:题目说的是"若a^b超过10^9,则输出-1",不是"大于等于"。也就是说,当a^b恰好等于1000000000时,应该输出1000000000,而不是-1。
这个边界怎么测?a=1000000000、b=1就是最典型的例子。1e9的1次方正好等于1e9,不超过阈值,输出1000000000。如果你判断条件写成ans >= 1000000000LL,这个点就挂了。
我做评测数据统计时发现,这类"恰好等于阈值"的边界点,是WA的高发区。选手一般不会主动去构造这种数据,但如果出题人想要有区分度,就特别喜欢在等号上做文章。所以写判断的时候要养成习惯:题目说"超过",就用>;题目说"不小于",才用>=。严格跟着题面走,不要凭感觉。
3.4 为什么不需要快速幂
说到这里,可能有选手会问:"我学过度快速幂,直接用快速幂算出a^b,再判断是否大于10^9,不也行吗?"
行,但没必要。快速幂的时间复杂度是O(log b),b最大10^9,log b也就30左右,和上面的"边乘边判断"是同一量级。但快速幂的代码量明显更大,中间还会涉及取模、二进制分解,对T1这种"送分题"来说属于过度设计。
竞赛里有个朴素的原则:**能用简单代码解决的事,绝不上复杂方案。**复杂方案意味着更多的出错点,意味着更多的调试时间。CSP-J的T1,你要的不是"炫技",是"稳稳拿下"。一个循环加一个判断就能AC的题,就别把快速幂搬出来了——那点代码量放到后面的T2、T3去用,价值大得多。
4. 参考实现与边界细节:从a=1到long long的选择
思路讲清楚了,下面给两版可以直接用的参考代码。两版都能AC,但思考的侧重略有不同,我把各自的适用场景也说明白。
4.1 版本A:最直观的"边乘边判断"
#include <iostream> using namespace std; int main() { long long a, b; cin >> a >> b; if (a == 1) { cout << 1 << endl; return 0; } long long ans = 1; const long long LIMIT = 1000000000LL; for (long long i = 1; i <= b; i++) { ans *= a; if (ans > LIMIT) { cout << -1 << endl; return 0; } } cout << ans << endl; return 0; }这个版本的关键点有三个:
a == 1的特判必须放在循环之前,否则b=10^9时循环会跑满,TLE;- 因为a≥2时最多30次乘法就触发return,所以循环条件里的
i <= b即使b=10^9也无所谓,反正走不了那么远; - 每次乘完后立刻判断
ans > LIMIT,既防溢出,又及时止损。
这里顺便解释一下为什么变量要用long long。a和b本身最大都是10^9,int完全放得下。但看循环里的ans *= a,当a接近10^9、ans接近10^9时,乘积接近10^18,远超int上限(约2.1×10^9),所以ans必须用long long。如果你用int存ans,第二次乘法就溢出了,等你再判断的时候,值早就不是真的乘积了。很多选手的WA就是这么来的——不是思路错,是类型选错。
4.2 版本B:乘法前预判,彻底告别溢出隐患
#include <iostream> using namespace std; int main() { long long a, b; cin >> a >> b; if (a == 1) { cout << 1 << endl; return 0; } long long ans = 1; const long long LIMIT = 1000000000LL; for (long long i = 1; i <= b; i++) { if (ans > LIMIT / a) { cout << -1 << endl; return 0; } ans *= a; } cout << ans << endl; return 0; }版本B的思路稍微绕一点:在真正执行乘法之前,先用除法判断"如果乘了a,会不会超过LIMIT"。如果ans * a会超过LIMIT,就说明再乘下去必然超限,直接输出-1。
ans > LIMIT / a这个条件的正确性,可以简单推导一下。整数除法LIMIT / a是向下取整的,比如LIMIT=10、a=3时,LIMIT / a = 3。如果ans=4,4>3,4×3=12>10,确实超了。如果ans=3,3>3不成立,3×3=9≤10,没超。所以这个条件在任何情况下都能准确预判"乘法是否会越过LIMIT"。
两个版本的区别在于:版本A是先乘后判断,版本B是先判断后乘。在本题的数据范围内,版本A其实也足够安全,因为a≥2时每次乘完马上判断,根本轮不到溢出就退出了。但版本B有一个额外价值——它完全不依赖"ans还没溢出"这个前提,哪怕数据范围再大一些(比如LIMIT接近long long上限),这种"除法预判"的写法依然成立。它是一种更通用的防溢出范式,以后你遇到"判断两个数相乘是否超限"的题目,可以直接套这个思路。
4.3 边界数据自测清单
写完之后,不要急着交。花两分钟把下面这一组边界数据跑一遍,全对再提交。这个习惯能帮你拦住80%的WA:
| 输入 | 预期输出 | 验证点 |
|---|---|---|
| 1 1 | 1 | 最小边界,a=1且b=1 |
| 1 1000000000 | 1 | a=1时永不超限,不能TLE |
| 1000000000 1 | 1000000000 | 恰好等于阈值,不输出-1 |
| 1000000000 2 | -1 | 第二次乘法直接超限 |
| 2 29 | 536870912 | 未超限,输出实际结果 |
| 2 30 | -1 | 2^30=1073741824>1e9 |
| 2 1000000000 | -1 | b巨大时依赖早停,不能TLE |
这里第5行和第6行是最经典的。2^29=536870912,离1e9还有距离;2^30=1073741824,刚好超过。很多选手的代码在这两个相邻输入上出现"一个对一个错"的情况,排查之后发现就是等号边界或者浮点精度造成的。如果你写的判断条件不是>而是>=,那2^30可能没超(1073741823?不,1073741824是>=1e9的,会输出-1),问题反而出在1000000000 1那个点上。所以这组数据最好一次性全跑,前后对照着看。
4.4 这段代码还能怎么改
如果你想在写法上更精简,可以把特判合并到循环里,用while实现:
#include <iostream> using namespace std; int main() { long long a, b; cin >> a >> b; long long ans = 1; while (b--) { ans *= a; if (ans > 1000000000LL) { cout << -1 << endl; return 0; } } cout << ans << endl; return 0; }这段代码确实更短,但有一个隐患:当a=1时,while (b--)同样会执行10^9次,依然会TLE。所以这种精简写法必须额外保留a==1的特判,或者改成"先特判再进入循环"的形式。我的建议是:**为了代码的可读性和安全性,保留显式的a==1特判。**竞赛不是比谁代码最短,比的是谁一次AC率高。
5. 一道T1教会我的事:考场上的边界意识与"伪签到题"识别
把这题讲完,我想跳出题目本身,聊聊它背后更值得琢磨的东西。
5.1 如何识别"伪签到题"
CSP-J的T1在多数年份确实是纯粹的送分题,比如让你做几个if判断、算个简单公式。但P8813这一年的T1显然不是"5分钟无脑AC"的类型。它有意识地混入了边界陷阱和数据规模陷阱,伪装成一道简单题,等着粗心的选手往里跳。
怎么识别这种题?有一个比较实用的经验:**如果一道题的题面你读完第一遍,觉得"这也能叫T1?",那多半它有隐藏的坑。**尤其要注意题面中的数据范围描述——看到"1≤a,b≤10^9"这种量级,第一反应就应该是:不能直接循环、不能直接用浮点函数、要考虑中间运算是否溢出。
还有一个观察角度:看题目是否带有一个奇怪的判定条件。比如这里的"如果结果超过10^9,则输出-1"。单纯算乘方的题不需要这个限制,加上这个限制之后,整个题的目标就从"计算"变成了"分类判断"。凡是带有这种人为限制的题目,出题人几乎必然在边界上布置了陷阱。你拿到题目后,先画出限制条件,再把限制条件的所有边界值列出来,逐一测一遍——这一步是"伪签到题"的破局点。
5.2 边界意识是入门级选手的第一道坎
我带学生做过一个统计:在CSP-J一轮模拟赛中,凡是T1类题目出现"超过阈值输出-1"这种表述的,错误率普遍在30%以上。错误原因高度集中:忘了a=1特判、等号边界、浮点函数、int溢出。这四种错误有一个共同点——选手对"数据尺度的感知"不够敏锐。
什么是数据尺度的感知?就是看到一个数,能立刻判断出它放在什么类型里、参与什么运算、会不会出问题。1e9这个量级,在int范围内;但1e9×1e9=1e18,就超出int了;连续乘30次以上,long long也可能兜不住。这种"算式还没写、心里已经有数"的能力,只能通过大量刷题和刻意练习来培养。
我建议入门级选手在平时刷题时养成一个习惯:每道题AC之后,不要马上切下一题,**把题面里数据范围的几个极端值手动构造出来,跑一遍你的代码,确认它对边界数据的处理是正确的。**这个过程很枯燥,但非常有效。练上几十道题之后,你再看P8813这类题,会自然地在脑子里冒出"a=1怎么办""2^30会不会超""等于1e9算不算超"这几个问题,根本不需要刻意提醒。
5.3 这类"溢出判断"思路还能用到哪里
P8813的核心思想——用除法预判乘法是否会溢出——不是一道题的专利,它在很多地方都适用。
比如判断两个整数a、b的乘积是否超过long long上限时,不要写成if (a * b > LIMIT)(因为乘法本身可能溢出),而要写成if (a > LIMIT / b)。这种写法避免了乘法运算,直接用除法比较,在任何数据范围内都是安全的。
再比如做二分答案、贪心算法里的累加判断时,如果累加值可能突破类型上限,同样可以用"先除后比较"或"边加边判"的模式。理解了P8813背后的这个模式,你就知道这道T1其实在帮你建立一种"防溢出直觉"——这种直觉在后面处理更高难度题目时,价值远大于一道签到题的AC本身。
5.4 考场上的时间分配建议
最后说说实战策略。CSP-J第二轮一共四道题,时间一般是三个半小时左右。T1作为第一题,正常应该在20分钟内解决,留足时间给后面的题。但"解决"不等于"交上去",我建议的流程是:
- 读题,划出数据范围和特殊条件;
- 先在草稿纸上列出所有边界情况(a=1、b=1、乘积等于阈值、乘积略超阈值等);
- 写代码,边写边注意类型选择和判断符号;
- 跑题目给的样例;
- 再跑自己构造的边界数据;
- 确认无误后提交。
这套流程看起来繁琐,实际花不了多少时间,但能显著降低"回头看T1发现WA了"的风险。记住一个残酷的事实:**CSP-J拉开差距的不是T4做没做出来,而是T1、T2有没有一次性稳稳拿下。**每年都有大量选手倒在最简单的题上,原因不是不会,是太急。
我自己带学生复盘P8813时发现,很多孩子第一遍写的都是暴力循环,看到TLE才意识到要"早停";还有一部分人写出了"边乘边判断"却忘了a=1的特判,在几组大数据上超时。印象最深的是有个学生用pow写完,本地测试样例全过,交上去WA了三个点,排查了半天才发现是浮点精度问题。他在课上苦笑着说:"我明明会做,就是栽在'觉得它简单'这四个字上了。"
这句话我一直记着。信息学竞赛的第一题,不考你会多少高级算法,就考你能不能在一个最简单的任务面前,把每一个边界都想清楚。这恰恰是入门级选手最应该先建立的能力,也是P8813这道"乘方"题真正想教给所有人的东西。下次再遇到这种"5分钟能写完"的签到题,多问自己一句:"真的这么简单吗?边界都处理了吗?"——你离稳稳的AC,就差这一句。