1. 从一道题说起:为什么“最大质因数”值得单独写一篇
第一次接触“求解最大质因数”这个问题,是在帮一个朋友准备编程面试的时候。题目很直白:给定一个正整数,找出它所有质因数中最大的那个。比如 13195 的最大质因数是 29,600851475143 的最大质因数是 6857。看起来简单,但真正动手写代码的时候,你会发现这里面藏着不少门道——从最朴素的试除法,到 Pollard‘s Rho 这种进阶算法,中间的选择空间比想象中大得多。
这篇文章适合谁看?如果你正在准备技术面试,或者刚学完基础语法想找一道题练手,又或者你单纯对“怎么把一个数拆干净”这件事感到好奇,那接下来的内容应该能帮到你。我会从最笨的办法开始,一步步优化,把每一步的取舍逻辑讲清楚,最后给出可以直接复制运行的代码。整个过程不依赖任何特殊库,Python 标准库就能跑。
需要提前说明的是,质因数分解本身是一个经典问题,它的应用场景远不止做题——密码学里的 RSA 加密、数论研究、甚至某些哈希算法的设计,底层都跟质因数分解的难度有关。所以花时间搞懂它,性价比很高。
2. 先把概念理清楚:质因数到底是什么
2.1 质数与因数的基本定义
质数(素数)是指大于 1 的自然数中,除了 1 和它本身之外没有其他因数的数。比如 2、3、5、7、11、13 都是质数,而 4、6、8、9 不是。因数则是能整除某个数的数,比如 12 的因数有 1、2、3、4、6、12。
把这两个概念合在一起,质因数就是“既是质数又是某个数的因数”的数。举个例子,12 的因数有 1、2、3、4、6、12,其中质数只有 2 和 3,所以 12 的质因数是 2 和 3。注意,这里说的是“质因数”而不是“质因数分解”——前者是一个集合,后者是把一个数写成质因数乘积的形式,比如 12 = 2² × 3。
2.2 为什么最大质因数比最小质因数更难求
求最小质因数其实很简单:从 2 开始往上试,第一个能整除的就是答案。但最大质因数不一样,你没法从大到小试——因为一个数的因数分布是不对称的,大的因数往往藏在后面。比如 600851475143,它的最小质因数是 71,但最大质因数是 6857,差了将近一百倍。
这就引出了一个关键思路:要找到最大质因数,最有效的办法不是从大往小找,而是从小往大除,把小的质因数一个个除掉,最后剩下的那个就是最大的质因数。这个思路是后面所有优化算法的基石,理解了它,后面的代码就都好懂了。
2.3 一个容易踩的坑:1 和负数怎么处理
在实际写代码的时候,有几个边界情况必须提前想清楚。第一,1 没有质因数,因为质因数要求大于 1,所以输入 1 的时候应该返回什么?通常我们会返回 None 或者抛异常,具体看你的接口设计。第二,负数怎么办?负数的因数里也有质数,但通常这类题目默认输入是正整数,如果非要处理负数,可以先取绝对值再算。第三,0 呢?0 能被任何非零整数整除,所以它的质因数集合是无限的,直接排除掉。
提示:在面试中遇到这类题目,先跟面试官确认输入范围,比直接写代码更能体现你的严谨性。
3. 最朴素的试除法:能跑,但别拿去面试
3.1 从 2 到 n-1 逐个试
最直观的做法是:对于给定的数 n,从 2 开始一直到 n-1,逐个检查能不能整除 n。如果能整除,再判断这个因数是不是质数,如果是,就记录下来。最后返回记录中最大的那个。
def is_prime(x): if x < 2: return False for i in range(2, int(x ** 0.5) + 1): if x % i == 0: return False return True def largest_prime_factor_naive(n): if n < 2: return None largest = None for i in range(2, n): if n % i == 0 and is_prime(i): largest = i return largest这段代码逻辑没问题,但效率极低。假设 n = 600851475143,循环次数接近六千亿次,就算用最快的计算机也要跑很久。所以这个版本只适合用来理解题意,实际使用必须优化。
3.2 第一个优化:只试到平方根
一个数的因数总是成对出现的。如果 d 是 n 的因数,那么 n/d 也是 n 的因数。这对因数中,较小的那个一定小于等于 √n。所以判断一个数是不是质数时,只需要试到 √n 就够了。这个优化把 is_prime 的复杂度从 O(n) 降到了 O(√n)。
但对于“找最大质因数”这个问题,光优化 is_prime 还不够,因为外层循环还是从 2 到 n-1。我们需要换个思路。
3.3 第二个优化:边除边缩小范围
回到前面说的核心思路:从小到大找质因数,找到一个就从 n 里除掉它。这样 n 会越来越小,循环的上界也跟着变小。具体做法是:
- 从 i = 2 开始。
- 如果 i 能整除 n,就把 n 除以 i,直到不能整除为止,同时记录 i 是质因数。
- i 加 1,继续。
- 当 i * i > n 时停止,此时如果 n > 1,那么 n 本身就是最后一个质因数。
def largest_prime_factor_better(n): if n < 2: return None factor = 2 last_factor = None while factor * factor <= n: if n % factor == 0: last_factor = factor while n % factor == 0: n //= factor factor += 1 if n > 1: last_factor = n return last_factor这个版本比朴素版快了几个数量级。以 600851475143 为例,循环只需要跑到 6857 左右,因为每次找到一个质因数后 n 都会大幅缩小。实测下来,这个版本在普通笔记本上跑一次不到一毫秒。
4. 还能更快吗?进阶算法与取舍分析
4.1 6k ± 1 优化:跳过明显的合数
上面那个版本虽然快,但 factor 每次只加 1,会检查很多明显不是质数的数,比如 4、6、8、9。一个常见的优化是:除了 2 和 3 之外,所有质数都可以写成 6k ± 1 的形式(k 是正整数)。所以我们可以先处理 2 和 3,然后从 5 开始,每次加 2 或 4 交替。
def largest_prime_factor_6k(n): if n < 2: return None last_factor = None for p in (2, 3): while n % p == 0: last_factor = p n //= p k = 1 while (6 * k - 1) ** 2 <= n: for p in (6 * k - 1, 6 * k + 1): while n % p == 0: last_factor = p n //= p k += 1 if n > 1: last_factor = n return last_factor这个版本比上一个版本大约快 2 到 3 倍,因为跳过了 2/3 的合数。但代码可读性稍微下降了一点,实际项目中要不要用,取决于你对性能的要求。
4.2 Pollard‘s Rho:大数分解的利器
如果输入的数非常大,比如上百位,上面这些方法都不够用了。这时候需要用到 Pollard’s Rho 算法,它的核心思想是利用生日悖论和 Floyd 判圈算法,在 O(n^(1/4)) 的期望时间内找到一个因子。
import math import random def gcd(a, b): while b: a, b = b, a % b return a def pollard_rho(n): if n % 2 == 0: return 2 if n % 3 == 0: return 3 while True: c = random.randint(1, n - 1) f = lambda x: (x * x + c) % n x, y, d = 2, 2, 1 while d == 1: x = f(x) y = f(f(y)) d = gcd(abs(x - y), n) if d != n: return d def largest_prime_factor_rho(n): if n < 2: return None if is_prime(n): return n d = pollard_rho(n) return max(largest_prime_factor_rho(d), largest_prime_factor_rho(n // d))Pollard‘s Rho 的代码明显复杂很多,而且需要配合 Miller-Rabin 素性测试才能完整工作。对于一般的面试题或者日常使用,前面那个 6k ± 1 的版本已经足够了。除非你确实要处理很大的数,否则没必要上这个。
4.3 不同算法的适用场景对比
| 算法版本 | 时间复杂度 | 适用场景 | 代码复杂度 |
|---|---|---|---|
| 朴素试除法 | O(n) | 教学演示 | 低 |
| 平方根优化 | O(√n) | 小规模数据 | 低 |
| 边除边缩小 | O(√n) 但常数更小 | 一般面试题 | 中 |
| 6k ± 1 优化 | 约 O(√n / 3) | 中等规模数据 | 中 |
| Pollard’s Rho | 期望 O(n^(1/4)) | 大数分解 | 高 |
选择哪个版本,取决于你的具体需求。如果只是做一道题,边除边缩小的版本最平衡;如果追求极致性能且不介意代码复杂度,可以上 6k ± 1;如果处理的是密码学级别的大数,那就必须用 Pollard‘s Rho 加上 Miller-Rabin。
5. 实测中的意外情况与排查记录
5.1 输入 1 和 0 时的返回值问题
第一次写测试用例的时候,我顺手把 1 和 0 也扔进去了,结果代码直接返回 None,但调用方没有处理这个情况,导致后面报错。后来我养成了一个习惯:所有涉及数学计算的函数,先把边界情况列出来,明确每个边界返回什么。对于“最大质因数”这个问题,我的约定是:n < 2 时返回 None,调用方必须显式处理 None。
5.2 大数运算中的整数溢出
在 Python 里不用担心整数溢出,因为 Python 的 int 是任意精度的。但如果你用 C++ 或 Java 写同样的逻辑,就要注意 n 的类型。比如 600851475143 这个数,用 32 位 int 存不下,必须用 64 位 long long。我在帮朋友看代码的时候,就遇到过因为用了 int 导致结果错误的情况。
5.3 性能测试中的意外发现
我拿几个不同的数做了性能对比,结果发现一个有意思的现象:对于某些数,比如 2 的幂次(如 2^30),边除边缩小的版本几乎瞬间完成,因为第一次循环就把所有 2 都除掉了。但对于质数本身,比如 999999999989,循环要跑到 √n 才能确认它是质数,耗时明显更长。这说明算法的实际表现跟输入数据的分布有很大关系,不能只看理论复杂度。
注意:如果你在面试中写的是边除边缩小的版本,面试官可能会追问“如果输入是一个很大的质数怎么办”,这时候你可以回答“对于质数,确实需要试到平方根,但这是不可避免的,因为判断质数本身就需要这么多操作”。
6. 可以直接抄作业的完整代码与使用建议
6.1 推荐版本:兼顾性能与可读性
综合来看,我最推荐的版本是边除边缩小加上 6k ± 1 优化,代码不算太复杂,性能也足够好。下面是完整代码,可以直接复制使用:
def largest_prime_factor(n): """ 返回正整数 n 的最大质因数。 如果 n < 2,返回 None。 """ if n < 2: return None last_factor = None for p in (2, 3): while n % p == 0: last_factor = p n //= p k = 1 while (6 * k - 1) ** 2 <= n: for p in (6 * k - 1, 6 * k + 1): while n % p == 0: last_factor = p n //= p k += 1 if n > 1: last_factor = n return last_factor6.2 测试用例与验证方法
写完代码后,我习惯用几组已知答案的测试用例来验证:
assert largest_prime_factor(13195) == 29 assert largest_prime_factor(600851475143) == 6857 assert largest_prime_factor(2) == 2 assert largest_prime_factor(1) is None assert largest_prime_factor(0) is None assert largest_prime_factor(97) == 97 assert largest_prime_factor(100) == 5这几组用例覆盖了普通合数、大数、质数、边界值等常见情况。每次修改代码后跑一遍,能快速发现回归问题。
6.3 实际使用中的几个小技巧
第一,如果你需要频繁调用这个函数,可以考虑加一个缓存,把已经算过的结果存起来。第二,如果输入的数范围不大,可以预先用筛法打出质数表,然后直接用质数表来试除,速度会更快。第三,如果只是判断一个数是不是质数,不需要找最大质因数,直接用 Miller-Rabin 测试比试除法快得多。
我在实际项目中遇到过一次需要批量处理几万个数的场景,当时就是先用筛法打出 10^6 以内的质数表,然后用这些质数去试除,整体速度比逐个用 6k ± 1 快了不少。所以具体用哪种方法,还是要看你的数据规模和调用频率。
最后再分享一个小经验:写这类数学算法的时候,先把数学原理想清楚,再动手写代码,比直接对着题目硬写要快得多。我见过不少人一上来就开始敲键盘,结果写到一半发现思路不对,又得推倒重来。花五分钟把思路理清楚,能省下半小时的调试时间。