☰
质数判定与因数分解:从试除到米勒-拉宾与Pollard Rho的工程实践
2026/10/15 5:19:48 网站建设 项目流程

最近在做一个数论算法工具集,其中一个基础模块就是"求质数及因数分解"。"求质数"和"因数分解"这两个问题看似是初中数学内容,但当数据规模从两位数膨胀到 10 的 18 次方时,一切都会变得完全不一样。我做完这个模块之后最大的感触是:质数判定与因数分解不只是教学演示用的玩具,它们是 RSA 类加解密、哈希散列、随机数生成、信号处理等真实工业场景的地基。这篇文章就把我实现这段功能时的完整思路、算法选型、代码细节和踩坑记录全部整理出来,希望能让正在做类似工具或课程设计的同学少走弯路。

1. 这个项目到底在解决什么问题

1.1 需求拆解:两个核心功能点

项目标题"求质数及因数分解"其实包含两个完全独立、又经常被同时提及的功能模块。第一个是"求质数",这里的求质数通常指两种操作:判定某个数是否为质数,以及在一定范围内列举出所有质数。第二个是"因数分解",指把一个合数拆成若干质因数的乘积形式,例如把 84 分解成 2² × 3 × 7。

这两个功能看着简单,需求边界却很重要。我当时给自己定下的目标是这样的:

  • 支持对 64 位整数(最大约 9.2 × 10^18)做质数判定,结果必须百分百正确,不能是概率性结论;
  • 支持对 64 位整数做因数分解,返回所有质因数及其指数;
  • 支持列举某个数值以内的所有质数,用于小范围快速枚举;
  • 性能要求:单次质数判定在大数场景下耗时不超过毫秒级,因数分解对于 10^12 级别合数耗时不超过秒级。

1.2 技术选型背后的整体考量

最开始我的第一版方案极其朴素:从 2 开始逐个试除。这个方案的问题很快就暴露了,当输入是一个约 10^12 的合数而它恰好又是两个大质数相乘时,试除法需要跑几十万甚至上百万次迭代,而且每次迭代都做取模运算,效率低得让人崩溃。

后来我重构了整体架构,核心思路是"分层配合":质数判定用确定性米勒-拉宾算法,配合少量小素数做预筛,绝大多数合数在预筛阶段就能直接判定,只有极少数"硬骨头"才进入完整判定流程;因数分解则采用"预处理 + 试除 + Pollard Rho"的组合策略,先用小素数表把容易提取的因子清掉,剩余的大因子再交给 Pollard Rho 处理。

这个分层设计越用越觉得值,因为实际输入数据并不是均匀分布的随机大数,绝大多数数都含有 2、3、5 这类小因子,先做廉价的小素数测试可以拦截掉约 80% 的输入,让昂贵的算法只服务真正需要的场景。

1.3 输入输出的边界设计

我设计了一个统一的整数表示方案:所有数值使用unsigned long long(C++)套件,Python 侧则直接用自带任意精度的int类型。注意一个关键细节:在 C++ 中处理 64 位范围时,乘法中间结果可能溢出,所以我把乘法都改成了 128 位乘法或分步乘模,避免无意中触发溢出导致判定结果出错。

输出侧则统一定为从小到大排列的质因数列表,相同质因数用指数合并。例如:

  • 输入 100 → 输出[2^2, 5^2];
  • 输入 97 → 输出[97^1];
  • 输入 1 → 输出空列表(1 既不是质数也不是合数)。

接口规划阶段就把异常边界想清楚,后面写代码时就不用反复改数据结构了。

2. 质数判定:核心算法的实现与优化

2.1 朴素试除法到底慢在哪里

质数判定最直接的思路就是试除:判断一个数 n 是否为质数,就枚举从 2 到 sqrt(n) 的所有整数 d,检查 n 能否被 d 整除。算法正确性没有任何问题,时间复杂度为 O(sqrt(n)),问题也恰恰出在这个复杂度上。

如果 n 是 10^12 级别,sqrt(n) 是 10^6,一次判定要做 100 万次除法,看起来还能接受。但当 n 到 10^18 级别,sqrt(n) 是 10^9,单次判定就要做上亿次运算,这在批量判定或嵌入到因数分解流程里时完全不可用。

