Miller-Rabin 素性检验
常规确定性素性检验的时间复杂度是 ,但是对于 规模的数据,该素性检验显得有些捉襟见肘了,于是我们引入 Miller-Rabin 素性检验法。该算法是一个随机化算法,能够在可接受的时间复杂度内,进行错误率在可接受范围内的素性检验。除此之外,可以证明,在一定规模内,选取合理的一组数据,可以实现确定性素性检验[1],在下面会进一步提到。
费马素性检验
由费马小定理,可以得到:若 是一个素数,则对于所有 ,有
该定理的逆否命题:若存在 ,满足 ,则 一定不是素数。这是一个真命题(逆否命题的性质)。
借此启发我们随机找一些底数 ,计算 的结果,如果该数字不为 ,则可判定其为合数。
然而遗憾的是,有一些合数能够通过某些费马素性验证。例如当 时,,然而 是一个合数。此时 称为 以 为底的伪素数。
更为惊人的是,有一些满足对于所有 ,都有 成立,这样的数被称为 Carmichael 数。
所以我们需要对费马素性检验进行改进。
二次探测定理
内容
如果 是奇素数,那么 的解为 和 。
证明
由原式可以导出 ,运用平方差公式得到 ,也就是说 。
由于 是一个素数,所以 的倍数必然含有素因子 ,所以 必然满足 和 其中一个。容易验证两者都成立,证毕。
Miller-Rabin 素性测试
结合费马素性检验和二次探测定理可以得到最终的素性检验。
我们称 的解 和 为其平凡解。
如果我们找到 的一个非平凡解,则可以判定 不是素数。
一个偶数的素性测试是容易的。
对于一个奇数 ,将 分解成 ,其中 是一个奇数。
对于一个随机的 ,先求 的值,然后对其平方 次,若发现非平凡平方根时,该数字不是素数。如果最终结果不为 ,则该数字不是素数。
时间复杂度分析
由于对 的规模没有限制,我们并不能默认单次乘法的时间复杂度为 。常规情况下对两个小于 的数单次乘法的时间复杂度为 。单次校验需要使用一次复杂度为 的快速幂算法,再进行 次乘法。故单次校验总时间复杂度为 。
如果对乘法使用快速傅里叶变换算法进行优化,单次乘法操作复杂度降为 ,于是总复杂度变为 。
假设使用朴素算法进行 次判断,则时间复杂度为 。
错误率分析
该算法的错误率取决于有多少个底数,使得一个合数被错误地判定为素数。
而这样的底数占总数的比率不超过 [2],所以单次错误率是 ,故进行 轮校验得到的错误率是 的,当 时错误率约为十万分之,这是非常可以接受的。
实现
这个程序实现了对 long long 范围内的整数进行 miller-rabin 测试,测试 TEST_TIME
中设定的次数次。
Sample Code (C++)
std::mt19937_64 Rad(std::chrono::steady_clock::now().time_since_epoch().count());
using ll = long long;
ll qpow(ll a, ll b, ll p) {
ll res = 1;
for (; b; b >>= 1, a = (__int128)a * a % p)
if (b & 1) res = (__int128)res * a % p;
return res;
}
ll rad(ll l, ll r) {
return std::uniform_int_distribution<ll>(l, r)(Rad);
}
const int TEST_TIME = 8;
bool miller_rabin(ll n) {
if (n < 3) return n == 2;
if (n == 3) return true; // 判掉一些边边角角的情况
ll u = n - 1, t = 0;
while (!(u & 1)) ++t, u >>= 1;
for (int i = 1; i <= TEST_TIME; ++i) {
ll a = rad(2, n - 2);
ll v = qpow(a, u, n);
if (v == 1) continue; // 如果 v 为 1,直接通过测试
int s;
for (s = 0; s < t; ++s) {
if (v == n - 1) break; // 在某一次 v 为 n - 1 时,以后 v 都是 1,通过测试
v = v * v % n;
}
if (s == t) return false; // 当 s == t 时,未通过费马素性检验
}
return true;
}
拓展-确定性判素
假设广义 Riemann 猜想成立,那么进行确定性判素只需要选择 为底,进行 Miller-Rabin 测试[1:1]。
但是即使广义 Riemann 猜想成立,这样判定时间复杂度变为 ,此时运行时间已然不可接受。
但是在算法竞赛中,通常我们只需要对 long long 范围内的素数进行判定。在一定范围内,我们可以选用若干个固定底数进行确定性判素。例如[3]:
对于 范围内的判素,选取 三个数为底。
对于 范围内的判素,选取 七个数为底。
如果记不住上面七个数,可以使用 十二个数(即前 12 个素数)为底,可以适用于 之内的判素。
Pollard-Rho 算法
分解质因数有 的朴素算法。
同样地,对于更大规模的数据,朴素算法显得力不从心了,我们引入 Pollard-Rho 算法,该算法可以在期望 的时间复杂度内求得 的其中一个 非平凡因子。
生日悖论
假设每年都是 365 天,求一个房间中至少多少人,才能使其中两个人生日相同的概率达到 ?
设房间里有 个人,有两个人同一天生日的概率为 ,那么没有两个人同一天生日的概率可以这样计算:
可以代入几个数字得到:
23 | 41 | 57 | 100 | |
---|---|---|---|---|
50.73% | 90.32% | 99.01% |
可以求得若有 23 人,即可使得两个人生日相同概率达到 。对于一个 41 人的房间,存在两个人生日相同的概率高达 。对于 57 人的房间,存在两个人生日相同的概率到了惊人的 。如果有 100 人,那么 没有 两个人同一天生日的概率达到了千万分之三。
这样的数学事实与我们的观念差异很大,所以称之为悖论。
这启示我们,在一个区间内随机选取一些数字,其中含有相同数字的概率很高。
更具体地,当在 个数字中随机选取大约 个数字[4]时,有 的概率选择到两个相同的数字。也就是选择大约 个数字,有 的概率选择到两个相同的数字。
如果在 个数字中选出两个不同的数字,期望需要进行 次选取。[5]
最大公约数求约数
如果我们直接在 中随机选出一个数,这个数字是 的约数的概率是较小的。
我们巧妙地利用最大公约数的性质:假设有一个数字 满足 ,那么 一定是 的一个非平凡因子。
满足 的数字是多的:每一个质因子的倍数都满足条件。这可以大大提高我们的成功概率。
然而直接找满足条件的 概率也并不太高,设 的最小质因子为 ,假如能找到 且 ,那么 是一个满足条件的 。
注意到模 剩余系的大小不超过 ,并且 不超过 。根据生日悖论,在 中期望随机 个数字,就能找到 且 ,利用 就能找出一个非平凡约数。
然而如果我们真的随机 个数字,两两进行求差,求 gcd 操作,时间复杂度为 ,比朴素做法更慢。所以我们使用两种方法进行实现:
Floyd 判环
下面将使用多项式函数 生成随机序列,其中 是一个随机参数,这个函数能够保证,对于 ,有 ,其中 是 的约数。
证明
设 ,那么 ,同理得到 。
,。所以 。
实际上任何多项式函数都满足这个性质。但是我们希望项数比较少以更快地得到结果。此外,经过本人测试,如果直接使用 在对 进行分解时会陷入死循环。所以下文实际使用的是
可以感受一下,这个性质保证了若 ,此时能够找到一个因子,则 仍然能够找到一个因子。
将任意一个数代入数列的第一项,就可以生成一个随机数列。但是由于模 的结果是有限的,所以数列必然出现循环节,也就是环。
考虑两个在操场上跑圈的人,当跑得快的人追上跑得慢的人时,至少遍历了整个环一圈。
设 ,每一次 ,也就是模拟 是跑得快的人,而 是跑得慢的人。每一次计算 的值,如果不为 ,那么找到一个非平凡因子。如果 ,此时已经进入了一个环,说明这个环中找不到答案,退出重新随机 。
至于到底因为是环上任意两个位置都不能找到答案,还是因为我们使用的方法只检测了部分的位置,导致我们找不到答案,我就不知道了。
下面给出 模板题 的示例代码,由于该做法常数较大,无法通过。
Sample Code (C++)
#include <chrono>
#include <cstdio>
#include <random>
std::mt19937_64 Rad(std::chrono::steady_clock::now().time_since_epoch().count());
using ll = long long;
ll rad(ll l, ll r) {
return std::uniform_int_distribution<ll>(l, r)(Rad);
}
const int TEST_TIME = 8;
ll qpow(ll a, ll b, ll p) {
ll res = 1;
for (; b; b >>= 1, a = a * a % p)
if (b & 1) res = res * a % p;
return res;
}
bool miller_rabin(ll n) {
if (n < 3) return n == 2;
if (n == 3) return true;
ll u = n - 1, t = 0;
while (!(u & 1)) ++t, u >>= 1;
for (int i = 1; i <= TEST_TIME; ++i) {
ll a = rad(2, n - 2);
ll v = qpow(a, u, n);
if (v == 1) continue;
int s;
for (s = 0; s < t; ++s) {
if (v == n - 1) break;
v = v * v % n;
}
if (s == t) return false;
}
return true;
}
ll f(ll x, ll a, ll n) {
return ((__int128)x * x + x + a) % n;
}
ll gcd(ll a, ll b) {
return b ? gcd(b, a % b) : a;
}
ll pollard_rho(ll n) {
ll a = rad(1, n);
ll x1, x2;
x1 = x2 = rad(0, n - 1);
while (true) {
x1 = f(x1, a, n);
x2 = f(f(x2, a, n), a, n);
if (x1 == x2) {
x1 = x2 = rad(0, n - 1);
a = rad(1, n);
continue;
}
ll d = gcd(abs(x1 - x2), n);
if (d > 1 && d < n) return d;
}
}
ll ans;
void find(ll n) {
if (miller_rabin(n)) {
ans = std::max(ans, n);
return;
}
ll p = pollard_rho(n);
find(p);
find(n / p);
}
int main() {
int tt;
scanf("%d", &tt);
while (tt--) {
ll n;
scanf("%lld", &n);
if (miller_rabin(n)) {
puts("Prime");
continue;
}
ans = 0;
find(n);
printf("%lld\n", ans);
}
}
倍增优化
单次调用 函数的复杂度为 ,经常调用会导致算法低效。注意到将若干个 乘起来,对 取模。如果存在一个 ,那么乘积与 的 也不为 。
所以我们可以累积一些乘积之后再求 。例如选择 128。
接着使用倍增进行优化。枚举一个步数 ,固定起点 ,从起点一步一步走 步,将差值累乘起来,每隔 步计算一次 。再将起点更新为终点,倍增步数。
下面给出模板的代码:
Sample Code (C++)
#include <chrono>
#include <cstdio>
#include <random>
std::mt19937_64 Rad(std::chrono::steady_clock::now().time_since_epoch().count());
using ll = long long;
ll rad(ll l, ll r) {
return std::uniform_int_distribution<ll>(l, r)(Rad);
}
const int TEST_TIME = 8;
ll qpow(ll a, ll b, ll p) {
ll res = 1;
for (; b; b >>= 1, a = (__int128)a * a % p)
if (b & 1) res = (__int128)res * a % p;
return res;
}
bool miller_rabin(ll n) {
if (n < 3) return n == 2;
if (n == 3) return true;
ll u = n - 1, t = 0;
while (!(u & 1)) ++t, u >>= 1;
for (int i = 1; i <= TEST_TIME; ++i) {
ll a = rad(2, n - 2);
ll v = qpow(a, u, n);
if (v == 1) continue;
int s;
for (s = 0; s < t; ++s) {
if (v == n - 1) break;
v = (__int128)v * v % n;
}
if (s == t) return false;
}
return true;
}
ll f(ll x, ll a, ll n) {
return ((__int128)x * x + x + a) % n;
}
ll gcd(ll a, ll b) {
return b ? gcd(b, a % b) : a;
}
ll pollard_rho(ll n) {
ll a = rad(1, n);
ll s = 0, t = 0, val = 1;
for (ll goal = 1; ; goal <<= 1, s = t, val = 1) {
for (ll step = 1; step <= goal; ++step) {
t = f(t, a, n);
val = (__int128)val * abs(t - s) % n;
if (!val) return n;
if (step % 128 == 0) {
ll d = gcd(val, n);
if (d > 1) return d;
}
}
ll d = gcd(val, n);
if (d > 1) return d;
}
}
ll ans;
void find(ll n) {
if (n == 1) return;
if (miller_rabin(n)) {
ans = std::max(ans, n);
return;
}
ll p = pollard_rho(n);
find(p);
find(n / p);
}
int main() {
int tt;
scanf("%d", &tt);
while (tt--) {
ll n;
scanf("%lld", &n);
if (miller_rabin(n)) {
puts("Prime");
continue;
}
ans = 1;
find(n);
printf("%lld\n", ans);
}
}
参考资料与链接
https://www.zhihu.com/question/308322307/answer/574767625 如何编程判断一个数是否是质数? - Wiener ES的回答 - 知乎 ↩︎ ↩︎
https://en.wikipedia.org/wiki/Miller–Rabin_primality_test#Accuracy Miller–Rabin primality test - Wikipedia ↩︎
https://www.luogu.com.cn/article/jr27vtto 题解 P4718/论 Miller-Rabin 算法的确定性化 - 洛谷专栏 ↩︎
https://oi-wiki.org/math/number-theory/pollard-rho/#生日悖论 分解质因数 - OI wiki ↩︎
https://en.wikipedia.org/wiki/Birthday_problem#Probability_of_a_shared_birthday_\(collision) Birthday problem - wikipedia ↩︎