
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这个数如果传入的数是01位数按严格定义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.powMath.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取余的结果继续求幂。比如357i % 100得到57然后当成十位数去三次方结果当然不对。十位数的标准取法是i / 10 % 10。这里的关键是理解整数除法会截断小数部分357除以10得到35不是35.735再对10取余得到5。而i % 100 / 10则是先取357的后两位5757除以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进阶的重要内容能帮你验证边界条件和容错处理。这道题非常适合拿来练手测试框架因为输入输出非常明确不会产生歧义。