水仙花数这个题目,我大概在C语言学习的各种场合见过不下十次。教材的循环章节会放它,在线练习平台的基础题会放它,甚至有人聊到C语言基本功时也会随口问一句:给你一个三位数,怎么判断它是不是水仙花数?我第一次写的时候,代码跑起来屏幕上干干净净,一个数字都没出来。折腾了半天才发现,判断之前我把待判断的数本身改成了0,后面的循环全在用0做文章。今天这篇就把这个经典题目完整拆一遍:从定义、基础版本、常用写法到n位扩展和排错思路,适合刚学完循环和函数的同学,也适合想回炉基本功的熟手。
1. 水仙花数到底在考什么:从数学定义到解题路线
1.1 先把这个定义说清楚
水仙花数(Narcissistic number)的数学定义是:一个 n 位数,它的每一位数字的 n 次方之和,恰好等于它本身。最常被拿来当例子的三位水仙花数是 153、370、371、407。以 153 为例:1^3 + 5^3 + 3^3 = 1 + 125 + 27 = 153。
注意定义里的“n”是随位数变化的。三位数是三次方,四位数四次方,五位数五次方,这一点很多人会漏掉,后面我会专门讲n位版本。这里的“位”指的是十进制中的数位,三位水仙花数就只可能在 100 到 999 之间,0、9 这些“不够三位”的数字不在讨论范围内。
1.2 题目真正想考察的三个能力
表面上看,这是一道数学题,实际上程序题考的是三件事。
第一件事是数位拆分。一个整数在变量里不是“分开的三个数字”,它是完整的一个数。你要拿到个位、十位、百位,就必须用 % 10 取余数,用 / 10 去掉最右一位,反复几次。
第二件事是循环与累加。对不确定位数的数,拆位循环怎么写、累加变量什么时候清零、原值要不要备份,这些都是循环基本功。
第三件事是条件判断与输出。判断成立之后的输出格式,恰恰是评测最容易出问题的地方。
1.3 从数学公式到代码步骤的翻译
把定义翻译成代码,总共四步。
第一步,确定候选范围。三位数就是 100 到 999。 第二步,对候选数拆位,用取余、整除循环。 第三步,把每一位的 n 次方累加起来。 第四步,比较结果和原数,相等就打印。
这四步很朴素,但几乎适合所有“数字统计类”的题目。类比一下:一个整数很像一串珠子,% 10 是摘下最右边一颗,/ 10 是让珠串变短,循环的终态是珠串只剩 0。这里有个天然陷阱:拆位操作会改变存数的变量,所以判断之前必须先把原始值复制到临时变量,再用临时变量去拆。这是初学最常错的一个点。
2. 先把三位数版本写对:循环、拆位与累加的细节
2.1 一个不依赖任何库的完整版本
下面是三位数版本,不用 math.h,不碰浮点,只用整数运算:
#include <stdio.h> int main(void) { for (int num = 100; num <= 999; num++) { int original = num; int sum = 0; while (original > 0) { int digit = original % 10; sum += digit * digit * digit; original /= 10; } if (sum == num) { printf("%d\n", num); } } return 0; }这份代码以 C99 以上的标准运行没有问题,现代编译器默认都能编译。核心思路很简单:外层 for 负责枚举,内层 while 负责把一个数的数字一个个拆出来,边拆边累加三次方。等 while 结束后,如果 sum 和 num 相等,就说明这个候选数是水仙花数。
2.2 这段代码里最容易被改坏的三处
第一处是忘记备份原数。如果不写int original = num;,而是直接在 num 上动手,那么 while 拆完以后 num 已经变成 0,这一步会连带影响外层的 for 循环计数,整个程序直接乱套。所以我习惯把“原值备份、临时值、判断结果”这种分工固定下来:凡是需要修改的临时变量,一律复制一份。
第二处是 sum 的初始化位置。sum 必须在每次外层循环开始时清零。如果把它定义在 for 外面,只清零一次,那么第一个数算完的 sum 会一直带着累加结果,后面的数几乎不可能判断正确。换句话说,外层循环的每次迭代都不能“继承”上一轮的垃圾值。
第三处是 while 里只写了取余,忘了整除。digit = original % 10之后必须紧接着写original /= 10,否则 original 一直不变,循环永远不会结束。看起来低级,但写顺手的时候真的很容易丢。我后来检查别人的代码,至少有一半的“死循环”问题出在这一行。
2.3 运行结果与验证方法
编译运行上面这段代码,输出应该是:
153 370 371 407怎么验证?拿其中任何一个数,比如 370,拆位得到 3、7、0,三次方分别是 27、343、0,加起来是 370,成立。另外两个同理。
还有一种更“笨”但不依赖答案的验证方法:把代码里 if 条件改成printf("%d %d\n", num, sum);跑一遍,看看每对数值是否符合预期。这个方法在排查任何数字类问题时都很实用,后面排错章节会再提到。
3. 我为什么不推荐你直接用pow函数
3.1 看着省事的pow版本
如果只追求代码短,自然会想到用 math.h 里的 pow。写出来大概是:
#include <stdio.h> #include <math.h> int main(void) { for (int num = 100; num <= 999; num++) { int a = num / 100; int b = num / 10 % 10; int c = num % 10; if (num == (int)pow(a, 3) + (int)pow(b, 3) + (int)pow(c, 3)) { printf("%d\n", num); } } return 0; }第一次看会觉得挺方便,至少不用写 while 循环了。但我不建议在整数判断场景里用它,原因有三个。
3.2 浮点误差:整数判断里最不该出现的东西
pow 是浮点函数,参数和返回值都是 double,数学库计算过程是近似实现。绝大多数时候它算出的值刚好是整数,但并不是所有平台、所有编译器都保证这一点。
在某些数学库实现中,类似 pow(10, 2) 这样简单的计算都可能返回 99.9999999;在特定浮点环境或交叉编译环境下,pow 的结果被 int 强转时会直接截断小数部分,10 就变成 9。这不是数学库“坏了”,而是浮点近似的固有属性:它天生表示的是“最接近真实值的浮点数”,不承诺精确。
更麻烦的是,double 和 int 比较时 int 会先转换成 double。就算误差只有 1e-12,== 判断依然成立不了。也就是说,一个纯整数的逻辑,混进浮点数之后凭空多了一层不确定性。这是最根本的问题。
提示:判断一个东西是不是整数,只要可能,就坚持用整数运算。浮点只留给真正需要小数的场景。
3.3 更好用的查表方案
统计一下就会发现,一位数字只有 0 到 9 十种可能,三次方结果完全可以在开头一次性算好。预计算放进数组:
int cube[10]; for (int i = 0; i <= 9; i++) { cube[i] = i * i * i; }循环累加时,把digit * digit * digit换成cube[digit]。从运行效果看,数组取值比重复三次整数乘法更直观,也更便于将来把表扩展成多位次方。
这种方式保留了整数运算的属性:不进浮点环境,不依赖数学库,所有结果都是确定值。对评测来说,确定值永远比“大概率正确”更让人放心。
3.4 三种写法的直观对比
| 写法 | 代码长度 | 精度风险 | 扩展性 | 推荐度 |
|---|---|---|---|---|
连乘三次digit * digit * digit | 短 | 无 | 三位没问题,n位需要改 | 适合初学 |
pow(a, 3) | 最短 | 有 | 位数变化要改指数 | 不推荐 |
查表cube[digit] | 稍长 | 无 | 容易扩展成二维表 | 推荐 |
选择标准就一句话:在整数判断场景里,优先保证运算结果是确定整数。写代码不是越短越好,而是越稳越好。
4. 从三位数走向n位数:位数判断、类型选择与查表优化
4.1 从“固定3次方”到“看位数取指数”
三位数的特点是幂次固定,所以可以用 a、b、c 硬编码。一旦题目变成“四叶玫瑰数”,即四位数的四次方,再写 a / b / c / d 就开始难看了。真正的通用做法是:先统计这个数有几位,再把每一位按位数取幂。
判断位数需要 count_digits 函数:
int count_digits(long long x) { int n = 0; do { n++; x /= 10; } while (x > 0); return n; }这里用 do-while 而不是 while,是为了让 x=0 时也能返回 1。实际判断中我们从 1 开始遍历,0 不参与,但写成 do-while 更稳健,函数本身也经得起各种边界输入。
有了位数 n,指数就确定了,拆位循环不需要知道数字具体在哪一位,只要每次取最右一位、查表累加即可。
4.2 查表法与long long的完整方案
既然指数会变,就把 0 到 9 每个数字的多次方都提前准备好。9 的 10 次方已经超过 32 位 int 上限,所以表用 long long:
long long table[10][11]; void build_table(void) { for (int d = 0; d <= 9; d++) { table[d][0] = 1; for (int p = 1; p <= 10; p++) { table[d][p] = table[d][p - 1] * d; } } }判断函数:
int is_narcissistic(long long x) { int n = count_digits(x); long long temp = x; long long sum = 0; while (temp > 0) { int digit = temp % 10; sum += table[digit][n]; temp /= 10; } return sum == x; }主函数:
int main(void) { build_table(); for (long long i = 1; i < 1000000LL; i++) { if (is_narcissistic(i)) { printf("%lld\n", i); } } return 0; }这段代码把“枚举”“判断”“输出”分成了清晰的三块。is_narcissistic 里先算位数,再按位拆、按位查表、累加,最后返回布尔结果。
另一个值得留意的点是 long long 的选择。写代码前最好先算一遍极端值:就算处理 10 位数,9^10 = 3486784401,超过 int 的 2147483647。类型选错,数据悄悄溢出,打印出来的结果就会莫名其妙,而且这种错误在本地很难一眼看出来。
注意:改类型之前先算极端值,这是避免溢出类bug最朴素的办法。
4.3 输出结果与扩大范围的注意事项
跑 1 到 999999,能得到:
| 位数 | 水仙花数 |
|---|---|
| 三位 | 153、370、371、407 |
| 四位 | 1634、8208、9474 |
| 五位 | 54748、92727、93084 |
| 六位 | 548834 |
如果想看 10 位以内的全部水仙花数,把循环上限改成 1000000000LL,程序会明显变慢。因为每判断一个数都要先数位数、拆位,时间复杂度大约是 O(上限 × 位数)。慢不是大问题,关键是想清楚为什么慢,再考虑是否要优化。
更高效率的做法不是遍历整数,而是改成枚举“数位组合”。比如 10 位数一共 10 个位置,每个位置放 0-9,再排除最高位为 0 的情况,排列组合数量远小于十亿次遍历。这个方向足够单独写一篇,本文先把基础路线打通。
5. 实测中最容易翻车的三个现象与排查思路
5.1 现象一:程序跑完,一行输出都没有
如果代码编译通过、运行正常,但屏幕上空白一片,先别急着改算法。最有效的办法是在判断条件前后临时加 printf,把 num 和 sum 打出来。比如判断 153 时 sum 是不是 153,判断 100 时 sum 是不是 1。看到几个输出,问题基本就定位了。
常见原因有三种。第一,sum 在外层只初始化了一次,后面每个数都带着前一轮的结果,导致判断条件永远不成立。第二,if 里比较的是 original,但 original 在拆位后已经变成 0,条件当然恒假。第三,printf 漏了换行或者被放在了 if 外面,结果看起来像“没输出”,实际上数据可能已经刷过去了。
5.2 现象二:程序死循环,窗口卡住不动
死循环的元凶绝大多数是拆位循环少写了一行。如果while (original > 0)里只有digit = original % 10,没有original /= 10,那么 original 一直不变,循环永远跳不出来。
判断是不是这个原因很简单:看循环体最后有没有“把变量变小”的操作。拆位、倍减这类算法,都需要一个操作把原值一步步推向 0。补上original /= 10之后,问题一般就消失了。
另一种不太常见但也会卡死的情况,是在拆位循环里误改了外层循环变量,比如把 num 当成 original 来操作。这种问题通过“原值备份到 temp”就能避开,原理和章节 2.2 说的一模一样。
5.3 现象三:本地好好的,一提交就判错
本地正常、评测出错,通常集中在三个点上。
第一个是 float/double 混入整数判断。本地编译器某个 pow 实现恰好精确,换到评测环境就不一定了。解法是回归纯整数运算,像查表法那样。
第二个是输出格式。很多评测系统要求每行一个结果,行尾必须有换行。printf("%d\n", num)和printf("%d ", num)的差异,足以让人改到怀疑人生。
第三个是范围理解。如果把 0 或 1 也输出了,大概率是循环范围从 0 开始,而不是 10^(n-1)。三位水仙花数要严格从 100 开始。
5.4 一份可以直接照做的排错清单
| 现象 | 优先怀疑点 | 快速验证方法 |
|---|---|---|
| 空白输出 | sum 未清零、比较变量被改、printf位置 | 打印 num 和 sum |
| 死循环 | 缺original /= 10 | 检查循环体最后一行 |
| 评测判错 | pow精度、输出格式、范围 | 本地多测几个数并对比格式 |
| 数字溢出 | int 类型装不下 9^10 | 改用 long long |
排错的总体思路其实很固定:把“中间结果”暴露出来,而不是盯着最终输出猜。编译时加上 -Wall -Wextra 也能提前抓到很多可疑代码,比如类型转换和可能未初始化的变量,都是靠这种编译选项先兜一层。
6. 水仙花数做完之后,下一题该练什么
6.1 换指数、换位数:四叶玫瑰数与五角星数
水仙花数按位数有各种叫法:四位数叫四叶玫瑰数,五位数叫五角星数。它们跟三位水仙花数是同一套逻辑,只是把范围改成 1000-9999、10000-99999,同时把幂次表的列从 3 换到 4、5。
用前面 n 位版本,只需要改主函数的遍历上限和 table 的列数,其他函数基本不动。这种“定义不变、参数一变就能跑”的体验,恰好说明把判断逻辑封装成函数有多省事。
6.2 同样用“拆位”思路的其它练习
拆位循环是 C 语言练习题里最常用的模式。数字反转:把 123 变成 321;回文数:判断正着读和倒着读是否一致;完数:找出因子并求和判断是否等于本身。这些题都会用到取余、整除、临时变量备份这三板斧。
练完水仙花数以后顺手做两三道相关题,循环体和变量作用域的边界会变得特别清楚。很多人到后面写链表反转、字符串处理时写得很顺,往往就是早期拆位题打下的底子。
6.3 从练习到工程:函数拆分与模块化意识
建议把最终版本按函数拆好:build_table 负责初始化,count_digits 负责统计位数,is_narcissistic 负责判断,main 只做枚举。每个函数只做一件事,名字说明意图。
这个习惯看着小,作用很大。刚学编程时大家都是一大坨 main,写久了就体会到:能拆函数的地方尽量拆,既方便调试,也方便复用。水仙花数可能是你写的第一个值得这样拆的题目,用心拆一次,之后很多类似的题都能顺手套用同一套结构。
我后来回头看,水仙花数真正让人受益的地方,不是背下四个数字,而是让人第一次认真想清楚循环里哪些变量要重置、临时变量要备份、类型要提前算。这几个习惯,放到以后的任何工程代码里都不过时。