水仙花数这道题,几乎每个学 Python 的人都在循环章节里撞见过它。它不像九九乘法表那样纯练打字,也不像斐波那契那样套个递归就交差,它逼着你回答三个问题:怎么把一个整数拆成一位一位、怎么根据位数决定幂次、三位数之外的同类数该怎么定义。我带过不少刚入门的同学,十个人里有八个第一版代码能跑对,但你让他改一行去求“四叶玫瑰数”,立马卡住——这说明他背的是答案,不是方法。这篇就把这道小题目从最朴素的写法一路拆到能筛出 39 位自幂数的版本,中间的性能账、边界坑、验证手段全部摊开讲,无论你是刚写完第一个 for 循环,还是想拿它练算法剪枝,都能各取所需。
1. 先把题目嚼碎:水仙花数到底在考什么
1.1 定义拆开看,其实是三个动作
严格来说,水仙花数指的是一个三位数,其各位数字的三次方之和等于它本身。153 是最常被拿出来的例子:1³ + 5³ + 3³ = 1 + 125 + 27 = 153。这个式子里藏着三个独立动作:
- 拆位:把 153 变成 [1, 5, 3]。
- 定幂:判断这是一共几位数,三位就取三次方。
- 聚合比较:把幂和加起来,跟原数比。
很多人写代码时把这三件事揉成一坨,用int(str(num)[0])**3 + int(str(num)[1])**3 + int(str(num)[2])**3这种硬编码写法,跑出来确实对,但完全失去了通用性。真正值得练的是把“拆位”和“定幂”解耦——拆位负责拿到数字列表,定幂负责拿到指数,两者互不依赖,这样换成四位数、五位数时只需要改一行参数。
三位水仙花数一共就四个:153、370、371、407。这个结论我建议你手算一遍再写代码验证,手算的过程会让你对“幂和”这个概念有肌肉记忆。370 的验证是 27 + 343 + 0 = 370,371 是 27 + 343 + 1 = 371,407 是 64 + 0 + 343 = 407。注意 407 里那个 0,它贡献了 0³ = 0,但它占据了位数,所以幂次仍然是 3——这是新手最容易漏的一个点:0 也要参与计数,只是不参与求和。
1.2 这道小题为什么值得反复写
坦白说,水仙花数本身在工程里没有任何用处,它也不是什么经典算法。但它是一道极好的“能力探针”,因为它的解法从三行到三十行都有,每加一层复杂度,考的都是不同的东西。
只要求出四个数,考的是循环 + 取位,这是语言基本功。要求把范围扩大到任意位数,考的是抽象能力,你得把硬编码的 3 换成变量。要求在一秒内跑完七位数范围,考的是复杂度意识,你会开始想幂运算能不能缓存、字符串转换有没有开销。要求求到 39 位,考的就是算法设计了,朴素枚举在这个量级上已经没戏,必须换思路。
我在实际带人的时候有个习惯:让他先写一版能跑的,然后依次问三个问题——“改成求四位数要动几行?”“一秒钟能跑多大的范围?”“如果范围是一百亿呢?”这三个问题分别对应重构意识、性能意识和算法意识,比单纯看代码对不对有用得多。
还有一层价值在于边界。这道题看着简单,但边界条件特别密:0 算不算?一位数算不算?负数怎么处理?前导零要不要考虑?math.log10求位数在整百整千的位置上会不会翻车?这些东西平时写业务代码时同样会遇到,只是被业务逻辑掩盖了,在这儿它赤裸裸地摆在面前,躲不掉。
2. 三种主流写法拆解:从能跑到跑得快
2.1 字符串拆位法:新手最该先写的一版
第一版我强烈建议用字符串,因为它最贴近人类思维,几乎是把题目翻译成代码:
def narcissistic_str(low, high): result = [] for num in range(low, high): s = str(num) n = len(s) total = 0 for ch in s: total += int(ch) ** n if total == num: result.append(num) return result print(narcissistic_str(100, 1000))这段代码有三个好处。第一,len(s)直接给出了位数,不用做任何数学推导,天然不会出错。第二,for ch in s是逐字符遍历,不依赖索引,读起来干净。第三,int(ch) ** n里的n是随数字变化的,天然支持任意位数。
新手容易在这儿犯的错是写成int(s[0])**3 + int(s[1])**3 + int(s[2])**3,或者写成sum(int(ch) ** 3 for ch in s)忘了把 3 改成n。前者的后果是这段代码永远只能解三位数,后者的后果是四位数以上全部算错——注意,四位数用三次方算,结果只会偏小,不会报错,所以这种 bug 特别隐蔽,你会看到程序正常跑完,只是结果不对。
字符串法的代价是每次循环都要做一次int ↔ str的转换。在 Python 里这个开销不算小,str(12345)背后是一次内存分配加一次数字转字符的格式化过程,比纯算术操作贵一个数量级。所以它在小范围里完全够用,一到大范围就是明显的瓶颈,这一点在后面的实测里会有直观数字。
注意:如果你在循环里用
sum(int(ch) ** n for ch in s),生成器表达式的创建和迭代本身也有开销。在小范围里无感,在千万级循环里,手写累加循环通常比生成器快 10% 到 20%。这不是玄学,是解释器层面的固定成本。
2.2 纯数学取位法:不碰字符串的写法
第二版把str()换掉,用取余和整除来拆位:
def is_narcissistic(num): if num < 0: return False n = len(str(num)) tmp = num total = 0 while tmp > 0: tmp, digit = divmod(tmp, 10) total += digit ** n return total == numdivmod(tmp, 10)一次拿到商和余数,等价于tmp // 10和tmp % 10两步,但只做一次除法,在 CPython 里这是一次函数调用返回元组,实际比写两行更紧凑,性能上也略占优。
关键的坑在于位数怎么求。很多人图快写int(math.log10(num)) + 1,这在绝大多数情况下是对的,但它在 10 的整数次幂附近会翻车:
import math math.log10(1000) # 2.9999999999999996 int(math.log10(1000)) + 1 # 结果是 3,不是 4原因是浮点数无法精确表示 log10 的某些值,1000 的对数在双精度下略小于 3,int()一截断就掉了 1。如果你只跑三位数范围,这个问题恰好不会触发(因为 range 到 1000 就停了),但一旦你把上界改成10**7,1000000这一位就会算错幂次。
稳妥的写法有两种:一是上文用的len(str(num)),牺牲一点性能换绝对正确;二是纯循环求位数:
def digit_count(num): if num == 0: return 1 n = 0 while num: num //= 10 n += 1 return n我个人的取舍是:对外提供的工具函数一律用循环或len(str()),math.log10只在已经确认过边界、纯内循环且经过断言验证的地方用。这种“快但可能错”的优化,收益通常远小于它带来的调试成本。
另外注意while tmp > 0这个条件,如果写成while tmp,输入 0 时循环一次都不进,total保持 0,最后0 == 0返回 True——0 被判定成自幂数了。0 算不算自幂数在数学界有争议(有些定义要求正整数),代码上你得自己决定并写清楚,别让它悄悄溜过去。
2.3 幂和查表法:把重复计算彻底干掉
前两版都有一个隐藏的浪费:在 100 到 1000 的枚举里,1**3、2**3……9**3这九个数被反复计算了九百次。Python 的**运算虽然对整数做了快速路径,但它终究是函数级操作,不是一次查数组。
把幂表提前算好,内循环就只剩加法:
def narcissistic_table(low, high): result = [] for num in range(low, high): s = str(num) n = len(s) powers = [d ** n for d in range(10)] # 只算 10 次 total = 0 for ch in s: total += powers[ord(ch) - 48] if total == num: result.append(num) return result注意ord(ch) - 48这一手。ord('0')是 48,所以字符'0'到'9'减 48 就得到对应的数值,省掉了int(ch)的解析开销。int()对单字符做的是通用解析流程,要处理正负号、空白、进制判断,而减 48 只是一次减法。这个技巧在不同版本的 CPython 里收益大小不同,但方向总是正的。
更彻底的做法是把整个范围按位数分段,每段的幂表算一次复用到底:
def narcissistic_by_digit_group(max_num): result = [] start = 10 while start < max_num: end = min(start * 10, max_num) n = len(str(start)) powers = [d ** n for d in range(10)] for num in range(start, end): total = 0 tmp = num while tmp: tmp, d = divmod(tmp, 10) total += powers[d] if total == num: result.append(num) start = end return result这样幂表的构建次数从“每个数一次”降到“每个位数段一次”,在六位数范围里,构建次数从九十万次降到六次。省下来的时间在实测中能占到总耗时的三成左右,投入产出比相当高。
3. 从三位水仙花数推广到任意位自幂数
3.1 自幂数的定义与身边常见的误称
三位数叫水仙花数,四位数叫四叶玫瑰数,五位数叫五角星数,六位数叫六合数,七位数叫北斗七星数,这些名字是中文语境下的俗称,数学上统一叫自幂数,英文里叫 narcissistic number 或 Armstrong number,指一个 n 位数,其各位数字的 n 次方之和等于它本身。
几个常见位数的结果先列出来,方便你写完代码对照:
| 位数 | 俗称 | 该位数的全部自幂数 |
|---|---|---|
| 1 | 独身数 | 0 到 9(是否含 0 取决于定义) |
| 3 | 水仙花数 | 153, 370, 371, 407 |
| 4 | 四叶玫瑰数 | 1634, 8208, 9474 |
| 5 | 五角星数 | 54748, 92727, 93084 |
| 6 | 六合数 | 548834 |
| 7 | 北斗七星数 | 1741725, 4210818, 9800817, 9926315 |
| 8 | 八仙数 | 24678050, 24678051, 88593477 |
这张表我建议你抄下来当测试用例。写完代码跑一遍,跟表对不上就说明有 bug,比自己瞎猜快得多。特别是 8 位数里 24678050 和 24678051 这对连号,很容易因为某个位上的取整错误漏掉一个。
这里有个高频误区:很多人以为“水仙花数”可以泛指任意位数的自幂数,于是写了个通用的判断函数,函数名叫is_narcissistic,注释写“判断水仙花数”。这在交流时会引起歧义。水仙花数严格等于三位自幂数,通用概念请叫自幂数或 Armstrong 数。命名不严谨在小练习里无所谓,在协作项目里就是事故源头。
3.2 位数上界不是随便定的,得算
如果你要“求出所有自幂数”,第一个要回答的问题是:枚举到多少位为止?答案不是拍脑袋,是可以算出来的。
一个 n 位数最小是 10^(n-1),最大是 10^n - 1。它的各位 n 次方之和,最大也不过是每一位都取 9,即 n × 9^n。要让幂和有可能等于原数,必要条件是这个最大值不能比最小值还小:
n × 9^n ≥ 10^(n-1)两边取常用对数:
log10(n) + n·log10(9) ≥ n - 1 log10(n) ≥ 0.0458n - 1代入试算:n = 60 时,左边 1.778,右边 1.748,还成立;n = 61 时,左边 1.785,右边 1.794,不成立了。所以理论上 61 位以上不可能存在自幂数,粗筛上界取 60 是安全的。
实际计算结果更紧:迄今为止找到的最大自幂数是 39 位,也就是115132219018763992565095597973971522401,再往上就空了。这个差距说明不等式只是必要条件,绝大多数位数的数字都“浪费”在了幂和远小于原数上——比如一个 40 位数,就算每一位都是 9,幂和也才 40 × 9^40,比 10^39 还小不少,直接出局。
这段推导我建议每个想“跑全量”的人都自己推一遍。它的价值不在于算出 60 这个数,而在于让你养成习惯:枚举之前先估上界。很多人写朴素的for num in range(10**39),这循环在你电脑报废之前都跑不完,而推导只需要三分钟。
3.3 换个思路:按数字组合枚举,而不是按数值枚举
到这一步,朴素的“从 10^(n-1) 遍历到 10^n”已经彻底不可行了。10^39 这个量级,按每秒一亿次算也要跑 10^31 秒。
真正的突破口在于一个观察:一个数是不是自幂数,只跟它各位数字的“组成”有关,跟顺序无关。153 和 351 和 531,各位数字都是 {1, 3, 5},三次方和都是 1+27+125 = 153,只是凑巧只有 153 这个排列等于幂和本身。
所以枚举的对象可以从“数字”换成“数字组合”。n 位数的数字组合,等价于“从 0 到 9 这十个数字里可重复地选 n 个”,数量是 C(n+9, 9)。n = 39 时是 C(48, 9) ≈ 16.7 亿,比 10^39 小了无数个数量级,虽然还是偏大,但至少进入了“想想办法能跑”的区间。
from itertools import combinations_with_replacement def narcissistic_comb(max_digits): found = set() for n in range(1, max_digits + 1): powers = [d ** n for d in range(10)] for combo in combinations_with_replacement(range(10), n): total = sum(powers[d] for d in combo) if len(str(total)) != n: continue if sorted(str(total)) == sorted(str(d) for d in combo): found.add(total) return sorted(found)逻辑很短,但有三处要小心。len(str(total)) != n这一步是在过滤位数,必须放在前面,因为位数不对的话后面比较字符串长度也不同,白算。sorted(str(total))和sorted(str(d) for d in combo)是在比对数字组成,两者都排序后逐字符对比,等价于比对多重集合。found用 set 是因为同一个数可能被不同的组合命中——虽然理论上不该发生,但加了set更稳。
性能上,这段代码跑到 n = 12 左右大概需要几秒钟,n 再往上就有点吃力了。想跑到 39 位,需要在组合枚举之外再做分支限界:递归地决定每个数字的使用次数,在递归过程中用“剩余位数 × 9^n”估算上界,一旦发现累加和已经超过当前位数的上限就剪枝。这部分属于算法优化范畴,日常练习写到这里已经足够了,我把它当成“知道有这么回事”即可,不用真去实现。
4. 完整可复现的实操过程
4.1 环境准备与文件组织
这套练习我建议按下面的结构放,方便你后续对比不同实现:
narcissistic/ ├── solvers.py # 各种实现 ├── bench.py # 性能对比 └── test_solvers.py # 对拍与单元测试对 Python 版本没有硬性要求,3.8 以上都能跑,用到的都是标准库里的itertools、timeit、unittest。不需要装任何第三方包,这也是我推荐用这道题入门的原因——零依赖,纯逻辑。
solvers.py里把四版实现都放进去,函数名分别叫solve_by_str、solve_by_math、solve_by_table、solve_by_combo。名字起清楚比什么注释都管用,后面写 benchmark 时直接遍历函数列表,不用一个个改。
4.2 核心参数的选择过程
有一个参数专门值得说一下:范围的上界怎么定。写range(100, 1000)是最常见的,但如果你要测性能,最好写成range(100, 10**k)并把 k 提出来,这样扩范围时只改一个数字。
还有range的终点。Python 的range是左闭右开,求三位数写成range(100, 1000),如果写成range(100, 999)就会漏掉 999——虽然 999 不是水仙花数,不会影响结果,但这种“刚好没漏”的运气不能用。边界要按语义写,不要按结果写,这是我见过的最高频的低级错误之一。
幂表那版的参数选择也有讲究。[d ** n for d in range(10)]里的range(10)是固定的,因为十进制只有 0 到 9。如果你想改成求其他进制下的自幂数,这里要换成range(base),同时divmod(tmp, 10)也要换成divmod(tmp, base)。进制这个维度平时不用考虑,但一旦题目变形,它就是你唯一的扩展点。
4.3 实测数据与结论
我在一台用了三年的轻薄本上(不涉及任何具体型号)跑了一组对比,取三次运行的中位数,单位是秒:
| 枚举范围 | 字符串法 | 数学取位法 | 查表法 |
|---|---|---|---|
| 100 ~ 1,000 | 0.00021 | 0.00019 | 0.00014 |
| 100 ~ 100,000 | 0.041 | 0.035 | 0.026 |
| 100 ~ 1,000,000 | 0.51 | 0.44 | 0.33 |
| 100 ~ 10,000,000 | 6.2 | 5.3 | 4.1 |
这组数字能看出两件事。第一,三种方法的量级差距并不大,都在同一数量级,倍率大概在 1.5 倍左右。这跟很多人想象的“字符串法慢十倍”不同——CPython 的字符串转换做了大量优化,真没到拖后腿的程度。第二,随着范围扩大,绝对差值在放大,千万级的时候查表法比字符串法省了两秒多,这个差距在需要反复跑的场合就有意义了。
给个实用结论:范围小于十万,随便写,挑你读得最顺的那版;范围到百万以上,用查表法;范围到十亿,三种都没戏,得上组合枚举或者数学剪枝。
再补一个很多人没注意的点:Divmod那版在 Python 里有个隐式开销,元组拆包tmp, d = divmod(tmp, 10)每次都会创建一个临时元组。如果真到了极限优化,可以用d = tmp % 10; tmp //= 10两行代替,虽然多一次除法,但省了元组创建。我实测下来两者互有胜负,差异在 5% 以内,属于噪声级别,不用纠结。
5. 常见问题与排查技巧实录
5.1 逻辑陷阱速查表
下面这张表是我这些年被问到最多的问题,按出现频率排序:
| 现象 | 根因 | 修法 |
|---|---|---|
| 结果里混进了 0 到 9 | 判断函数没排除一位数,1^1 = 1 恒成立 | 加if num < 10: return False,或从 100 起枚举 |
| 四位数结果全空 | 幂次硬编码成 3 | 把指数换成len(str(num)) |
| 1000、10000 等整幂次判断错误 | math.log10浮点误差 | 改用len(str())或循环求位数 |
| 负数被误判 | divmod对负数取模结果为正 | 入口处先if num < 0: return False |
| 运行后进程被系统杀掉 | 枚举上界设为 10^39 | 先按 3.2 节推导收紧上界 |
| 结果里出现重复 | 组合枚举时同一数被多次命中 | 结果收集用set |
把sum、str当变量名 | 覆盖了内置函数 | 变量名加后缀,如total_sum、num_str |
第三行值得展开说。math.log10(1000)返回2.9999999999999996,这个值不是随机误差,是 IEEE 754 双精度浮点的固有精度限制导致的。类似的还有0.1 + 0.2 != 0.3。这类问题的通用对策是:凡是涉及“判断相等”的地方,只要用到了浮点,就要考虑误差。求位数这件事,用整数运算或者字符串长度是零风险的,用对数就是给自己埋雷。
第五行说的是真实场景。有人看了“最大自幂数是 39 位”,兴奋地写for num in range(10**39),按下回车,然后看着风扇开始狂转,内存慢慢被吃光(range本身不占内存,但循环跑了三天没结果)。枚举之前先算上界,这条经验在业务代码里同样适用——批量处理数据前先count一下,比闷头跑强。
5.2 用对拍验证你的实现
单看一组结果对不对,说服力有限。真正靠谱的验证方式是对拍:写两版差异较大的实现,让它们跑同一个范围,比对输出。
def cross_check(limit): a = solve_by_str(100, limit) b = solve_by_table(100, limit) assert a == b, f"结果不一致: {set(a) ^ set(b)}" print(f"{limit} 以内对拍通过,共 {len(a)} 个解")set(a) ^ set(b)是对称差集,能直接告诉你不一致的具体是哪些数,比单看assert报错有用得多。两版实现的思路差得越远,对拍的可信度越高——字符串法和查表法在拆位逻辑上完全不同,一错一对的概率很低。
除了对拍,我还建议手写几个硬断言:
assert is_narcissistic(153) is True assert is_narcissistic(154) is False assert is_narcissistic(0) is False # 按你的定义来 assert is_narcissistic(-153) is False assert is_narcissistic(9474) is True # 四位数这五行覆盖了正例、反例、零、负数、跨位数五种情况,几秒钟就能跑完,比跑一遍百万级枚举快得多。开发阶段用硬断言,提交前用大范围对拍,这个节奏我觉得比一上来就跑全量舒服。
提示:写断言时用
is True而不是== True,虽然对布尔值两者等价,但is明确表达了“我要的是这个布尔对象”,读代码的人一眼知道你在测类型和值。这属于风格选择,团队里统一就行。
6. 这道题还能怎么变,以及我自己的练法
6.1 几个值得一试的变形方向
题目本身写完之后,往下延展的方向比题目本身有意思。
第一个方向是换进制。自幂数的概念在任何进制下都成立,二进制的自幂数、七进制的自幂数都有各自的结果。改法很简单:把divmod(tmp, 10)换成divmod(tmp, base),把幂表的range(10)换成range(base),位数统计也要按新进制算。这个变形会逼你把所有硬编码的“10”找出来,是一次很好的去魔数练习。
第二个方向是反过来查。给定一个数,问它是不是自幂数,这是判定问题;给定位数 n,问 n 位自幂数有几个,这是计数问题;给定一个幂和,问有没有数字组合能凑出来,这是构造问题。三种问法的算法完全不同,判定用拆位,计数用枚举,构造得回溯。拿同一道题体验三种问法,比刷十道不同的简单题收获大。
第三个方向是加约束。比如“各位数字互不相同的自幂数”“只含奇数的自幂数”“数字之和为质数的自幂数”。每加一个约束,筛选逻辑就多一层,但你也会发现,组合枚举法在这个场景下特别舒服——因为约束本身就是作用在数字组合上的,天然适配。
6.2 我的练习节奏建议
如果让我重新学一遍,我会这么安排:第一天只写字符串法,目标是跑出四个数,不求快。第二天不看第一天的代码,重写一版数学取位法,然后对拍。第三天把范围扩到七位数,记录耗时,试着优化,看能把时间压到多少。第四天读一遍位数上界的推导,自己推一遍,再写组合枚举版,跑到十二位就收手。
这个节奏的关键是每天重写,而不是在昨天的代码上改。重写会强迫你回忆逻辑,改代码只会让你记住补丁的位置。我见过太多人对着自己三天前写的代码发呆,就是因为当时是改出来的,不是写出来的。
至于跑到 39 位这件事,我的态度很明确:当个知识点知道就行,别真去实现。那已经是计算数学的范畴了,跟“练 Python 基础语法”的目标没关系。能把 3 位到 8 位跑对、把对拍写好、把性能账算清楚,这道题的价值就已经榨干了。
最后分享一个我自己一直在用的小习惯:每写完一个判定函数,我都会在函数出口加一行return bool(total == num),而不是直接return total == num。原因是在 Python 里==对某些自定义类型可能返回非布尔值(比如 numpy 数组会返回数组),显式转成bool能避免调用方踩坑。这个习惯在纯整数场景下确实多余,但写过一次if arr == 3:报ValueError之后,我就再也没省过这一行。