☰
素数判定算法全解析:从试除法到 Miller-Rabin 的工程实践
2026/10/13 4:20:00 网站建设 项目流程

很多年前我第一次写素数判定程序时,用的还是从 2 一直除到 n-1 的笨办法。当时觉得“能跑就行”,直到某天需要判断一个十几位的大数,程序直接卡死,我才意识到素数的判定远不是“循环试除”这么简单。后来我在实际项目里陆续接触了 Miller-Rabin、筛法、甚至用于密码学的安全素数生成,才慢慢把这块知识补完整。这篇博文就围绕“素数的判定”这个主题,把我这些年踩过的坑、用过的方案和整理过的经验一次性讲清楚。

这篇文章适合三类人:刚学编程不久、想搞懂素数判定基础算法的学生;工作中需要处理大数素性检测的开发者;以及纯粹好奇“计算机怎么快速判断一个数是不是素数”的爱好者。我会从最朴素的试除法讲起,一路聊到工程中真正在用的概率性检测算法,中间穿插实际代码、复杂度分析和排错经验。

1. 素数的定义与判定问题的本质

素数也叫质数,指的是大于 1 的自然数中,除了 1 和它本身以外不再有其他因数的数。比如 2、3、5、7 都是素数;而 4、6、8、9 都不是素数,因为它们都能被某个介于 1 和自身之间的整数整除。

这里有个很容易被忽略的点:1 不是素数。我记得很多初学者第一次写判定函数时,都会把 1 误判成素数,这是因为他们只检查了“能否被 2 整除”,而没有单独处理边界条件。所以任何合格的判定函数,第一步一定是先排除小于等于 1 的情况。

判断一个数 n 是否为素数,本质上是在回答一个问题:在区间 [2, n-1] 内是否存在某个整数 d,使得 n 能被 d 整除。如果存在,n 就是合数;如果不存在,n 就是素数。这个问题的暴力解法非常直观,就是枚举所有可能的 d,逐一做取模运算。

但问题在于,当 n 稍微大一点,比如 n 是 10 的 12 次方量级,暴力枚举的计算量就会变得非常恐怖。假设计算机每秒能做 10 亿次取模运算,那也需要上千秒才能完成一次判定。更别说在 RSA 密钥生成等场景里,需要快速找到几百位的大素数,暴力试除完全不可行。

所以素数的判定,从计算复杂度上看,其实是在“正确性”和“效率”之间做取舍。基础算法能保证绝对正确但速度慢,高级算法速度快但要么带有概率性,要么依赖尚未被证明的数学猜想。理解这条主线,后面看到各种算法时就不会迷路。

2. 朴素试除法:从暴力到开根号优化

2.1 最原始的试除思路

最直接的思路就是遍历从 2 到 n-1 的所有整数,逐个判断 n 是否能被整除。写成代码非常短:

def is_prime_naive(n): if n < 2: return False for d in range(2, n): if n % d == 0: return False return True

这个算法的时间复杂度是 O(n)。当 n=100 时没问题,但 n=1000000 时就要循环 100 万次,能明显感觉到卡顿。我在初学阶段写过这个版本,跑了一个 10 亿次循环后,程序整整卡了几分钟才出结果,从那以后我就记住了:暴力只能用来验证小整数,绝对不能用在真实场景。

2.2 关键的 sqrt(n) 优化

其实我们根本不需要遍历到 n-1,只需要遍历到 sqrt(n) 就够了。原因很简单:如果 n 是一个合数,那么它一定可以写成 a×b 的形式,其中 a 和 b 都大于 1。a 和 b 不可能都大于 sqrt(n),否则乘积会大于 n。所以至少有一个因子小于等于 sqrt(n),我们只要在 [2, sqrt(n)] 范围内寻找因子即可。

代码改起来几乎零成本:

import math def is_prime_sqrt(n): if n < 2: return False if n == 2: return True if n % 2 == 0: return False limit = int(math.isqrt(n)) for d in range(3, limit + 1, 2): if n % d == 0: return False return True

这个版本只检查奇数,因为所有大于 2 的偶数都能被 2 整除,不可能成为素数。这一步直接把计算量再砍了一半。整体复杂度降到 O(sqrt(n)),当 n=10^12 时,只需要检查约 50 万个奇数,现代计算机能在几毫秒内完成。我第一次体会到“优化带来的愉悦感”就是在把试除法从 n 优化到 sqrt(n) 的那一刻,那种感觉比中彩票还爽。

