9月的GESP二级机考一结束,就有好几个学生来问我:第三题“优美的数字”到底怎么做。说实话,这道题名字听着挺玄乎,但把题面一拆开,你会发现它考的其实是非常基础也非常经典的一套组合拳:把数字的每一位拆出来,再逐个判断能不能整除原数。难的地方不在于循环不会写,而在于你有没有想清楚0这个特殊数字,以及循环里原始数据被悄悄改掉这类细节。这篇文章从题面理解、算法推导、完整代码再到考场避坑一次讲透。正在备考二级的同学可以照着敲一遍,带考级班的老师也能直接把整套思路拿去给学生讲。
1. 题目到底在考什么:先读懂“优美的数字”
1.1 题面还原与样例说明
考后大家在群里对题面有过不少讨论,结合多个回忆版本,比较通行的说法是这样的:一个正整数被称为“优美的数字”,需要同时满足两个条件——第一,它的每一位数字都不能是0;第二,它的每一位数字都能整除这个数本身。什么叫“整除”?就是做除法余数为0,用C++写出来就是x % digit == 0。举个例子,128就是优美的数字,因为百位的1、十位的2、个位的8都能整除128:128除以1得128,128除以2得64,128除以8得16,三次都除得尽。而29不是,因为个位的9不能整除29,29除以9会余2。更典型的反例是210,它压根连第一个条件都过不了,个位是0,0不能做除数,自然谈不上一一验证。
最后我把这个回忆版本整理成了一道大家都熟悉的输入输出风格题目,方便下面直接展开讲解。完整题目样貌大致如下(具体以官方试卷为准):
输入格式:第一行一个正整数n,表示接下来要判断的数字个数。随后n行,每行一个正整数x,范围在1到10^6之间。
输出格式:对每个x,如果它是“优美的数字”,输出一行YES,否则输出一行NO。
输入样例:
3 128 210 29输出样例:
YES NO NO
这里要提醒一句:有些同学备考时喜欢背原题,但我更建议大家把重心放在“判断函数怎么写”上。因为哪怕是同一个题名,不同年份、不同级别出的输入格式可能完全不同,今年让你判断n个数,明年可能让你统计区间[L, R]里的优美数字数量。核心逻辑不变,变来变去的只是外面那层输入输出壳。
1.2 考点拆解:拆位、整除与“AND”逻辑
这道题表面上是在考“优美”这个花哨概念,实际上拆开就是三件事。第一件事是拆位,也就是把一个多位数拆成一位一位的独立数字;第二件事是整除判断,判断每个数位digit是否满足x % digit == 0;第三件事是逻辑归纳——要求的是“每一位都满足”才输出YES,这本质是一个“AND”逻辑,而不是“OR”。很多同学前两步都会写,恰恰栽在第三步:只要有一位满足就把整个数字判成了YES,结果样例都对不上。
从GESP二级的出题习惯来看,这种“数字性质题”非常典型,后面我还会专门聊备考方向。这里先建立最基本的算法骨架:读入x之后,用循环从低位到高位把每一位取出来,逐个验证,全部通过才合法。判断过程中一旦遇到0或者遇到一个不整除的数,就立即结束判断,没有必要再往后看剩下的位了。
2. 核心算法设计:从思路到代码的推导过程
2.1 拆位是这道题的地基
拆位在整个小学到初中的编程竞赛里都是高频操作,它的原理只有一条:对10取模拿到个位,整除10丢掉个位。比如拿128来说,128 % 10等于8,我们就拿到了个位8;接着128 / 10等于12,数字就变成了12;再对12 % 10拿十位2,12 / 10变成1;最后1 % 10拿到百位1,1 / 10变成0,循环结束。整个过程就像你数一叠钱,一次抽出一张放在旁边,抽完为止,只不过这里的“钱”是数字的每一位。
用生活化的类比来记:数字的十进制表示就像一列火车车厢,%10是只打开最后一节车厢的门,把里面的乘客拿出来;/10则是把这节空车厢直接卸掉,让后面的车厢往前补位。循环做下去,所有车厢都会被检查一遍。理解了这个,后面学进制转换、数字反转、回文数判断都会顺手很多。
这里有一个非常关键的原则:千万不要直接在原数x上做除法。因为在后面的判断里,我们还需要用到完整的原数来求余数,如果边拆位边改变x,拆到一半x就已经变成0了,后面的x % digit全部失去意义。正确的做法是定义一个临时变量tmp,让tmp等于x,循环里只改变tmp,x始终保持原样。这一个细节能挡住至少三成以上的出错代码。
2.2 把“全部满足”翻译成代码逻辑
设计逻辑的时候,我习惯用一个叫作“旗标”的布尔变量。它的英文说法flag,中文叫法很多,有人叫标记,有人叫标志位。核心思想是:先假设这个数字是合格的,让flag等于true,然后在循环里一旦发现不合格的证据,立刻把flag改成false。循环结束后,flag的最终值就代表这个数字是否优美。
伪代码写出来非常清晰:
读入 x tmp = x flag = true while tmp > 0: digit = tmp % 10 tmp = tmp / 10 // 两条淘汰条件:digit是0,或者digit不能整除x 如果 (digit == 0 或者 x % digit != 0): flag = false 提前退出循环 如果 flag 为 true: 输出 YES 否则: 输出 NO这里最容易写错的一个顺序问题是:判断条件里的两条子条件必须先判断digit是否为0,再去执行x % digit。原因很简单,任何数字除以0都是没有意义的,C++里面直接对0取模会引发运行时错误,虽然有的编译器在简单整数除零时会让程序直接崩掉,但更隐蔽的情况是运算结果未知,导致整个判断逻辑错得莫名其妙。先写digit == 0,再用或运算把后面的取模短路掉,才是安全的写法。
2.3 输入范围与数据规模:要不要用快读和long long
题目给的数据范围是1到10^6,也就是最多一百万。这个规模对C++来说非常友好,用最普通的cin >> x配合while循环拆位,每个数最多循环7次,全部处理完连毫秒都用不到。所以二级的同学完全不需要学什么快读优化,把cin和cout用熟练就够了,时间不会成为瓶颈。
至于数据类型,10^6用int完全装得下,int最大能到约21亿。我的习惯是只要题目允许,就统一声明成int,因为二年级阶段int范围内几乎不会溢出。但我也见过有同学在函数参数里写成long long,这也没有问题,只是没必要。真正的重点是:在读入、传参、取模这些环节保持类型一致,别混用。
3. 完整实现与运行验证
3.1 C++参考实现:单文件可直接编译
下面这份代码是完整的可运行版本,包含头文件和主函数,直接拷贝到Dev-C++、CodeBlocks或者VS Code里都能跑。我特意把判断逻辑写在主函数内部,因为二级机考的编程环境通常不要求你写出标准的多函数结构,简洁直观、能通过测试才是第一位的。
#include <iostream> using namespace std; int main() { int n; cin >> n; for (int i = 0; i < n; i++) { int x; cin >> x; int tmp = x; // 用临时变量去拆位 bool ok = true; // 先假设是优美的 while (tmp > 0) { int digit = tmp % 10; // 取当前最低位 tmp = tmp / 10; // 去掉这位 // 数字含0直接淘汰;不能整除也淘汰 if (digit == 0 || x % digit != 0) { ok = false; break; } } if (ok) { cout << "YES" << endl; } else { cout << "NO" << endl; } } return 0; }注意看第5行的cin >> n和第8行的循环变量i,这两个地方都不能写错。有些同学容易把第一行的n直接当成后续要判断的数字,写成了for (int i = 0; i < n; i++) { int x = n; ... },结果所有输入都只用同一个数判断,前功尽弃。
3.2 函数封装与区间统计版本
如果题目不是“依次判断n个数”,而是改成“统计区间[L, R]内优美数字的数量”,直接在主函数里堆一整段循环也能做,但代码会显得很臃肿。更推荐的做法是把“判断一个数是否优美”抽成一个函数,既方便复用,又让主函数层次清楚。这在考级编程题里是一个非常重要的意识:把重复出现的判断逻辑封装起来。
#include <iostream> using namespace std; bool isBeautiful(int x) { if (x <= 0) return false; int tmp = x; while (tmp > 0) { int digit = tmp % 10; tmp /= 10; if (digit == 0 || x % digit != 0) { return false; } } return true; } int main() { int L, R; cin >> L >> R; int cnt = 0; for (int i = L; i <= R; i++) { if (isBeautiful(i)) { cout << i << " "; cnt++; } } cout << endl << cnt << endl; return 0; }函数版本的返回值是bool类型,函数名用isBeautiful这种“is开头”的命名,读起来就是“是不是优美数字”,语义很清楚。这个函数不止能用在区间统计里,将来遇到“找出第k个优美数字”“求优美数字的平方和”这类变体,只要判断逻辑不变,照样可以复用同一个函数。平时写代码把这个习惯养成了,考场上改题会快很多。
3.3 Python参考实现:备考Python方向的同学看这里
GESP分为C++和Python两条路线,如果你考的是Python,核心逻辑完全一样,只是语法表达不同。Python里同样的拆位思路写出来是这样:
def is_beautiful(x): tmp = x while tmp > 0: digit = tmp % 10 tmp //= 10 if digit == 0 or x % digit != 0: return False return True n = int(input()) for _ in range(n): x = int(input()) print("YES" if is_beautiful(x) else "NO")Python版本的几个细节值得说清楚。tmp //= 10是整除赋值,也就是tmp = tmp // 10的简写,因为Python里/是浮点除法,直接tmp = tmp / 10会让12变成12.0,从而陷入死循环,所以整除符号//绝对不能省。另外Python没有传统C++那样的for (int i = 0; i < n; i++)写法,用for _ in range(n)来循环n次更简洁,循环变量用下划线表示“我暂时用不到它”。这两处是C++选手转身写Python时最容易踩的语法坑。
3.4 多组测试数据的运行验证
代码写完必须先用手算验证,不能直接提交。我一般会准备一组覆盖各种边界情况的测试数据,包括最小数字1、含0的数字、个位不整除的数、所有位数都相同的数字、还有最大的7位数。把这组数据跑一遍,得到的输出和手推结果完全一致,才敢点提交。
| 输入数字 | 拆位过程 | 判断结果 | 正确输出 |
|---|---|---|---|
| 1 | 先取1 | 1能整除1,通过 | YES |
| 12 | 2、1 | 2和1都能整除12 | YES |
| 21 | 1、2 | 2不能整除21 | NO |
| 10 | 0、1 | 含0,直接淘汰 | NO |
| 100 | 0、0、1 | 含0,直接淘汰 | NO |
| 121 | 1、2、1 | 2不能整除121 | NO |
| 111 | 1、1、1 | 每一位1都能整除111 | YES |
| 128 | 8、2、1 | 8、2、1都能整除128 | YES |
这张表最想说明的一点是:手推的时候人是从高位往低位看的,但程序拆位是从低位往高位拆的,两者顺序相反,却完全不影响判断结果,因为这道题只关心“每一位都要满足”,不关心顺序。理解了这个,你就不会被程序里的拆数顺序绕晕。
4. 考场上最容易掉进的五个坑
4.1 第一个坑:循环里改坏了原始数据
这是最常见也最可惜的错误。有学生写成这样:
while (x > 0) { int digit = x % 10; x = x / 10; if (x % digit != 0) { ... } }表面上看while循环条件用x本身没什么问题,拆位也确实拆出来了,但循环结束后x已经变成0。更致命的是循环内部每一轮还用已经被修改过的x去做x % digit,这时候的x早就不是原来的那个数了,判断结果完全乱套。解决办法就是永远用临时变量tmp = x去拆位,原数x从头到尾保持不动。
4.2 第二个坑:0的处理顺序不对
还有同学知道要判断0,但把顺序写反了:
if (x % digit != 0 || digit == 0) { ok = false; }C++判断或条件时是从左往右执行的,如果digit是0,先执行x % 0,这时候程序就已经出问题了。虽然有些环境上结果看起来还能跑,但属于未定义行为,换一台机器就可能是另一个结果。正确写法必须把digit == 0放前面,利用或运算的短路特性:左边已经为true,右边根本不会执行,自然就避开了除零风险。
4.3 第三个坑:把“都满足”写成了“有一个就行”
逻辑关系写反是另一类重灾区。我见过这样的代码:
if (digit != 0 && x % digit == 0) { ok = true; }问题很明确:只要有一个数位满足条件,ok就被设置成true;下一个数位不满足时,又会被改成false;再下一个数位满足,又变回true。最后一个数位决定最终结果,整个判断毫无意义。正确写法是flag初始为true,在循环内一旦发现不满足就置为false并break,而不是反着来。一句话记忆:找反例用“一旦发现就否定”,找正例则是“全部通过才算数”。
4.4 第四个坑:循环边界和枚举区间细节
如果你写的是区间枚举版本,for (int i = L; i <= R; i++)里的等号很容易丢。丢了等号,右端点R本身就不被检查,如果R正好是优美数字,答案就错一个数。同理while拆位循环的条件是tmp > 0,而不是tmp >= 0,后者会导致tmp变成0后还在循环里取模,死循环加除零双重事故。这种边界问题平时看着不起眼,考场上紧张的时候特别容易手滑,所以提交之前的检查清单里一定要有“所有边界等号是否齐全”这一项。
4.5 第五个坑:输出格式与题面要求
题面要求输出大写YES和NO,就千万别输出小写yes或no;要求统计个数,就别把每个优美数字也print出来;要求每个数字一行,就不要全都挤在一行。听起来像废话,但每次考完都有同学在这些地方丢冤枉分。还有一个建议是:程序写完以后,把样例原封不动地输入一遍,肉眼对比输出的每一个字符,包括换行位置,这是一个成本极低但收益极高的检查动作。
5. 从这一题看GESP二级的备考方向
5.1 二级编程题常见的四类题眼
GESP二级的编程题不会超出一个入门者用循环和分支能解决的范围,归纳起来大概四类。第一类是数字性质题,比如判断素数、判断回文数、判断水仙花数、统计约数个数,核心都是拆位和取模。第二类是累加累乘问题,典型场景是求1到n的平方和、阶乘、多项式求和。第三类是图形输出题,用嵌套循环打印三角形、菱形、矩形边框,考察对循环层数的把控。第四类是简单数据处理题,比如求一组数的最大值、最小值、平均值,或者做简单的筛选统计。今天这道“优美的数字”就属于第一类,而且是把拆位、取模、逻辑判断融合到一道题里的综合考察。
把这道题吃透,对备考二级的意义不只是会做这一道题。你会发现判断素数也用到x % i,回文数也用到拆位,水仙花数更是一模一样的拆位再加立方和判断。数字性质题的技术栈高度重合,练会一道,等于打通了一整片。
5.2 用变体题训练“举一反三”
备考阶段我建议把同类型题自己做一遍变体,而不是只记一套模板。比如把本题定义改成“各位数字之和为素数”,那核心代码就从“拆位后逐个判断整除”变成“拆位后累加求和,再判断素数”。拆位部分完全保留,只需要把验证逻辑换成写一个isPrime函数。再比如把定义改成“数字中不出现重复数字”,那就需要用一个长度为10的布尔数组记录每个数字是否出现过,出现重复就淘汰。这两种变体在二级模拟卷里都出现过,底层技能和这道题完全互通。
动手练变体的时候有一个很实用的方法:把已经AC的原题代码复制出来,只修改判断函数那一小段,然后自己构造测试数据验证。这样能够在最短时间内体验到“逻辑改动”和“逻辑复用”的区别,比反复做全新题目更能提升代码修改能力。考场上的原题往往就是平时练习题的组合或变体,提前适应这种变化很占优势。
5.3 考试时间的合理分配
二级机考的时间通常是比较充裕的,但每年都有学生因为前面选择判断磨蹭太久,最后编程题草草提交。我的建议是:拿到试卷先花两三分钟通读一遍所有题目,把编程题在脑子里过一遍,确定哪道题最容易、哪道题最耗时;先做有把握的题,把稳拿的分收进兜里,再回头啃难题。像“优美的数字”这种中等偏易的题目,读题加写代码加测试,15分钟以内就应该全部搞定。
写完代码后的检查环节,别只跑题目给的样例。样例只能证明你的代码没有语法错误,不能证明逻辑正确。我会额外测试几个刁钻输入:1(最小正整数)、10(含0)、11(所有位都相同)、128(教科书级优美数)、999999(接近上限的值)。尤其要测与题目给出样例不同的值,因为很多错误代码恰好能蒙对样例,却在隐藏数据上翻车。
我个人在实际带学生备考时最常说的一句话是:这类数字性质题,真正卡人的不是算法,而是细心。写完代码后拿样例手推一遍,再额外测几个刁钻数字,往往比你多背十道题模板都管用。这道“优美的数字”如果按照拆位、判0、判整除的顺序一步步来,稳住心态,满分拿下并不难。