目录

题目描述

507. 完美数

题意分析

给一个正整数 num,判断它是不是「完美数」:把它所有小于自身的正因子加起来,和恰好等于它本身。例如 28 的真因子是 1, 2, 4, 7, 14,和为 28,所以 28 是完美数。

「小于自身」这四个字是全部题意的重心——num 自己是自己的因子,但不能计入。等价的说法是:把 num 的全部因子求和后减去 num,看是否还等于 num;也就是全体因子之和是否等于 2 * num。两种口径都对,但必须在代码里从头到尾只用其中一种,混着用必错。

约束 $1 \le num \le 10^8$ 给出了明确信号:$O(num)$ 地把 1num - 1 全试一遍是一亿次取模,在判题机上是压线甚至超时的;而这个上界对 $O(\sqrt{num})$(一万次循环)来说宽松得离谱。所以正解应当把枚举范围压到根号级别。压缩的依据来自因子的天然结构:因子总是成对出现的——只要 i 能整除 numnum / i 也一定能整除,且这两个数一个不超过 $\sqrt{num}$、另一个不小于 $\sqrt{num}$。枚举小的那一半,大的那一半白送。

边界有三处。num = 1 时真因子集合是空集,和为 0 而不是 1,必须返回 falsenum 是完全平方数时(如 366 × 6),配对的两个因子重合,只能计一次;i * ii 接近 $10^4$ 时不会溢出 32 位,但把循环条件写成 i <= num / i 或用 long 相乘更保险。

解法:遍历因子成对求和

核心思路

最朴素的写法是让 i1 跑到 num - 1,凡是能整除 num 的就累加,最后比较总和与 num。逻辑上无懈可击,但 num 上限一亿,这一亿次取模运算是纯粹的浪费——因为真正的因子极其稀疏,一亿个候选里可能只有几十个是因子。

瓶颈在于我们把每个因子都独立地找了一遍,完全没有利用因子的结构。关键观察是:因子是成对的。若 num = a * b,那么 ab 同时都是因子,而且 $\min(a, b) \le \sqrt{num} \le \max(a, b)$。这意味着每一对因子里,小的那个一定落在 $[1, \sqrt{num}]$ 区间内。于是只要把 i 扫到 $\sqrt{num}$ 为止,就能一个不漏地枚举出所有因子对,把大的那半通过 num / i 直接算出来,枚举量从 $10^8$ 降到 $10^4$。

接着处理「真因子」这个限制。1 永远是因子,与它配对的是 num 自己,而 num 不能计入。与其在循环里做特判,不如把这一对拆开手工处理:直接令累加器初值为 1(把小的那个计入),永不加入 num(把大的那个排除)。这样循环就可以从 i = 2 开始,循环体内不需要任何「是不是 num 自己」的判断。同时这个初值天然让 num = 1 的情况出错——1 的真因子和应为 0 却被算成了 1——所以必须在最前面用 num <= 1 提前返回 false

最后是完全平方数。当 i * i == num 时,inum / i 是同一个数,两次累加会把它算重。加一个 other != i 的判断即可。

循环不变量:每轮循环开始前,sum 等于「num 的全部真因子中,那些小于当前 i 的因子,以及它们各自配对的大因子」之和。循环结束时 i 已越过 $\sqrt{num}$,所有因子对都被处理过,sum 就是完整的真因子和。

解题步骤

  • 先判 num <= 1 直接返回 false为什么1 的真因子集合为空、和为 0,而主逻辑把 sum 初始化成 1,会误判 1 为完美数;同时题目保证 num 为正,把非法与最小规模一起挡在门外,主循环就不必再操心。
  • sum = 1为什么:这一步等价于「先把因子对 (1, num) 手工拆开」——小因子 1 计入,大因子 num 是它自己,按定义排除。有了这个初值,后面的循环体里不再需要任何排除自身的判断。
  • i2 开始循环,条件写成 i <= num / i。它等价于 $i^2 \le num$,覆盖每对因子中较小的一方,同时从表达式上避免乘法溢出。
  • num % i == 0,先 sum += ii 从 2 起且不超过平方根,因此一定小于 num,属于真因子。
  • 再取 other = num / i,仅当 other != isum += other为什么other 是与 i 配对的大因子,因为 i >= 2 所以 other <= num / 2 < num,同样是真因子、同样该计入;而当 num 是完全平方数且 i 恰为其平方根时两者相等,加两次就重复了。
  • 返回 sum == num为什么:题目要的就是真因子和与自身相等这个布尔判定,不需要额外的标志位。

num = 28 走一遍28 > 1,通过前置判断。sum = 1(对应因子对 (1, 28) 中取 1)。

i = 24 <= 2828 % 2 == 0sum = 1 + 2 = 3other = 14,与 i 不等,sum = 3 + 14 = 17

i = 39 <= 2828 % 3 == 1,不是因子,跳过。

i = 416 <= 2828 % 4 == 0sum = 17 + 4 = 21other = 7,不等,sum = 21 + 7 = 28

i = 525 <= 2828 % 5 == 3,跳过。

i = 636 > 28,循环结束。返回 28 == 28true

注意 714 这两个大因子从未被 i 直接枚举到,它们是在 i = 4i = 2 时以 num / i 的身份被捎带算进来的——这正是把枚举量砍到根号级别的原因。再看一个完全平方的例子 num = 36i = 636 % 6 == 0sum += 6,随后 other = 6i 相等,被 other != i 拦下,只算了一次;最终 sum = 1 + 2 + 18 + 3 + 12 + 4 + 9 + 6 = 55 != 36,返回 false