2.3 试除法的局限

尽管已经优化到 O(sqrt(n)),但这个算法依然只能对付中等规模的整数。当 n 达到 10^18 量级,需要检查大约 10^9 个奇数,单次判定可能需要几秒到几十秒,这在实际工程中是不可接受的。更不用说在密码学场景下,动辄需要判定 200 位以上的大数,sqrt(n) 接近 10^100,这在物理上就不可能遍历完。

所以我们需要换思路:不再执着于严格找因子,而是用数学工具去“侦测”合数。后面的概率性素性检测算法,走的都是这条路线。

提示:任何试除类的算法,都务必先判断 n%2,再跳过所有偶数因子。我见过不少人直接在 for 循环里写 range(2, limit+1),那等于白白浪费一半计算量,在小数据上无所谓,在接近极限时差别非常明显。

3. 经典筛法:批量判定素数的利器

3.1 埃拉托斯特尼筛法

单个数判定的场景用试除法就够了,但有时候我们需要知道某个范围内所有的素数,比如 100 万以内有多少个素数、哪些数是素数。这时候逐个数调用判定函数会非常慢,用筛法才是正解。

埃拉托斯特尼筛法(Sieve of Eratosthenes)的思路是:准备一个从 2 到 N 的布尔数组,初始都标记为“是素数”。然后从最小的素数 2 开始,把 2 的所有倍数标记为“合数”;接着找到下一个未标记的数 3,把 3 的所有倍数标记为合数;继续这个过程,直到遍历到 sqrt(N)。最终仍然标记为素数的那些数,就是真正的素数。

实现代码如下:

def sieve_of_eratosthenes(n): if n < 2: return [] is_prime = [True] * (n + 1) is_prime[0] = is_prime[1] = False for i in range(2, int(n ** 0.5) + 1): if is_prime[i]: for j in range(i * i, n + 1, i): is_prime[j] = False return [i for i in range(n + 1) if is_prime[i]]

这里有个细节:内层循环从 ii 开始,而不是从 2i 开始。因为对于 i 来说,比 ii 小的那些倍数(比如 2i、3i 等)已经被更小的素数因子筛过了。比如筛 5 的倍数时,52=10 已经在筛 2 时被筛掉了,53=15 在筛 3 时被筛掉了,所以直接从 55=25 开始,能省掉大量重复工作。

这个筛法的时间复杂度是 O(n log log n),空间复杂度是 O(n)。在我的电脑上筛 1000 万以内的素数只需要不到 1 秒,非常实用。实际工作中如果你要生成素数表供后续查询,用这个筛法预计算一次,之后每次判定的时间复杂度就是 O(1)。

3.2 线性筛:更优的工程选择

埃氏筛有个小缺点:同一个合数可能被多个素数筛掉,比如 12 会被 2 筛掉,也会被 3 筛掉,只是顺序有先后,浪费了一些操作。线性筛(欧拉筛)通过保证每个合数只被它的最小质因子筛一次,把复杂度优化到严格的 O(n)。

def linear_sieve(n): if n < 2: return [] is_prime = [True] * (n + 1) primes = [] for i in range(2, n + 1): if is_prime[i]: primes.append(i) for p in primes: if i * p > n: break is_prime[i * p] = False if i % p == 0: break return primes

这个代码的核心是if i % p == 0: break。它保证每个合数只会被其最小质因子筛去。举个例子,当 i=4 时,首先筛掉 42=8,然后因为 4%2==0,直接跳出循环,不会去筛 43=12。因为 12 的最小质因子是 2,应该由 i=6 时筛 6*2=12 来完成,而不是由 4 和 3 的乘积筛掉。

我在项目中需要频繁生成素数表时,更倾向于用线性筛。虽然代码比埃氏筛复杂一点点,但性能更好,而且当 N 达到千万级别时,运行时间差距很可观。第一次跑线性筛时我还专门加了个计数器统计每个合数被筛了几次,验证确实只筛一次,那种“理论被代码验证”的满足感很值得体验。

注意:筛法适用于预处理固定范围。如果你的判定场景是离散的、毫无规律的大整数,那筛法帮不上忙,还得靠后面的概率性算法。

4. 费马小定理与 Miller-Rabin 素性检测

