2023 年 9 月的 GESP 一级试卷里,有一道让不少第一次参加机试的选手印象很深的小题——"小明的幸运数"。题面不长,条件也就一两行,但它把一级考试最想试探的三件事全塞进去了:能不能把日常语言翻译成数学条件,会不会用取模和整除把整数的每一位拆出来,能不能把循环、分支和布尔逻辑干净利落地组合在一起。这道题难吗?真不难。但它就像一面镜子,能照出你平时写代码时那些最容易偷懒的地方。
由于不同练习版本对"幸运"的定义会有点出入,我先按流传最广的版本展开:小明认为,能被 7 整除的数,或者十进制表示中至少包含一位数字 7 的数,都是他的幸运数。输入一个正整数 n,判断它是否幸运,幸运输出 YES,否则输出 NO。如果你拿到的题面上把 7 换成了别的数字,或者把"或"换成了"且",这篇文章里的思路和骨架完全一样,改一下参数就能用。
1. 先别急着写循环:把"小明的幸运规则"翻译成程序语言
1.1 题面到底在说什么
很多刚接触编程的同学,看到一个题第一反应就是"快写代码"。但像"小明的幸运数"这种题,真正的考点在第一分钟:你到底有没有看懂规则。
题面里有两个条件,我建议拿笔划出来:
- 能被 7 整除;
- 十进制表示中至少有一位是 7。
这两个条件用"或者"连接,意味着只要满足其中一个就算幸运。
"能被 7 整除"在程序里怎么写?用取模运算:n % 7 == 0。取余数,余数为 0 就是整除。
"至少有一位是 7"怎么写?这是另一个独立的问题,暂时不用管细节,先把它当成一个黑盒子:contains7(n),一会儿再把它拆开。
于是整体判断逻辑就变成:
if (n % 7 == 0 || 含有数字7) { // 幸运 } else { // 不幸运 }这是这道题最重要的建模过程。你不需要一开始就盯住 while 循环不放,先把规则变成表达式,再考虑怎么实现那个表达式。
1.2 一口气把典型输入想明白
我在给初学者讲这题的时候,习惯先不看代码,拿几个数字做一张判断表:
| 输入数 | 能被7整除? | 含有数字7? | 结果 |
|---|---|---|---|
| 1 | 否 | 否 | NO |
| 7 | 是 | 是 | YES |
| 14 | 是 | 否 | YES |
| 17 | 否 | 是 | YES |
| 18 | 否 | 否 | NO |
| 70 | 是 | 是 | YES |
| 77 | 是 | 是 | YES |
这张表看似简单,其实非常有用。你把 7、14、17、18 这四个数字一摆出来,就会发现自己对规则的每一个边界都清楚了:14 只满足整除条件,17 只满足含 7 条件,18 两边都不沾。能把这张表在脑子里过一遍,写代码的时候就不容易把"或"误写成"且"。
1.3 最常见的一类误读:把"或者"当成"并且"
考试里很多同学不是不会%,而是读题的时候把"或者"看成了"并且"。如果题目要求两个条件必须同时满足,那么 14 就要输出 NO,17 也要输出 NO,只有 7、70、77 这类数输出 YES。你看,只差一个连接词,结果差得十万八千里。
我建议所有人在读题时做一个小动作:把条件中的"或""且""同时""之一"这些词圈出来,然后在草稿纸上写||或&&。别看这个动作简单,它能帮你避免考场中最冤枉的丢分。
2. 一个关键机制:为什么 % 和 / 能逐个揪出数字
2.1 一次除法,同时给你两个信息
要判断一个数里有没有数字 7,最笨也最可靠的办法是把每一位都看一遍。怎么"看"某一位?靠的就是取模和整除两个操作。
打个比方,你手里有一盒积木,比如数字 1234,它其实是 1 个千、2 个百、3 个十、4 个一叠在一起。现在你想把"最下面"那一块取出来,该怎么办?
1234 % 10就是做这件事。整数除以 10 的时候,商是 123,余数是 4。取模运算拿到的正是这个余数,也就是个位数字 4。
那怎么把这一位"扔掉"、好去看下一位?1234 / 10,在整数运算里,结果就是 123。这一步相当于把最右边的那块积木丢掉了,剩下的 123 就是前三位。
如此反复:
- 123 % 10 = 3,得到十位;
- 123 / 10 = 12;
- 12 % 10 = 2,得到百位;
- 12 / 10 = 1;
- 1 % 10 = 1,得到千位;
- 1 / 10 = 0,循环结束。
整个过程就像从右往左一层一层剥洋葱,剥到 0 的时候说明每一位都看完了。
2.2 为什么必须用一份"副本"
这是新手写这题最容易踩的坑。请看这段错误示范:
int n; cin >> n; bool has7 = false; while (n > 0) { if (n % 10 == 7) has7 = true; n = n / 10; } if (n % 7 == 0) { ... } // 这里 n 已经是 0 了!问题很明显:在 while 循环里,n被不断除以 10,最后变成了 0。你确实把每一位都检查完了,但原来的数字也没了。等你后面再想判断"能被 7 整除"的时候,手里只剩下一个 0。
正确做法是在拆位之前先复制一份:
int x = n; // x 用来拆位,n 继续保留 while (x > 0) { if (x % 10 == 7) has7 = true; x = x / 10; }这个"副本"思路不只在本题有用。以后你写任何需要一边遍历一边保留原始值的代码,比如数字反转、回文判断、进制转换,都会靠它救命。
2.3 0 和负数怎么处理
竞赛题通常给的是正整数,所以很多人写的while (x > 0)就够了。但如果你想写一个通用的"是否含有某数字"函数,就得考虑特殊情况。
对于 0 这个数:while (x > 0)根本不会执行,也就是说在默认写法下,0 会被判定为"不含有数字 7"。但如果题面明确问你 0 算不算幸运,那要看它是否满足整除条件——0 对任何非零数求余数都是 0,所以 0 实际上是能被 7 整除的。不过一般题面会限定正整数,这个边界你心里有数就行。
负数的情况更少见,但如果输入范围没有排除负数,稳妥的做法是先做一次绝对值转换,再把 x 变成正数去拆位。不同语言对负数取模的结果不一样,这一手能帮你避开很多莫名其妙的运行结果。
3. 参考代码:C++ 版和 Python 版,从零写到能提交
3.1 C++ 版本
GESP 一级最常用的语言是 C++ 和 Python,我先给出 C++ 的完整写法:
#include <iostream> using namespace std; int main() { int n; cin >> n; bool lucky = false; // 条件一:能被 7 整除 if (n % 7 == 0) { lucky = true; } // 条件二:十进制表示中含有数字 7 int x = n; while (x > 0) { if (x % 10 == 7) { lucky = true; break; } x = x / 10; } if (lucky) { cout << "YES" << endl; } else { cout << "NO" << endl; } return 0; }这段代码的要点有几个。
第一,bool lucky像一个小旗子,初始是 false,只要两个条件里任何一个满足了,就把它翻成 true。
第二,先判断整除条件,再拆位,顺序没有硬性要求。但请一定记住在拆位循环里用的是x而不是n。
第三,拆位循环里一旦找到数字 7 就可以break。这不是什么花哨的优化,纯粹是"反正已经确定幸运了,剩下的位数没必要再看"。不加 break 也完全正确,但如果加了,代码会快一点,逻辑也更清晰。
3.2 把判断"含有 7"单独拎出来
前面的代码把所有逻辑都堆在主函数里,能跑,但不够清爽。如果你想为后续更复杂的题目打基础,我建议顺手写成函数:
#include <iostream> using namespace std; bool contains7(int x) { if (x == 0) { return false; } while (x > 0) { if (x % 10 == 7) { return true; } x = x / 10; } return false; } int main() { int n; cin >> n; if (n % 7 == 0 || contains7(n)) { cout << "YES" << endl; } else { cout << "NO" << endl; } return 0; }你看,主函数只剩两行判断,一眼就能读懂。函数把"拆位找 7"这件事封装起来了,以后碰到"含不含 9""含不含 3"的问题,直接改个函数名和数字就行。
3.3 Python 版本
Python 的写法思路一样,只是注意整除要用//,不是/。在 Python 3 里,/会得到浮点数,比如17 / 10 = 1.7,这可不是我们想要的。
n = int(input()) def contains7(x): while x > 0: if x % 10 == 7: return True x //= 10 return False if n % 7 == 0 or contains7(n): print("YES") else: print("NO")有同学可能会问:我能不能直接用'7' in str(n)来判含不含 7?能,而且结果也对。但我不太建议在入门阶段这样写,原因是 GESP 这类考试在入门级更希望你展示对基本运算符和循环结构的掌握,拆位练习本身也是后面很多题的基础。字符串方案当成旁门左道了解就行,主线还是用循环。
3.4 扩展:如果题目改成统计区间内的幸运数数量
有些版本会把这道题改成:输入两个正整数 l 和 r,请你数一数从 l 到 r 之间有多少个"小明的幸运数"。
这种题本质没有任何变化,只是从"判断一个数"变成了"对一堆数做判断"。
C++ 参考写法:
#include <iostream> using namespace std; bool isLucky(int x) { if (x % 7 == 0) { return true; } while (x > 0) { if (x % 10 == 7) { return true; } x = x / 10; } return false; } int main() { int l, r; cin >> l >> r; int cnt = 0; for (int i = l; i <= r; i++) { if (isLucky(i)) { cnt++; } } cout << cnt << endl; return 0; }Python 参考写法:
l, r = map(int, input().split()) def is_lucky(x): if x % 7 == 0: return True while x > 0: if x % 10 == 7: return True x //= 10 return False cnt = sum(1 for i in range(l, r + 1) if is_lucky(i)) print(cnt)这里唯一要注意的是:for循环的i是循环变量,千万别在 while 里把i拿来反复除以 10,否则外层循环会直接乱掉。正确做法永远是复制一份x = i,然后在 x 上做拆位。
时间复杂度的简单评估:如果区间总长度是十万或百万级别,这种直接遍历再判断的写法完全没问题,一级考试的常见数据范围不会难为你。如果给到十亿级别,就需要另想办法了,但那不是一级该考虑的事。
4. 考场上最容易翻车的四个细节
4.1 细节一:原数被 while 循环消耗掉了
前面已经强调过,这是最高频的错法。考试时人一紧张,很容易顺手就写:
while (n > 0) { if (n % 10 == 7) flag = true; n /= 10; }然后后续判断整除时发现 n 已经变成 0。这种问题在 IDE 里一跑就能发现,但如果你只在脑子里模拟运行,很容易漏掉。我的建议是:任何时候只要需要保留原数去判断别的东西,就先int x = n;,不然后面一定后悔。
4.2 细节二:"能被 7 整除"被写成"个位是 7"
有一个特别有迷惑性的写法:
if (n % 10 == 7) { cout << "YES" << endl; }很多新手会把"能被 7 整除"和"个位数字是 7"混在一起。n % 10 == 7只看个位,14 的个位是 4,但它明明能被 7 整除,应该输出 YES。这两个条件完全是两码事。
怎么区分?记住:
- 整除:对整个数求余数,
n % 7 == 0; - 个位:对 10 求余数,
n % 10 == 7。
一个是模 7,一个是模 10,数值上就差着十万八千里。
4.3 细节三:区间统计时把循环变量改掉了
区间版本里,有人图省事,不写函数,直接在外层循环里拆位:
for (int i = l; i <= r; i++) { bool flag = false; while (i > 0) { if (i % 10 == 7) flag = true; i /= 10; } // ... }这个代码里i被 while 循环不断修改。对于外层 for 循环来说,i++本来打算每次加 1,现在 i 很可能一下子变成了 0,循环就彻底失控了。这是实际判题里非常经典的 0 分代码。
解决办法有两种:要么封装成isLucky函数,用局部变量x去拆位;要么在 for 循环内部复制一份int x = i;。总之,循环变量的安全性永远是第一位的。
4.4 细节四:输出格式和标记变量状态
最后这个细节技术含量不高,但特别影响得分。
输出必须严格按题面来。题面写YES,你不能写Yes或yes,很多 OJ 对大小写是敏感的。题面要求每个结果占一行,你就老老实实输出换行。
另外一个容易踩的坑是"多组测试数据"。如果题目说会连续输入若干个数,每组输出一个 YES 或 NO,那你每组数据的标记变量都要重新初始化。很多人习惯把bool flag定义在宏外面,上一组数据已经把 flag 改成 true 了,下一组数据开始之前没重置,结果直接输出 YES,白白丢分。稳妥做法是把bool lucky = false;放在每组数据处理的开头。
4.5 一组建议自测用例
不管你的代码写成什么样,提交之前先用下面这些数据过一遍:
| 输入 | 期望输出 |
|---|---|
| 1 | NO |
| 7 | YES |
| 14 | YES |
| 17 | YES |
| 18 | NO |
| 70 | YES |
| 77 | YES |
| 107 | YES |
| 100 | NO |
| 1000000007 | YES |
这些数字覆盖了"只满足整除""只满足含 7""两个都满足""两个都不满足"四种情况,还覆盖了比较大的数。能把这些用例全部跑对,这题的正确率基本就稳了。
5. 换一个数字、换一个条件,你还认识它吗
5.1 把"7"抽成参数
这题练完之后,我最常做的一件事是让初学者把代码改成通用版本,而不是只盯着一个数字 7。比如:
bool containsDigit(int x, int d) { if (x == 0) { return d == 0; } while (x > 0) { if (x % 10 == d) { return true; } x /= 10; } return false; }这个函数可以判断任意一个数里有没有指定数字 d。加上整除条件之后:
bool isLucky(int n, int divisor, int luckyDigit) { return n % divisor == 0 || containsDigit(n, luckyDigit); }这样一轮改动之后,你就从一个具体题目跳出来,看到了所有"幸运数"问题的公共骨架。
5.2 变式一:数位和版本
另一种很常见的考法不是"含有某个数字",而是"各位数字之和满足某个条件",比如"各位数字之和等于 7"。
思路依然是拆位,只不过不找 7 了,而是把每一位累加起来:
int sum = 0; int x = n; while (x > 0) { sum += x % 10; x /= 10; } if (sum == 7) { cout << "YES" << endl; }注意,数位和为 7 和含有数字 7 是完全不同的概念。比如 25 的各位和是 7,但它一个 7 都没有;17 含有数字 7,但各位和是 8。千万不要被"幸运数"这个名头带偏,一切以题面条件为准。
5.3 变式二:输出所有幸运数
有的题目不让你判断一个数,也不让你计数,而是让你把区间里所有幸运数按顺序输出。
这种情况最简单的处理方式是:遍历区间,对每个数调用isLucky判断,是幸运数就打印出来,中间用空格隔开。
输出格式要注意:很多高中生写的代码会在最后一个数后面多打一个空格,有些 OJ 允许,有些不允许。稳妥的做法是加一个"有没有输出过数"的标记,第一个数直接打印,后面的数先打印一个空格再打印数字。这个技巧在后面的各种题目里都会用到。
5.4 变式三:数字反转、回文数
如果你已经把拆位练熟了,你会发现"小明的幸运数"这套骨架可以丝滑地迁移到数字反转:
int rev = 0; int x = n; while (x > 0) { rev = rev * 10 + x % 10; x /= 10; }也能迁移到回文数判断:反转后和原数比较,相等就是回文。
这些题看起来一个比一个复杂,底层却都是同一个东西:循环里用% 10拿低位,用/ 10丢掉低位,再配合条件或累加。这就是为什么我反复说,基础拆位值得你花时间做扎实。
6. 一点个人复盘:这道题留给备考的价值
我带入门选手复盘这套题的时候,最常说的一句话是:不要把它当成一道 15 分的题去背,要把它当成一块试金石。
如果你能在五分钟内读完题面,正确地把"或"翻译成||,用副本变量完成拆位,并且一口气跑过全部边界用例,说明一级的核心基本功已经很稳了。如果你在某一步卡住了,卡住的地方就是接下来一周需要重点补的内容——可能是取模和整除概念不清,可能是循环变量边界不熟,也可能是读题时不够细心。
真正的考试里,题目不会原封不动重现,但数字拆解这一套东西肯定会以各种面貌出现。刷题贵精不贵多,把这道题亲手写三遍:第一遍直接判断,第二遍改成区间计数,第三遍改成数位和判断。写完之后你会发现,后面再遇到跟"逐位处理整数"相关的题,心里会踏实很多。
这道题教给你的,本质上不是怎么数 7,而是怎么把一个看似具体的规则,拆成计算机能够执行的一连串小步骤。想明白这一步,比多刷十道题都值。