☰
自幂数算法全解析:从暴力枚举到组合优化
2026/10/31 14:09:20 网站建设 项目流程

数学里有一类数字,看起来平平无奇,但拆开每一位、做几次乘方再求和,居然能原封不动地变回自己。这类数字叫自幂数,也叫阿姆斯特朗数。我第一次接触它是在一个算法练习里,题目只给了一句话——“找出所有的自幂数”,没有范围、没有说明,全靠自己琢磨。后来才发现,这个看似简单的问题,背后牵扯到数位拆分、幂运算、位数判定、搜索空间剪枝等一连串细节,是个特别适合拿来练手的小项目。这篇内容就围绕“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自幂数个数
10, 1, 2, 3, 4, 5, 6, 7, 8, 910(含0)
2无0
3153, 370, 371, 4074
41634, 8208, 94743
554748, 92727, 930843
65488341
71741725, 4210818, 9800817, 99263154
824678050, 24678051, 885934773
9146511208, 472335975, 534494836, 9129851534
1046793077741

从表里能看出两个有意思的现象。第一,两位数一个都没有,这不是巧合,后面会解释原因。第二,随着位数增加,自幂数的个数并没有明显增长,反而很稀疏。这个稀疏性直接决定了我们后面搜索策略的选择——暴力枚举到高位会非常慢,必须想办法优化。

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)倍数差距
31,000220约 4.5 倍
5100,0002002约 50 倍
710,000,00011440约 874 倍
91,000,000,00048620约 2 万倍
1010,000,000,00092378约 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 作为算法教学的案例

自幂数这个题目特别适合用来讲“换枚举对象”这个思想。从暴力枚举到组合枚举的转变,是算法优化里非常典型的一课。我在带新人时经常拿它当例子,因为它的问题描述简单、优化效果明显、代码量适中,讲起来不费劲,听的人也能立刻看到效果。

我个人在实际操作中的体会是,这类数学味浓的算法题,最大的价值不在于最后那几行结果,而在于推导和优化的过程。把定义抠清楚、把复杂度算明白、把边界条件测到位,这套功夫练熟了,遇到其他问题也能照搬这套方法论。自幂数只是块敲门砖,真正带走的是拆解问题的习惯。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询