day5,我给自己安排了三个数字相关的小练习:求阶乘结果末尾0的个数、找“怪数”、找满足条件的abc三位数。这三个题放在一起,不是因为它们难,而是因为它们都在逼我搞清楚一件事——别让计算机硬算,先找规律。这也是我在这个学习阶段最想练的东西。
说句实话,这三个题如果是第一次见,很容易写出“能跑但很笨”的代码:先把阶乘算出来、把数字拆开、挨个试。跑通不难,难的是跑得稳、快、有底气。这篇文章就把我自己的思路、推导过程、代码实现和踩过的坑完整记一遍,后面的人可以直接照着抄,也可以拿来做参考。
1. 三个题目的庐山真面目
1.1 阶乘尾部0的个数到底在问什么
题目描述通常是这样:输入一个正整数 n,求 n!(1×2×3×...×n)的十进制表示中,末尾连续有多少个 0。注意,这里说的是“末尾连续”的0,不是整个数字里所有0的个数。比如 10! = 3628800,末尾有两个连续的0,但数字中间也有一个0,那个不计入答案。
这个题第一反应是先把阶乘算出来,然后转成字符串从右往左数0。但是只要把 n 稍微调大一点,比如 n=100,100! 已经是一个超过150位的数;n=1000 时更是天文数字。要是题目再狠一点,n 给到 10^7,那这个思路基本等于自杀。所以必须从数学上找规律。
规律的核心是:十进制下一个数末尾的0,来自因子10,而10 = 2 × 5。在 n! 的连乘过程中,因子2的个数远远多于因子5的个数。比如从1到10,偶数有5个,能贡献很多2;但5的倍数只有2个。所以决定末尾0数量的是因子5的总个数,不是因子2。
1.2 “怪数”究竟哪里怪
“怪数”这个说法在不同题库里定义可能不一样。我这里按最常见的一种练习定义来说:一个正整数,如果它等于它各位数字的阶乘之和,就称为“怪数”。什么意思?看例子:
145 = 1! + 4! + 5! = 1 + 24 + 120 = 145
这种数自己等于自己,但又好像是被某种规则“算”出来的,所以叫“怪数”挺贴切。搜索的时候会发现它还有标准名字,英文叫 factorion,中文一般叫“阶乘数”或者“数字阶乘和数”。
已知的正整数范围内,这类数非常少。如果按 0! = 1 的约定来计算,并且从正整数开始算,一共只有四个:1、2、145、40585。你可以先自己验证 40585:4! + 0! + 5! + 8! + 5! = 24 + 1 + 120 + 40320 + 120 = 40585,确实成立。注意这里 0! = 1,很多人会在这个细节上翻车。
1.3 abc数字,其实就是水仙花数
“abc数字”是我自己起的小名,题目原意一般叫“水仙花数”:一个三位数 abc,它的百位数字是 a,十位是 b,个位是 c,满足 a³ + b³ + c³ = 100a + 10b + c。也就是说,这个三位数等于它每一位数字的三次方之和。
举最经典的例子:153 = 1³ + 5³ + 3³ = 1 + 125 + 27 = 153。
三位水仙花数一共有四个:153、370、371、407。很多人都会背,但这里我建议不要只是背答案,而是把枚举逻辑练熟,因为后面做四位、五位甚至更多位的自恋数都可以复用同一套思路。
2. 数学原理:为什么这样算才靠谱
2.1 尾零:统计因子5的来龙去脉
前面说了,末尾0的个数等于 n! 中因子5的总个数。那么怎么统计呢?一种直观做法是遍历 1 到 n,对每个数不断除以5,累加能除几次。这个做法正确,但不够优雅,复杂度是 O(n log n)。更好的做法是直接用整数除法:
count = n // 5 + n // 25 + n // 125 + ...
为什么这么加?因为 n//5 统计的是1到n里有多少个5的倍数,每个至少贡献一个因子5;n//25 统计有多少个25的倍数,每个额外再贡献一个因子5;n//125 继续统计有多少个125的倍数,每个再额外贡献一个因子5。以此类推,直到除数大于 n 为止。
我拿 n=25 手工算一遍:
- n//5 = 5,对应 5、10、15、20、25 这5个数;
- n//25 = 1,对应25,它包含两个因子5,已经在 n//5 里算了一个,这里补上第二个。
所以总数是 5 + 1 = 6。你可以验证 25! 末尾确实有6个0。
再看一个容易错的地方:像50这个数,它等于 2×5×5,贡献两个因子5。在 n//5 那层它被算了一次,在 n//25 那层因为它大于等于25,又被补了一次,所以最终统计为2。这就是每层都加的原因,不需要担心重复,因为每一层加的本来就是对更高次幂的“补差”。
这种做法的复杂度只有 O(log₅ n),n 再大也就是几十次循环,非常稳。
2.2 怪数:为什么搜索上界可以定在约254万
如果不知道“怪数”的总数,直接无脑从1循环到无穷大,程序永远跑不完。所以要先确定一个合理的搜索上界。
对于一个 k 位数,它本身的最小值是 10^(k-1)(比如三位数最小是100)。它的各位数字阶乘之和最大是什么情况?每一位最多是9,那一整项最大是 k 个 9!,即 k×9!。当 k 增大到一定程度后,k×9! 会小于 10^(k-1),也就是说,任何 k 位数自身都比它能算出来的阶乘和还要大,那就不可能存在解。
我算几个数看看:
- k=1,最大阶乘和是 9! = 362880,显然比一位数大;
- k=7,最大值 7×9! = 2540160,而七位数最小是1000000,所以七位数界面还有可能;
- k=8,最大值 8×9! = 2903040,而八位数最小已经是10000000,2903040 < 10000000,从八位数开始就不可能再有解了。
所以只需要搜索到七位数里的最大值 7×9! = 2540160 即可。这也解释了为什么最终的结果只有四个,因为在这个范围内符合条件的数就是1、2、145、40585。
这个推导过程的价值不在于背一个上限,而是给你一个思路:很多“找特殊数字”的题目,都可以通过“自身大小”vs“构造表达式最大值”来圈定范围。
2.3 水仙花数的枚举与位数规则
三位水仙花数最简单的方法就是枚举。因为三位数一共就900个,从100到999,逐个判断就行,复杂度根本不是问题。
判断一个三位数 n 是否为水仙花数,需要拆出百位、十位、个位:
- 百位 a = n // 100
- 十位 b = (n // 10) % 10
- 个位 c = n % 10
然后比较 a³ + b³ + c³ 和 n 是否相等。
这里有一个细节:百位 a 不可能为0,因为三位数最小是100。但十位和个位可以是0,比如407,b=0,这时 b³=0,不影响判断。
如果你不想用单层循环拆位,也可以用三重循环直接枚举百位、十位、个位:
for a in range(1, 10): for b in range(0, 10): for c in range(0, 10): n = a100 + b10 + c if a3 + b3 + c**3 == n: print(n)
两种方式本质一样。三重循环可能更直观一点,但单层循环更能练习“如何从数字里提取每一位”,建议都写一遍。
3. 完整代码实现与运行实录
3.1 求阶乘尾部0个数的实现
我用的 Python 代码如下:
def trailing_zero_count(n): count = 0 while n > 0: n //= 5 count += n return count for n in [5, 10, 25, 100, 1000]: print(f"n={n}, trailing zeros={trailing_zero_count(n)}")运行结果:
| n | 结果 | 对应说明 |
|---|---|---|
| 5 | 1 | 5! = 120,末尾1个0 |
| 10 | 2 | 10! = 3628800,末尾2个0 |
| 25 | 6 | 25! 末尾6个0 |
| 100 | 24 | 100! 末尾24个0 |
| 1000 | 249 | 1000! 末尾249个0 |
这里有个小坑:函数内部把传入的 n 修改了,如果后面还要用 n 的原值,需要先复制一份,比如temp = n。我在实际写代码时习惯写成这样:
def trailing_zero_count(n): count = 0 while n > 0: n //= 5 count += n return count虽然能跑,但同事看了说“你这不是在改入参吗”,后来我改成:
def trailing_zero_count(original_n): count = 0 n = original_n while n > 0: n //= 5 count += n return count其实只是习惯问题,但能让代码更清晰。
3.2 怪数搜索的完整代码
我写了一个预计算阶乘表的版本。为什么预计算?因为判断每个数都要多次用到某个数字的阶乘,比如判断 40585 时要算 4!、0!、5!、8!、5!,如果每次都重新用循环乘一遍,效率不高。预计算一个长度为10的数组,下标就是数字0到9,值就是对应阶乘,查表最快。
# 预计算 0! 到 9! fact = [1] * 10 for i in range(2, 10): fact[i] = fact[i - 1] * i def is_weird(n): total = 0 t = n while t > 0: digit = t % 10 total += fact[digit] t //= 10 return total == n LIMIT = 7 * fact[9] # 2540160 weird_numbers = [n for n in range(1, LIMIT + 1) if is_weird(n)] print(weird_numbers)输出:
[1, 2, 145, 40585]验证一下:
- 1! = 1,所以1是;
- 2! = 2,所以2也是;
- 145 上面验证过;
- 40585 也验证过。
注意这里从1开始循环,0没有算进去。如果题目允许0,0! 是1,0!=0不成立,所以0也不是。放心排除。
这个程序在我的笔记本上(Python 3.10,普通配置)跑完大概0.9秒。如果不用预计算阶乘表,可能要接近2秒,提升还是很明显的。
3.3 abc数字两种解法与输出
先写三重循环版:
print("Three-digit narcissistic numbers:") for a in range(1, 10): for b in range(0, 10): for c in range(0, 10): n = a * 100 + b * 10 + c if a ** 3 + b ** 3 + c ** 3 == n: print(n)再写单循环拆位版:
for n in range(100, 1000): a = n // 100 b = (n // 10) % 10 c = n % 10 if a ** 3 + b ** 3 + c ** 3 == n: print(n)两个版本输出一致:
153 370 371 407我建议你两种都写一遍。三重循环帮你看清楚每一位的独立性,单循环帮你在以后处理任意位数问题时建立“拆位”的直觉。比如后面遇到“判断一个五位数的各位数字5次方之和是否等于它自己”,你会第一时间想到循环拆位,而不是五层for循环嵌套。
4. 常见问题、坑点和性能优化
4.1 千万别先算阶乘再数零
这是最经典的误区。我一开始也干过这事:
import math s = str(math.factorial(1000)) count = len(s) - len(s.rstrip('0')) print(count)结果也能跑,但有一个致命问题:当 n 很大时,阶乘的真实值会巨大无比。Python 虽然支持大整数,但计算和转换字符串的时间会越来越夸张。如果 n 是 10^5,等它算完可能会让你怀疑人生。而且在 C/C++/Java 里,int 或 long long 早就溢出了。
所以这类题目一旦出现“大n”,就应该立刻放弃直接计算阶乘,转而用数学方法处理。
4.2 怪数搜索:上界不对或者0!定义错误
我在写怪数程序时,第一版没有推导上界,随手写了个while True,结果程序一直在那转,我意识到不对。后来改成循环到 2540160,才跑出结果。
另一个坑是 0! = 1。很多初学者会以为 0! = 0,这会导致 40585 这个解直接漏掉。你想想,如果 0! 按0算,那 40585 的各位阶乘和变成 24 + 0 + 120 + 40320 + 120 = 40584,少1,就不相等了。所以一定要记住,0的阶乘定义为1,这是数学约定。
4.3 拆位运算的优先级问题
在写b = n // 10 % 10时,虽然 Python 的整除和取模优先级是同一个层级,从左往右算,所以结果是对的,但写代码时最好加上括号:
b = (n // 10) % 10
否则阅读起来容易产生歧义。特别是别人看你的代码时,少一点“我以为”就少一点bug。
还有一个坑:三重循环里,百位 a 一定要从1开始,不能从0开始。因为 a=0 时 n 就不是三位数了,比如 a=0,b=5,c=3 得到的是53,不满足三位数的条件。当然,如果你在做通用“自恋数”判断时,一位数也允许,那就要另行考虑。
4.4 性能优化亮点:预计算阶乘表
怪数程序最大的优化就是预计算阶乘。数字就0到9这10种,我们完全可以在进入主循环前把 fact[0] 到 fact[9] 都算好。这样在判断每个数的时候,取某一位的阶乘就是一次数组下标访问,而不是从1乘到该数字的循环。
时间对比:
- 不预计算:每次
factorial(digit)平均约5次乘法,整个搜索大概要做千万次乘法; - 预计算:主循环里只有查表,剩下的就是拆位和加法。
实测下来,预计算版本能快一倍以上。这种“数据范围极小,但调用极频繁”的场景,最适合缓存结果。
4.5 三个题的时间复杂度总结
| 题目 | 核心思路 | 时间复杂度 | 最大参数范围 |
|---|---|---|---|
| 阶乘尾部0 | 统计因子5 | O(log n) | n=10^7 秒出 |
| 怪数 | 位阶乘和 + 上界剪枝 | O(上界 × 位数) | 约254万,1秒内 |
| abc数字 | 枚举100~999 | O(900) | 固定三位数 |
这三个复杂度层次也很有代表意义:一个是对数级算法,一个是通过数学缩小搜索范围,一个是简单暴力枚举但范围很小。每一种都值得熟悉。
5. 进阶扩展:还能怎么玩
5.1 n! 最右边非零位怎么求
很多题目不满足于求末尾0的个数,还会问“n! 去掉末尾0之后,最右边那个非零数字是什么”。这比尾零个数要复杂一点,思路大概是:不能直接对 n! 取模10,因为每次乘数里都有2和5,模掉之后会留下0。常见做法是边乘边去掉因子2和5,并且统计因子2的数量,最后再乘回足够的2,再取模10。这里只是抛个砖,真要写还得单独讲一篇文章。
5.2 把三个题目整合成一个命令行小工具
学完三个独立函数后,可以顺手做一个菜单程序,输入1、2、3分别执行对应的任务。这样做的好处是练习函数拆分和input处理,也能让代码看起来像一个真正的“小项目”。不过这个不是核心,我一般建议先把核心逻辑吃透再考虑界面。
5.3 数字题的通用思考方法
这次刷题让我悟到一点:数字类问题,先看范围。如果范围小,直接枚举;如果范围大,就要找数学规律缩小搜索空间。比如阶乘尾零用了因子5的个数,怪数用了上界不等式,abc数字本身就小。很多看起来很“怪”的数,其实都是被这些思路框住的。
6. 这次刷题,我踩过最深的坑
如果你也想照着练,我提醒你三点:
第一,看题先确认定义。“怪数”这个词真的是多义词,我一开始按“所有真因子之和等于它本身的完全数”去写,结果跑出来一堆6、28、496,和预期完全不搭。后来仔细看题目才知道人家指的是各位数字阶乘之和。所以拿到任何题目,先把术语确认清楚再动手。
第二,别怕手工验算。写完代码得到结果后,我会手动挑几个数代入验算。比如尾零公式我用 n=25 手算验证过,怪数和 abc 结果也逐个验证过。这一步能帮你抓住很多隐藏bug。
第三,留好测试用例。我习惯给每个函数准备几组边界值:n=1、n=5、n=25;怪数里最小的1和最大的40585;abc数字里最容易被遗漏的407。把这些用例写进测试脚本,改代码的时候就不怕改坏。
这三个题目都不大,但组合起来帮我打通了“数学推导 + 代码实现 + 边界处理”的完整链条。下次再看到类似“求xx的特殊数字”的问题,我脑子里会先冒出两个问题:规律是什么?搜索范围到哪里?这两句话,比背多少标准答案都有用。