☰
C语言水仙花数从入门到写出通用n位版本:拆位、整数运算与排错
2026/10/20 9:34:14 网站建设 项目流程

水仙花数这个题目,我大概在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,写久了就体会到:能拆函数的地方尽量拆,既方便调试,也方便复用。水仙花数可能是你写的第一个值得这样拆的题目,用心拆一次,之后很多类似的题都能顺手套用同一套结构。

我后来回头看,水仙花数真正让人受益的地方,不是背下四个数字,而是让人第一次认真想清楚循环里哪些变量要重置、临时变量要备份、类型要提前算。这几个习惯,放到以后的任何工程代码里都不过时。

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

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

立即咨询