1. 从一道机试题说起:幂次方到底在考什么
第一次看到“幂次方”这三个字出现在机试题里,很多人下意识觉得就是写个循环乘一乘,顶多注意一下数据范围。但我带过几届准备机试的学生之后发现,这道题真正拉开差距的地方,根本不是“会不会写乘法”,而是你能不能把一个看似简单的数学问题,翻译成计算机能高效执行的判断逻辑。贵州大学2015年机试里的这道幂次方题,放在今天来看依然是一道非常典型的入门级算法题,它同时踩中了三个考点:整数幂的判定、循环与边界处理、以及大数场景下的思维转换。
先把这道题的核心需求说清楚。所谓“幂次方”类题目,通常的表述是:给定一个整数 n,判断它是否能表示成某个整数 b 的 k 次方(k 为大于1的整数),或者要求输出 n 是哪个数的几次方。不同年份、不同学校的版本会有细微差别,有的要求判断“是不是2的幂”,有的要求“分解成幂次方之和”,还有的像这道题一样,考察的是把一个数拆解成若干个幂次方项。不管具体表述怎么变,底层能力要求是一致的:你要会做整数范围内的幂运算,并且要能控制好循环的终止条件,避免死循环或者溢出。
为什么这道题值得单独拿出来讲?因为它是那种“看起来会做,一提交就错”的典型。我见过太多同学,思路完全正确,代码写出来也能跑,但就是过不了全部测试点。问题往往出在几个特别隐蔽的地方:比如把 1 当成特殊情况漏掉了,比如循环上界取错了导致漏判,再比如用pow()函数做整数判断时被浮点误差坑了。这些坑,光看教科书是看不出来的,只有真正在机试环境里被卡过几次,才会形成肌肉记忆。
这篇文章我会按照机试实战的思路,把这道幂次方题从审题、建模、编码到调试的完整链路拆开讲。不管你是刚开始学 C++ 的新手,还是已经刷过一些题但总在边界上翻车的同学,都能从里面找到可以直接抄作业的东西。我会尽量用大白话把每个选择背后的理由讲透,让你下次遇到同类题时,不是靠背代码,而是靠一套可复用的判断逻辑。
2. 审题与建模:把自然语言翻译成算法语言
2.1 题目常见表述与核心诉求拆解
机试题的题干通常写得很简洁,甚至有点模糊,这就需要你先做一轮“翻译”。以幂次方类题目为例,常见的表述有这么几种:
- 给定整数 n,判断它是否为 2 的幂次方。
- 给定整数 n,判断它能否表示为某个正整数的 k 次方(k > 1)。
- 给定整数 n,把它拆成若干个 2 的幂次方之和,输出拆分方案。
- 给定整数 n,求它最少能由几个幂次方数相加得到。
这几种表述对应的算法难度差别很大。第一种最简单,一个位运算就能搞定;第二种需要枚举底数和指数;第三种和第四种就涉及到贪心或者动态规划了。所以拿到题目的第一件事,是把“幂次方”这个模糊概念具体化:到底是判断、是分解、还是求和?
我个人的习惯是,读完题先在草稿纸上写三行字:输入是什么、输出是什么、中间要做什么判断。比如对于“判断 n 是否能表示为 b 的 k 次方”这类题,我会写:
- 输入:一个整数 n
- 输出:YES / NO,或者具体的 b 和 k
- 判断:是否存在整数 b ≥ 1 和 k ≥ 2,使得 b^k = n
这三行写下来,题目的骨架就清楚了。接下来才是考虑怎么用代码实现这个判断。
2.2 为什么不能直接用 pow 函数做整数判断
这是新手最容易踩的第一个坑。很多人第一反应是:我用pow(n, 1.0/k)求出底数,然后判断它是不是整数不就行了?想法很自然,但在机试环境里这么写,大概率会挂。
原因在于pow()返回的是浮点数,而浮点数在计算机里是近似存储的。举个例子,pow(8, 1.0/3)理论上应该等于 2,但实际算出来可能是 1.9999999 或者 2.0000001。你再用floor或者round去处理,边界情况就会出错。更麻烦的是,当 n 比较大的时候,浮点数的精度损失会更明显,本来应该判定成功的案例会被判成失败。
提示:机试里凡是涉及整数运算的题目,尽量全程用整数类型处理,不要中途转成浮点数再转回来。浮点误差是隐形的,调试的时候很难发现。
正确的做法是用整数乘法去逼近。也就是说,我想判断 n 是不是 b 的 k 次方,那就老老实实用循环把 b 连乘 k 次,看结果等不等于 n。这样全程都是整数运算,不存在精度问题。
2.3 枚举范围的确定:上界到底取到哪里
确定了用整数乘法之后,下一个问题就是:底数 b 和指数 k 分别枚举到多少?
先说指数 k。因为题目要求 k > 1,而 2 是最小的底数,所以 k 的最大值满足 2^k ≤ n。对于 32 位整数来说,n 最大约 21 亿,2 的 31 次方已经超过这个范围了,所以 k 最多枚举到 31 左右就够了。实际写的时候,我一般直接枚举到 32,或者用while循环让幂值自然增长,超过 n 就停。
再说底数 b。b 的最大值就是 n 本身(当 k = 1 时),但 k > 1 的情况下,b 最大是根号 n(当 k = 2 时)。所以底数枚举到sqrt(n)就够了。不过为了保险,也可以直接枚举到 n,反正内层循环会很快因为幂值超过 n 而退出。
这里有个经验:枚举上界宁可稍微取大一点,也不要取小。取大了顶多多跑几次循环,取小了就会漏掉合法解,直接导致答案错误。机试的测试数据往往会在边界上做文章,比如 n = 1、n = 2、n = 4 这种,上界取错就很容易翻车。
3. 核心实现:从暴力枚举到边界处理
3.1 基础版本:双重循环判断幂次方
先把最直观的版本写出来。思路很简单:外层枚举底数 b,内层不断乘 b,看能不能正好等于 n。
#include <iostream> using namespace std; bool isPower(int n) { if (n <= 1) return true; // 1 是任何数的 0 次方,特殊处理 for (int b = 2; b * b <= n; b++) { long long val = b; while (val < n) { val *= b; } if (val == n) return true; } return false; } int main() { int n; cin >> n; if (isPower(n)) cout << "YES" << endl; else cout << "NO" << endl; return 0; }这段代码有几个细节值得说。第一,val用了long long类型,因为b * b在 b 接近 46341 的时候会接近 int 的上限,继续乘下去会溢出。用long long可以多撑一段,但如果 n 本身接近 int 上限,还是有可能溢出,所以更稳妥的写法是在乘法之前判断一下val > n / b,超过就提前退出。
第二,循环条件b * b <= n是控制底数上界的。当 b 的平方已经超过 n 时,b 的更高次方肯定也超过 n,没必要再枚举了。这个条件比直接写b <= n效率高很多。
第三,n <= 1的情况单独处理了。1 比较特殊,它可以看作是任何数的 0 次方,但题目一般要求 k > 1,所以 1 到底算不算,要看具体题意。如果题目明确说 k ≥ 2,那 1 应该返回 false;如果没说,通常按 true 处理。这个细节一定要看清楚题干。
3.2 优化版本:用快速幂减少乘法次数
上面那个版本对于单次查询已经够用了,但如果题目要求对多个 n 进行判断,或者 n 的范围特别大,就可以考虑用快速幂来加速。
快速幂的核心思想是把指数用二进制拆分,从而把 O(k) 次乘法降到 O(log k) 次。比如要算 b 的 13 次方,13 的二进制是 1101,也就是 8 + 4 + 1,所以 b^13 = b^8 * b^4 * b^1。这样只需要算几次平方和乘法就够了。
long long fastPow(long long base, int exp) { long long result = 1; while (exp > 0) { if (exp & 1) result *= base; base *= base; exp >>= 1; } return result; }用快速幂改写判断逻辑:
bool isPowerFast(int n) { if (n <= 1) return true; for (int k = 2; (1 << k) <= n; k++) { int lo = 2, hi = n; while (lo <= hi) { int mid = lo + (hi - lo) / 2; long long val = fastPow(mid, k); if (val == n) return true; else if (val < n) lo = mid + 1; else hi = mid - 1; } } return false; }这个版本用了二分查找来定位底数,配合快速幂,整体复杂度是 O(log n * log n * log n),对于 n 达到 10^18 的情况也能轻松处理。不过对于机试题里常见的 int 范围,基础版本已经完全够用了,写复杂了反而容易出错。
提示:机试的原则是“能过就行,别过度设计”。如果基础版本能 AC,就不要为了炫技去写快速幂加二分。代码越复杂,出 bug 的概率越高,调试时间也越长。
3.3 特殊情况的处理清单
幂次方类题目有几个高频的特殊情况,我整理成了一张表,建议做题前先过一遍:
| 输入值 | 说明 | 常见处理方式 |
|---|---|---|
| n = 0 | 0 不能表示为正整数的正整数次方 | 通常返回 false |
| n = 1 | 1 是任何数的 0 次方,但 k > 1 时不成立 | 看题意,多数返回 false |
| n = 2 | 最小的质数,只能是 2 的 1 次方 | k > 1 时返回 false |
| n = 4 | 2 的 2 次方 | 返回 true |
| n 为负数 | 负数的幂次方涉及符号 | 机试题一般限定正整数 |
| n 接近 int 上限 | 乘法可能溢出 | 用 long long 或提前判断 |
这张表里的每一行,都是我在实际做题或者帮别人 debug 时真实遇到过的坑。尤其是 n = 1 和溢出这两个,几乎每次都有同学栽在上面。
4. 完整实操:从建工程到提交通过
4.1 开发环境的选择与配置
机试环境一般有两种:一种是直接用考场提供的 IDE,比如 Dev-C++ 或者 CodeBlocks;另一种是允许自己带环境,但只能用指定的编译器。不管哪种,提前把环境调顺手非常重要。
如果考场用的是 Dev-C++,我建议提前熟悉它的快捷键,尤其是编译(F9)、运行(F10)和调试(F5)。Dev-C++ 的调试功能比较弱,断点有时候不太灵,所以更稳妥的做法是用输出语句来定位问题。在关键位置打印中间变量,比单步调试快得多。
如果允许用 VS Code,那就要提前配好 C++ 环境。核心是三个东西:编译器(MinGW 或者 MSVC)、调试器(gdb)、以及tasks.json和launch.json两个配置文件。我见过有同学到了考场才发现 VS Code 没配好,编译都编译不了,那就很被动了。
{ "version": "2.0.0", "tasks": [ { "label": "build", "type": "shell", "command": "g++", "args": ["-g", "${file}", "-o", "${fileDirname}\\${fileBasenameNoExtension}.exe"], "group": { "kind": "build", "isDefault": true } } ] }这段配置的意思是:用 g++ 编译当前文件,带上-g参数生成调试信息,输出到同目录下的 exe 文件。配好之后按 Ctrl+Shift+B 就能一键编译。
4.2 代码编写与本地测试
环境准备好之后,就可以开始写代码了。我的习惯是先写主函数框架,再填核心逻辑。这样能保证输入输出格式先对,不会出现“算法写对了但格式错了”的情况。
#include <iostream> using namespace std; int main() { int n; // 先处理输入 while (cin >> n) { // 核心逻辑待填 cout << "TODO" << endl; } return 0; }注意这里用了while (cin >> n),因为很多机试题是多组测试数据,读到文件结束为止。如果题目只要求处理一组,用普通的cin >> n就行。这个细节要看清楚,不然会莫名其妙地少输出或者多输出。
核心逻辑填进去之后,就要开始本地测试了。测试用例不能只测题目给的样例,还要自己构造边界数据。我一般会准备这么几组:
- 最小值:n = 0, n = 1
- 小质数:n = 2, n = 3, n = 5
- 完全幂次方:n = 4, 8, 9, 16, 27, 32
- 非幂次方:n = 6, 10, 12, 15
- 大数:n = 1000000000, n = 2147483647
把这些用例跑一遍,如果结果都符合预期,那基本就稳了。
4.3 提交前的自查清单
在点击提交之前,花两分钟做一遍自查,能避免很多低级错误。我总结了一个清单:
- 输入输出格式:是不是多组数据?有没有多余的空格或换行?大小写对不对?
- 数据类型:会不会溢出?需不需要用 long long?
- 边界条件:n = 0、n = 1、n 为最大值时结果对不对?
- 循环终止:有没有可能死循环?内层循环的退出条件是什么?
- 数组越界:如果用了数组,下标有没有超范围?
- 头文件:用到的函数有没有包含对应的头文件?
这个清单看起来简单,但每一条都对应着真实的翻车案例。我自己就曾经因为忘了处理多组数据,导致提交后只过了一半测试点,查了半天才发现问题。
5. 常见问题与排查技巧实录
5.1 为什么我的答案总是差一个测试点
这是机试里最让人抓狂的情况:样例过了,自己造的用例也过了,但提交就是有一个测试点过不了。根据我的经验,这种情况九成以上是边界条件没处理干净。
最常见的边界就是 n = 1。很多题目的测试数据里都会放一个 1,而 1 到底算不算幂次方,取决于题目的具体定义。如果题目说“k 为大于 1 的整数”,那 1 就不算;如果题目说“k 为非负整数”,那 1 就算。这个区别一定要从题干里抠出来。
另一个高频边界是 n = 0。0 不能表示为任何正整数的正整数次方,所以一般返回 false。但如果你的代码里循环上界写的是b <= n,当 n = 0 时循环一次都不执行,直接返回 false,这反而是对的。可如果上界写的是b <= sqrt(n),那 sqrt(0) = 0,循环也不执行,结果也对。所以 0 这个边界反而不容易出错,真正容易错的是 1。
提示:如果实在不确定 1 怎么处理,可以两种都试一次,看哪个能过。机试的反馈是即时的,试错成本很低。
5.2 溢出问题:一个隐蔽的杀手
溢出是 C++ 机试里最隐蔽的错误之一。它不会报错,不会崩溃,只会默默地给你一个错误的结果。在幂次方题里,溢出主要发生在两个地方:一是b * b计算底数上界时,二是内层循环里val *= b累乘时。
对于第一种,如果 b 接近 46341,b * b就会超过 int 的上限(约 21 亿),变成负数。负数肯定小于 n,循环条件判断就会出错。解决办法是把 b 声明为long long,或者把条件写成b <= n / b,用除法代替乘法来避免溢出。
对于第二种,val *= b在 val 接近上限时也会溢出。解决办法是在乘法之前判断:如果val > n / b,说明再乘一次就会超过 n,直接退出循环即可。
while (val < n) { if (val > n / b) { val = n + 1; break; } // 提前退出,避免溢出 val *= b; }这段代码的意思是:如果 val 已经大于 n/b,那 val * b 肯定大于 n,没必要继续乘了,直接把 val 设成一个大于 n 的值,让外层判断失败就行。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 样例过,提交错 | 边界条件没处理 | 测试 n=0, n=1 | 根据题意补充特判 |
| 结果时对时错 | 整数溢出 | 打印中间变量 | 改用 long long 或提前判断 |
| 程序卡死 | 死循环 | 检查循环条件 | 确保循环变量会变化 |
| 编译错误 | 头文件缺失 | 看报错信息 | 补上对应头文件 |
| 输出格式错 | 多了空格或换行 | 对比题目要求 | 严格按格式输出 |
| 多组数据只输出一组 | 没用 while(cin>>n) | 检查输入部分 | 改成循环读入 |
这张表里的每一行,都是我在实际教学和做题中反复见到的。尤其是第一行和第六行,几乎每次机试都会有人中招。
5.4 调试技巧:打印大法好
机试环境里调试工具往往不好用,这时候最靠谱的方法就是打印中间变量。在关键位置插入输出语句,把循环变量、累乘结果、判断条件都打出来,一眼就能看出问题在哪。
for (int b = 2; b * b <= n; b++) { long long val = b; while (val < n) { val *= b; cout << "b=" << b << " val=" << val << endl; // 调试输出 } if (val == n) return true; }这样跑一遍,就能看到每个底数对应的幂值变化过程。如果发现某个 val 突然变成负数,那就是溢出了;如果发现循环根本没进去,那就是上界取错了。打印虽然土,但真的管用。
6. 从这道题延伸出去:幂次方类题目的通用套路
6.1 判断类、分解类、求和类的区别
幂次方类题目可以分成三大类,每类的解法思路差别很大:
判断类:判断 n 是不是幂次方。核心是枚举底数和指数,用整数乘法验证。难度最低,但边界最多。
分解类:把 n 拆成若干个幂次方之和。典型的是二进制拆分,因为任何正整数都能唯一表示成 2 的幂次方之和。这类题的核心是位运算。
求和类:求最少用几个幂次方数能凑出 n。这类题通常用贪心或者完全背包来做,难度最高。
拿到题目先判断它属于哪一类,然后套对应的套路,比从头想快得多。
6.2 位运算在幂次方题里的妙用
如果题目限定是“2 的幂次方”,那位运算就是最快的解法。判断一个数是不是 2 的幂,只需要一行:
bool isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; }原理很简单:2 的幂次方在二进制里只有一个 1,比如 4 是 100,8 是 1000。而 n - 1 会把那个 1 变成 0,后面的 0 全变成 1,比如 4 - 1 = 3 是 011。两者按位与,结果必然是 0。这个技巧在机试里非常实用,遇到 2 的幂相关题目可以直接用。
6.3 快速幂的适用场景
快速幂虽然强大,但并不是所有幂次方题都需要它。它的适用场景是:指数很大,需要频繁计算幂值。比如题目要求计算 b 的 k 次方对某个数取模,k 可能达到 10^9,这时候就必须用快速幂,否则 O(k) 的循环肯定超时。
但如果只是判断一个 int 范围内的数是不是幂次方,指数最大也就 31,用普通循环完全够用。强行上快速幂反而增加了代码复杂度和出错概率。
提示:选择算法的时候,先看数据范围。数据范围小,就用最简单的写法;数据范围大,再考虑优化。不要一上来就想着写最优解。
6.4 机试实战的心态与策略
最后说点非技术的东西。机试和平时刷题最大的区别是有时间压力和心理压力。平时可以慢慢想,机试就两三个小时,还要面对编译错误、测试失败等各种状况。
我的建议是:先易后难,先拿分再优化。拿到题目先扫一遍,把最有把握的题先做掉,确保基础分拿到手。遇到卡住的题,不要死磕,先跳过,等做完其他题再回来想。很多时候,换个环境再回来看,思路反而清晰了。
另外,代码要写得干净。变量名起得清楚一点,关键步骤加个注释,这样调试的时候自己能看懂。机试时间紧张,没人要求你写得多优雅,但至少要保证自己能读懂。
7. 我个人在实际操作中的几点体会
这道幂次方题我前前后后讲过很多遍,每次都有同学问类似的问题。总结下来,我觉得最关键的不是算法本身有多难,而是你有没有养成处理边界的习惯。很多同学代码写得很快,思路也对,但就是不愿意花两分钟去想想 n = 1 怎么办、会不会溢出。结果就是样例过了,提交挂了,然后开始怀疑人生。
我的做法是,每写完一道题,强制自己花一分钟过一遍边界清单:最小值、最大值、特殊值、溢出、多组数据。这一分钟看起来是浪费,实际上能帮你省下十分钟的调试时间。
还有一点,不要怕写笨代码。机试不是代码比赛,能过就是好代码。我见过有同学为了追求“优雅”,用了一堆模板和位运算技巧,结果自己都调试不出来。反而是那些老老实实写双重循环的人,稳稳当当拿了满分。先把题做出来,再考虑优化,这个顺序不能反。
最后分享一个我自己的小习惯:每次做完一道题,把踩过的坑记在一个本子上。下次遇到同类题,先翻一遍本子,看看有没有类似的陷阱。这个习惯坚持下来,你会发现很多错误其实是在重复犯,记下来就能避免。幂次方这道题,我的本子上就记了三条:n = 1 要特判、乘法要防溢出、多组数据要用 while 读入。这三条,后来帮我省了不少事。