☰
从水仙花数到Java基础:拆数、幂运算与性能优化实战
2026/10/12 22:07:28 网站建设 项目流程

1. 水仙花数并不只是一个编程练习题

第一次接触“水仙花数”这五个字,是在学完循环和分支语法之后。当时教我的前辈说:你把三位数里的水仙花都找出来,就算入门了。我心想这有什么难的,不就是拆数、求幂、再比较吗。结果自己上手一写,输出的结果只有153一个,跟标准答案里的153、370、371、407对不上,查了半天才发现是十位数的拆法出了问题。

水仙花数(Narcissistic Number)也叫阿姆斯特朗数,是指一个n位数,它的每个位上的数字的n次方之和恰好等于它本身。比如上面的四位水仙花数1634,就是1^4 + 6^4 + 3^4 + 4^4 = 1634。这个概念来自数论中的一个分支,原意是“自恋数”——一个数自我欣赏到极致,各个位上的数字组成它自己的“全部魅力”。

但我觉得,这道题真正的价值根本不在数学概念上,而在于它把Java中几个核心基础点串在了一起:运算符优先级、整数除法和取余的差异、循环边界、幂运算的精度、方法的封装意识。一套组合拳下来,正好检验你对“基本类型到底怎么在内存里转圈”的理解程度。

很多初学者会把水仙花数当成一道“背答案”的题目,搜一段代码跑通就过去了。这样做就白白浪费了这个练习。我这篇就按从易到难的节奏,把这道题的多种实现方案、性能差异、踩坑记录全部摊开来讲。无论你是刚学完语法的新手,还是准备面试的求职者,顺着这篇文章的思路走一遍,对Java基础的理解会明显扎实一截。

2. 最直观的写法:先把三位数算明白

思考水仙花数问题的第一步,不是写代码,而是先想清楚“三位数的水仙花数”具体要满足什么数学关系。

设一个三位数的百位是a、十位是b、个位是c,那么这个数就是100a + 10b + c。水仙花条件为a^3 + b^3 + c^3 = 100a + 10b + c。我们要做的,就是把100到999之间所有整数拆成a、b、c三个数字,逐个验证等式是否成立。

在Java里,拆分一个三位数有各种姿势,我的建议是先别耍花活,老老实实掌握“除法和取余”这一套,因为这是最底层、也是性能最优的拆法。

2.1 拆分数字的三种常见姿势对比

任何一种“把一个数拆成单独数字”的需求,本质上都是利用“整数的除法和取余是在十进制位权上操作”这个特性。

方案一是纯算术运算,用连续除法和取余组合实现。要拿到百位,就直接除以100,整数除法会自动丢掉小数点;要拿十位,先除以10丢掉个位再对10取余;要拿个位,直接对10取余就行。这套方式只涉及整数运算,没有任何类型转换,跑得最快。

方案二是转换成字符串,用charAt取字符然后再转回int。写起来直观,但每次转换都要创建字符串对象,在循环里用的话会产生垃圾对象,而且字符转数字还得做一次ASCII运算(或者调用Character.digit方法)。性能差距在小数据量下看不出,但是养成了习惯,遇到大数据量就会吃亏。

方案三是用Math.pow求幂,直接在判断条件里写成Math.pow(a, 3) + Math.pow(b, 3) + Math.pow(c, 3)。这个写法最容易把初学者带沟里去,因为Math.pow的签名是double pow(double a, double b),返回值是double,跟int做相等判断会涉及类型转换。虽然三位数场景下没问题,但隐藏着精度隐患。

我给出的建议很明确:基础阶段把方案一练成肌肉记忆,把方案二当备用手段,方案三在“必须用幂运算”的场景才上,而且要做类型匹配处理。

2.2 从数学式到Java代码的完整推演

先写最标准的版本,代码放在循环里,从100遍历到999,每次循环拆出三个数字,计算立方和,再与原来的数比较。