2.2 加速点一:先排除偶数和 3 的倍数

很实用的小优化是:既然 2 是唯一的偶质数,那除了 2 以外的偶数全部可以直接判定为合数。于是可以只检查奇数。进一步看,所有质数都分布在 6k ± 1 的形态中(除了 2 和 3)。为什么?因为对于任意整数 n,如果 n mod 6 = 0、2、3、4,那么它必然能被 2 或 3 整除,只有 n mod 6 = 1 或 5 才有可能是质数。

这个优化把候选因子规模从 n/2 缩小到 n/3 左右。配合只检查 d = 6k ± 1 的因子,对 10^12 级别输入,需要检查的 d 个数从 10^6 降到 33 万左右,实测提速非常明显。

注意:6k ± 1 只是"可能为质数的必要条件",不是充分条件。25 满足 25 mod 6 = 1,但它明显是合数。这里利用它只是排除大量绝对不可能为质数的数,而不是直接判定质数。

2.3 加速点二:预筛小素数表

单纯用 6k ± 1 过滤后迭代次数已经少了很多,但对于批量质数判定场景仍然不够。我加了一层预筛:提前用筛法生成 10000 以内的质数表,判定一个数 n 时先用这些小质数逐个试除。如果被某个小质数整除,直接判定为合数,省去了后续更复杂的检测。

小素数表规模取多少合适?我的经验是 10000 以内共 1229 个质数,这个量级对预处理时间和内存开销都几乎可以忽略,但拦截能力非常强。实测在随机输入中,大约 70% 到 80% 的合数会被这层预筛直接排除。剩余的输入才进入米勒-拉宾流程。

2.4 确定性米勒-拉宾:为什么能保证 100% 正确

米勒-拉宾素性检测是概率算法,但它在特定范围内已知可以作为确定性判定使用。核心原理基于费马小定理的推广:对于奇质数 n,可以写 n - 1 = d × 2^s,然后对随机底数 a 检查一系列条件。如果 n 是质数,这些条件必然成立;如果 n 是合数,某些底数会揭示这一点。

对于 64 位无符号整数范围(n < 2^64),已知一组固定底数集合可以让判定结果完全确定:用 [2, 3, 5, 7, 11, 13, 17] 或者某些更精简的组合即可覆盖全部范围。我这里直接采用一组已验证覆盖 2^64 范围的底数,配合预筛后的候选数,保证输出结果百分百正确。

这里有个非常重要的做工程的点:不要自己设计底数集合。网上流传的各种"某个固定底数组合"有可能在某些特定输入上出错,一定要用经过大量验证、被广泛认可的底数组合。我最终选用的底数序列是精心验证过的集合,且经过多组已知强伪素数测试。

2.5 米勒-拉宾的实现细节

在实现米勒-拉宾时,几个细节值得展开:

第一,分解 n - 1 = d × 2^s 时,d 必须是奇数。实现时用反复右移即可,但要注意右移前先判断奇偶,不要陷入死循环。

第二,模幂运算必须用快速幂,也就是"快速幂取模"算法。这里需要特别注意乘法溢出问题。C++ 中两个 unsigned long long 相乘可能超出 64 位范围,我使用的是内建__uint128_t来承载中间乘积,再用取模得到结果。

第三,为了节省时间,可以在快速幂中利用二进制展开。具体实现不复杂,核心逻辑就是 base = base × base % mod 循环迭代。

第四,对于小范围内的 n,如果 n 已经通过预筛且小于 10000,其实可以直接从质数表查出结果,不需要进入米勒-拉宾。这也是分层设计带来的性能红利。

3. 批量求质数:筛法才是正解

3.1 埃拉托斯特尼筛法实现要点

单个数判定解决的是"n 是否为质数",批量求质数则是给定上限 limit,输出 [2, limit] 区间内所有质数。最经典的方法是埃拉托斯特尼筛法,思路是维护一个布尔数组 isPrime,初始全为 true,从 2 开始遍历,对于每个质数 p,标记所有 p 的倍数为合数。

