☰
水仙花数Java实现:从拆位原理到通用自幂数算法
2026/10/13 14:02:39 网站建设 项目流程

说实话,水仙花数这道题,在Java基础进阶的学习路径里一直是个“神奇”的存在。你说它难吧,无非就是一个三位数,把百位、十位、个位拆出来,算个立方和,跟原数比一比。你说它简单吧,我见过不少同学能把153、370、371、407背得滚瓜烂熟,但换个四位数的场景,代码就完全写不出来了。

这个现象其实点出了一件事:水仙花数表面上是一道编程练习题,本质上却是一道“数学建模题”。你的代码能不能写得对、写得快、写得稳,取决于你到底有没有理解十进制、位权、取模和整除这些最基础的概念。这篇文章就从一道最普通的水仙花数题出发,把背后的数学原理、实现方案、优化思路、常见坑点一次说清楚。不管你是刚学完循环和运算符的初学者,还是想把这题讲清楚的老手,应该都能从中捞到点东西。

1. 为什么说水仙花数是一道“数学题”而不是“编程题”

1.1 从定义到代码:翻译比背代码重要

水仙花数(也叫阿姆斯特朗数、自幂数),数学定义是这样的:一个 n 位自然数,如果它的每个位上的数字的 n 次方之和等于它本身,那这个数就是水仙花数。三位水仙花数就是各位数字的立方和等于自己的三位数。

拿153来验证一下:153是三位数,所以n=3,1³+5³+3³=1+125+27=153,等式成立,153是水仙花数。370、371、407同理,大家可以手动验一遍,验完你会发现一个共同点:这三个数的三次方和都精确落在各自原本的百位、十位、个位上,一个不多一个不少。

关键点在于:很多初学者背的是“i/100是百位,i/10%10是十位,i%10是个位”这段口诀,代码也写得滚瓜烂熟,但只要题目一变,比如让求四位数甚至任意n位数的所有情况,就完全懵了。说白了,你记住的不是解题能力,而是那一行行代码的“形”,没抓住背后的“神”。

1.2 十进制位权:为什么拆位一定要用取模和整除

要从根上理解拆位,就要回到十进制。任何一个十进制数,比如153,其实是1×10² + 5×10¹ + 3×10⁰。这里每个“10的几次方”就是位权,而前面的系数1、5、3就是这一位上的数字。

整除 / 10 的本质是“右移一位”:153 / 10 = 15,十位变成了个位;取模 % 10 的本质是“取当前个位”:153 % 10 = 3。这种整除和取模的交替,就是在一步步剥开十进制数的“洋葱皮”,从个位一直剥到最高位。其实二进制的位运算也是同一个套路:x & 1 取最低位,x >>> 1 右移一位。学会水仙花数,将来你处理进制转换、二进制位运算时都会顺手很多。

1.3 三位水仙花数总共就四个

从100到999有整整900个数,但真正满足条件的只有4个:153、370、371、407。很多数学游戏网站会拿这些数字做“魔术”,其实背后就是三次方和这个强约束在起作用。

这里顺便埋一个伏笔:自幂数在各位数区间里都非常稀少。为什么?等位数变大,我们看一个粗略估算:n位数的最大数字是10ⁿ-1,而各位数字n次方和的最大值是n×9ⁿ。如果 n×9ⁿ 都够不到最小的n位数10ⁿ⁻¹,那这个位数区间里就根本不可能存在水仙花数。这个不等式会在第5章展开讲,它直接决定了你写循环时可以停在哪个范围。

2. 位数的拆分:水仙花数真正的技术分水岭

2.1 “先把数字转成字符串”的写法错了吗?

很多人会这样写:

String s = String.valueOf(i); int a = s.charAt(0) - '0'; int b = s.charAt(1) - '0'; int c = s.charAt(2) - '0';

如果只看输出结果,它完全正确,甚至代码看起来更“优雅”:数字转字符串,取字符,减去'0'得到数字值。我记得有不少培训班会这样教,因为对初学者来说字符串比取模好理解。

