东华OJ的基础题列表刷到第64题时,很多人会被“N的倍数”这个名字骗过去,以为就是按N加一加的事:N的倍数嘛,从N开始数,N、2N、3N往外列不就行了。可实际开始写C++代码、准备提交的时候,又会发现题目里其实藏着不少默认规则。你以为是“算出来就行”,但OJ判题判的是“输出格式正确”“边界条件兜住”“运行时间不超限”。我刷这道题时反复提交过几次,不是错在计算,而是错在把问题想简单了。
这题的常规描述是:输入两个正整数N和M,要求在1到M这个范围内找出N的所有倍数,按从小到大的顺序输出。如果范围内没有符合条件的数,就不用输出。题目要求用C++完成,这在东华OJ基础题里属于典型的“会取模就能写,但写得对不对要看细节”的题。对刚接触OJ的新手来说,这道题最大的价值不是学会用%判断倍数,而是学会把“人能直接看出来的答案”翻译成“机器能稳定执行且不越界不超时”的程序。
1. 拿到“64-N的倍数”这题,我先拆出这三个关键信息
1.1 题意核心:倍数本质上是一条“等差数列”
在数学里,判断一个数x是不是N的倍数,标准做法就是看x除以N的余数是否为0。写成C++就是x % N == 0。
但OJ题目很少只想让你判断一个数。它通常会让你处理一个范围,例如从1到M,把这些数里是N的倍数的全部挑出来。也就是说,你最后输出的其实是一个数列:N、2N、3N……只要它还小于等于M,就继续往后加。
这一点非常关键。一旦你把问题理解为“等差数列求和”或者“每隔N个数取一个”,你就能意识到,这道题不只是用%暴力过滤,它还可以直接通过步长遍历来实现。很多新手只记住%判断,忽略了“倍数本身就在等差数列上”这个本质,才会写出低效代码。
1.2 数据范围决定要不要提前把类型选好
这类基础题大多不会给特别离谱的数据范围,但M上界到10^9甚至接近int上限的情况并不少见。如果M是10^9,你仍然声明int i = 1去循环,理论上也不是不行,但当你需要让循环变量从N开始并且每次加N时,就存在一个风险:循环变量i接近int最大值时,再做一次i += N可能直接溢出变成负数,循环条件i <= M立刻失效,程序提前退出,输出全会少掉。
我的习惯是:凡是涉及步长加法且上界可能接近int极限的循环变量,直接声明成long long。用long long不会让代码变慢多少,但能把溢出问题从根本上堵死。OJ新手最容易忽略的就是这种“看起来没问题,跑起来随机出错”的越界场景。
1.3 输出格式:数之间怎么分隔,往往比算出来更重要
写OJ题最容易被判WA的地方,不是结果算错,而是输出格式不符合要求。“按递增顺序输出N的所有倍数”这种措辞,通常意味着多个数之间用空格隔开,末尾不能留多余空格,最后要换行。
为什么不能留末尾空格?因为很多OJ的判题程序会先去掉你输出的末尾空白,但部分严格判题会直接做字符串匹配。一个多余空格就可能让整份代码被标记为Wrong Answer。就算这道题对空格不敏感,养成“自己控制分隔符”的习惯也不会吃亏,后面的题目早晚会对这个有要求。
2. 两种能过样例的写法,复杂度却可以差出一个数量级
先声明一下,这两种写法在数据量很小的时候都能通过,但当你理解它们的差异后,以后遇到“输出某个公倍数序列”“判断区间内有多少个倍数”这类变形题时,就能立刻想到最合适的实现方式。
2.1 传统写法:从1到M逐个判断
最容易想到的写法是遍历1到M之间的每一个数,用i % N == 0判断它是不是N的倍数,是就输出。
#include <iostream> using namespace std; int main() { int n, m; cin >> n >> m; bool first = true; for (int i = 1; i <= m; i++) { if (i % n == 0) { if (!first) cout << ' '; cout << i; first = false; } } cout << '\n'; return 0; }这种写法的好处是容易理解,也容易套用到“判断一个数是不是N的倍数”这种单点判断场景。但它的问题是:循环次数是M次,和N没有关系。假设M是10^9,N是1,那么你需要循环10亿次。即使每轮循环只做一次取模和一次比较,10亿次运算也足以让时间超限。
2.2 步长写法:直接按倍数跳着走
既然倍数序列是N、2N、3N……那我们根本不需要检查中间的“非倍数”,只需要让循环变量从N开始,每次加上N,直到超过M为止。
#include <iostream> using namespace std; int main() { int n, m; cin >> n >> m; bool first = true; for (long long i = n; i <= m; i += n) { if (!first) cout << ' '; cout << i; first = false; } cout << '\n'; return 0; }这段代码的循环次数大约是M/N次。如果N很小,比如N=1,它依然要输出从1到M的全部数字,这和题目本身要求的输出量有关,再怎么优化也躲不掉;但只要N不是1,步长写法的优势就会立刻显现。N=10,M=10^9时,第一种写法要循环10亿次,第二种写法只需要循环1亿次,直接少一个量级。
而且步长写法还有一个额外好处:不用做取模运算。取模在CPU指令里比加法慢一些,虽然单次差异微乎其微,但在大规模循环里,能用加法解决的事就不需要额外引入除法指令。
2.3 两种写法怎么选:看题目是“筛选”还是“生成”
你可能会问:如果两种写法都能过,为什么还要纠结复杂度?
因为你需要建立一种直觉:题目里说“找倍数”时,它到底是在“筛选一段连续整数”,还是在“生成一个倍数序列”。
如果是筛选,例如“给你一段区间内的所有整数,请判断哪些数是N的倍数”,此时输入数据里可能本身带有一个数组,你就必须用%逐个判断。
如果题目给你N和M,让你自己从1到M里面找N的倍数,那本质上你是在生成一个以N为首项、以N为公差的等差数列。这个时候应该生成,不应该遍历全部整数再筛选。
把这两种场景区分开,比背下某一道题的答案重要得多。以后你做到“请你输出区间内所有同时是a和b倍数的数”这类题时,思路会立刻变成:先求a和b的最小公倍数L,然后从L开始按L递增即可,而不是把区间内每个数都拿去判断一次。
2.4 复杂度对比
| 写法 | 循环次数 | 核心操作 | 适用场景 |
|---|---|---|---|
| 逐个判断 | M次 | 每次取模 | 输入本身就是数组、需要逐个判断 |
| 步长生成 | M/N次 | 每次加法 | 从1到M生成N的倍数序列 |
当M很大而N也很大时,两者可能差不多;但当M很大而N居中时,步长写法优势明显。判断一道题能不能用步长写法,就看题目给的是“区间范围”还是“一堆离散的数”。
3. 可以直接提交的C++版本代码,以及输出细节为什么这样写
3.1 单组数据的提交版本
上面那段步长代码已经可以直接提交,但我再强调几个细节,避免你在其他OJ上复制后踩坑。
#include <iostream> using namespace std; int main() { int n, m; while (cin >> n >> m) { bool first = true; if (n <= 0 || m < 1) { cout << '\n'; continue; } for (long long i = n; i <= m; i += n) { if (!first) { cout << ' '; } cout << i; first = false; } cout << '\n'; } return 0; }这里我用了while (cin >> n >> m),是为了兼容“题目可能包含多组测试数据”的情况。很多OJ题不会明确告诉你“输入有多组”,但它会给你一组以EOF结尾的数据,直到你读不到新的整数为止。如果只用一次cin >> n >> m,程序会在读完第一组后就结束,后面几组数据全都不处理,自然导致WA。
如果你确定题目只给一组数据,把while (cin >> n >> m)改成直接cin >> n >> m也没有问题。但多做一步兼容不会带来坏处,我一般建议保留,因为你不确定OJ的测试数据里是否藏了第二组。
3.2 手工控制分隔符,不要用固定套路
输出空格时,最常见的错误写法是先输出一个数,再输出空格,最后导致结尾多一个空格。比如:
for (long long i = n; i <= m; i += n) { cout << i << ' '; }这段代码在最后也会输出一个空格,一旦判题严格,就会被判WA。正确思路是用一个bool first标记:第一个输出的数前面不加空格,后续每个数前面都加一个空格。这等价于把题目中的“数字之间用空格分隔”真正落实成“从第二个数开始,每个数前补一个空格”。
3.3 换行符使用习惯
基础题结尾一般要求换行,用cout << '\n'就够了,不需要cout << endl。endl除了换行还会刷新缓冲区,刷新的操作比单纯换行要慢。虽然一道题的输出量不大时看不出区别,但你要尽早养成“需要换行就写\n”的习惯。因为当输出量达到几万行时,endl逐个刷新缓冲区会明显拉慢程序,容易导致TLE。
3.4 还有一种变体:需要判断某个数x是否N的倍数
不是所有“N的倍数”题都让你输出倍数列表,有些版本会改成“给你若干个整数,请输出其中能被N整除的数”或者“判断M是否是N的倍数”。如果是这种,核心代码就一句话:
if (x % n == 0) { cout << x << " is a multiple of " << n << '\n'; }这种题真正的考点不是倍数判断本身,而是你能不能正确读入多个数据,以及能不能在循环中维护好输出格式。比如判断一个数是不是N的倍数,只需要看余数是不是0;等于0就是,不等于0就不是,没有任何灰色地带。
4. 提交后最常见的四种报错现场
4.1 忘记处理输入可能有多组数据
“基础题”并不一定只有一个测试用例。很多题目看起来像是单次输入,实际测试文件里藏着多组样例。只要你不小心写成了单次输入,程序跑完第一组样例后就直接return,后续的测试点全部判为错误。
排查方法很简单:看题目是否写了“多组测试数据”“输入包含多行”“直到文件结束”之类的话。就算没写,把主逻辑放进while (cin >> ...)循环里也不会带来什么风险。我宁可用一个看似多余的while,也不愿在一个可能的隐藏多组数据上翻车。
4.2 N为0或负数时,程序直接卡死
如果题目没有明确说N≥0,你就要警惕N=0的情况。数学上,0没有“倍数”的概念,因为任何数乘0都等于0,没法得到一个正常递增的倍数序列。如果程序在输入N=0时执行i += n,因为n是0,循环变量永远不会变化,for循环会一直卡在原地,直到超时被oj强制终止。
规范做法是在主逻辑前先特判:
if (n <= 0) { return 0; }如果你确定OJ数据里不会出现N=0,这个判断可以省略。但保留这个判断成本极低,还能防止本机调试时因为输入错误而陷入死循环。遇到这种非正常输入时,是直接结束程序还是输出换行,要看题目有没有约束——大多数基础题都会约束N为正整数,你只需要在小范围特判一下即可。
4.3 末尾多空格和漏换行
这是OJ新手最容易遭遇的问题。题目说“数之间用一个空格隔开”,很多人会写一个循环,每输出一个数后面都跟一个空格。这时最后一个数后也有空格,而OJ判题是否允许末尾空格,取决于出题人用的比较方式。
最稳妥的办法就是我前面用bool first控制的方式:只在第二个及之后的数前面添加空格。这样可以保证整行末尾不会有空格。
漏换行也常见。很多题要求每组输出占一行;如果你漏了换行,下一组数据会直接接着上一组输出,导致整段输出粘在一起。对于只有一组数据的情况还好说,但如果有两组数据,漏换行几乎必然WA。
4.4 输出量极大时,用字符串缓冲还是直接输出
如果M很大,N=1,那么你要输出的内容本身就非常庞大。这时不管用什么方法,输出都可能成为瓶颈。有一个小技巧是先用string把结果拼起来再一次性输出,但这会占用大量内存,而且拼接字符串本身也有开销。
我更推荐的做法是保持直接cout输出,但把ios::sync_with_stdio(false)和cin.tie(nullptr)打开。这两行能取消C++标准流与C标准IO之间的同步,减少不必要的性能损耗,在输出量大的OJ题里经常能省下不少时间。别小看这个优化,很多同学在数据量一大就TLE,加了这两行后就直接AC。
#include <iostream> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; while (cin >> n >> m) { bool first = true; for (long long i = n; i <= m; i += n) { if (!first) cout << ' '; cout << i; first = false; } cout << '\n'; } return 0; }5. 由“N的倍数”延伸出去的几个常见考法
这道题表面上是基础题,但它背后藏着几个经典数论考法。我建议你刷完这道题后,顺手把下面几个变体思路也过一遍,它们会在后面不断出现。
5.1 区间内N的倍数的个数
不让你输出具体倍数,只问“从L到R之间N的倍数有几个”。这时不需要枚举,用公式:
long long countMultiples(long long l, long long r, long long n) { return r / n - (l - 1) / n; }原理是:从1到r之间N的倍数有r / n个,从1到l-1之间N的倍数有(l - 1) / n个,两者相减就得到区间[l, r]内的个数。这种题在竞赛里很常见,核心就是用整除运算代替枚举,把O(R-L)的复杂度降成O(1)。
5.2 同时是多个数的倍数
如果题目变成“输出同时是a和b倍数的数”,你不需要对每个数同时取模判断。你只需要先计算a和b的最小公倍数,然后按最小公倍数递增。
int lcm = a / gcd(a, b) * b; // 先除后乘,减少溢出机会这里用到了最大公约数gcd。C++17后可以直接用std::gcd,需要包含头文件<numeric>;如果OJ环境比较老,自己写一个欧几里得算法即可。
int gcd(int a, int b) { return b == 0 ? a : gcd(b, a % b); }注意计算最小公倍数时,建议先除后乘,也就是a / gcd(a, b) * b。如果先做a * b再除,两个int相乘可能直接溢出,得到错误结果。
5.3 字符串形式的大数能否被N整除
有些题不会老老实实给你int类型的M,它会给你一个很长的十进制数,用string读入。此时如果要判断这个数能否被N整除,不能用stoll转成整数再取模,因为大数早就超出long long范围。
正确做法是模拟手工除法,从左到右逐位取模:
bool isMultiple(const string& num, int n) { long long mod = 0; for (char c : num) { mod = (mod * 10 + (c - '0')) % n; } return mod == 0; }这个思路的基础就是本题的取模判断,只不过把“一个大数”拆成了“逐位构造大数”,每构造一位就立刻对N取模。只要中间结果一直在int或long long范围内,就不会溢出。以后你遇到“大数整除性判断”,第一时间就应该想到这个写法。
5.4 只由0和1组成且是N的倍数
网络上关于“N的倍数”经常还能搜到一类经典题:给定一个正整数N,找到一个最小的、只由数字0和1组成的正整数,并且这个数是N的倍数。这道题和上面聊的基础题完全不同,它需要BFS搜索,但核心仍然是取模判断。
搜索时从数字1开始,每一层在末尾追加0或1,生成新的数:cur * 10和cur * 10 + 1,不断判断能不能被N整除。由于数据可能很大,常见的优化是保存余数而不是完整的大数,这就用到了mod = (mod * 10 + digit) % n的逐位取模思想。你会发现,今天在基础题里练熟的取模逻辑,到更复杂的题里依旧跑不掉。
6. 复盘:这道题让我重新认识“基础”两个字
“N的倍数”是一道基础题,但“基础”不等于“简单到不需要思考”。我最初写这题时,也以为只要i % N == 0就完事了,结果在输出空格和循环范围上反复调整了好几轮。后来我才意识到,基础题的真正价值是让你把每一处细节都内化成习惯:循环变量要不要防溢出,输出格式怎么控制,输入是不是多组,大范围下能不能用步长替代取模。
如果你也在刷东华OJ的基础题,这道题建议不要只抄一个能AC的代码就结束。你可以按顺序做三件事:第一,把写法从逐个判断改成步长生成,感受循环次数变化;第二,把所有输出格式控制逻辑拆开看,理解bool first为什么能避免末尾空格;第三,把N=0、N>M、M特别大这些边界条件都手工跑一遍,确认程序不会死循环、不会溢出、不会输出错误。
我个人的体会是,OJ题刷到后面,真正让你掉分的往往不是高深的算法,而是这些最基础的控制流细节。把这道题彻底吃透,后面再遇到任何“倍数类”问题,你都会比那些只背模板的人多一分从容。