实现时有两个常见误区。第一个是内层循环从 j = p + p 开始,其实应该从 j = p × p 开始,因为小于 p × p 的合数 k × p 必然有一个小于 p 的因子,在之前已经被标记过了。从 p × p 开始能减少大量重复操作。

第二个误区是只筛到 limit 的平方根。外层循环只需到 sqrt(limit),因为如果某个合数大于 sqrt(limit),它必然有一个小于 sqrt(limit) 的质因子,会在之前的筛选中被标记。继续往后遍历只是浪费。

3.2 线性筛(欧拉筛)的优势

埃拉托斯特尼筛法虽然简单,但存在一个理论隐患:同一个合数可能被多个质因子标记多次。例如 12 会被 2 标记、也会被 3 标记。虽然现代 CPU 下性能尚可,但为了追求更干净的打法,我改用了欧拉筛(线性筛),保证每个合数只被其最小质因数标记一次。

欧拉筛维护一个质数表 primes,并对每个数 i,遍历 primes 中的质数 p:

  • 如果 i × p > limit,跳出;
  • 标记 i × p 为合数;
  • 如果 p 能整除 i,跳出。

这个"p 能整除 i 就跳出"的条件是线性筛的核心。它能保证后续更大的 p 不会是最小质因数,避免重复标记。虽然代码比埃氏筛多几行,但整个过程是严格的 O(n),对于 10^7 级别的筛取任务非常稳定。

3.3 内存优化:分段筛的思路

当 limit 到 10^9 级别时,一个 bool 数组占用约 1GB 内存,这不现实。常用的方案是分段筛:把区间 [2, limit] 拆成大小为 B 的分段,先筛出 sqrt(limit) 以内的质数,然后用这些质数逐段标记合数。

我实现时每个分段用 bitset 做标记,每段 1MB 约能覆盖 800 万个整数,在实际使用中已经能把内存压到非常低。这个优化对于后面做因数分解预筛也有用。

4. 因数分解:从试除法到 Pollard Rho

4.1 小因子场景:试除法依然是首选

因数分解最简单直接的方法还是试除法,从最小质数开始逐个尝试,一旦某个质数 p 能整除 n,就记录 p,并将 n 更新为 n / p 后重新检查 p(因为同一个因子可能出现多次)。

试除法的上限是 sqrt(n),所以当 n 在 10^12 级别时,配合小质数表做预筛,性能尚可。实测对一个 10^12 以内的数做完整分解通常只需要几十毫秒,对于教学和大多数应用场景完全够用。

但问题同样存在:如果 n = p × q,其中 p 和 q 都是 10^9 级别的质数,那么试除法需要枚举约两千万个候选因子,耗时变得不可接受。这正是 Pollard Rho 介入的场景。

4.2 Pollard Rho 的直觉与实现

Pollard Rho 用了一个非常聪明的想法:与其老老实实从 2 数到 sqrt(n),不如用一个伪随机序列生成一系列数,然后在序列中寻找两个数,它们的差与 n 有非平凡公约数。

具体讲,定义一个函数 f(x) = (x² + c) mod n,从 x1 开始生成序列 x1, x2, x3, ...,这序列的每一项与某个整数对之间的差,可能以某种概率与 n 共享因子。一旦 gcd(|x - y|, n) 是一个大于 1 且小于 n 的值,就找到了一个非平凡因子。

实现对 gcd 的计算使用辗转相除法,gcd 的调用次数和序列长度相关。为了避免频繁调用 gcd 拖慢速度,常见的优化是每若干项(比如每 128 项)做一次批量乘积取模,把多个差值的乘积收集起来,统一与 n 求一次 gcd。

Pollard Rho 的边界条件也得处理好:如果找到的因子等于 n 本身,说明这次随机函数没撞到有用的因子,就换一个 c 参数重新跑。一般来说,这个算法找到因子非常快,复杂度接近于 O(n^(1/4)),实测分解 10^18 级别的合数通常在几十毫秒到几百毫秒内完成。

4.3 因数分解的完整流程设计

