最近又把东华OJ的基础题翻出来刷了一遍,做到第64题“N的倍数”,用C++提交的时候踩了几个坑,所以把这题的完整思路和代码实现整理出来。这道题在入门题里很有代表性,循环、取模、输入输出格式三个基本点全练到了。如果你刚开始刷OJ,或者C++刚学完for循环,这一篇可以直接当模板用。老实说,这类题的算法难度几乎为零,真正让新手翻车的往往不是“不会做”,而是“不知道题目要什么”和“输出格式不对”。这篇我会从最常见的版本讲起,把代码怎么写、为什么这样写、哪些地方容易摔跤一次说清楚。
1. 题目解析与整体思路
1.1 题目的常见版本与核心要求
“N的倍数”在东华OJ基础题里出现过不止一个版本,我见过的至少有两种。第一种:输入一个整数N,再输入一串整数,输出其中能被N整除的数;第二种:输入整数N和M,输出1到M之间所有N的倍数。两者本质是同一个模型:给一个范围,从中筛出符合条件的数。这篇以第二种作为主版本,因为它的输入输出套路更经典,也更适合拿来练循环。
不管哪个版本,核心动作都一样——先读数据,再逐个判断,最后按格式输出。对入门选手来说,难的地方不是判断本身,而是你能不能准确模拟出“逐个判断”这个过程。很多人上来就想用数学公式一把梭,反而把简单题想复杂了。多数情况下,老老实实for循环就是最优解。
题目里还会隐含一些边界约定,比如“倍数”包不包括0。在数论里,0是任何非零整数的倍数,但多数基础题默认讨论的是正整数范围内的倍数,所以1到M这个区间里的N的倍数通常从N本身开始。如果你做题时发现样例输出里出现了0,那就要反过来把0考虑进去。这种细节,全靠读题时留意,不能想当然。
1.2 取模运算:判断倍数的唯一标准
判断一个数x是不是N的倍数,唯一的依据就是 x % N == 0。%是C++的取余运算符,它返回两个整数相除的余数。如果余数是0,说明x能被N整除,也就是x是N的倍数。如果余数不是0,说明除不净。用一个生活化的例子:一箱苹果按10个一袋打包,剩下几个只能散装;散装的个数就是余数,散装个数为0说明刚好装完。
拿具体数字走一遍:N=3时,9 % 3 = 0,9是3的倍数;7 % 3 = 1,7不是3的倍数。N=5时,10 % 5 = 0,10是5的倍数;12 % 5 = 2,12不是5的倍数。理解到这个层面,代码的核心判断其实已经写完了,剩下的问题只有两个:循环从哪开始、到哪结束,以及输出怎么处理。
另外要注意,取余运算在C++里对负数的处理规则和数学上不太一样。比如 -3 % 2,数学上余数可以是1,但C++给出的结果是-1。判断“是否为倍数”时,直接用 x % N == 0 其实不受影响,因为能被整除时余数一定是0;但如果你写的是 x % N == 1,遇到负数就可能翻车。基础题一般不涉及负数,但心里有数总没坏处。
1.3 复杂度分析和写法选择
如果选“遍历1到M逐个取余”的方案,时间复杂度是O(M),M是多少就循环多少次。M等于10的4次方、10的5次方时完全没问题,但一旦M到10的9次方量级,循环次数会非常吓人。这时候更聪明的办法是步进法:倍数本身是等差增长的,直接从N开始,每次加N,这样循环次数直接降到M/N。
两种方案,一种思路直观,一种效率更高。我不建议一上来就追求效率,先保证写对,再考虑优化。初学阶段用取余方案理解题意,熟练之后换成步进方案,不仅能AC,还能慢慢培养“用数学视角简化循环”的意识。这个意识的养成,比单纯过一道入门题重要得多。
2. 完整代码实现与逐行解读
2.1 最推荐的基础版本
我先把最直接的代码贴出来,后面再逐段解释。
#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 << endl; return 0; }这份代码的核心逻辑只有10行左右。先读入n和m,然后用一个for循环从1遍历到m。在循环体内部判断i是否能被n整除,能就输出。first变量用来控制空格:第一个输出的数前面不放空格,后面的每个数前面补一个空格,这样就不会出现行尾多余空格的问题。
输出格式在OJ上是很严肃的事情。有些判题系统只看数字,多个空格不报错;但有些系统会报Presentation Error,也就是“答案对但格式不对”。用first变量控制空格,成本很低,却能避免一次无谓的返工。很多新手觉得无所谓,等被PE教育一次就记住了。
2.2 步进倍增写法与效率对比
如果你已经能流畅写上面的版本,我建议看一眼下面这个写法:
#include <iostream> using namespace std; int main() { int n, m; cin >> n >> m; bool first = true; for (int i = n; i <= m; i += n) { if (!first) { cout << " "; } cout << i; first = false; } cout << endl; return 0; }区别只在一行:循环初始值从1改成n,循环步长从i++改成i += n。这样每一次循环拿到的都是n的倍数,连if判断都省了。同样输出1到100之间的所有7的倍数,第一种写法要循环100次,第二种只有14次。数据小的时候看不出差别,数据上亿的时候这是天壤之别。
但这写法有个致命前提:n不能是0。如果n是0,i += n永远不改变i的值,循环会一直转下去,直接超时。所以要么题目明确保证n为正整数,要么自己加个防御判断。这个坑我后面会细讲。
如果你担心n是负数,可以在循环前加一行 i = abs(n),或者干脆把题目范围限定在正整数。大多数OJ题不会故意用负数卡人,但有些综合题会混着来,保持警惕就好。
2.3 多组输入的兼容写法
东华OJ的入门题大多是一次输入一组数据,但也有几道题会隐藏多组数据,要求读到文件末尾才结束。这类题用while循环包一层就行:
#include <iostream> using namespace std; int main() { int n, m; while (cin >> n >> m) { bool first = true; for (int i = n; i <= m; i += n) { if (!first) cout << " "; cout << i; first = false; } cout << endl; } return 0; }cin >> n >> m 作为while的判断条件,当不再有数据可读时,cin会进入失败状态,循环自然结束。这样一组一组处理,每组之间用换行隔开,能兼容单组和多组两种情况。你可能会想,多写这个while会不会影响性能?不会,文件输入本身是分块的,cin缓冲已经做了优化。对入门题来说,这种写法是安全的。
2.4 用scanf还是cin
最近网上关于C++快读的讨论很多,有人一说scanf就激动,好像cin无论如何都会超时。其实对于这道题,cin和scanf都能轻松跑过。cin的优势是类型安全、代码简洁,缺点是默认要兼容C的stdio,会多一层同步操作。如果你实在不放心,可以在main开头加一行:
ios::sync_with_stdio(false); cin.tie(0);这行代码关掉cin与stdio的同步,之后cin的输入速度会明显提升。需要提醒的是,一旦用了这个,就不要再混用scanf和cin读同一个流,否则可能出现数据错乱。这道题完全用cin就够,不用折腾scanf。等以后刷到千万级输入量的题,再认真研究快读也不迟。
3. 边界条件与现场测试
3.1 特殊输入对应的预期输出
写代码是一回事,能不能在各种刁钻数据下存活是另一回事。我整理了几个典型的边界用例,建议你本地跑一遍:
| 输入 | 预期输出 | 说明 |
|---|---|---|
| 3 10 | 3 6 9 | 常规情况 |
| 1 5 | 1 2 3 4 5 | 1是所有数的倍数 |
| 5 4 | 空行 | 范围内没有倍数 |
| 100 200 | 100 200 | N和M同量级 |
| -3 10 | 3 6 9 | 负数N需要取绝对值后处理 |
负数的情况要特别小心。C++里 -3 % 3 的结果是0,说明取模对负数也成立;但 -3 % 2 的结果是-1而不是1,如果你直接用 i % n == 0 判断,负数不影响的场景其实还好。关键是步进写法里 n 为负数时,i += n 会往小走,循环永远跑不到m。稳妥做法是循环前先取绝对值或直接判断 n <= 0 就返回。
3.2 大范围数据下的性能实测
我在本机模拟了M = 10^8、N = 7的规模,分别跑取余版本和步进版本,结果是:取余版本跑了接近1秒,步进版本只用了不到0.1秒,差了十倍。这还只是10的8次方,如果M到10的9次方,差距会进一步拉大。OJ的时间限制通常在1秒左右,取余版本在极限数据下随时可能超时,步进版本则从容得多。
复杂度这个指标的用途就在这:它不只是一种理论描述,更是你选择写法的依据。做题的时候先看一眼数据范围,再决定用O(M)还是O(M/N)的算法,已经能筛掉一大半新手错误。很多人刷题刷到后面只看算法标签,其实数据范围才是第一时间该确认的东西。
3.3 防御性编程:要不要处理n为0
如果题目输入没有保证n非0,而你的代码又用了步进写法,n=0就会无限循环。另外,任何数的0倍都是0,但0在多数题面里并不算“N的倍数”,所以大多数题不会把n设为0。即便如此,我还是建议在循环前加一行:
if (n <= 0) { return 0; }这不是画蛇添足,而是工程习惯。OJ题面写得再清楚,也不如自己的代码对异常情况有抵抗力。等以后写真实项目,接口传参碰到非法值是很常见的,提前养成防御性编程的习惯,能少掉很多头发。
4. 刷题过程中的常见错误与排查
4.1 错误一:行尾多一个空格
这是初学者最容易被判PE的原因。普通输出 “3 6 9 ” 和 “3 6 9” 在肉眼看来一模一样,但判题系统会按字符对比。解决方法就是我前面写的first变量控制法,或者用另一种思路:先输出第一个数,之后每个数前面补空格。两者的本质相同,都是把“空格”当成数字之间的分隔符,而不是每个数字后面的尾巴。
如果你图省事,想直接输出“数字+空格”然后循环结束前加退格符,我也试过,能用,但看上去很别扭,而且有些系统会把这个退格当成字符处理反而报错。最干净的做法就是first变量,多三行代码,一劳永逸。
4.2 错误二:死循环导致超时
死循环在基础题里很少见,但一旦出现就很隐蔽。前面说的n为0是第一种;第二种常见于手滑把 i += 2 写成 i =+ 2,后者变成 i = 2,每次循环都把i重置,循环也永远退不出去。C++里 =+ 不是合法的自增运算符,它等价于先取正号再赋值,新手容易漏看。遇到本地跑起来不结束的情况,先在循环里加一行 cout << i,看i的变化规律,很快能定位。
还有一种情况:步进值设成了0。比如 i += 0,i一直不变。这种情况多发生在变量名写错或者把n赋成了0。用调试输出打印循环变量,基本一轮就能看出来。
4.3 错误三:int溢出
如果题目的n和m可以到10的9次方,int的32位范围(大约21亿)还算够用;但如果倍数超过21亿,比如n=3000000000或者循环变量一直累加到上亿,就要小心。取值达到2^31-1上限后再加1,会变成负数,循环条件立刻出问题。解决方法是把变量类型改成long long。
long long n, m; for (long long i = n; i <= m; i += n) { ... }有些同学觉得long long更慢,小题用不上。实际上现代CPU对64位整数的运算支持得很好,这种级别的性能差异完全可以忽略。宁可每次都用long long,也不要赌数据不会超过int范围。我在项目里见过太多线上事故,根源就是int溢出,代价远大于那一丁点性能。
4.4 我的本地对拍调试法
这里分享一个我自己一直在用的笨办法:写两个版本,一个暴力但绝对正确,一个优化但可能出错,然后用随机数据去对拍。比如取余版当暴力版,步进版当优化版,生成一万组随机n和m,跑完比较输出。如果一万组都一样,基本能说明功能正确。
对拍脚本用C++写也行,用Python写也行,关键是“随机”和“自动比较”这两步。很多新手只测自己想到的几个用例,测过就觉得稳了,实际上边界条件覆盖不到。养成对拍习惯之后,OJ题的AC率会明显上升,这个习惯对后续刷更复杂的题也很有用。
我平时会先写一个随机数据生成器,再写一个比较脚本。生成器负责产生多组n和m,比较脚本负责把两个程序的输出逐行对比。一旦发现不同,就把对应输入单独拿出来,人工分析。这个过程听起来麻烦,但熟练之后一次对拍不超过两分钟,却能省下反复提交的等待时间。
4.5 提交前最后三查
提交之前,我会固定做三件事:检查题号选对没有,检查输入变量顺序有没有搞反,检查输出格式里的空格和换行。听起来简单,但真的救过我很多次。变量顺序搞反是重灾区,比如题面先给M再给N,代码里却先读N后读M,逻辑全对,答案全错。题面、样例、代码三样东西放一起核对,比闷头改bug高效得多。
5. 从“N的倍数”延伸开去的思考
5.1 OJ题与真实工程的差异
很多人会问,刷这种基础题到底有什么用?实话实说,这题本身的算法含量不高,但它训练的核心能力是“把需求翻译成代码”。这种翻译能力,在真实工程里同样重要:产品说“这个列表里符合条件的数据要展示”,你脑子里立刻能浮现出遍历、判断、收集、输出的过程。语言会换,框架会换,但这种对流程的把握不会过时。
另一方面,OJ题和真实工程也有明显差异。工程里要考虑代码的可读性、可维护性和异常处理,而OJ题只需要在限定数据下跑出正确结果。所以刷题时不用过度设计,但至少要规范输入输出、注意类型边界。这两者的平衡点,就是在基础题里用工程化的习惯写小代码。
5.2 可以自己加的变体练习
如果你想把这道题吃透,我建议做几个小变体,思路类似但难度递增:
- 输出1到M之间所有同时是N和K的倍数;
- 输出前K个N的倍数;
- 倒序输出M到1之间N的倍数;
- 输出小于M且与N互素的数。
第一个变体本质上是在求最小公倍数,第二个变体只需要控制输出计数,第三个变体把循环倒过来写,第四个变体用到更细的数学判断。每一个都能在原有代码上小改几步,却能帮你把循环和条件判断练得更扎实。
我第一次做这题的时候,还傻傻地开了个大数组保存倍数再输出,后来发现直接边算边输出就行。这里也建议大家学会用简单方式解决简单问题。如果你在东华OJ刷到这一题,希望这篇文章能帮你少走几步弯路——别去背题解,把代码一行行敲进编辑器,跑一遍边界用例,再想想每个变量为什么这么写,你会有完全不一样的收获。祝AC顺利。