代码实现

class Solution {
    public boolean checkPerfectNumber(int num) {
        // 1 的真因子和为 0,而下面 sum 初值为 1,必须提前挡掉。
        if (num <= 1) {
            return false;
        }
        // 初值 1 相当于手工拆开因子对 (1, num):计入 1,排除 num 自身。
        int sum = 1;
        for (int i = 2; i <= num / i; i++) {
            if (num % i == 0) {
                sum += i;
                int other = num / i;
                // num 是完全平方数时 other 与 i 重合,只能算一次。
                if (other != i) {
                    sum += other;
                }
            }
        }
        return sum == num;
    }
}
func checkPerfectNumber(num int) bool {
	// 1 的真因子和为 0,而下面 sum 初值为 1,必须提前挡掉。
	if num <= 1 {
		return false
	}
	// 初值 1 相当于手工拆开因子对 (1, num):计入 1,排除 num 自身。
	sum := 1
	for i := 2; i <= num/i; i++ {
		if num%i == 0 {
			sum += i
			other := num / i
			// num 是完全平方数时 other 与 i 重合,只能算一次。
			if other != i {
				sum += other
			}
		}
	}
	return sum == num
}

复杂度分析

  • 时间复杂度:$O(\sqrt{num})$。凭什么:循环变量 i2 递增到 $\sqrt{num}$ 为止,共约 $\sqrt{num}$ 轮,每轮只有一次取模、一次除法和常数次加法与比较;num 上限 $10^8$ 时循环体最多执行一万次。
  • 空间复杂度:$O(1)$。凭什么:只用了 sumiother 三个整型变量,没有开数组、没有递归,占用与 num 的大小无关。

关键点总结

  • 「因子成对出现,小的那个必然不超过 $\sqrt{n}$」是所有因子枚举题的通用降维手段,能把 $O(n)$ 变成 $O(\sqrt{n})$。面试中被问到求因子、求约数个数、判质数时,这句话应该脱口而出。
  • 把一个特殊的因子对(这里是 (1, num))提到循环外手工处理,可以让循环体保持无分支的干净形态。用初值编码边界,比在循环里加判断更不容易写错。
  • 完全平方数导致配对重合,是「成对枚举」这一手法的固定副作用,写下 num / i 的同时就该条件反射地补上去重判断。
  • 「真因子」与「全部因子」是两套口径,差一个 num 本身。先在纸上把口径钉死再动手,可以避免绝大多数偏差。
  • 循环条件写 i * i <= num 时要留意乘法溢出;用 i <= num / i 改写既避免溢出又不需要类型转换,是更稳的写法。
  • 面试延伸:完美数在 $10^8$ 内其实只有 6, 28, 496, 8128, 33550336 五个,因此打表也能过。但直接答打表会被认为回避了考点,正确的做法是先讲清 $O(\sqrt{n})$ 枚举,再把打表作为「已知数据范围极小时的工程优化」补充说明。

易错点总结

  • 忘记 num <= 1 的前置判断num = 1sum 保持初值 1,返回 1 == 1true,但 1 没有真因子,正确答案是 false
  • sum 初值写成 0num = 28 → 因子 1 从未被累加(循环从 2 开始),最终 sum = 27,返回 false
  • sum 初值写成 1 却让循环也从 i = 1 开始num = 28i = 1 时又加了一次 1,还把 other = 28 也加进去,sum = 56,彻底跑偏。
  • 漏掉 other != i 的去重num = 36 时平方根 6 会被加入两次,算出 61 而不是真因子和 55。即使该例最终仍为 false,因子和已经失真。
  • 上界写成 i <= num / 2num = 99999999 → 循环执行约五千万次,虽然结果正确,但白白慢了三个数量级,在更严格的时限下超时。
  • 上界写成 i < num 且不做成对累加num = 10^8 → 一亿次取模,直接超时。
  • num 自身也加进 sumnum = 28sum = 56,返回 false;若同时把判定改成 sum == 2 * num 才对,但两种口径混用必错。
  • 循环条件写 i * i <= num 且不考虑位宽:当前上界下碰巧安全,但扩展到完整 32 位输入会溢出;i <= num / i 从表达式上消除了风险。
  • other 写成 num / i 后又误加了 i:即在 if (other != i) 分支里写 sum += i → 小因子被加两次,num = 28sum = 34,判定失败。
  • num % i == 0 判定后直接 return:把「找到一个因子」当成了终止条件 → 只累加了最小因子就返回,任何合数都被判成 false

相似题目

题目 难度 考察点
204. 计数质数 中等 要一次性判定 [2, n) 全体的整除性,逐个开根号会超时,须用筛法
367. 有效的完全平方数 简单 只关心 $\sqrt{n}$ 是否为整数,可用二分或牛顿迭代做到 $O(\log n)$
633. 平方数之和 中等 同样是找一对数相配,但配对关系是加法而非乘法,用双指针从两端夹逼
728. 自除数 简单 拆的是十进制数位而不是因子,判断的是数被自己每一位整除
172. 阶乘后的零 中等 统计的是质因子 5 的总个数,靠不断除以 5 累计而非枚举因子
263. 丑数 简单 只需反复除尽 2, 3, 5 三个指定质因子,不必枚举全部因子
69. x 的平方根 简单 求的是根号本身而非用它做上界,考点在二分的下取整与溢出处理
1071. 字符串的最大公因子 简单 把整除关系搬到字符串长度上,答案由 gcd 决定而非逐个试除