4.1 费马小定理的引入

到了工程中的大数判定阶段,试除法和筛法都显得力不从心。这时候我们依赖的是数论中的费马小定理:

如果 p 是一个素数,且 a 与 p 互质,则 a^(p-1) ≡ 1 (mod p)。

这个定理的意思是,对于一个素数 p,随便拿一个不是 p 倍数的 a 出来,a 的 p-1 次方除以 p 的余数一定是 1。反过来,如果某个数 n 对某个 a 不满足这个性质,那 n 一定不是素数。这个“反证”逻辑是可行的:存在一个 a 使得 a^(n-1) ≠ 1 (mod n),那就说明 n 的素数身份存疑,直接判定为合数即可。

但反过来并不成立:存在一些合数 n,对所有与 n 互质的 a,都满足 a^(n-1) ≡ 1 (mod n)。这种数叫卡迈克尔数(Carmichael 数),最小的例子是 561=3×11×17。用费马测试去测 561,对几乎所有 a 都会误判为素数,所以单独用费马小定理做素性检测是不安全的。

4.2 Miller-Rabin 算法的核心思想

Miller-Rabin 在费马测试的基础上做了一层更强的约束。它先把 n-1 写成 2^k × m 的形式,其中 m 是奇数。然后计算 a^m mod n,再连续平方 k 次。如果在平方过程中提前出现了 1,且前一步不是 ±1,那么就能断定 n 是合数。

原理说起来有点绕,我用一个生活化的类比来解释:想象你要验证一个人声称自己是“本地居民”,费马测试只检查他有没有带一张通用通行证,而 Miller-Rabin 则会查他的身份证信息是否一致、水电账单是否匹配、邻居证词是否对得上。每一轮测试都从不同角度去“质疑”他,如果任何一处对不上,就立刻判定造假。

实际实现时我们会随机选择多个底数 a 进行多轮测试。只要通过足够多轮的 Miller-Rabin 测试,n 是素数的概率就极高。标准做法是:对于 32 位以内的整数,用确定性底数 {2, 7, 61} 即可保证 100% 正确;对于 64 位以内,用 {2, 3, 5, 7, 11, 13, 17} 即可保证 100% 正确;对于更大的数字,取随机 k 个底数进行测试时,单轮出错的概率不超过 4^(-k)。

4.3 Python 实现 Miller-Rabin

import random def miller_rabin(n, k=8): if n < 2: return False for p in [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37]: if n % p == 0: return n == p # 将 n-1 写成 d * 2^r d = n - 1 r = 0 while d % 2 == 0: d //= 2 r += 1 # 进行 k 轮测试 for _ in range(k): a = random.randrange(2, n - 2) x = pow(a, d, n) if x == 1 or x == n - 1: continue for _ in range(r - 1): x = pow(x, 2, n) if x == n - 1: break else: return False return True

代码里我加了一个小优化:先用前 12 个小素数做一次快速试除,如果 n 本身就是这些小素数就直接返回 True,如果 n 能整除其中任何一个但 n 比它大,那 n 就是合数,直接返回 False。这个小优化能提前排除大约 90% 的合数,避免进入复杂的模幂运算。

这里的pow(a, d, n)是 Python 内置的快速模幂函数,它能高效计算 a^d mod n,时间复杂度是 O(log d),远快于先算 a^d 再取模。我见过有初学者在这边直接写pow(a, d) % n,当 d 很大时,中间结果的位数会爆炸,内存和时间都吃不消。必须用三参数的pow(a, d, n)。

4.4 关于确定性与概率性的工程选择

在实际开发中,我通常这样划分使用场景:

  • 如果 n 是 32 位或 64 位整数,用固定小底数集合的确定版 Miller-Rabin,速度极快且结果绝对正确。
  • 如果 n 是几百位的大整数,比如密码学场景,则使用随机底数做 8 到 16 轮 Miller-Rabin。虽然理论上是概率性的,但连续多轮全部误判的概率低到 4^(-16),这在工程上完全可以忽略。

另外还有更严格的 AKS 算法,它是第一个被证明在多项式时间内确定性地判定素数的算法,但实际实现复杂度很高,常数因子巨大,工程中几乎没人使用。作为兴趣了解可以,但别把 AKS 写进生产代码里。