但我的建议是:水仙花数这道题,尽量不要一上来就用字符串方案。原因有三条:

  • 效率:数字转字符串在底层涉及字符数组分配、填充,单次运行无所谓,可一旦放进大循环里,垃圾对象和内存开销就堆起来了,完全没有必要。
  • 通用性:一旦题目变成“任意n位数”,用charAt就得动态处理下标,代码反而变复杂,远不如循环取模来得清爽。
  • 本质:用字符串拆位,掩盖了十进制位权的数学本质,你很难意识到取模和整除这两个操作如此有力量。

当然,字符串拆位也不是一无是处。在需要判断某一位是不是某个特定数字的场景下,它确实更直白。但就水仙花数这道题而言,取模整除才是正统思路。

2.2 取模与整除:三步拆出百位、十位、个位

先看固定的三位数版本。假设当前数是i,拆位公式一眼就能看懂:

  • 百位:i / 100
  • 十位:i / 10 % 10
  • 个位:i % 10

为什么这么拆?以153为例:i/100=1,直接把百位取出来了。i/10=15,这时候原来的十位5已经“掉”到了个位上,再 %10 取个位,自然就是5。i%10=3,这个最直接,一个数除以10的余数就是它的个位数字。

如果位数更多,就要用循环:

int temp = num; while (temp > 0) { int digit = temp % 10; // 取当前最低位 // 这里处理digit temp /= 10; // 把最低位移走 }

这个循环每次从个位“剥”一位出来,直到temp变成0。它不关心num到底是几位数,几位的数都能处理。这一小段代码,是后面通用自幂数实现的地基,强烈建议默写。

2.3 拆位方式的横向对比

拆位方案易读性性能通用性数学本质体现
字符串 charAt高,但依赖固定下标低,有对象分配差,位数变了要改下标弱
整数取模 + 整除中,需要理解位权高,纯整数运算好,循环可处理任意位数强
数组预先存每一位中高一般中

我自己教学的时候,见过太多人死在“字符串方案转通用版本”这一步。反而是老老实实用取模的同学,遇到四位、五位水仙花数时只改一个循环边界就搞定了。所以别嫌取模丑,它才是真正能陪你走远路的写法。

3. 第一版实现:从100到999,把四个水仙花数“算”出来

3.1 为什么要写成单层循环而非三层循环

先看另一种写法。有人喜欢这样:

for (int a = 1; a <= 9; a++) { for (int b = 0; b <= 9; b++) { for (int c = 0; c <= 9; c++) { int num = a * 100 + b * 10 + c; if (a*a*a + b*b*b + c*c*c == num) { System.out.println(num); } } } }

这段代码有一个天然优势:a、b、c本身就是每一位的数字,根本不用拆位,逻辑非常直观。对初学者来说,这甚至比单层循环+拆位更容易写对。

但我个人还是偏向单层循环。原因很简单:单层循环直接遍历[100,999]这个连续整数区间,更贴近实际业务里“扫描一批数据”的常态;拆位逻辑独立出来后,扩展到四位数、五位数时,只需要把循环上下界改掉,其他代码原封不动。而三层循环一旦要求四位水仙花数,就要再加一层for,五位数再加一层……代码瞬间变成俄罗斯套娃,写起来想死。

3.2 完整代码与运行结果

public class NarcissisticNumber { public static void main(String[] args) { for (int i = 100; i < 1000; i++) { int hundreds = i / 100; // 百位 int tens = i / 10 % 10; // 十位 int units = i % 10; // 个位 if (hundreds*hundreds*hundreds + tens*tens*tens + units*units*units == i) { System.out.println(i); } } } }

运行结果:

153 370 371 407

这里有个细节值得注意:等号左边是三个int直接相乘再相加,整个过程都是整数运算。你可能觉得“这不是理所当然吗”,但真有人在这里用Math.pow,给自己埋了一颗雷。下一小节专门说这个事。

3.3 为什么用“aaa”而不是“Math.pow(a, 3)”?

先亮观点:在本题这种场景下,Math.pow(a, 3)属于“能用但不好”的方案。

原因第一点是精度。Math.pow走的是浮点运算,返回double。对小整数,比如Math.pow(5, 3),返回125.0,跟int的125比较时自动提升为double后也是精确相等的,一般情况下踩不到雷。但浮点运算是出了名的“在边界搞事情”,你自己写double计算时一定遇到过0.1+0.2不等于0.3的情况。一旦出现类似的舍入误差,拿double和int直接==比较,结果就可能与预期相反,而且这种bug非常难排查。

原因第二点是性能。Math.pow是native方法,底层要处理指数运算的通用逻辑,比直接乘两个数重得多。虽然单次看不出来,但循环几百万次时差距就明显了。

所以在这个场景下,直接用整数乘法最稳、最快、最直白。要养成一个习惯:能用整数运算解决,就不要把浮点拉进来。

4. 把代码改到极致:缓存与剪枝的进阶思路

4.1 预计算0到9的立方表

900次乘法对现代CPU来说微不足道,但“预计算不变量”是一种很重要的代码嗅觉。聪明的地方在于:循环过程中,0到9的立方一直在被反复计算,而这些值从头到尾都不会变。那我们干脆在循环开始前就把它们算好存起来:

public class NarcissisticNumberOptimized { public static void main(String[] args) { int[] cube = new int[10]; for (int i = 0; i < 10; i++) { cube[i] = i * i * i; } for (int i = 100; i < 1000; i++) { int sum = cube[i / 100] + cube[i / 10 % 10] + cube[i % 10]; if (sum == i) { System.out.println(i); } } } }

你可能会问:代码好像没有变短,甚至多了几行,意义在哪?意义在于思路的转变:从“每次现算”变成“提前备好”。如果今天不是三次方,而是九次方;循环不是900次,而是几百万次,这招的收益立刻拉开数量级。后面的通用自幂数实现里,预计算幂表几乎是必须做的,不然每一轮循环都重复算一堆幂,性能很难看。

4.2 生成一个组合,而不是遍历一个区间

这算是最有“进阶感”的一节。我们观察一下水仙花数的判断过程:153满足条件,那531呢?351呢?315呢?它们的数字组合都一样,立方和也都是1+125+27=153。也就是说,一个数是不是水仙花数,只取决于“它包含了哪些数字、每个数字出现几次”,跟数字的排列顺序完全无关。

顺着这个思路,可以玩一个很大的操作:不再一个个遍历数字,而是去枚举“数字组合”。比如三位数,就是枚举0到9每个数字各出现几次(总次数是3),然后算组合对应的n次方和;如果这个和恰好是n位数,且这个和里面各个数字出现的次数正好等于自己枚举的组合,那这个和就是自幂数。

这就是组合生成法,是高效生成超大位数自幂数的核心思想。后面第7章我还会提一次。它最值钱的地方在于:把“遍历10ⁿ个数”的问题,变成“枚举组合”的问题。等位数大到10以上,一个一个遍历完全不现实,组合生成却依然能跑。很多看起来无解的问题,其实换个角度看,复杂度就塌下来了。

4.3 这段时间该不该优化?

说句大实话:如果只是完成作业,三位数版本完全不需要优化,直接跑就完了。但如果你想把“Java基础进阶”这几个字落到实处,我建议做两件事:第一,把代码改成通用版本,支持任意n位的自幂数判断;第二,试着写一个带预计算幂表的版本,感受一下“把循环内不变的东西提到循环外”这个习惯。这个习惯看起来不起眼,但在你以后写算法题、写性能敏感的工程代码时,价值会成倍放大。

5. 从立方到n次方:通用自幂数实现指南

5.1 自幂数家族的命名规则

很多人不知道,水仙花数只是自幂数家族里三位数的专用名字。四位数叫玫瑰花数,五位数叫五角星数,六位数叫六合数,七位数叫北斗七星数,八位数叫八仙数,九位数叫九九重阳数,十位数叫十全十美数。

这些花哨的民间名字其实不重要,重要的是它们共用同一套判断逻辑:位数为n,每位数字的n次方之和等于自身。所以,只把水仙花数写成“只有3位”是自断后路。真正会写的人,一套代码通吃所有位数的自幂数。

5.2 通用实现:根据位数动态计算指数

下面这个版本,输入任意正整数,都能判断它是不是自幂数:

public class NarcissisticNumberAdvanced { public static void main(String[] args) { // 输出三位和四位的自幂数 for (int num = 100; num < 10000; num++) { if (isNarcissistic(num)) { System.out.println(num); } } } static boolean isNarcissistic(int num) { int n = String.valueOf(num).length(); // 位数 int sum = 0; int temp = num; while (temp > 0) { int digit = temp % 10; sum += power(digit, n); if (sum > num) { // 剪枝:和已经超过自身了,直接放弃 return false; } temp /= 10; } return sum == num; } static int power(int base, int exp) { int result = 1; for (int i = 0; i < exp; i++) { result *= base; } return result; } }

运行结果:

153 370 371 407 1634 8208 9474

几个设计的考量:

  • 位数n用String.valueOf(num).length()拿,够用了,也简洁。
  • 自己写power整型幂函数,绕开Math.pow的浮点问题,一劳永逸。
  • 加了一个剪枝:一旦sum超过num本身,就提前return,后面的位数不用再算了。别小看这一行,跳过的是大量的无效计算。

5.3 位数越大,坑越深:int溢出问题

通用版本写好了,但你还得知道它的能力边界。int能表示的最大值是2147483647,而9ⁿ增长极快:9⁹=387420489,还在int范围内;9¹⁰=3486784401,已经超过int上限,直接溢出变成负数。

这意味着什么?n≥10的时候,幂运算结果已经不是int能装下的了,System.out.println出来的甚至可能是一堆负数。如果非要继续往上探,必须换成long,n再大一点就要上BigInteger。这也解释了为什么很多讲自幂数的算法文章会强调:高性能通用实现必须考虑大数运算,远不是几行int代码那么简单。

那是不是位数越大自幂数越多?不是。回到第1章那个不等式:n位自幂数要存在,至少需要n×9ⁿ ≥ 10ⁿ⁻¹。两边取对数算一下,你会发现n大到一定程度后,不等式根本不成立。所以十进制里的自幂数不是满天飞,而是非常稀少,整体数量是有限的。这个“先估上界、再写循环”的思路,在工程里非常实用——很多人写程序跑半天不出结果,问题往往出在“根本没意识到这个问题在这个规模下有没有解”。

5.4 从三位到六位,一览家族成员

把循环范围改成100到999999,通用代码会自动算出来:

位数自幂数
三位153、370、371、407
四位1634、8208、9474
五位54748、92727、93084
六位548834

看着这些数没觉得有什么规律,对吧?其实它们之间没有任何通项公式,只能一个一个算。这也是为什么“遍历+判断”这种朴素思路在数学里其实挺不可替代的——很多数论问题根本没有捷径可走。

6. 这些坑我替你踩过了:写给初学者的避坑清单

6.1 循环边界与位数判断

最常见的坑反而是最简单的坑。比如:

  • 循环写成 for(int i = 100; i <= 99; i++),直接空转,什么都没输出。
  • 循环写成 i < 1000 没错,但有人会手滑写成 i < 999,把999漏掉。999本身不是水仙花数,但漏掉边界的行为一旦养成习惯,以后做“判断质数”“找完数”都会出事。
  • 用String.valueOf(num).length()判断位数时,负数会带着负号一起算,比如“-153”长度是4。所以水仙花数判断一定得保证输入是自然数,要么循环从正数开始,要么进来先做一次num < 0的判断。

6.2 Math.pow 的浮点精度陷阱

这个坑在第3章已经提过,这里再展开一点。浮点数的隐患在于:它在二进制里根本没法精确表示很多十进制小数,所以会在某个你没注意到的地方突然给你一个意外值。比如经典的double累加0.1,最终得到0.30000000000000004。

用Math.pow时,它内部做的是浮点指数运算,理论上就存在舍入误差的可能。尽管对小整数幂来说,绝大多数情况下结果是精确的,但“绝大多数”不等于“100%”。一旦某天你在一个临界数值上遇到误差,你会在一个看起来很简单的题上浪费一整天。我的建议很简单:整数的固定次幂,一律写乘法或者自定义整型幂函数,不要让浮点进到这个逻辑里。

6.3 用字符串取数时忘了减'0'

如果你确实用了charAt方式拆位,那“取出字符后减'0'”这步必须刻进DNA里。charAt返回的是char,它的本质是ASCII码,'0'的ASCII码是48。如果你写s.charAt(0)而不减'0',那拿到的是48而不是0,整个立方和会膨胀到离谱,结果一个水仙花数都找不出来。

这算是“看着代码没错,跑起来全错”的经典案例。排查的时候,优先检查这种类型转换的地方。

6.4 水仙花数、完全数、回文数、自守数别搞混

基础概念一锅粥的情况我也见过不少。这里放一张表,一次分清:

名称定义例子
水仙花数(自幂数)n位数的各位数字n次方之和等于自身153、370、371、407
完全数所有真因子之和等于自身6、28、496
回文数正读倒读都一样12321、123321
自守数平方的末尾还是自己5(5²=25)、6(6²=36)、76

判别逻辑完全不同,但它们都是“遍历区间+条件筛选”这个模式的实例。你不妨把四个都写一遍,写完之后你对“数学定义到程序”的翻译能力会明显上一个大台阶。

6.5 测试边界值的习惯

最后说一个非常实用的工程习惯:写完代码不要只跑“标准答案”。至少手动验证几个点:

  • 153是水仙花数,你的代码能输出它吗?能。
  • 123不是水仙花数,你的代码会把125误判吗?大概率不会,但要测。
  • 100和999这两个边界,循环有没有正确覆盖?
  • 如果改成通用版本,100这个数位数是3,1³+0³+0³=1,不等于100,结果是正确的。

我见过有同学写的代码,基本数都能过,但边界值会漏。养成“边界值清单”的测试习惯之后,这类问题基本能一次性堵死。

7. 水仙花数之外:一类“遍历加筛选”算法思想的通用性

7.1 从自幂数到完全数:换个筛选条件而已

把水仙花数的代码骨架抽象一下,其实就是:

for (范围里的每个数) { if (这个数满足特定条件) { 输出这个数; } }

“特定条件”换成“真因子之和等于自身”,就变成了完全数。比如求10000以内的完全数:

public class PerfectNumber { public static void main(String[] args) { for (int i = 2; i < 10000; i++) { int sum = 0; for (int j = 1; j <= i / 2; j++) { if (i % j == 0) { sum += j; } } if (sum == i) { System.out.println(i); } } } }

运行结果就是6、28、496、8128。你会发现,这套代码的骨架跟水仙花数几乎一模一样。差别只在“条件判断怎么实现”。这个抽象能力,远比多背几段代码有用。以后你遇到任何“在一堆数里筛出满足性质的一群数”的问题,都会第一时间建立这个框架。

7.2 组合生成思想:从10ⁿ次遍历到组合遍历

第4章已经讲过组合生成法的基本思想,这里再说说它的威力。如果要找出所有20位的自幂数,遍历10²⁰个数?就算每纳秒处理一个,也要几百亿年。但组合生成法把问题缩减到“枚举0到9每个数字出现几次”,状态数量急剧压缩,普通PC也能在可接受时间内跑完。

这个思路的价值远超水仙花数本身。它教你的东西是:遇到一个大得离谱的搜索空间,先停下来想“空间能不能折叠”。很多看似不可能的搜索问题,换一个角度后复杂度会断崖式下降。这种思维,项目里处理海量日志、做去重、做组合优化时都非常吃香。

7.3 我建议你怎么练这道题

作为Java基础进阶题目,我建议按这个顺序练:

  1. 先不看任何答案,写一个纯三位数版本,跑出153、370、371、407。
  2. 写完后,把它改成通用n位版本,并跑通三位和四位的全部结果。
  3. 给通用版本加上预计算幂表,再跑一遍,感受性能变化。
  4. 照着完全数的题目写一遍,体会“换条件不换骨架”。
  5. 学有余力的话,查一下组合生成法,试着生成五位往上的自幂数。

按这个顺序练完,这道题就不再是“背答案题”,而是一套完整的算法思维训练。我平时带人入门Java,也一直是用这个顺序。从最简单的循环开始,一层一层加“数学理解、边界意识、性能优化、泛化抽象”,全部练到位了,基础进阶这四个字才算真正落地。

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

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

立即咨询