数学里有一类数字,看起来平平无奇,但拆开每一位、做几次乘方再求和,居然能原封不动地变回自己。这类数字叫自幂数,也叫阿姆斯特朗数。我第一次接触它是在一个算法练习里,题目只给了一句话——“找出所有的自幂数”,没有范围、没有说明,全靠自己琢磨。后来才发现,这个看似简单的问题,背后牵扯到数位拆分、幂运算、位数判定、搜索空间剪枝等一连串细节,是个特别适合拿来练手的小项目。这篇内容就围绕“02自幂数”这个题目展开,把我从理解定义到写出可运行代码、再到优化搜索的完整思路摊开讲清楚,适合刚学编程想找练手题的读者,也适合想看看别人怎么拆解数学类算法题的朋友。
1. 先把“自幂数”这个概念嚼碎
动手写代码之前,最怕的就是概念没吃透,结果代码逻辑跑偏。自幂数这块尤其容易混淆,因为网上流传着好几种叫法,什么水仙花数、阿姆斯特朗数、自恋数,初学者很容易被绕晕。所以第一步必须把定义钉死。
1.1 定义拆解:一个n位数,每位数字的n次方之和等于它本身
自幂数的严格定义是这样的:如果一个正整数有 n 位,把它每一位数字分别取 n 次方,再全部加起来,结果恰好等于这个数本身,那它就是自幂数。注意这里的关键是“n 次方”里的 n 指的是这个数本身的位数,不是固定值。这一点是很多人第一次写错的地方。
举个最经典的例子,153 是个三位数,n = 3,那么:
- 1³ + 5³ + 3³ = 1 + 125 + 27 = 153
结果等于 153 本身,所以 153 是自幂数。再比如 9474 是四位数,n = 4:
- 9⁴ + 4⁴ + 7⁴ + 4⁴ = 6561 + 256 + 2401 + 256 = 9474
也对上了。而 123 呢,1³ + 2³ + 3³ = 1 + 8 + 27 = 36,不等于 123,所以它不是。
这里要特别提醒一个常见误解:水仙花数只是自幂数的一个特例。水仙花数专指三位自幂数,也就是 n = 3 的情况,只有 153、370、371、407 这四个。很多人把水仙花数当成自幂数的全部,这是不对的。自幂数覆盖所有位数,一位数、两位数、三位数……一直到理论上更高的位数都可能存在。
1.2 为什么一位数全都算自幂数
这个点经常被忽略,但写代码时如果处理不当会漏解。按照定义,一位数 n = 1,它只有一位数字 d,那么 d¹ = d,永远等于它本身。所以 0 到 9 这十个一位数,严格来说全都满足自幂数的定义。
不过实际做题时,很多题目会把 0 排除掉,因为 0 作为一位数虽然满足 0¹ = 0,但讨论起来没什么意义。我的建议是:代码里把 0 单独处理或者明确说明是否包含,避免结果对不上标准答案。1 到 9 这九个一位数则是毫无争议的自幂数,写验证逻辑时可以直接拿它们当测试用例。
1.3 位数与幂次的关系表
为了让大家对搜索范围有个直观感受,我把常见位数的自幂数整理成一张表。这张表在后面设计算法、验证结果时都用得上。
| 位数 n | 自幂数 | 个数 |
|---|---|---|
| 1 | 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 | 10(含0) |
| 2 | 无 | 0 |
| 3 | 153, 370, 371, 407 | 4 |
| 4 | 1634, 8208, 9474 | 3 |
| 5 | 54748, 92727, 93084 | 3 |
| 6 | 548834 | 1 |
| 7 | 1741725, 4210818, 9800817, 9926315 | 4 |
| 8 | 24678050, 24678051, 88593477 | 3 |
| 9 | 146511208, 472335975, 534494836, 912985153 | 4 |
| 10 | 4679307774 | 1 |
从表里能看出两个有意思的现象。第一,两位数一个都没有,这不是巧合,后面会解释原因。第二,随着位数增加,自幂数的个数并没有明显增长,反而很稀疏。这个稀疏性直接决定了我们后面搜索策略的选择——暴力枚举到高位会非常慢,必须想办法优化。
2. 从暴力枚举到聪明搜索:算法思路的演进
概念清楚了,接下来就是怎么让计算机把自幂数找出来。这里我打算把思路的演进过程完整走一遍,因为直接甩一个最优解出来,读者很难理解为什么要这么设计。真实的开发过程就是不断试错、不断优化的。
2.1 最朴素的做法:从1枚举到上限,逐个判断
最直接的想法是:给定一个范围,比如 1 到 1000000,对每个数判断它是不是自幂数。判断函数也很直白——先算出这个数有几位,然后把每位数字取出来做幂运算求和,最后比较。
这个思路的伪代码大概是这样:
def is_armstrong(num): digits = str(num) n = len(digits) total = sum(int(d) ** n for d in digits) return total == num def find_armstrong(limit): result = [] for i in range(1, limit + 1): if is_armstrong(i): result.append(i) return result这段代码能跑,逻辑也没错,但问题很明显:时间复杂度是 O(limit × 位数)。当 limit 到一百万时还能接受,到一亿、十亿就慢得没法看了。而且大部分数字在判断时很快就失败了,做了大量无用功。
我实测过,纯 Python 跑这个暴力版本,找 1 到 1000 万的自幂数大概要几十秒,再往上就基本没法用了。所以暴力法只适合验证小范围结果,不能作为最终方案。
2.2 换个角度:按位数分组,而不是按数值枚举
暴力法慢的根源在于,它把大量明显不可能的数也拿来算了。其实自幂数有个很强的约束:一个 n 位数的自幂数,它的值一定落在 [10^(n-1), 10^n - 1] 这个区间里。换句话说,位数和数值范围是绑定的。
那能不能反过来想——先确定位数 n,然后只在这个位数的范围内找?这样至少能把搜索空间按数量级切开,每一段单独处理。更进一步,n 位数的每位数字只能是 0 到 9,而 n 次方的结果只取决于“每个数字出现了几次”,跟数字的排列顺序无关。
这个观察非常关键。比如 153 和 351、531,它们的数字组成都是 {1, 3, 5},每位三次方之和都是 1 + 27 + 125 = 153,但只有 153 本身等于这个和。也就是说,我们可以枚举“数字的多重集合”,而不是枚举具体的数。对于 n 位数,数字组合的数量远小于 10^n 个具体数值。
2.3 组合枚举的核心:统计每个数字出现的次数
基于上面的思路,优化方案就出来了。对于固定的位数 n,我们不去遍历所有 n 位数,而是遍历“0 到 9 每个数字各出现多少次”的所有可能组合。设数字 d 出现 c_d 次,那么约束是:
- c_0 + c_1 + ... + c_9 = n(总共 n 位)
- c_0 不能等于 n(否则就是前导零,不是 n 位数)
对于每一种组合,计算 S = Σ(c_d × d^n),然后检查 S 这个数的各位数字出现次数,是否恰好和我们的组合一致。如果一致,S 就是一个自幂数。
这个方法的妙处在于,组合的数量是“把 n 个球放进 10 个盒子”的方案数,也就是 C(n+9, 9),比 10^n 小得多。比如 n = 10 时,10^10 是一百亿,而 C(19, 9) 只有 92378,差了五个数量级。这就是算法设计里常说的“换枚举对象”,往往能带来质的飞跃。
2.4 两种方案的复杂度对比
为了让大家直观看到差距,我把两种方案在不同位数下的搜索规模列出来对比。
| 位数 n | 暴力枚举规模 10^n | 组合枚举规模 C(n+9,9) | 倍数差距 |
|---|---|---|---|
| 3 | 1,000 | 220 | 约 4.5 倍 |
| 5 | 100,000 | 2002 | 约 50 倍 |
| 7 | 10,000,000 | 11440 | 约 874 倍 |
| 9 | 1,000,000,000 | 48620 | 约 2 万倍 |
| 10 | 10,000,000,000 | 92378 | 约 10 万倍 |
位数越高,组合枚举的优势越明显。当然,组合枚举每次判断的成本比暴力法单次判断要高一些(需要统计数字频次),但架不住总数少太多,综合下来还是快得多。实际写代码时,我一般用组合枚举找高位结果,用暴力法交叉验证低位结果,两边对上了才放心。
3. 把思路落成代码:关键实现细节
思路讲完了,现在进入动手环节。这一节我会把核心代码拆开讲,重点说清楚几个容易写错的地方。代码用 Python 写,因为它的字符串和集合操作比较方便,读起来也直观。如果你用 C++ 或 Java,逻辑是一样的,只是数字拆分要手动做。
3.1 位数计算与数字拆分
判断一个数是不是自幂数,第一步是拿到它的位数,第二步是把每位数字拆出来。Python 里最省事的做法是转字符串:
def digit_count(num): return len(str(num)) def split_digits(num): return [int(ch) for ch in str(num)]转字符串的好处是代码短、不易错,缺点是有一点性能开销。如果追求极致性能,可以用数学方法:
def split_digits_math(num): digits = [] while num > 0: digits.append(num % 10) num //= 10 return digits数学方法拿到的是逆序的数字,用的时候注意顺序。对于自幂数判断来说顺序无所谓,因为求和是交换的。我个人建议初学者先用字符串版本,把逻辑跑通,等确认结果正确了再考虑性能优化。过早优化容易引入 bug,得不偿失。
3.2 幂运算的预计算技巧
每次判断都现算 d^n 是有浪费的,因为 n 固定时,0 到 9 的 n 次方就那十个值。完全可以提前算好存成一个数组,用的时候直接查表。这个优化在组合枚举里尤其重要,因为组合枚举会反复用到这些幂值。
def build_power_table(n): return [d ** n for d in range(10)]建表之后,计算 S 就是简单的乘加:
power = build_power_table(n) total = sum(count[d] * power[d] for d in range(10))别小看这个改动,在组合枚举里它能省下大量重复计算。我实测过,加了幂表之后整体速度能提升三成左右,位数越高提升越明显。
3.3 组合枚举的递归实现
组合枚举的核心是生成所有“数字出现次数”的组合。用递归最自然:从数字 0 开始,依次决定它出现几次,剩下的位数交给后面的数字。
def search_by_combination(n): power = build_power_table(n) results = [] counts = [0] * 10 def dfs(digit, remaining): if digit == 9: counts[9] = remaining total = sum(counts[d] * power[d] for d in range(10)) if total >= 10 ** (n - 1) and total < 10 ** n: if is_valid(total, counts, n): results.append(total) return for c in range(remaining + 1): counts[digit] = c dfs(digit + 1, remaining - c) counts[digit] = 0 dfs(0, n) return results这段代码有几个细节值得说。第一,递归到数字 9 时,剩下的位数全部给 9,不用再循环,这是个小剪枝。第二,算出来的 total 要先检查位数是否等于 n,因为组合算出来的和可能位数不对,比如组合里全是 0 但实际不构成 n 位数。第三,is_valid 函数负责核对 total 的各位数字频次是否和 counts 一致。
3.4 频次校验函数怎么写才不出错
is_valid 是整个算法的最后一道关卡,写错了就会漏解或误判。它的逻辑是:把 total 拆成数字,统计每个数字出现次数,然后和传入的 counts 逐位比较。
def is_valid(total, counts, n): actual = [0] * 10 temp = total while temp > 0: actual[temp % 10] += 1 temp //= 10 return actual == counts这里有个坑要注意:如果 total 里含有前导零的情况,比如组合算出来是 0 开头的数,实际数值会少位,频次就对不上,自然被过滤掉,这是符合预期的。另外,比较时用列表相等判断最省事,不用手动逐位比。
提示:调试阶段建议把 is_valid 里比较失败的案例打印出来看看,确认是组合本身不成立,而不是校验逻辑写错了。我一开始就因为在 counts 里没清零,导致结果多出一堆假自幂数,排查了半天。
4. 实测结果与踩坑记录
代码写完不代表结束,跑通、跑对、跑快是三个不同的阶段。这一节我把实际运行中遇到的问题和验证方法分享出来,这些内容在教科书里基本看不到,但真正动手时一定会碰到。
4.1 用已知结果做回归验证
最靠谱的验证方式,是拿已知的自幂数列表去比对。前面那张表里的结果就是标准答案,代码跑出来的结果必须和它完全一致。我一般会写一个测试脚本,把每个位数的结果和预期列表做集合比较:
expected = { 1: [0,1,2,3,4,5,6,7,8,9], 3: [153,370,371,407], 4: [1634,8208,9474], 5: [54748,92727,93084], 6: [548834], } for n, exp in expected.items(): got = sorted(search_by_combination(n)) assert got == sorted(exp), f"位数{n}结果不符: {got}"这种断言式测试的好处是,一旦哪里写错,立刻就能定位到具体位数。我建议每改一次代码就跑一遍,别等全部写完再测,否则出了问题很难判断是哪次改动引入的。
4.2 两位数为什么一个自幂数都没有
这个问题我当初想了很久,后来推导明白了,值得单独说说。两位数 n = 2,设十位是 a、个位是 b,那么自幂数要求 a² + b² = 10a + b。整理一下:
- a² - 10a + b² - b = 0
- a(a - 10) + b(b - 1) = 0
因为 a 在 1 到 9 之间,a(a-10) 是负数;b 在 0 到 9 之间,b(b-1) 在 b=0 或 1 时为 0,其余为正。要让两者相加为 0,需要 b(b-1) 恰好抵消 a(10-a)。逐个试下来,没有任何 (a, b) 组合满足。所以两位数确实不存在自幂数,这不是程序漏了,是数学上就没有。
这个推导过程也提醒我们:程序结果为空不一定是 bug,可能是数学事实。遇到空结果先别急着改代码,动手推一推,确认是不是本来就没有解。
4.3 高位搜索时的性能瓶颈在哪
组合枚举虽然快,但位数到 15 以上时也会开始吃力。瓶颈主要有两个:一是组合数量 C(n+9,9) 随 n 增长,二是每次组合都要算一次大数幂和。我实测下来,n = 15 时单机跑完所有组合大概要几秒到十几秒,n = 20 就得上分钟了。
如果只是想找某个范围内的自幂数,比如 1 到 10 位,组合枚举完全够用,几毫秒到几十毫秒就出结果。真要挑战更高位数,就得考虑更高级的剪枝,比如根据当前部分和的范围提前判断是否可能落在目标区间,或者用多线程把不同数字的起始分支并行化。不过对于练手项目来说,10 位以内已经足够体现算法功力了,没必要死磕高位。
4.4 几个容易踩的实现坑
除了前面提到的 counts 清零问题,还有几个坑我踩过或者见别人踩过,列出来供参考。
第一个是整数溢出。如果用 C++ 或 Java 写,n 较大时 d^n 和累加和可能超出 int 范围,必须用 long long 或 BigInteger。Python 自动处理大整数,所以没这个问题,但换语言时一定要留意。
第二个是0 的处理不一致。有的题目把 0 算进去,有的不算,代码里要明确。我的做法是搜索时从 1 开始,最后如果需要包含 0 再单独加,这样逻辑清晰。
第三个是位数边界判断。组合算出的 total 可能刚好等于 10^(n-1) 或者 10^n,前者是 n 位数,后者是 n+1 位数。判断条件要用>= 10**(n-1) and < 10**n,两边都不能马虎。我见过有人写成> 10**(n-1),结果把 10 的幂次这种边界值漏掉了。
5. 这个小题目的延伸玩法
自幂数本身是个小问题,但围绕它能延伸出不少有意思的练习方向。如果你已经跑通了基础版本,不妨试试下面这些扩展,对提升算法能力很有帮助。
5.1 改成通用框架:任意幂次的自幂数
标准自幂数要求幂次等于位数,但可以放宽这个限制,定义“每位数字的 k 次方之和等于本身”的数。k 固定时,这类数的分布又是另一番景象。改代码很简单,把幂表从 d^n 换成 d^k 就行,搜索范围也可以固定。这个变体适合练习参数化设计,把位数和幂次解耦。
5.2 用不同语言实现并对比性能
同一个算法用 Python、C++、Java 各写一遍,对比运行时间和代码量,是很好的跨语言练习。Python 代码短但慢,C++ 快但要注意溢出和内存,Java 居中。我做过这个对比,同样找 1 到 10 位的自幂数,Python 几十毫秒,C++ 几毫秒,差距主要在大数运算和解释器开销上。这种对比能帮你建立对不同语言特性的直观认识。
5.3 把结果可视化
自幂数很稀疏,把它们在数轴上标出来,或者统计每个位数区间的个数,画成图会很有意思。用简单的柱状图就能看出“位数增加但个数不增”的稀疏特性。这类可视化练习对理解数据分布很有帮助,也适合作为项目展示的加分项。
5.4 作为算法教学的案例
自幂数这个题目特别适合用来讲“换枚举对象”这个思想。从暴力枚举到组合枚举的转变,是算法优化里非常典型的一课。我在带新人时经常拿它当例子,因为它的问题描述简单、优化效果明显、代码量适中,讲起来不费劲,听的人也能立刻看到效果。
我个人在实际操作中的体会是,这类数学味浓的算法题,最大的价值不在于最后那几行结果,而在于推导和优化的过程。把定义抠清楚、把复杂度算明白、把边界条件测到位,这套功夫练熟了,遇到其他问题也能照搬这套方法论。自幂数只是块敲门砖,真正带走的是拆解问题的习惯。