经验:做 Miller-Rabin 时一定要处理边界条件,尤其是 n=2、n=3 这种小素数,以及 n 是偶数的情况。否则会出现索引越界或循环死循环的问题。我最初实现时没处理偶数,导致输入 4 时进入无限循环,排查了半天才发现是 n-1 分解时 d 变成了奇数,但 n 本身是偶数,某些底数 a 取到了和 n 不互质的数,结果就不对了。

5. 实际项目中的素性检测方案与优化

5.1 完整流程设计

在实际项目中,我一般不会单独只用一个判定算法,而是设计一个多级判断流程,从快到慢逐层递进。整体流程如下:

  1. 先判断 n 是否小于 2,是则返回 False。
  2. 再检查 n 是否偶数,是则返回 n == 2。
  3. 用若干小素数(比如 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37)做快速试除,能排除绝大多数合数。
  4. 如果 n 小于某个阈值(比如 10^12),直接用优化的试除法即可,因为 sqrt(n) 最多 100 万次检查,毫秒级完成。
  5. 如果 n 更大,则使用 Miller-Rabin 判定。

这种分层结构的好处是:对小整数用最快的方式给出确定性结果,对大整数用概率性算法保证效率。我把它封装成下面这个函数:

import math import random _SMALL_PRIMES = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37] def is_prime(n, rounds=12): if n < 2: return False for p in _SMALL_PRIMES: if n % p == 0: return n == p # 小整数使用完全确定性的试除 if n < 10**12: limit = math.isqrt(n) for d in range(37, limit + 1, 2): if n % d == 0: return False return True # 大整数使用 Miller-Rabin d = n - 1 r = 0 while d % 2 == 0: d //= 2 r += 1 for _ in range(rounds): a = random.randrange(2, n - 2) x = pow(a, d, n) if x == 1 or x == n - 1: continue for _ in range(r - 1): x = pow(x, 2, n) if x == n - 1: break else: return False return True

这个函数是我日常最常用的“万能判定器”。小整数走确定性分支,大整数走概率分支,综合正确性、速度和代码复杂度都表现良好。

5.2 生成大素数的实用技巧

素数判定通常和“生成大素数”的需求一起出现。比如在加密算法中,需要生成一个随机的 1024 位大素数。直接随机选一个奇数然后判定,显然很低效。更聪明的做法是先在本地找一些相对较小的素数作为候选的基础,或者使用增量搜索。

一个实践中很有效的策略是:随机生成一个奇数 n,先用小素数表对 n 做一次快速取模检查,如果 n 能被某个小素数整除,立刻放弃并生成下一个候选,而不是直接调用完整的 Miller-Rabin。因为 Miller-Rabin 内部的模幂运算开销远大于一次取模,用 1000 个小素数做预筛能把大部分合数挡在门外,大幅提升生成速度。

我做过一次测试:用随机奇数直接做 12 轮 Miller-Rabin,和加入 128 个小素数预筛后再做完全相同的 Miller-Rabin,后者生成一个 256 位素数的时间要快 3 到 4 倍。原因很简单,大部分随机奇数都能被一个小素数整除,预筛直接把它们过滤掉了,进入复杂判定流程的只是少数。

5.3 并行化与查表加速

如果应用场景需要一次性判断许多数,可以考虑并行化。Miller-Rabin 的每轮测试相互独立,因此可以分配给多个线程或进程。Python 里可以用concurrent.futures.ThreadPoolExecutor实现多线程并发,但由于 GIL 的存在,CPU 密集型任务更适合用多进程。用multiprocessing.Pool把不同候选素数的判定任务分发到多核上,能在素数生成场景中明显提升吞吐量。

另外一个实用技巧是:如果服务器内存充足,可以预先筛出 10^6 到 10^7 以内的所有素数,存成一张查找表。判定小于表范围的 n 时,直接查表即可,时间复杂度 O(1)。在我的某个模拟项目里,用这种方式把判定吞吐量提升了近 20 倍,代价仅仅是几十 MB 内存,非常划算。

6. 常见问题与排查技巧实录

6.1 为什么我的试除法输入大数会卡死

很多初学者跑试除法时,输入一个 10 位数就卡住了,这不是死循环,而是复杂度太高。比如 n=9999999999,sqrt(n) 大约是 10 万,按优化后的试除法只需要检查 5 万次,按理说不应该卡。如果还是很慢,多半是循环内使用了range(2, n)而不是range(3, int(math.isqrt(n)) + 1, 2)。检查一下循环边界和步长,能立刻解决问题。