我把因数分解封装成了一个递归流程:

  1. 先用小素数表(2 到 10000 内的质数)尝试逐个去除输入 n,去掉所有小因子;
  2. 如果剩余的 n 等于 1,结束;
  3. 如果剩余的 n 已经是质数(用上一章的确定性米勒-拉宾判定),直接记录并结束;
  4. 如果剩余的 n 是两个大质数的乘积(用米勒-拉宾确认是合数),调用 Pollard Rho 找到一个非平凡因子 d;
  5. 对 d 和 n/d 分别递归执行步骤 1 到 4。

这个流程有个妙处:递归天然处理了"找到的因子本身可能是合数"的情况,无需额外维护复杂的数据结构,只要保证递归出口正确(剩余值为 1 或质数)。我用一个有序 map 存储质因数和指数,最终按照从小到大提取即可。

4.4 Pollard Rho 的随机种子选择

Pollard Rho 依赖随机数来生成伪随机序列,随机种子的选取对性能波动影响很大。我最初直接使用当前时间作为随机种子,结果发现在某些特定输入上,个别种子会导致算法退化成接近试除法的效率。

后来我做了个简单优化:如果某次 Pollard Rho 调用在指定迭代次数内没有找到因子,就自动换一个随机种子重新尝试,最多重试若干次。实测中这个策略几乎总是能在第一或第二次尝试就成功找到因子。

5. 完整代码实现与测试记录

5.1 Python 版核心代码

考虑到可读性和教学用途,我先用 Python 写了原型版本,关键代码如下:

import math import random def is_probable_prime(n): if n < 2: return False small_primes = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29] for p in small_primes: if n % p == 0: return n == p d = n - 1 s = 0 while d % 2 == 0: d //= 2 s += 1 for a in [2, 3, 5, 7, 11, 13, 17]: if a >= n: continue x = pow(a, d, n) if x == 1 or x == n - 1: continue for _ in range(s - 1): x = x * x % n if x == n - 1: break else: return False return True def pollard_rho(n): if n % 2 == 0: return 2 while True: c = random.randint(1, n - 1) x = random.randint(2, n - 1) y = x d = 1 while d == 1: x = (x * x + c) % n y = (y * y + c) % n y = (y * y + c) % n d = math.gcd(abs(x - y), n) if d != n: return d def factorize(n, result): if n == 1: return if is_probable_prime(n): result.append(n) return d = pollard_rho(n) factorize(d, result) factorize(n // d, result)

这段代码的逻辑非常直白,先判质数,再 Pollard Rho 分裂,递归到底。实际使用下来,Python 版本对于 10^12 级别的合数已经很快,唯一心疼的是大数场景下pow(a, d, n)会调用 C 内置快速模幂,速度还算理想。

5.2 C++ 版关键代码

Python 原型验证通过后,我把它移植到了 C++,毕竟在很多工具场景里还是要一个更快的内嵌实现。C++ 版的难点主要在两处:一是无符号 64 位乘法溢出,二是随机数的可靠性。

乘法取模用了__uint128_t:

using u64 = unsigned long long; using u128 = __uint128_t; u64 mul_mod(u64 a, u64 b, u64 mod) { return (u128)a * b % mod; } u64 pow_mod(u64 a, u64 d, u64 mod) { u64 res = 1; while (d) { if (d & 1) res = mul_mod(res, a, mod); a = mul_mod(a, a, mod); d >>= 1; } return res; }

这个mul_mod一出现,很多原本可能被溢出坑掉的边界测试全都稳定了。C++ 版本在 -O2 优化下对 10^18 级别合数作完整因数分解,耗时在几十毫秒量级,完全满足我最初设定的性能指标。

5.3 测试用例与验证结果

我准备了一个比较完整的测试集,覆盖各种典型输入:

  • 质数:2、3、5、97、9999999967(这是一个著名的 10^10 级别质数)、18446744073709551557(接近 2^64 的最大质数);
  • 完全平方数:1000003²、9999991²;
  • 大合数:2 × 3 × 5 × 7 × 11 × 13 × 17 × 19,以及若干通过程序生成的两个大质数的乘积;
  • 边界值:1(应该返回空)、0(要求报错)、2^64 - 1 = 18446744073709551615 = 3 × 5 × 17 × 257 × 641 × 65537 × 6700417,这个数非常经典,分解结果完全对得上。

