先解释一个很多新手容易看走眼的地方。完数这个定义里的“因子”,指的是“除它本身以外的正因子”,也叫真因子。如果不注意这个限定,6的全部因子是1、2、3、6,加起来等于12,那所有数都不可能是完数了。题目里那句“例如6=1+2+3”,其实已经把规则写清楚了,但实际写代码时,不少人会不小心把自身加进去,导致判断结果永远是 false,这是第一个隐藏的坑。
这道题我见过太多次了。它经常出现在Java基础面试、大学编程作业、还有各种“经典算法题合集”里。它的价值不在于完数本身有多难,而是这一道题几乎把循环边界、取余判断、因子成对、代码性能这些基本功全考了一遍。我拿它面试过不少候选人,也帮人改过很多版本,先亮结论:1000以内只有三个完数,分别是6、28、496。但这篇文章不只是告诉你答案,我要把整道题从题意理解、暴力实现、数学优化、批量筛法,到常见调试错误全部拆开讲一遍,保证看完你能写出比普通教学示例好得多的代码。
阅读对象主要是三类人:正在学Java基础、想搞懂完数算法的新手;准备面试、怕被追问优化的求职者;以及想拿这道经典题练手、顺便学点底层思路的业余选手。下面我们一步一步来。
1. 先搞懂“完数”到底在考什么:题意与数学底子
1.1 完数的定义与容易踩的概念坑
完数的标准定义是:一个数恰好等于它的真因子之和,这个数就是完数。这里的“真因子”是解题的第一关键。我见过一些网上的解释,直接把因子之和写成“所有因子相加”,这个表述是有歧义的。严谨地说,应该是“除自身以外的所有正因子相加”。所以在判断n是否为完数时,求和范围天然就不应该包含n本身。
另一个容易混淆的概念是“因子”和“质因数”。因子是能整除n的正整数,比如12的因子有1、2、3、4、6、12;质因数是因子中那些本身就是质数的部分,比如2和3。完数题目里用的是因子,不是质因数。如果你按质因数去求和,那6会被判成1+2+3=6,好像也正确,但换到28,质因数只有2和7,加起来显然不对。这个细节在面试时偶尔会被追问,答不好就容易露怯。
我们再看定义里另一个隐含条件:1是不是完数?1的真因子是没有的(空集),真因子和可以认为是0,0不等于1,所以1不是完数。2到5这些数呢,真因子只有1,和是1,显然不等于自身。所以遍历时从1开始没问题,但代码里要单独注意n比较小的情况。
1.2 1000以内为什么只会出现这几类结果
如果不考虑数学结论,直接让程序遍历1到1000,理论上每个数都要判断一次。但完数本身是很少的,1000以内只有6、28、496三个。这背后有个经典数学结论:偶完数都满足 ( 2^{p-1} \times (2^p - 1) ) 的形式,其中 ( 2^p - 1 ) 必须是质数,也就是梅森素数。比如p=2时得到6,p=3时得到28,p=5时得到496,p=7时得到8128。
这个结论对解决这道编程题来说不是必须的,但理解它有几个好处。第一,你知道暴力遍历不会有太多输出,方便验证结果;第二,如果面试官问“1000以内完数有哪些”,你可以直接秒答6、28、496;第三,当题目范围扩大到10万甚至100万时,你可以利用这个数学性质快速生成候选数,而不是傻乎乎地对每个数做因子求和。
不过作为编程练习,我不建议一开始就用数学结论硬套。原因很简单:这道题考察的核心是“因子求和”的代码能力,不是“背诵梅森素数”。先用最朴素的思路把功能做出来,再一步一步优化,才是正确的学习节奏。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Java实现第一版:从最简单暴力的思路出发
2.1 暴力枚举:怎么写最容易理解
第一版代码的思路非常直白:对1到1000的每一个数n,都从1到n-1遍历一遍,把所有能整除n的数累加起来,最后比较累加和是否等于n。这种写法最大的优点是贴近定义,几乎不可能写错,适合作为第一步功能实现。
java复制public class PerfectNumberV1 {
public static void main(String[] args) {
int limit = 1000;
for (int n = 1; n <= limit; n++) {
if (isPerfect(n)) {
System.out.println(n);
}
}
}
// 暴力判断:遍历 1 到 n-1
public static boolean isPerfect(int n) {
int sum = 0;
for (int i = 1; i < n; i++) {
if (n % i == 0) {
sum += i;
}
}
return sum == n;
}
}
这段代码放到IDE里,编译运行就能看到结果:6、28、496。如果你只想交作业,这个版本就已经能满足题目要求。但我觉得既然已经把这道题当作学习素材,就别停留在“能跑就行”的程度。这个版本最大的问题在于循环次数太多。外层遍历n次,内层最多遍历n次,整体时间复杂度是O(n²)。当n=1000时CPU毫无感觉,但如果面试官把范围改成100000,这个版本就会明显变慢,甚至出现让人尴尬的卡顿。
2.2 暴力版本的时间代价与分析
我们来实际算一下。判断1到1000之间所有完数,暴力版本大概要执行多少次数?外层循环1000次,内层平均也执行约n/2次,总操作大约50万次,这个量级毫秒级完成。但如果范围变成100000,内层总数大约是100000 × 50000 = 50亿次,这就不是“一瞬间”能搞定的了。
我自己做测试时,limit=100000的暴力版本在普通机器上跑了好几秒钟。如果你在基础面试题里把范围从1000改成不限,仍然用暴力写法,基本等于主动暴露了时间复杂度概念不扎实。所以这道题的进阶方向,就是怎么减少内层循环次数。
这里有个很小的经验:写这种循环累加的代码时,把求余判断放最前面,条件成立才做加法,这是个习惯问题,但能避免一些没必要的性能损耗。虽然现代JIT编译器会做优化,但代码可读性上,“先判断再累加”也更直观。
3. 进阶优化:因子成对出现的核心套路
3.1 为什么循环到 sqrt(n) 就够了
暴力版本的内层循环从1到n-1,但这里面有大量无用功。关键规律是:如果a是n的因子,那么n/a也一定是n的因子。因子是成对出现的。比如n=28,因子对包括(1,28)、(2,14)、(4,7)。这条规律意味着,我们根本不用从1遍历到n-1,只需要遍历到√n附近,每找到一个因子,就顺手把配对的那个因子也加进总和。
为什么是√n?因为当a从1开始增大时,n/a会从n开始减小,两者在√n处相遇。超过√n之后,剩下的因子其实都是之前某个因子的“配对伙伴”,已经被处理过了。比如28的√n大约5.29,循环到4的时候,我们同时发现了4和7,再加上前面发现的1、2、14,所有真因子就齐了。如果继续循环到14,那是在重复劳动。
同时要注意,配对的因子里如果两个数相等,只能加一次。这种情况只出现在完全平方数上,比如16,i=4时另一个因子也是4,你需要在代码里特判,否则16的真因子和就会算成1+2+8+4+4=19,显然不对。
3.2 优化版完整代码与关键点注释
下面是优化之后的isPerfect方法。为了方便解释,我先把求和逻辑单独拆成一个方法:
java复制public class PerfectNumberV2 {
public static void main(String[] args) {
int limit = 1000;
for (int n = 2; n <= limit; n++) {
if (isPerfect(n)) {
System.out.println(n);
}
}
}
public static boolean isPerfect(int n) {
if (n <= 1) {
return false;
}
// 1 一定是真因子,直接初始化进 sum
int sum = 1;
// 从 2 开始遍历到 sqrt(n)
for (int i = 2; i * i <= n; i++) {
if (n % i == 0) {
sum += i;
int other = n / i;
if (other != i) {
sum += other;
}
}
}
return sum == n;
}
}
这段代码有几个细节值得说。把sum初始化为1,是因为1一定是任何大于1的正整数的真因子,这样可以少一次循环判断。循环从2开始,避免把1再加一遍。循环条件写成i * i <= n,等价于i <= √n,但避免了调用Math.sqrt的浮点运算。最后other != i的判断,就是针对完全平方数做的防重复处理。
当n=2到5时,循环里的i从2开始,i*i大于n,循环直接不执行,sum保持1,判断sum == n自然为false,完全正确。
3.3 参数选择:i * i <= n 还是 i <= n / i 还是 Math.sqrt(n)
很多新手会纠结循环条件到底怎么写。我按实际使用场景给你排个序:如果题目明确n在int范围内且比较小,三种写法都可以;但为了代码稳健和面试表现,我推荐优先写i <= n / i。原因是i * i有整数溢出风险,当n接近Integer.MAX_VALUE时,i*i可能超过int上限变成负数,导致循环提前结束或死循环。而i <= n / i完全在整数除法范围内,不会溢出,也不涉及浮点数精度问题。
Math.sqrt(n)的问题在于它是浮点运算。虽然现代CPU计算sqrt非常快,但在循环条件里每次都要调用,并且拿int和double比较,会有类型转换。对于这道题完全无所谓,但如果你在写高性能代码,最好避免在循环条件里做浮点计算。所以我的建议是:小范围用i * i <= n,图省事没问题;大范围或者想展示专业度,就写i <= n / i。
说句题外话,这种循环边界的小细节,恰恰是我面试时喜欢追问的点。很多人优化到i * i <= n就以为自己已经做到最好了,但一问“如果n是Integer.MAX_VALUE,你的代码还正确吗”,就卡住了。真正理解边界、溢出、类型转换的人,会在这种细节上拉开差距。
4. 面试升级:批量找出1~N所有完数的“筛法”思路
4.1 从单个数判断到批量计算的转变
到这里为止,我们都在对每个n单独判断。但如果题目要求的是“找出1~1000以内所有完数”,其实还有一条更漂亮的路径:先用一个数组预处理出每个数的真因子和,然后一次性扫描数组,凡是“真因子和等于自身”的下标就是完数。这种思路类似埃氏筛,利用的是因子的倍数关系,而不是为每个数单独做一遍除法。
为什么批量场景下筛法更优?因为单独判断每个数时,很多重复计算没法避免。比如判断24和48的因子时,因子2、3、4、6、8等都被反复验证了很多次。筛法的核心思想是反向操作:不去找n有哪些因子,而是遍历每个可能的因子i,把它加到所有以i为因子的数上。仔细体会这句话,思路一下就顺了。
4.2 筛法预处理因子和的完整实现
直接看代码。limit是查找上限,factorSum[i]表示i的真因子之和,初始全部为0。外层循环i从1到limit/2,内层循环j从2i开始,每次加i。这样每个j都会累积上所有小于j的因子i。
java复制public class PerfectNumberV3 {
public static void main(String[] args) {
int limit = 1000;
findAllPerfectNumbers(limit);
}
public static void findAllPerfectNumbers(int limit) {
int[] factorSum = new int[limit + 1];
// 枚举所有可能的真因子 i
for (int i = 1; i <= limit / 2; i++) {
// i 是 j 的真因子,所以累加到 j 上
for (int j = i * 2; j <= limit; j += i) {
factorSum[j] += i;
}
}
for (int i = 2; i <= limit; i++) {
if (factorSum[i] == i) {
System.out.println(i);
}
}
}
}
这段代码的循环为什么要从j = i * 2开始?因为j等于i的时候,i不是自身的真因子,不能加进去。从一开始就排除掉“自身”这个因子,正好对应完数定义里的“真因子”。你可以把j想象成一个“倍数集合”,i每轮都会给它的所有倍数贡献一份因子。
我实测过这段代码,limit=100000时运行速度比暴力版快了一个数量级。当limit更大,比如500000,暴力版已经很难等了,筛法版还在毫秒到秒级之间。
4.3 复杂度比较与适用场景
暴力版本是两层循环,外层n次,内层可能n次,整体近似O(n²)。优化版本每个数只遍历到√n,整体大约O(n√n)。筛法版本是调和级数累加,总操作量大约为 n / 1 + n / 2 + n / 3 + ...,约等于n log n。当n比较大时,n log n比n√n小得多。三者的差距在n=1000时体现不出来,在n=100000之后会有天壤之别。
适用场景也要分清楚。如果只是判断单个数字,比如“输入一个数,输出它是不是完数”,用sqrt优化法就够了,筛法反而浪费了数组空间和预处理时间。如果是批量输出1到N的所有完数,并且N比较大,筛法是更优的选择。实际面试中,如果对方问的是“找出1000以内所有完数”,你可以先用单数判断法答一遍,再主动提出“如果需要批量处理更大范围,我还能用筛法预处理”,这个加分项会非常亮眼。
5. 常见问题与调试经验实录
5.1 五个容易翻车的细节
我自己给团队新人出过这道题,也帮网友看过不少代码,整理下来最容易翻车的点有五个。
第一个是忘记排除自身。很多第一版代码写for (int i = 1; i <= n; i++),然后判断n % i == 0就把i加进sum。这样自身的n也会被加进去,导致任何数都不可能满足sum == n。如果你发现程序运行完一个输出都没有,优先检查是不是这个原因。
第二个是完全平方数重复加因子。比如n=16,在i=4时,another=4,如果不判断other != i,4就会被加两次,最后结果偏大。这个问题在n为完全平方数时才会出现,而这恰恰是测试用例不容易覆盖到的角落。
第三个是1的态度。有些人会把1误判为完数,因为1的真因子和如果按“从1到n-1遍历”会得到0,不会误判;但如果有人把sum初始化为1,又不做n<=1的过滤,虽然sum==n对1也不成立,因为1 != 1?等等,如果初始sum=1,对n=1,它确实等于1,会被误判为完数。所以n <= 1时直接返回false,是稳妥的做法。
第四是性能问题。limit=1000时无所谓,但如果你把题目扩展成“10000以内”,暴力版的慢感就出来了。这时候别嫌机器卡,先回头看看自己是不是还在用O(n²)写法。
第五是打印格式。这道题通常要求输出6、28、496,一般换行输出即可。但有的题目要求“6 = 1 + 2 + 3”这种表达式,那就要额外保存因子列表。看清楚题目再动手,别做完才发现要求不符。
5.2 运行结果验证与输出格式
无论你用哪种实现,输出结果都应该是6、28、496。我建议交代码前做一次手工验证,这是最笨但也最有效的自查方式。
6的真因子:1、2、3,和是6。
28的真因子:1、2、4、7、14,和是28。
496的真因子:1、2、4、8、16、31、62、124、248,和是496。
如果程序输出里多了别的数,或者少了这三个数,那基本就是上面五类问题之一。你也可以先用limit=30做小范围测试,期望输出只有6和28;再改成limit=500,期望输出6、28、496。这样分段验证,比直接跑1000更容易定位问题。
5.3 面试被追问时怎么回答
这道题如果出现在Java面试里,考察点通常不只是“写出来”,而是“能不能越写越好”。我模拟一段典型问答。
面试官先问:“写一个方法,判断一个数是不是完数。”你先给出sqrt优化版本,并解释因子成对原理。这已经超过大多数人的答案。
面试官接着问:“那找出1000以内的所有完数呢?”你可以说:可以复用isPerfect遍历,时间复杂度约O(n√n);如果想更快,可以用筛法预处理因子和,整体O(n log n),并大概描述思路。如果能讲清楚代码,基本稳了。
面试官再往下问:“为什么循环到√n就够了?”你要能画出因子对的数轴解释,说明在√n之后只会遇到已经配对过的因子。最后如果问“1000以内有哪几个完数”,你可以直接答出6、28、496。整道题就算啃透了。
6. 延伸思考:这道题还能怎么玩
6.1 从1000到更大的范围
把limit改成100000,我们的筛法版本依然能很快跑完,输出会变成6、28、496、8128。8128是第四个完数,它满足p=7的欧几里得-欧拉公式。如果你继续加大范围,比如1000000以内,还会出现33550336,但这时候要注意int溢出和内存占用问题。因子和数组要用long[],limit+1可能超过内存范围,需要根据机器情况调整。
这种题目扩展方式在面试和练习中都很常见:把“1000以内”改成“10万以内”,其实就是为了逼你放弃暴力解法。我建议你把V1、V2、V3三个版本都保留下来,自己写一个测试类对比三种实现的计算耗时,这样对“复杂度分析”会有非常直观的感受。
6.2 打印出“6=1+2+3”这种因子表达式
有些题目要求输出格式更漂亮,比如“6=1+2+3”。这个改造也不复杂,只需要在isPerfect方法里额外收集因子,或者写一个辅助方法返回因子列表。这里我给出一个简单的实现思路:判断完数时,记录下所有真因子,然后拼接字符串。注意最后不要多出一个“+”号,可以用StringJoiner或者循环判断索引。
java复制import java.util.ArrayList;
import java.util.List;
public class PerfectNumberWithExpression {
public static void main(String[] args) {
int limit = 1000;
for (int n = 2; n <= limit; n++) {
List<Integer> factors = getFactors(n);
if (sum(factors) == n) {
System.out.println(n + " = " + joinFactors(factors));
}
}
}
public static List<Integer> getFactors(int n) {
List<Integer> factors = new ArrayList<>();
factors.add(1);
for (int i = 2; i * i <= n; i++) {
if (n % i == 0) {
factors.add(i);
int other = n / i;
if (other != i) {
factors.add(other);
}
}
}
return factors;
}
public static int sum(List<Integer> list) {
int total = 0;
for (int num : list) {
total += num;
}
return total;
}
public static String joinFactors(List<Integer> list) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < list.size(); i++) {
if (i > 0) {
sb.append(" + ");
}
sb.append(list.get(i));
}
return sb.toString();
}
}
这里有一个小细节:getFactors方法返回的因子列表是无序的,比如28会输出“1 + 14 + 2 + 7 + 4”,而不是“1 + 2 + 4 + 7 + 14”。如果想按升序输出,可以在拼接前对列表排序,或者调整循环姿势。这个细节很能体现代码洁癖,虽然不影响判断结果,但输出美观度会有区别。
6.3 数学结论与编程思维的边界
最后聊点我自己的观点。完数这道题之所以经典,是因为它站在“数学性质”和“编程实现”的交叉口。你完全可以用数学公式直接生成候选数,比如遍历p,算出 ( 2^{p-1} \times (2^p - 1) ),然后判断 ( 2^p - 1 ) 是否为质数。这样做代码更简短,运行更快,但如果你只知道这个公式而不会因子求和,那你其实没有练到编程基本功。
我在实际学习里的建议是:先把V1暴力版写出来,确保理解题意;再写V2优化版,理解因子成对和√n边界;最后写V3筛法版,体会批量处理和复杂度优势。这三步走完,完数这道题才算真正消化了,以后遇到任何“求一段范围内满足某性质的数”的题目,你都会有一套清晰的方法论。
根据我个人在实际操作中的体会,光看代码和听讲解是不够的,你一定要亲手把三个版本都运行一遍,改一改limit,观察输出和耗时的变化。踩过“完全平方数重复加因子”和“忘记排除自身”这两个坑之后,你对循环边界和问题拆解的理解会扎实很多。如果面试时遇到这道题,别急着背诵答案,先把“真因子”和“因子成对”这两点讲清楚,再动手写代码,效果会好很多。