如果确认了算法没问题但依然很慢,那就是数据规模超出预期了。这时候不要纠结试除法,直接把 n 交给 Miller-Rabin 处理。在项目中遇到性能瓶颈时,不要只想着微调现有代码,换个算法思路往往更有效。

6.2 Miller-Rabin 误判了怎么办

理论上 Miller-Rabin 存在误判可能,但实际工程中极少出现。如果你要绝对确定性,可以先用小素数预筛,再用若干固定底数做全确定测试。对小于 3,474,749,660,383 的数,底数 {2, 3, 5, 7, 11, 13} 就可以保证结果绝对正确。这个范围覆盖了很多实际场景。

如果你发现程序偶尔把合数判成了素数,建议先检查底数选取逻辑。我踩过一个坑:底数 a 的范围写成range(2, n-2),当 n 很小时,这个区间是空的,循环体一次都不执行,函数直接返回了 True。后来我加上对小素数的前置判断才修复。这类问题通常不是算法本身的问题,而是边界条件写错了。

6.3 为什么 pow 计算结果不对

Python 内置函数pow(a, d, n)返回的是 a 的 d 次方对 n 取模后的结果。如果写成pow(a, d) % n,当 a 和 d 都很大时,中间结果可能占用数千字节内存,计算速度极慢。而且如果 d 是 0 或负数,pow(a, d, n)的行为也会不一样,需要特别小心。

有一次我在生成大素数时,因为把一个参数传错了位置,导致pow(a, d, n)里的 n 为 1,任何数对 1 取模都是 0,结果判断逻辑全乱,程序报错。后来我把整段代码加了详细的日志,打印每个阶段的参数,才定位到问题。所以排查素性检测问题时,第一步永远是确认输入参数是否正确。

6.4 常见问题速查表

问题现象可能原因解决方案
n=1 返回了 True缺少 n<2 的边界判断在函数开头加上if n < 2: return False
偶数被判定为素数没有排除偶数先判断n % 2 == 0后返回n == 2
试除法大数卡死循环边界写成了 n 而不是 sqrt(n)使用math.isqrt(n)限制循环上界
Miller-Rabin 对小的合数返回 True底数范围为空或底数选取有问题增加小素数前置试除,修正 base 范围
模幂运算很慢使用了pow(a,d) % n改为pow(a, d, n)
随机生成了很多合数但判定慢缺少预筛步骤先用小素数表做快速取模过滤
素数表生成慢使用埃氏筛但内层起点为 2i内层循环从 i*i 开始
多线程判定没有加速使用 Python 线程处理 CPU 密集任务改用multiprocessing.Pool多进程

7. 关于确定性算法和未来方向的一点思考

很多开发者在看完 Miller-Rabin 后都会问:既然它只是概率性算法,为什么工程上普遍用它,而不是用确定性算法?答案很简单:确定性算法在理论复杂度上已经达成多项式时间,但常数因子巨大,实际运行时间远超 Miller-Rabin。当前工业界的主流选择是在可接受的低错误概率下换取高性能,这既现实也可靠。

另外有一些确定性算法值得了解,比如基于椭圆曲线的 ECPP 素性证明,它虽然没有 Miller-Rabin 那么快,但能生成一个可以被独立验证的“素性证书”,在需要向他人证明某个数是素数时很有用。某些领域会用它来替代概率性检测。

从个人经验来说,学习素数判定不要只停留在背代码,更重要的是理解每一步背后的数论原理。比如为什么检查 sqrt(n) 就够了,为什么 Miller-Rabin 要分解 n-1,为什么平方检测能抓出费马测试漏掉的合数。把这些原理想明白,遇到奇怪的 bug 时才能有方向,而不是靠盲猜。

我自己的学习路线是:先手写一遍试除法,然后写筛法,接着实现费马测试,最后升级到 Miller-Rabin。每走一步,我会故意输入一些特殊数字,比如 561、1105、1729 这些卡迈克尔数,观察算法的表现。亲眼看到费马测试被卡迈克尔数骗过,再看到 Miller-Rabin 正确识破,对算法的理解立刻加深了一层。这种“亲手验证”的体验,比看十篇教程都有用。

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

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

立即咨询