全部测试通过后,我额外跑了一组性能基准:随机抽取 1000 个 10^15 到 10^18 之间的合数做分解,C++ 版平均耗时不足 200 毫秒,Python 版平均约 500 毫秒。这个结果让我放心地把模块集成为后续工具库的正式组件。

6. 实操中常踩的坑与避坑技巧

6.1 类型溢出是最大的隐形杀手

做这段项目时,我遇到最隐蔽的 bug 不是算法思路问题,而是类型溢出。用unsigned long long做乘法时,一旦溢出,结果可能变成一个小数或者 0,这时你会得到一个完全错误的质数判定或因子分解结果,甚至可能进入死循环。

排查方法很简单:把所有中间乘法都用 128 位承接,或者至少对可能溢出的运算加上确认。Python 没有这个问题,但如果你用 C、C++、Go 或 Java,一定要把乘法溢出当成头号风险。

6.2 预处理小素数表可以救很多命

试除法和 Pollard Rho 的预筛阶段,都依赖一份可靠的小素数表。这个小素数表不要每次动态生成,而是在模块初始化时用筛法一次性生成,存到静态数组里。这样后续每次判定或分解都不用重复筛选,性能收益非常可观。

我实际测试过一个场景:对 1 万个 10^12 级别的随机数逐个做完整因数分解,使用预筛表比不用预筛表能快 4 倍以上。原因是绝大多数输入都有小因子,预筛直接把最常见的因子提取掉了,剩余输入规模骤减。

6.3 Pollard Rho 需要防退化

Pollard Rho 并非总是高效,在最坏情况下它可能退化到 O(sqrt(n)) 级别。虽然这种退化出现的概率极低,但在工程里不得不防。我的策略是三管齐下:

  • 限制单次随机种子的尝试次数;
  • 当检测到多次失败时,换 c 参数并重新随机;
  • 对于斐波那契数列这类容易构造伪随机序列"短循环"的输入,额外加一段固定次数的 Floyd 判圈逻辑,防止死循环。

这套处理下来,在实际运行中再也没碰到过退化成暴力枚举的情况。

6.4 递归深度的控制

因数分解的递归流程在极端情况下可能很深。例如一个数是几个小质数的高次幂相乘,递归会把小因子逐个拆解出来。虽然单个因子很小,但递归层数可能会到数百层,在 Python 中必须考虑递归深度问题。

我的处理方式是把pollard_rho返回的因子继续递归时,如果发现当前 n 是一个完全平方数,就提前用平方根检测分流。这个优化不仅减少了递归层数,还让平方数大合数的分解速度明显提升。

6.5 一个非常好用的调试技巧

做因素分解时,调试路径非常曲折。我建议在代码中预留一个"核查模式",每次递归结束时递归地验证:所有记录的质因数的乘积是否等于原始输入。这个核查往往能在第一时间发现溢出或逻辑错误。

此外,对于 10^12 附近的人工构造合数,我建议手算验证结果的时候直接使用快速模乘 + 逐步乘开的方式复算一遍,多一次独立验证,后面才能放心集成进更大的项目。

7. 最后的经验分享

整个项目做下来,我最大的心得是"分层"这两个字。无论是质数判定还是因数分解,不要上来就上最复杂最炫的算法,而是先用最廉价的预筛把绝大多数情况拦掉,再逐步升级到高成本的判断手段。这样设计出来的工具既有理论上的完备性,也有实践上的高效率。数据结构和接口设计同样重要:判质数的功能被因数分解反复调用,因数分解又可能被更高层的模块调用,把它们解耦成自治的小工具,后面组合使用时几乎不用改代码。

如果你也正在做类似的数论模块,我的建议是从最朴素的实现开始,先确保正确性,再做性能优化。优化的方向可以参考我这里的顺序:小素数表预筛、6k ± 1 过滤、米勒-拉宾确定性判定、Pollard Rho 分裂。每个优化点都是独立可验证的,逐步叠加不容易出大错。等到全部跑通之后,你会有一种很扎实的感觉——手头这套代码不仅能把 10^18 级别的合数干净利落地拆掉,还能解释清楚每一步到底为什么快、为什么对。这一点,比代码本身更值得好好记住。

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

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

立即咨询