public class NarcissisticNumberDemo { public static void main(String[] args) { System.out.println("三位水仙花数:"); for (int i = 100; i < 1000; i++) { int hundreds = i / 100; // 百位 int tens = i / 10 % 10; // 十位 int units = i % 10; // 个位 int sum = hundreds * hundreds * hundreds + tens * tens * tens + units * units * units; if (sum == i) { System.out.println(i); } } } }

这个代码跑出来的结果是153、370、371、407,四个数全对。

这里有必要解释一下i / 10 % 10这个写法。先执行i / 10,因为i是int,所以得到的是一个去掉个位的数。比如371除以10,整数结果是37,然后再对10取余,得到7,正好是原数的十位。同理,i / 100得到3,就是百位。写成i % 100 / 10也能得到十位,结果一样,但思维路径略有不同——前者是“先扔掉低位再取低位”,后者是“先截断高位再取中间”。两种写法的前提都是整数除法向下截断,这个知识点不到二进制位运算层面是绕不开的,建议牢牢记住。

还有细节值得注意:循环边界i < 1000用的是开区间,等价于i <= 999。这里别写错,写i <= 1000就会出现1000这个四位水仙花数的误报——四位数的幂指数就不该是3了,这是逻辑错误。

整个循环从100开始,不需要从0或1跑,因为三位数水仙花本身不可能是1位或2位。但如果你一开始不确定“三位数”的边界,直接从1开始跑也只是一次完整的数学验证,代码依然正确,只是多做了一些无用功。真正的工程思维是尽量缩小搜索空间。

2.3 这段代码有哪些隐藏问题

第一个问题,命名。hundreds、tens、units这种命名比直接用a、b、c清晰得多。我在看人家代码的时候,见过大量int a = i / 100; int b = i / 10 % 10; int c = i % 10;的写法,代码能跑,但是两个月后回看就忘了a是百位还是十位。基础代码的命名习惯,一开始就要立好。

第二个问题,幂运算的写法。代码里我直接用了hundreds * hundreds * hundreds,这在三维时正确,但换个位数就要重写逻辑,扩展性为零。如果一眼要先跑通验证,可以先这么写,接下来进通用版本再重构。

第三个问题,这段代码没法处理0。如果改成四位数,有某个位上是0,比方说数字1024,那么tens = 1024 / 10 % 10算出来的是2,正确;但如果数字的某一位连续有多个0,比如1005,拆的时候会得到1、0、0、5,这个逻辑在“按位拆”的场景下没问题。问题出在你想把0也当作一个“位”参与幂运算,那逻辑要写成另外的版本,比如用字符串。所以我总提醒学生:先确定好“每一位上的数字”,还是“每一个数字0到9的出现次数”,这两条路对应不同算法,别混在一起想。

3. 进阶玩法:从三位扩展成任意位数

一个只会算三位数的程序,练的是基础语法;能处理任意位数的程序,才是真的把“递归思想”“循环控制”“类型范围”这些东西融会贯通了。

水仙花数的定义本身就是通用的:一个n位正整数,每个位上的数字的n次方之和等于它本身。那么我们完全可以写一个方法,传入一个整数,返回它是否满足“n位自幂数”的条件。

3.1 位数的获取与幂运算的处理

获取一个数的位数,最简单的办法是循环除以10,每除一次计数器加1,直到数变成0。这里的边界陷阱是对于0这个数:如果传入的数是0,1位数,按严格定义0^1 = 0也算水仙花数。但大多数题目默认讨论正整数,所以我在通用实现里把0排除掉,或者单独处理。

求某个数字digit的n次方,最直接的办法是写个自乘循环,不依赖Math.pow,避免double转int的精度问题。

static long power(int base, int exp) { long result = 1; for (int i = 0; i < exp; i++) { result *= base; } return result; }

这里为什么用long而不是int?因为随着位数变大,幂运算结果增长极快。比如9^7是4782969,超出int的21亿还远,但到10位数时9^10是3486784401,超过21亿的上限了。用long能撑到更大的范围,但long也不是无限大的,后面我还要讲溢出问题。

3.2 通用版本完整代码实战

public class GeneralNarcissisticNumber { public static void main(String[] args) { int count = 0; for (int num = 1; num < 10000000; num++) { if (isNarcissistic(num)) { System.out.println(num); count++; } } System.out.println("总计:" + count); } static boolean isNarcissistic(int num) { int digits = countDigits(num); long sum = 0; int temp = num; while (temp > 0) { int digit = temp % 10; sum += power(digit, digits); if (sum > num) { return false; // 提前终止,减少无效计算 } temp /= 10; } return sum == num; } static int countDigits(int num) { int count = 0; while (num > 0) { count++; num /= 10; } return count; } static long power(int base, int exp) { long result = 1; for (int i = 0; i < exp; i++) { result *= base; } return result; } }

在循环里加了一行if (sum > num) return false;,这是我很早就想加的优化。因为水仙花要求sum必须严格等于num,如果累加过程中sum已经超过了num,后续再加正数只会更大,直接判断不满足,省掉后面所有幂运算。这个操作在循环遍历大量数字时,能省下不少无效计算。

这个程序跑到1000万以内,输出的是所有位数不大于7的水仙花数。你会看到除了三位数的4个,还有四位数的1634、8208、9474,五位数的54748、92727、93084,六位数的548834,以及七位数的1741725等。

跑这个通用版本,你需要留意int的最大值是2147483647,所以循环上限无法超过10位数。如果真想算10位以上,就要换long甚至BigInteger。

3.3 为什么我建议自己写power而不是用Math.pow

Math.pow用起来简单,但它有两个问题。

一个是精度问题。Math.pow返回double,而double的精度是有限的。在数字比较大的时候,比如计算9^10这种,double能精确表示的整数范围其实只到2^53以内,超出这个范围就会产生舍入误差。虽然9^10的数字还在安全范围内,但当你算到更高次幂,误差会累积,最后比较相等时可能出现“明明数学上相等,程序里却不相等”的诡异现象。

另一个是语义问题。Math.pow(a, b)的b是double类型,底层实现会走log和exp的计算路径,性能远比循环自乘慢。如果你只调一两次感觉不到,但在循环里成千上万次调用Math.pow,性能差异就会显现。写一个专门的整数幂方法,语义明确,也没有类型转换的隐患。

这三行代码换来的是一劳永逸。等到后面用“预计算幂表”做优化时,这个power方法还能直接复用。

4. 再进一步:换思路解同一道题

前面的实现方式是“按数位拆解”,换个流行说法,就是把每个数看作一个“位串”,逐位处理。但这道题还有另一种解题思路,核心变化是从“按数找数字”变“按数字拼数”。

4.1 字符串拆分法:写起来快,但也容易掉坑

有人喜欢把数字转成字符串,然后逐个字符处理,这种写法很适合快速验证。

static boolean isNarcissisticByString(int num) { String s = Integer.toString(num); int n = s.length(); int sum = 0; for (int i = 0; i < n; i++) { int digit = s.charAt(i) - '0'; sum += power(digit, n); } return sum == num; }

这段代码逻辑很直白,但有一个新手几乎必踩的坑:charAt(i)返回的是char类型,直接做加法会得到它的ASCII码。如果你写int digit = s.charAt(i);,'0'的ASCII码是48,那么取出来的“0”其实是48,“1”是49,算出来的幂就全错了。正确做法一定是charAt(i) - '0',用字符'0'的码值做偏移量,把数字字符映射回真正的0到9。

这个坑看起来小,但在面试现场手写代码的时候特别容易犯。我见过好几个练习时用算术法很熟的同学,一换字符串写法就翻车。所以两种写法都要会,就要记住这个映射关系。

4.2 用组合思路实现更高位的搜索

如果只是暴力遍历到1000万,那算到七位数还行,八位数就需要1000万次的幂计算,还能撑住;但十位数就需要100亿次,根本跑不动。这时候就需要换个思路:不去遍历所有整数,而是枚举0到9每个数字出现多少次。

一个n位数里,每个数字出现的次数之和是n。一旦确定了各个数字的出现次数,就相当于确定了一个“候选数”的数字组成,此时可以算出它的幂和,再检查幂和对应的那个数的数字组成是否恰好就是原来那组次数。比如你假设某个n位数里数字1出现1次、数字5出现2次、数字7出现1次,那么幂和就是1^n + 5^n + 5^n + 7^n,算出一个结果,再统计结果数字里有没有1个1、2个5、1个7。如果匹配,这个幂和就是一个水仙花数。

这种“回溯剪枝”策略,复杂度从10^n骤降到组合数级别。我在实际开发中做“数字统计类”的算法题目时经常用到这个思路。但要注意,这套逻辑虽然数学上更优雅,代码复杂度也明显上一个台阶,适合学习递归和剪枝之后回头挑战,不建议刚学完基础语法就硬啃。

4.3 递归实现水仙花数检测

递归在这个题目里其实不做主流程,但可以用它来遍历所有数字组合。比如生成某一位固定后的剩余位数的所有排列,或者计算位数时用递归代替循环。

递归的写法确实比循环优雅,但初学者用在这里容易陷入两种误区:一是递归深度过大导致栈溢出,二是忘记设置终止条件而无限循环。就拿统计位数来说,递归写法大概是countDigits(num) = countDigits(num / 10) + 1,终止条件是num == 0时返回0。这种写法本身没什么问题,但对性能的要求远高于循环,因为每次递归调用都要压栈。

我的实际经验是:在Java里,递归更多的是一种思维方式训练,而不是常规解题工具。你练水仙花数,核心是要掌握循环和分支,递归属于“以后用到了再补”的内容。真正的面试官不会因为你用循环而否定你,但会因为你递归边界不清而扣分。

5. 写水仙花数最容易踩的坑

这题虽然基础,但我见过太多人在细节上翻车。下面几条都是我亲眼见过、或者自己踩过后才明白的坑,逐条整理出来。

5.1 除法和取余的混用是重灾区

第一个高频错误就是把十位数的取法写成i % 100,然后拿这个对100取余的结果继续求幂。比如357,i % 100得到57,然后当成十位数去三次方,结果当然不对。

十位数的标准取法是i / 10 % 10。这里的关键是理解整数除法会截断小数部分:357除以10得到35(不是35.7),35再对10取余得到5。而i % 100 / 10则是先取357的后两位57,57除以10得到5。殊途同归,但建议固定用第一种,形成习惯。

如果你连“整数除法截断”这个机制都还没想通,我建议用一个具体的调试案例走一遍。比如把371这个数代入代码,自己拿纸笔算一遍百位、十位、个位怎么一步步取出来的。这种模拟计算的过程,比看十遍代码都管用。

5.2 Math.pow带来的浮点数精度暗坑

在泛化版本里用Math.pow会对int和long产生精度影响。举个例子,Math.pow(9, 18)真实结果是150094635296999121,但double类型实际能表示的是150094635296999120。相差1,在某些强比较场景就会出问题。

我的建议很直接:在水仙花数这个题目里,幂运算一律用自乘或预计算表,不要跟Math.pow纠缠。等以后去了真正的后端开发,涉及浮点运算时再单独处理精度问题,现在基础阶段先把精度隐患规避掉。

5.3 int溢出之后你看到的是负数

当循环上限超过int最大值,比如用for (int i = 1; i < 1000000000; i++)这种写法,i本身在跑到约21亿之后会溢出成负数,循环会陷入死循环,甚至跳出水仙花的范围。我在帮人排错时见过几次,代码逻辑完全没问题,就是int的范围不够。

解决方法是使用long类型,上限提升到9223372036854775807。如果你真想算几十位数的水仙花,那就得引入BigInteger,但那属于另一个层面的复杂度了。就这道题而言,把类型范围想清楚就已经及格。

5.4 循环边界的半开区间习惯

Java的for循环我习惯写成i < 1000这种半开区间,因为这样处理边界最自然,不用纠结“等于”要不要包含。但在水仙花数这道题里,容易弄错的是你搜索上限到底该写多少。

三位数搜索范围是100到999,循环写成for (int i = 100; i < 1000; i++)。如果有人写成i <= 999,也对,但在我带过的项目代码评审里,看到这种闭区间写法我会顺手改成开区间,一是习惯,二是避免出现“应该算到999却写成1000”的笔误。这跟数据库分页查询左闭右开的习惯是同一个道理。

5.5 把功能封装成方法的意识

前面几版代码虽然都能跑,但如果你把判断水仙花、统计位数、求幂这三个逻辑全塞在main方法里,代码会越来越长,维护性跌到谷底。

我建议从一开始就用独立方法隔离这三个功能。isNarcissistic(int num)只负责判断,countDigits(int num)只负责数位统计,power(int base, int exp)只负责幂运算。每个方法只干一件事,单元测试好写,代码可读性也高。这个“单一职责”的思维习惯,越早养成越好。等你后面的项目代码复杂起来,就知道我当时为什么反复强调这个了。

6. 从水仙花数延伸出去的几个知识点

写完了核心实现和避坑指南,最后再聊几个从这道题延伸开来的知识点。这些内容跟水仙花数本身不完全一样,但都是同一个家族的数字谜题,理解之后能帮你加深“数位运算”的整体感觉。

6.1 自幂数家族的其他成员

水仙花数只是自幂数中的一种特例。三位数叫水仙花数,四位数叫玫瑰花数,五位数叫五角星数,六位数叫六合数,七位数叫北斗数。这个命名纯粹是为了好玩,但本质都是同一个公式:各位数字的位数次方之和等于它本身。

如果你把“位数次方”改成“固定次方”,比如所有三位数里满足每位数字的三次方之和等于本身的,你得到的就是153、370、371、407。但改成“固定次方”这个限制条件更灵活,能练习不同幂指数下的搜索逻辑。我曾经在改版题目中让学生自己生成“任意指数下的1到n位数的自幂数”,结果发现处理大数位时性能掉得厉害,逼着他们去想组合枚举的优化。这些深入思考对培养算法思维很有帮助。

6.2 面试里的变体和升级方向

在面试中,水仙花数本身很少直接出,但它的变体经常出现。

比如“给定一个整数,判断它是否能写成各位数字的m次方和”这类,本质上还是同一套位运算。更常见的是“统计某个区间内水仙花数的个数”,这种题会考察你循环边界的处理和剪枝优化。

还有一种变体是把数字反转、回文数、数字之和、数字乘积混在一起出。比如“请写一个方法,判断一个字符串表示的数字是否是自幂数”这种,考察你对大数的处理能力。如果你只会用int挨个遍历,遇到这种带字符串限制的题目就会懵。所以我把字符串拆分法也放进文章里,就是为了应对这类情境。

6.3 这道题对Java基础学习的真实意义

水仙花数这道题的真正价值,不在于那几个神秘的数字,而在于它逼迫你把每个基础语法点在“真实问题”里过一遍。你在课堂上学过运算符优先级、学过类型转换、学过循环嵌套,但只有当你动手写这道题时,才会发现你对这些东西的理解有多模糊。

我第一次写这道题时,卡在了十位数的取法上;第二次写,卡在Math.pow的double返回值;第三次写,才注意到long的范围问题。每一次卡住,都是对Java基础的一次加固。

如果你现在正在学Java,我建议你把这道题当成“自我测试题”:不看任何参考答案,从零开始写一遍三位数版本,再改成任意位数版本,再想想能不能用字符串或者组合法解。每换一种思路,你对这个语言的理解就会更深一层。

另外再补充一个建议:实现完成后,试着用单元测试跑几个典型值。测试用例的设计本身也是Java进阶的重要内容,能帮你验证边界条件和容错处理。这道题非常适合拿来练手测试框架,因为输入输出非常明确,不会产生歧义。

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

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

立即咨询