素数这个概念,凡是写过几行代码的人应该都不陌生。但你要是问一句"怎么快速判断一个数是不是素数",再问一句"怎么高效找出 N 以内的所有素数",能脱口而出的人可能就没那么多了。我见过不少刷题的朋友,试除法能写明白,但一提到筛法就开始犯迷糊,埃氏筛还没捋清,又看到一个叫"欧拉筛"的东西,听说它是 O(n) 的,就觉得这玩意儿肯定很玄。其实真没这么吓人,关键就一句话:理解"筛"到底在筛什么。
这篇文章我打算从素数的定义一路讲到欧拉筛,把判断、试除、埃氏筛、线性筛全部串一遍,重点把欧拉筛那个经典的 if (i % primes[j] == 0) break; 掰开揉碎讲清楚。适合正在学算法准备比赛的在校学生,也适合工作之后想补一补数论底子的工程师。不需要任何前置的数论知识,你只要会循环和数组,就能把这段内容啃下来。
1. 从定义出发:先搞清楚素数和合数
1.1 素数的定义到底在说什么
教科书的定义是这样的:一个大于 1 的自然数,如果除了 1 和它本身以外不再有其他因数,那它就是素数,也叫质数。反过来,大于 1 的自然数如果还有别的因数,那就是合数。
这里有三个非常容易被忽略的细节。
第一,1 不是素数也不是合数。很多初学者会下意识把 1 当成素数,因为 1 确实"只能被 1 和它本身整除"——但定义里明确写了"大于 1 的自然数",1 被排除了。为什么非要排除掉?因为如果 1 算素数,整个数论体系就乱套了,"把所有正整数唯一分解成素数的乘积"这种基本定理就没法成立,因为你可以在分解式里随便乘一堆 1。所以数学上干脆把 1 单独拎出来,不参与素数和合数的讨论。
第二,2 是唯一一个既是偶数又是素数的数。这个性质经常被拿来当考点,也是很多优化算法的基础。后面提到的素数判断优化里,2 和 3 这两个数会被特殊对待,原因也在这里。
第三,素数的本质是"不可分解性"。合数可以拆成更小的因数乘积,素数拆不动。这个性质天然决定了判断素数的基本思路:看看这个数能不能被某个比它小同时又比 1 大的整数整除。
1.2 为什么程序里要先处理小数字
不管是判断单个素数还是初始化筛法数组,代码里最先处理的往往都是 n 小于 2 的情况。这不是代码写得不耐烦,而是逻辑上必须兜底。
比如你要写一个 isPrime(n) 函数,如果输入 n = 0 或 n = 1,直接返回 false 就完事。如果你不做这个判断,后面进入循环,用 for (int i = 2; i < n; i++) 这种写法,i < n 在 n 小于等于 2 时根本不成立,循环跑都不跑,最后稀里糊涂返回一个 true,那就错了。类似这种边界条件在竞赛里属于典型的"隐藏分水岭",看着题目简单,一交上去就 WA。
所以我的习惯是,写任何和素数有关的函数,第一行先处理 n < 2 的情况,第二行单独判断 n == 2 或 n == 3 这些特例,然后再进入通用逻辑。这个习惯能帮你省掉大量无意义的调试时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单个数值判断:暴力试除与一步步优化
2.1 最朴素的试除法
如果现在只问你一个数 n 是不是素数,最直接的想法就是从 2 试到 n-1,看有没有哪个数能整除 n。写成代码就三五行:
cpp复制bool isPrime(int n) {
if (n < 2) return false;
for (int i = 2; i < n; i++) {
if (n % i == 0) return false;
}
return true;
}
这个写法本身没错,但效率很差。假如 n 是一百万,最坏情况要循环差不多一百万次。如果是质数,每一次取模都白算。放在竞赛里,让你判断几十个数还行,让你判断几十万个数,这代码直接超时。
问题出在哪?我们把很多根本不可能成为因数的数也拿来试了。举个例子,判断 101 是不是素数,真的需要试到 100 吗?不需要。因为如果一个数 n 能被某个大于 √n 的数整除,那它一定也能被某个小于 √n 的数整除。这个性质非常关键,是所有素数试除优化的理论基石。
2.2 为什么要枚举到 √n 就够了
理解这一点其实用生活化的类比最方便。想象你要判断一个数 n 是不是合数,也就是能不能找到一对因数 a 和 b,使得 a × b = n,并且 a 和 b 都大于 1。如果 a 和 b 同时都大于 √n,那乘积就大于 n 了,矛盾。所以这一对因数里,必然有一个小于等于 √n。
这说明我们只需要检查 2 到 √n 这个范围就够了。如果在这个范围里找不到 n 的因数,那比 √n 大的范围里也必然找不到。代码改一行就行:
cpp复制bool isPrime(int n) {
if (n < 2) return false;
for (int i = 2; i * i <= n; i++) {
if (n % i == 0) return false;
}
return true;
}
注意循环条件用的是 i * i <= n,而不是 i <= sqrt(n)。为什么?因为 sqrt() 是浮点函数,每次循环都算一次又慢又可能产生精度误差。i * i 是整数乘法,又快又准,唯一的风险是 i 足够大时 i * i 可能溢出,但判断 int 范围内的数素数是够用的。如果你处理的数是 long long 级别的,建议写成 i <= n / i,效果一样,还能顺便避免溢出。
2.3 6 的倍数优化:把试除范围再压缩
到这里,如果你把 2 和 3 单独提出来,还有一个更快的判断技巧:除了 2 和 3 以外,所有素数都分布在 6 的倍数附近,也就是形如 6k-1 或 6k+1。
为什么?因为 6k、6k+2、6k+3、6k+4 这几个形式分别能被 6、2、3、2 整除,显然都是合数,只剩下 6k+1 和 6k+5 可能有戏。而 6k+5 其实就是 6(k+1)-1,跟 6k-1 是一回事。所以除开 2 和 3,素数的"候选形态"只有 6k±1 两种。
这样判断循环就能跳着走,每 6 个数只试两个。代码是这样:
cpp复制bool isPrime(int n) {
if (n < 2) return false;
if (n == 2 || n == 3) return true;
if (n % 2 == 0 || n % 3 == 0) return false;
for (int i = 5; i * i <= n; i += 6) {
if (n % i == 0 || n % (i + 2) == 0) return false;
}
return true;
}
这段代码我用了很多年,判断 int 范围内的单个数时基本够用了。不过说句实在话,6k±1 这个形式只是减少了试除的"候选者",复杂度量级还是 O(√n),常数变小而已。真要处理几百万个数的批量判断,还是得靠筛法。
2.4 为什么单点判断还不够
假设题让你统计 1 到 100000 里有多少个素数,你用 isPrime 一个个去判断,每个数最坏要跑 √n 次,加起来大概要几千万次运算,勉强能过。但如果 n 是 10^7,复杂度就到了数十亿,肯定超时。更关键的是,这种"每次从头试除"的写法完全浪费了之前计算得到的信息——你判断 101 是素数的时候,其实没有把 2 到 100 之间的筛除结果保存下来复用。
筛法就是干这个的:一次性把一整个区间内的"合数身份"标记出来,剩下没被标记的天然就是素数。这跟收拾房间一个道理,与其每次找某件东西都满屋翻一遍,不如先花点时间把屋子整理一遍,后面找东西就快了。
3. 批量筛素数:埃拉托斯特尼筛法
3.1 筛法的核心思想
埃拉托斯特尼筛法,简称埃氏筛,思路朴素得有点像"划名单"。先假设 2 到 N 这一整行数字都是素数,然后从 2 开始,把 2 的所有倍数(4、6、8、10……)全部划掉;再走到 3,3 没被划掉,说明它是素数,于是把 3 的所有倍数(6、9、12、15……)划掉;4 已经被划过了,跳过;5 没被划掉,是素数,把 5 的倍数划掉……这样反复操作下去,最后剩下的没被划掉的数字,就是素数列表。
写成代码:
cpp复制const int MAXN = 1000000;
bool isPrime[MAXN + 1];
void eratosthenesSieve(int n) {
for (int i = 2; i <= n; i++) {
isPrime[i] = true;
}
for (int i = 2; i * i <= n; i++) {
if (isPrime[i]) {
for (int j = i * i; j <= n; j += i) {
isPrime[j] = false;
}
}
}
}
注意内层循环我写的是 j = i * i,不是 j = i + i。这是一个很经典的细节优化:i 的倍数里,所有比 i 小的因子 k 对应的倍数 k × i,其实已经被更小的素数 k 筛过了。比如 i = 5 时,5 × 2 = 10 早就被 2 筛了,5 × 3 = 15 早就被 3 筛了,5 × 4 = 20 也早被 2 筛了,真正需要从 5 × 5 = 25 开始。这个优化能让内层循环少跑不少,代码还更干净。
3.2 埃氏筛的复杂度到底是多少
埃氏筛的时间复杂度是 O(n log log n)。这个复杂度是怎么来的?内层循环的总工作量大约是所有素数 p 的 n/p 之和,也就是 n × (1/2 + 1/3 + 1/5 + 1/7 + ...),括号里是所有素数的倒数和。数学上可以证明这个和约等于 log log n。所以总复杂度是 O(n log log n)。
这个复杂度有多优秀?n = 10^7 时,log log n 大概只有 2.8 左右,基本相当于常数倍的线性。所以很多场景下埃氏筛已经够用了,不一定非要上欧拉筛。
但埃氏筛有一个明显毛病:同一个合数会被不同的素数重复标记。最典型的例子是 12,它既是 2 的倍数又是 3 的倍数。当 i = 2 时,12 被划掉一次;当 i = 3 时,12 又被划掉一次。虽然重复标记不改变最终结果,但浪费了不少时间。在 n 很大时,这种浪费会积累成明显的性能缺口。
4. 欧拉筛(线性筛)的原理与实现
4.1 一个目标:每个合数只被筛一次
欧拉筛的核心目标,一句话就能说清:让每个合数只被它的最小质因数筛掉一次。
只要做到了这一点,那么筛法的时间复杂度就是严格的 O(n),因为每个数最多被访问一次,加上外层循环每个数都要过一遍,总共就是两次常数操作。
这里我想多说两句。不少教程上来就丢代码给你背,结果是背下来了,但一旦题面稍微变形就懵了。真正该做的是先把"最小质因数"这四个字刻在脑子里,后面所有代码细节都是围绕它展开的。
4.2 欧拉筛代码该怎么写
先给出一份标准的 C++ 代码:
cpp复制const int MAXN = 10000000;
int primes[MAXN], cnt = 0;
bool isComposite[MAXN + 1];
void eulerSieve(int n) {
for (int i = 2; i <= n; i++) {
if (!isComposite[i]) {
primes[cnt++] = i;
}
for (int j = 0; j < cnt && i * primes[j] <= n; j++) {
isComposite[i * primes[j]] = true;
if (i % primes[j] == 0) break;
}
}
}
和埃氏筛不同,这里我用的是 isComposite 数组,表示"i 是不是合数"。如果你用 isPrime 数组,逻辑上也一样:初始 isPrime[i] = true,被标记后改成 false。但用 isComposite 的好处是初始值默认全是 false,正好对应"还没被标记成合数"的状态,省去一个初始化循环。
4.3 最关键的一行:break 到底在干嘛
现在重点来了,整段代码最核心的就是这行:
cpp复制if (i % primes[j] == 0) break;
它的作用一句话可以概括:当 primes[j] 能整除 i 时,就停止本轮的后续乘法,避免同一个合数被重复筛选。为什么能避免?我们看一个具体的例子。
假设当前 i = 4,之前筛出来的素数列表是 2、3。内层循环先算 4 × 2 = 8,把 8 标记成合数。然后检查 4 % 2 == 0,条件成立,立刻 break。于是 4 × 3 = 12 这个乘法就不会发生。
12 什么时候被标记?等到 i = 6 时,6 是合数,没有被加入 primes,但内层循环照常执行。6 × 2 = 12,标记完成。而且 6 % 2 == 0,break。所以 12 被 i = 6、质因子 2 这一组合筛掉,且只筛了这一次。
为什么不能让 i = 4、质因子 3 去筛 12?因为 12 的最小质因数是 2,如果让质因子 3 和 4 相乘得 12,那么这个合数就被一个偏大的质因子筛掉了。以后当 i = 6 时,它还会被 2 再筛一次,等于一个合数被筛了两次。这恰恰是欧拉筛想避免的。
4.4 为什么每个合数都会被筛到而且只筛一次
要证明这点,可以这么想。任意一个合数 x,设它的最小质因数为 p,那么 x = p × m,并且 p ≤ m(因为 p 是 x 的最小质因子,它不可能大于 √x,更不可能大于 x/p = m)。
当外层循环走到 i = m 时,此时从 2 到 m 之间的所有质数都已经找出来了,p 自然也在 primes 数组里。内层循环从 j = 0 开始,依次拿质数从小到大去乘 m。因为数组里的质数是按 2、3、5、7 这样递增排列的,一定会先碰到一个质数 q,它可能小于 p,也可能等于 p。
如果先遇到 q < p,那说明 m 能被某个比 p 更小的质数整除——但这样一来,x = p × m 的最小质因数就应该是 q,而不是 p,违背了我们"p 是最小质因数"的前提。所以 m 不能被任何比 p 小的质数整除。于是当 j 达到 primes[j] = p 时,m × p = x 被标记为合数,同时 i % primes[j] == 0 成立,break。如果 m 还能被 p 整除,那也不影响——x 已经在 j 指向 p 这一步被标记了。
结论:x 会被筛到,而且是通过它的最小质因数 p 筛到的。因为同一个合数的最小质因数只有一个,所以它只会被筛一次。外循环遍历 n 个数,内循环所有标记的总次数恰好等于合数的数量,所以总复杂度 O(n)。
4.5 欧拉筛的几个实现细节
这段代码看起来短,真放到实际环境里跑,有几个点很容易踩坑。
第一个是内层循环的越界判断。我写的是 j < cnt && i * primes[j] <= n,注意这两个条件的顺序不能换。&& 是短路求值,先判断 j < cnt,如果 j 已经到边界,就不会去计算后面的乘法,也就不会访问 primes[j] 越界。如果你把乘法和越界判断换个顺序,一旦 primes[j] 越界访问,轻则读到垃圾数据,重则程序崩溃。
第二个是整数溢出。i * primes[j] 在 i 和 primes[j] 都很大的时候可能超过 int 的最大值,比如 i 接近 1e7,primes[j] 也是这个量级,乘积轻松上亿亿,int 直接溢出变成负数。溢出后跟 n 比较就全乱了。稳妥写法是把条件改成 primes[j] <= n / i,效果等价,但所有运算都不会溢出:
cpp复制for (int j = 0; j < cnt && primes[j] <= n / i; j++) {
isComposite[i * primes[j]] = true;
if (i % primes[j] == 0) break;
}
第三个是 cnt 的清理。如果你在同一个程序里多次调用欧拉筛,上一次调用留下的 cnt 不会自动归零,primes 数组里残留旧数据,新筛出来的素数会接在后面,导致结果完全不对。我的习惯是每次调用前主动 cnt = 0,或者把整个筛子封装成一个函数,初始化必须做干净。
5. 欧拉筛和埃氏筛,到底差在哪
5.1 时间和空间的实际对比
理论复杂度上,埃氏筛是 O(n log log n),欧拉筛是 O(n)。但理论归理论,实际操作里差别没有想象中那么夸张。我用 n = 10^7 测过一组数据,埃氏筛大概要跑 0.2 秒左右,欧拉筛 0.15 秒左右,差距确实存在,但不构成"一个能过一个不能过"的鸿沟。
到了 10^8,差距会拉大一些。埃氏筛因为有不少重复标记,碰到重复的数字多,耗时可能超过 2 秒,欧拉筛在 1.5 秒附近。如果是竞赛里卡时间比较狠的题,这个差距就很关键了。
空间上,两者都离不开一个长度为 n 的布尔数组来记录合数标记。欧拉筛额外多了一个 primes 数组存已经找出来的质数,但质数的数量大概是 n / ln n,对内存的影响很小。所以单纯看空间,两者都是 O(n)。
5.2 什么场景选哪个更合适
我的经验是,大多数普通题目的数据范围在 10^6 到 10^7 之间,用埃氏筛已经绰绰有余,没必要非上欧拉筛。埃氏筛代码更短,写起来不容易出错,逻辑也更直观。
但是有几个场景建议直接上欧拉筛:
第一,题目明确要求在线性时间内完成筛选,或者需要预处理积性函数,比如欧拉函数 φ、莫比乌斯函数 μ。埃氏筛没法在筛选过程中顺带把这些函数算出来,欧拉筛可以。
第二,n 的规模接近 10^8,时间卡得很紧,每次重复标记都在烧你的运行时间。
第三,你在积累模板。欧拉筛的模板很短,背下来并不吃亏,以后遇到各种数论题直接拿来改,心理压力小很多。
5.3 埃氏筛还有一个变体值得记
既然提到埃氏筛,顺手说一下区间筛。如果题目让筛的是 [L, R] 区间里的素数,但是 R - L 很小,R 本身很大,比如 R 到 10^12,直接开 10^12 的数组不现实。这时候可以用埃氏筛的思想:先用普通筛法筛出 2 到 √R 的所有素数,然后用这些小素数去标记 [L, R] 区间里的合数。因为 √R 最大也就是 10^6 的量级,数组完全开得下。欧拉筛想在这类问题上做区间筛反而比较别扭,因为线性筛依赖从 2 开始的完整顺序。
这个变体很多教程不会展开讲,但在一些中高级题目里非常实用。你可以在理解埃氏筛的基础上把它当成一个延伸练习,亲手写一遍会很有收获。
6. 常见问题与排查实录
6.1 数组开小导致越界
筛法题里最容易犯的错误就是数组越界。你的循环条件是 i <= n,那数组长度就得是 n + 1,下标才能访问到 n。很多老手用了 const int MAXN = 1000000,然后 n 取 1000000,最后访问 isPrime[1000000] 时,数组长度只有 1000000,下标从 0 到 999999,直接越界。
解决方式很简单:养成习惯,数组长度一律开成 MAXN + 1,或者干脆往大了开,比如 MAXN = 1000000 就开 1000005。多开的几个下标几乎不占内存,但能救你一次无谓的调试。
6.2 isPrime 和 isComposite 语义搞混
这一类问题属于"逻辑对但实现错"的典型。你如果用 isPrime 数组,初始化要全部置为 true,筛的过程把合数置为 false。如果用 isComposite 数组,默认全是 false,筛的过程把合数置为 true。两种语义都行,但你不能混着用。
我自己就犯过这种错:开头用了 isComposite,后面统计素数时写成了 if (isComposite[i]) cnt++,等于把所有合数当素数数了一遍。这种错在数据小的时候很容易发现,数据一大,答案离正确值差一截,反而更隐蔽。建议在变量命名时就把语义写清楚,isPrime 还是 isComposite,别用 isNotPrime 这种绕来绕去的名字。
6.3 break 位置写错或漏写
欧拉筛的 break 如果漏了,它就退化成一种重复标记很多的筛法,虽然最终答案可能没错,但性能大打折扣,也就失去了用它的意义。如果 break 写错位置,比如写到了循环外头,那整个行为就完全不可控了。
调试这种问题有个笨办法:拿一张纸,手动模拟 n = 20 的筛选过程。把 i、primes 数组、isComposite 的变化一步步写下来,跟正确结果对比。手动走两遍比盯着屏幕发呆有效得多。
6.4 忽略边界条件
边界条件最常见的坑有两个。一是 n < 2 时,直接不应该进入任何循环,你的代码能不能正确处理?二是 n = 2、n = 3 时,内层循环的乘法是否会产生越界?比如 n = 2,外层 i = 2 是素数,加入 primes,内层 j = 0,primes[0] = 2,判断 2 <= 2 / 2 = 1,不成立,循环结束,一切正常。但如果你原来的代码写的是 i * primes[j] <= n,那 i * primes[j] = 2 * 2 = 4 > 2,也不会进入循环,同样没问题。
真正出问题的往往是 n = 0 或 n = 1 时还强行调用筛法,或者 cnt 没有清零导致数组越界。这类问题在线上评测里经常表现为莫名其妙的 RE 或段错误,排查起来很费时间。提早练成"边界值先想一遍"的习惯,会省很多事。
6.5 常见问题速查表
| 问题 | 现象 | 排查思路 |
|---|---|---|
| 数组长度不够 | 运行报越界 | 检查数组是否开了 n+1 及以上 |
| cnt 未清零 | 多次调用筛子结果错乱 | 每次调用前重置 cnt = 0 |
| 乘法溢出 | 答案莫名不对 | 把 i * primes[j] <= n 换成 primes[j] <= n / i |
| 循环条件短路顺序错 | 数组越界崩溃 | 先判断 j < cnt,再判断乘法条件 |
| 边界 n < 2 未处理 | 小样例就错 | 函数入口先写 n < 2 返回 |
| 1 被当作素数 | 统计结果偏大 | 统计时从 i = 2 开始,或初始化时处理 0 和 1 |
7. 实战演练:统计 N 以内孪生素数的对数
7.1 题目长什么样
不少在线评测系统里都有这么一类题:给定 N,统计 N 以内孪生素数的对数。孪生素数定义很简单,就是两个素数之差为 2,比如 (3, 5)、(5, 7)、(11, 13)、(17, 19)、(29, 31)、(41, 43)、(59, 61)、(71, 73) 都是。
这种题在简单了解素数筛之后就能做,属于"筛一遍然后扫一遍"的基础应用,非常适合拿来检验你对筛法掌握得牢不牢。
7.2 解题思路展开
第一步,用欧拉筛把所有小于等于 N 的素数筛出来,得到一个 isPrime 或 isComposite 标记数组。
第二步,从 3 开始遍历到 N-2,因为最小的孪生素数对是 (3, 5),从 3 开始可以保证配对的两个数都在范围内。对每个 i,检查 i 和 i + 2 是否都是素数,如果都是,计数器加一。
为什么从 3 开始?因为 (2, 4) 不可能是孪生素数,4 不是素数。而如果从 2 开始,2 和 4 的判断会白白浪费一次比较,更重要的是逻辑上容易把别人搞晕。从 3 遍历语义更清晰。
7.3 完整代码
cpp复制const int MAXN = 1000000;
int primes[MAXN], cnt = 0;
bool isComposite[MAXN + 1];
void eulerSieve(int n) {
cnt = 0;
for (int i = 2; i <= n; i++) {
if (!isComposite[i]) {
primes[cnt++] = i;
}
for (int j = 0; j < cnt && primes[j] <= n / i; j++) {
isComposite[i * primes[j]] = true;
if (i % primes[j] == 0) break;
}
}
}
int countTwinPrimes(int n) {
eulerSieve(n);
int ans = 0;
for (int i = 3; i + 2 <= n; i++) {
if (!isComposite[i] && !isComposite[i + 2]) {
ans++;
}
}
return ans;
}
这段代码在 N = 100 时返回 8,在 N = 1000 时返回 35,你可以拿这两个数当测试用例验证自己的实现。
如果你想再优化一层,可以先把所有素数存到 primes 数组里,然后只遍历 primes 中的每个素数 p,看 p + 2 是否也是素数。这样遍历的次数从 N 缩减到 π(N),也就是 N 以内素数的个数,在实际运行中会快不少。
7.4 顺手验证一下 6k-1 的直觉
前面提到除 2、3 外素数只可能出现在 6k±1 这两种形态。对应到孪生素数上,你会发现绝大多数孪生素数对都落在 6k-1 和 6k+1 这一对相邻位置,比如 (5, 7) 对应 k=1,(11, 13) 对应 k=2,(17, 19) 对应 k=3。而 (3, 5) 是唯一的例外。
顺便说一句,形如 6k-1 的素数有无穷多个这件事,是可以用反证法证明的,思路跟欧几里得证明"素数无穷多"非常像。这类结论在数论里看起来平平无奇,实际却能引出不少有趣的题目,比如让你在某个区间内尽可能多地找出 6k-1 型素数。等你把筛法玩熟了,可以往这个方向多看看。
8. 素数问题还能往哪里延伸
8.1 用欧拉筛预处理积性函数
很多学算法的人第一次接触欧拉筛,是为了筛素数,但真正让它大放异彩的是筛积性函数。
比如欧拉函数 φ(n),表示小于等于 n 且与 n 互质的正整数个数。线性筛可以顺带把每个数的 φ 值算出来,核心代码就是在发现 i 是质数时 φ[i] = i - 1,在 i % primes[j] == 0 时 φ[i * primes[j]] = φ[i] * primes[j],否则 φ[i * primes[j]] = φ[i] * (primes[j] - 1)。
这个逻辑跟素数的"最小质因子"思路完全同构。你只要真正理解欧拉筛那行 break 的意义,看这些扩展代码就能秒懂;反过来,如果你只是背了埃氏筛,没理解最小质因子,扩展代码会越看越乱。
8.2 从"筛素数"走到质因数分解
素数筛出来之后,最常见的下一步是质因数分解。唯一分解定理说,任意大于 1 的正整数都能唯一地表示为若干个素数的乘积。
用筛法可以先算出每个数的最小质因子,然后用这个数组快速分解任意数。这也是为什么有些题目会叫"Lucas 的数论"之类的名字——别被名字吓到,很多数论综合题的底层工具其实就是一张最小质因子表,配合欧拉筛一次就能预处理完。
8.3 大素数与更高级的算法
如果 n 的范围大到 10^18,普通筛法和试除法就无能为力了。这时候需要 Miller-Rabin 素性检测和 Pollard-Rho 大数分解算法。它们是概率算法,但实际使用中误判率极低,是密码学和超大数论题里的主力。
不过我不建议一上来就学这两个算法。你得先精通筛法和积性函数,建立好"数论工具包",再往高阶走才会觉得顺。不然连最小质因子和欧拉函数都没吃透,直接上 Miller-Rabin 大概率是听天书。
8.4 最后分享一点个人体会
我从第一次接触欧拉筛到真正理解 break 那行代码,中间隔了大半年。当时看着别人的模板背得滚瓜烂熟,可一旦题目要求输出筛选次数统计,或者让你顺便求欧拉函数,我就抓瞎。后来我花了一晚上,手动在纸上模拟 n = 30 的筛法流程,把每个合数是被哪个 i 和 primes[j] 筛掉的都写下来,才把整件事彻底想通。
所以如果你现在也卡在这一步,我的建议不是再看一篇教程,而是自己动手模拟一遍、跟代码跑一遍。把 12 是被 (6, 2) 筛的、18 是被 (9, 2) 筛的、30 是被 (15, 2) 筛的,这类对应关系全部列出来之后,你会突然觉得欧拉筛不过如此。以后再遇到"为什么不能从 i = 4 和 primes[j] = 3 去筛 12"这种问题,你可以直接用"因为 12 的最小质因子是 2,它应该由 i = 6 和 2 的乘积筛掉"来回答,这正是欧拉筛整个设计的核心。
