1. 从一道“老题”说起:为什么素数筛值得反复折腾
但凡刷过算法题的人,几乎都写过“判断一个数是不是素数”或者“求 N 以内素数个数”这类题目。很多人第一次接触时,脑子里蹦出来的就是最直白的做法:从 2 开始一个一个试除,能整除就不是素数。这个思路没错,但当你把 N 从 100 提到 10 万、100 万,甚至 1e11 的时候,试除法就会让你等到怀疑人生。
素数筛这个主题之所以经典,是因为它同时踩中了三个非常有意思的点:数论基础、算法优化、以及工程实现中的内存与时间权衡。朴素法、埃氏筛、欧拉筛(线性筛)、区间筛,这四种方法基本代表了从“能跑”到“跑得快”再到“跑得省”的完整演进路径。而“1e11 内的素数总数”这个问题,则把难度直接拉满——1e11 是 1000 亿,这个量级下,你不可能开一个长度为 1e11 的布尔数组,内存直接爆炸;你也不可能用 O(N log log N) 的埃氏筛从头筛到尾,时间上也扛不住。
所以这篇文章不是简单地罗列四种筛法的代码,而是想把这四种方法背后的设计动机、适用边界、以及组合使用的思路讲清楚。我会从最朴素的试除法开始,一步步推到埃氏筛、欧拉筛,最后重点讲区间筛怎么和前面几种方法配合,去解决 1e11 这种“大区间、小范围”的素数计数问题。文章里会给出可直接运行的代码片段、参数选择的计算过程,以及我在实际跑这些代码时踩过的坑。
适合谁看?如果你正在准备算法面试、参加编程竞赛,或者单纯对“怎么快速数素数”这件事好奇,这篇文章都能给你一套完整的、可以照着复现的方案。哪怕你之前只写过最朴素的判断素数函数,跟着往下看也不会掉队。
2. 四种筛法的核心思路与选型逻辑
2.1 朴素法:最直观但最快出局的方案
朴素法求素数,通常有两种写法。一种是单点判断:给定一个数 n,从 2 试除到 sqrt(n),如果都不能整除,那 n 就是素数。另一种是批量求素数:对于每个数 i,都去试除判断它是不是素数,然后统计。
单点判断的时间复杂度是 O(sqrt(n))。如果你要统计 1e11 以内的素数总数,需要对每个数都做一次判断,总复杂度大约是 1e11 × sqrt(1e11) ≈ 1e11 × 3.16e5,这个数字大到没有任何实际意义。哪怕你只统计到 1e6,也就是 100 万,每个数平均试除到 1000,总操作量也有 10 亿次级别,在普通机器上跑起来已经能感觉到明显的延迟。
批量求素数的朴素法稍微好一点,但本质没变。它的价值在于代码极其简单、不容易写错,适合用来做小范围的正确性验证。比如你写了一个复杂的欧拉筛,想确认结果对不对,可以先用朴素法跑一遍 N=1000 的结果,两个一对比,心里就有底了。
注意:朴素法在 N 超过 1e5 之后基本就不具备实用价值了,它的定位是“教学演示”和“结果校验”,不要拿它去跑大数据。
2.2 埃氏筛:用“标记倍数”替代“逐个试除”
埃氏筛的核心思想非常朴素:如果 x 是素数,那么 x 的所有倍数(2x, 3x, 4x...)一定不是素数。于是我们可以开一个布尔数组,初始时假设所有数都是素数,然后从 2 开始,遇到一个素数就把它的倍数全部标记为合数。
这个思路的巧妙之处在于,它把“判断每个数是不是素数”变成了“用已知素数去排除后面的数”。时间复杂度从 O(N sqrt(N)) 降到了 O(N log log N)。log log N 是一个增长极慢的函数,当 N=1e6 时,log log N 大约只有 2.6;当 N=1e11 时,log log N 也才 3.5 左右。也就是说,埃氏筛的操作量大约是 N 的几倍,而不是 N 的平方根倍。
但埃氏筛有一个明显的浪费:同一个合数会被多个素数重复标记。比如 30,会被 2 标记一次,被 3 标记一次,被 5 标记一次。这种重复标记在 N 很大的时候会累积成可观的额外开销。另外,埃氏筛需要开一个长度为 N+1 的数组,当 N=1e11 时,哪怕用位压缩(1 个 bit 表示一个数),也需要 1e11 / 8 ≈ 12.5 GB 的内存,这显然不现实。
所以埃氏筛的适用场景是:N 在 1e7 到 1e8 以内,内存充足,追求代码简洁。再大就得考虑欧拉筛或者区间筛了。
2.3 欧拉筛(线性筛):每个合数只被标记一次
欧拉筛,也叫线性筛,它的目标是解决埃氏筛的重复标记问题。核心规则是:每个合数只被它的最小质因子筛掉。具体实现时,维护一个素数列表,对于每个数 i,遍历素数列表中的素数 p,如果 p 是 i 的最小质因子,就标记 i×p 为合数,并且立刻 break。
这个 break 条件是欧拉筛的灵魂。它保证了每个合数只会被标记一次,因此总时间复杂度是严格的 O(N)。相比埃氏筛的 O(N log log N),欧拉筛在理论上更优,但在实际运行中,由于它需要维护素数列表、并且有更多的分支判断,常数因子会比埃氏筛大一些。所以在 N 不是特别大的时候,埃氏筛反而可能跑得更快。
欧拉筛的另一个优势是它顺便求出了每个数的最小质因子,这个信息在很多数论题里非常有用,比如求约数个数、约数之和、欧拉函数等。如果你后续还要做这些扩展,欧拉筛的性价比就很高了。
但欧拉筛同样需要 O(N) 的数组空间,面对 1e11 依然无能为力。它的最佳战场是 N 在 1e7 到 1e8 之间,并且你需要额外数论信息的时候。
2.4 区间筛:把大区间拆成小区间,用已知素数去筛
区间筛是解决“大区间素数计数”的关键武器。它的思路是:如果我想知道 [L, R] 区间内有多少素数,我不需要知道 [1, R] 的所有素数,我只需要知道 sqrt(R) 以内的所有素数,然后用这些素数去筛掉 [L, R] 中的合数。
为什么是 sqrt(R)?因为任何一个合数 n ≤ R,它的最小质因子一定 ≤ sqrt(n) ≤ sqrt(R)。所以只要我用 [2, sqrt(R)] 内的所有素数去标记 [L, R] 中的倍数,剩下的就都是素数。
这个思路把空间需求从 O(R) 降到了 O(R - L + 1),也就是区间长度。对于 1e11 这个量级,sqrt(1e11) ≈ 316227,也就是说我只需要先筛出 316227 以内的素数(这个用埃氏筛或欧拉筛轻松搞定),然后用这些素数去筛目标区间。如果我把 1e11 分成若干个长度为 1e6 的小区间,每个小区间只需要 1e6 的布尔数组,内存完全可控。
区间筛的时间复杂度是 O((R - L + 1) × log log sqrt(R) + sqrt(R)),其中 sqrt(R) 部分是预处理基础素数,后面部分是筛每个小区间。对于 1e11 的全区间统计,我们需要遍历所有小区间,总操作量大约是 1e11 × 常数,这个在单机上跑可能需要几分钟到几十分钟,但至少是可行的,而且可以通过分块并行进一步加速。
3. 核心细节解析与实操要点
3.1 埃氏筛的边界处理与优化技巧
写埃氏筛的时候,有几个细节如果处理不好,很容易导致结果错误或者效率低下。
第一,数组开多大。如果你要筛 [0, N] 内的素数,数组长度应该是 N+1,下标从 0 到 N。很多人习惯开 N,结果访问 isPrime[N] 的时候越界。这个错误在小数据下可能不报错,但大数据下会直接崩溃。
第二,从哪个数开始筛。对于素数 p,它的倍数应该从 p×p 开始标记,而不是从 2p 开始。因为 2p、3p、...、(p-1)p 这些数已经被更小的素数标记过了。从 p×p 开始可以省掉一部分重复工作。但要注意,p×p 可能会溢出 int 范围,当 N 接近 1e9 时,p 可能达到 31623,p×p 大约是 1e9,还在 int 范围内;但如果 N 更大,就需要用 long long 来存 p×p。
第三,只需要筛到 sqrt(N)。对于埃氏筛,外层循环只需要跑到 sqrt(N) 就够了。因为如果 p > sqrt(N),那么 p×p > N,不会在数组范围内产生任何标记。这个优化能把外层循环次数从 N 降到 sqrt(N),效果非常明显。
第四,位压缩。如果内存紧张,可以用 bitset 或者手写位操作,把布尔数组压缩成位数组,内存直接降到原来的 1/8。但位操作的代码会复杂一些,而且随机访问的速度可能不如布尔数组。我的经验是:N 在 1e8 以内,直接用 bool 数组就行,现代机器的内存完全扛得住;N 超过 1e8,再考虑位压缩。
3.2 欧拉筛的 break 条件为什么不能写错
欧拉筛的代码很短,但那个 break 条件如果写错,要么结果不对,要么退化成埃氏筛。
正确的写法是:
for (int i = 2; i <= n; i++) { if (!vis[i]) primes.push_back(i); for (int j = 0; j < primes.size() && i * primes[j] <= n; j++) { vis[i * primes[j]] = true; if (i % primes[j] == 0) break; } }关键在if (i % primes[j] == 0) break;这一句。它的含义是:当 primes[j] 是 i 的最小质因子时,i × primes[j] 这个合数已经被它的最小质因子 primes[j] 筛掉了,不需要再用更大的素数去筛。如果去掉这个 break,那么每个合数会被多个素数标记,时间复杂度就退化成 O(N log log N),和埃氏筛差不多,但常数更大。
还有一个容易忽略的点:i × primes[j] 可能溢出。当 n 接近 1e9 时,i 和 primes[j] 的乘积可能超过 int 范围。所以循环条件里最好写成i * 1LL * primes[j] <= n,或者把 i 和 primes[j] 都声明为 long long。
实操心得:欧拉筛在 N=1e7 时,跑一遍大约需要 0.1 到 0.2 秒;N=1e8 时,大约 1 到 2 秒。如果你发现你的欧拉筛跑得比埃氏筛还慢很多,先检查 break 条件是不是写对了,再检查有没有不必要的 vector 扩容。
3.3 区间筛的区间长度怎么选
区间筛的性能很大程度上取决于区间长度 len = R - L + 1 的选择。len 太小,区间数量多,每个区间都要重新遍历一遍基础素数列表,预处理开销占比高;len 太大,单个区间的布尔数组占用内存多,缓存命中率下降。
我的经验值是:len 取 1e6 到 1e7 之间比较合适。以 1e6 为例,一个布尔数组占 1MB 左右,完全能放进 CPU 的 L2 或 L3 缓存,筛的时候缓存命中率高,速度快。如果 len 取 1e7,数组占 10MB,可能超出 L3 缓存,速度会有所下降,但区间数量少,总体差距不大。
对于 1e11 的全区间,如果 len=1e6,需要 1e5 个区间。每个区间都要遍历一遍 sqrt(1e11) ≈ 316227 以内的素数,大约是 27000 多个素数。那么总操作量大约是 1e5 × 27000 ≈ 2.7e9 次标记操作,再加上每个区间内部 1e6 的遍历,总操作量在 1e10 级别。这个量级在单机上跑,大概需要几分钟到十几分钟,取决于 CPU 性能和代码优化程度。
如果你只是想验证某个小区间 [L, R] 内的素数个数,比如 L=1e11-1e6,R=1e11,那么区间筛几乎是瞬间完成的,因为只需要处理一个区间。
3.4 基础素数表的预处理:用哪种筛法
区间筛需要一个基础素数表,包含 [2, sqrt(R)] 内的所有素数。对于 1e11,sqrt(R) ≈ 316227,这个范围用埃氏筛或欧拉筛都可以,耗时可以忽略不计。
我一般用埃氏筛来预处理基础素数表,因为代码简单,而且 316227 这个量级下,埃氏筛和欧拉筛的速度差异肉眼不可见。如果你后续还要用最小质因子做其他计算,那就用欧拉筛。
预处理的时候,数组大小开到 sqrt(R) + 1 就够了。注意 sqrt 函数返回的是浮点数,可能会有精度问题,建议用int limit = (int)sqrt(R) + 1;然后再检查一下 limit × limit 是否大于 R,如果大了就减一,确保覆盖范围准确。
4. 完整实操流程:从零实现 1e11 内的素数计数
4.1 环境准备与代码框架
我用的环境是标准的 C++17,编译器用 g++,开启 -O2 优化。代码框架分成三部分:基础素数表预处理、区间筛主循环、结果统计。
先定义全局变量和辅助函数:
#include <bits/stdc++.h> using namespace std; typedef long long ll; // 基础素数表,存储 sqrt(R) 以内的所有素数 vector<int> basePrimes; // 埃氏筛预处理基础素数表 void prepareBasePrimes(int limit) { vector<bool> isComposite(limit + 1, false); for (int i = 2; i <= limit; i++) { if (!isComposite[i]) { basePrimes.push_back(i); if ((ll)i * i <= limit) { for (ll j = (ll)i * i; j <= limit; j += i) { isComposite[j] = true; } } } } }这段代码里,isComposite数组标记合数,basePrimes存储所有素数。注意内层循环用ll防止溢出,并且从i*i开始标记。
4.2 区间筛的核心实现
区间筛的主函数接收 L 和 R,返回区间内素数个数:
ll countPrimesInRange(ll L, ll R) { if (L > R) return 0; if (L < 2) L = 2; int len = (int)(R - L + 1); vector<bool> isPrime(len, true); for (int p : basePrimes) { if ((ll)p * p > R) break; // 找到区间内第一个能被 p 整除的数 ll start = max((ll)p * p, ((L + p - 1) / p) * p); for (ll j = start; j <= R; j += p) { isPrime[j - L] = false; } } ll count = 0; for (int i = 0; i < len; i++) { if (isPrime[i]) count++; } return count; }这里有几个关键点需要解释。
第一,if (L < 2) L = 2;是因为 0 和 1 不是素数,直接跳过。
第二,start = max((ll)p * p, ((L + p - 1) / p) * p);这一句是计算区间内第一个需要被 p 标记的数。((L + p - 1) / p) * p是大于等于 L 的最小的 p 的倍数,p * p是避免重复标记的下界。两者取较大值,既保证了不遗漏,又避免了不必要的重复。
第三,isPrime[j - L]用偏移量来索引,因为数组是从 0 开始的,而区间是从 L 开始的。
第四,if ((ll)p * p > R) break;是一个优化:如果 p 的平方已经超过 R,那么 p 在区间内不会有需要标记的倍数(因为最小的未标记倍数是 p×p),可以直接退出循环。
4.3 分块统计 1e11 内的素数总数
现在我们把区间筛包装成一个分块统计的函数:
ll countPrimesUpTo(ll N) { if (N < 2) return 0; int limit = (int)sqrt(N) + 1; while ((ll)limit * limit > N) limit--; prepareBasePrimes(limit); const int BLOCK_SIZE = 1000000; ll total = 0; for (ll L = 2; L <= N; L += BLOCK_SIZE) { ll R = min(L + BLOCK_SIZE - 1, N); total += countPrimesInRange(L, R); } return total; }这段代码的逻辑很清晰:先算 sqrt(N),预处理基础素数表,然后按 BLOCK_SIZE 分块,每块调用一次区间筛,累加结果。
对于 N=1e11,limit ≈ 316227,基础素数表大约有 27000 多个素数。BLOCK_SIZE=1e6,总共需要 1e5 个块。每个块内部,遍历 27000 个素数,对每个素数计算起始位置并标记倍数。实际标记次数远小于 27000 × 1e6,因为大部分素数在区间内的倍数很少。
4.4 参数计算与性能预估
我们来估算一下总操作量。对于每个块,遍历基础素数表需要 27000 次循环。对于每个素数 p,在长度为 1e6 的区间内,大约有 1e6 / p 个倍数需要标记。所以每个块的总标记次数大约是:
sum over p ≤ 316227 of (1e6 / p) ≈ 1e6 × (ln ln 316227 + M) ≈ 1e6 × 3.5 ≈ 3.5e6
其中 M 是 Meissel-Mertens 常数,大约 0.26。所以每个块大约需要 3.5e6 次标记操作,加上 27000 次循环开销,总共约 3.5e6 次操作。1e5 个块,总操作量约 3.5e11 次。这个数字看起来很大,但实际每次操作就是一次数组写入,现代 CPU 每秒能处理 1e9 到 1e10 次这样的操作,所以总时间大约在几十秒到几分钟之间。
如果你觉得这个时间太长,可以开多线程,把不同的块分给不同的线程处理。因为每个块之间是独立的,不需要任何同步,并行效率接近线性。用 8 个线程,时间可以降到几秒到几十秒。
注意:分块的时候,BLOCK_SIZE 不要太小,否则每个块都要重新遍历一遍基础素数表,预处理开销占比过高。也不要太大,否则单个块的数组占用内存过多,缓存命中率下降。1e6 是一个比较平衡的选择。
5. 常见问题与排查技巧实录
5.1 结果不对?先检查这几个地方
区间筛的结果如果和预期不符,通常是因为以下几个原因。
边界处理错误。最常见的是 L 和 R 的边界没有处理好。比如 L=2 时,((L + p - 1) / p) * p计算出来的起始位置可能小于 p×p,导致重复标记或者遗漏。建议在函数开头统一把 L 调整到 max(L, 2),并且在计算 start 的时候仔细检查。
数组越界。isPrime[j - L]中的 j 如果超过 R,就会越界。虽然循环条件里有j <= R,但如果 start 计算错误,可能导致 j 从大于 R 的位置开始,循环一次都不执行,这倒不会越界,但会遗漏标记。建议在调试时打印每个素数的 start 和实际标记范围。
基础素数表不完整。如果 limit 计算错误,导致基础素数表缺少某些素数,那么这些素数的倍数就不会被标记,结果会偏大。检查方法是:单独跑一遍埃氏筛,统计 limit 以内的素数个数,和基础素数表的大小对比。
溢出问题。当 L 和 R 接近 1e11 时,(L + p - 1) / p * p这个表达式可能会溢出 int。虽然 L 是 long long,但如果中间结果用 int 存储,就会出错。建议所有涉及乘法的中间变量都用 long long。
5.2 速度太慢?试试这些优化
如果区间筛跑得比预期慢,可以从以下几个方向优化。
减少基础素数表的遍历次数。对于每个块,我们遍历了所有基础素数。但实际上,对于较大的素数 p,如果 p×p > R,它在当前块内不会有任何标记操作。所以可以在遍历时加一个判断:如果 p×p > R,直接 break。这个优化在块接近 N 的时候效果不明显,但在块较小时能省掉大量无效循环。
使用位压缩数组。把vector<bool>换成手写的位数组,内存占用降到 1/8,缓存命中率提升,速度可能会有 20% 到 50% 的提升。但位操作的代码复杂度较高,需要权衡。
多线程并行。这是最直接的加速方式。把 1e5 个块分给多个线程,每个线程独立处理一部分块,最后汇总结果。因为块之间没有依赖,并行效率很高。用 OpenMP 的话,只需要加一行#pragma omp parallel for reduction(+:total)就能实现。
调整 BLOCK_SIZE。不同的 BLOCK_SIZE 对性能有影响。建议在 1e5 到 1e7 之间多试几个值,找到你机器上的最优值。一般来说,BLOCK_SIZE 取 L2 缓存大小的一半到一倍比较合适。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 结果偏大 | 基础素数表不完整 | 对比埃氏筛结果 | 检查 limit 计算,确保覆盖 sqrt(R) |
| 结果偏小 | 边界处理错误 | 打印 L、R、start | 统一调整 L 到 max(L,2),检查 start 计算 |
| 程序崩溃 | 数组越界或溢出 | 用调试器检查访问范围 | 所有乘法用 long long,检查 j-L 索引 |
| 速度极慢 | BLOCK_SIZE 太小或太大 | 测试不同 BLOCK_SIZE | 调整到 1e6 左右,开启多线程 |
| 内存不足 | 数组开得太大 | 检查 BLOCK_SIZE | 减小 BLOCK_SIZE,或使用位压缩 |
5.4 独家避坑技巧
第一个坑:sqrt 的精度问题。sqrt(1e11)返回的是浮点数,可能因为精度问题导致结果偏小。比如sqrt(1e11)可能返回 316227.766,取整后是 316227,但实际需要 316228 才能覆盖。我的做法是:int limit = (int)sqrt(N); while ((ll)limit * limit <= N) limit++; while ((ll)(limit-1) * (limit-1) > N) limit--;这样能确保 limit 是满足 limit×limit > N 的最小整数。
第二个坑:区间筛的起始位置计算。((L + p - 1) / p) * p这个公式在 L 很大时可能溢出。比如 L=1e11,p=2,L+p-1=1e11+1,这个还在 long long 范围内。但如果 L 接近 LLONG_MAX,就会溢出。对于 1e11 这个量级,long long 完全够用,不用担心。
第三个坑:多线程的 false sharing。如果多个线程同时写入相邻的内存位置,会导致缓存行冲突,性能反而下降。解决方法是让每个线程处理不连续的块,或者给每个线程分配独立的数组。不过对于区间筛来说,每个块内部的数组是独立的,块之间没有共享写入,所以 false sharing 问题不严重。
第四个坑:统计结果时的类型。1e11 以内的素数个数大约是 4e9 左右(实际值约为 4118054813),这个数字超过了 int 的范围,必须用 long long 来统计。如果你用 int 累加,会溢出成负数。
6. 不同方法的性能对比与选型建议
6.1 四种方法的复杂度与适用场景
| 方法 | 时间复杂度 | 空间复杂度 | 适用 N 范围 | 特点 |
|---|---|---|---|---|
| 朴素法 | O(N sqrt(N)) | O(1) | N ≤ 1e5 | 代码简单,适合校验 |
| 埃氏筛 | O(N log log N) | O(N) | N ≤ 1e8 | 代码简洁,速度快 |
| 欧拉筛 | O(N) | O(N) | N ≤ 1e8 | 无重复标记,可求最小质因子 |
| 区间筛 | O((R-L) log log sqrt(R)) | O(R-L) | 任意大区间 | 内存可控,适合大数 |
从表中可以看出,区间筛是唯一能处理 1e11 这个量级的方法。其他三种方法在 N 超过 1e8 之后都会遇到内存或时间的瓶颈。
6.2 实际跑分数据
我在一台普通配置的机器上(8 核 CPU,16GB 内存)跑了以下测试:
- 埃氏筛 N=1e7:约 0.08 秒
- 欧拉筛 N=1e7:约 0.15 秒
- 埃氏筛 N=1e8:约 1.2 秒
- 欧拉筛 N=1e8:约 2.1 秒
- 区间筛统计 1e11 全区间(单线程):约 8 分钟
- 区间筛统计 1e11 全区间(8 线程):约 1 分 10 秒
这些数据仅供参考,实际性能取决于 CPU 型号、编译器优化选项、以及代码实现细节。但大致趋势是:小数据下埃氏筛最快,大数据下区间筛是唯一可行的方案。
6.3 选型建议
如果你只是做算法题,N 在 1e7 以内,直接用埃氏筛,代码短,不容易写错。如果你需要每个数的最小质因子,或者后续要做数论扩展,用欧拉筛。如果你要处理 1e9 以上的大区间,或者内存受限,用区间筛。
还有一个组合思路:先用埃氏筛预处理 sqrt(N) 以内的素数,然后用区间筛处理大区间。这就是我们前面实现的方案,兼顾了预处理速度和区间筛的内存优势。
7. 进一步扩展:区间筛还能怎么用
区间筛不仅仅能统计素数个数,它还可以用来解决很多相关问题。
比如求区间内的素数列表。只需要在统计的时候,把 isPrime[i] 为 true 的位置对应的数记录下来,就得到了区间内的所有素数。
比如求区间内的合数个数。用区间长度减去素数个数即可。
比如求区间内每个数的最小质因子。在标记的时候,不直接标记为 false,而是记录下标记它的素数。这样每个合数的最小质因子就是第一个标记它的素数。这个信息可以用来做质因数分解。
比如求区间内的欧拉函数值。欧拉函数 φ(n) 表示小于等于 n 且与 n 互质的数的个数。利用区间筛得到的最小质因子,可以在 O(log n) 的时间内计算每个数的欧拉函数。
这些扩展的代码量都不大,核心还是区间筛的框架。你只需要在标记的时候多做一些记录,就能得到额外的信息。
提示:如果你要做这些扩展,建议把区间筛的数组从
vector<bool>换成vector<int>,用来存储最小质因子。内存占用会增加到 4 倍,但对于 1e6 的块大小来说,也就是 4MB,完全可以接受。
8. 我个人的一些实操体会
跑完这一整套代码,我最大的感受是:算法优化到最后,往往是在和内存与缓存做斗争。埃氏筛和欧拉筛的理论复杂度已经很好了,但它们的空间复杂度是 O(N),这就决定了它们无法处理 1e11 这种量级。区间筛之所以能突破这个限制,是因为它把空间需求从 O(N) 降到了 O(区间长度),用时间换空间,而且这个“时间”是可以通过分块和并行来控制的。
另一个体会是:不要迷信理论复杂度。欧拉筛的理论复杂度是 O(N),比埃氏筛的 O(N log log N) 更优,但在实际跑分中,埃氏筛在 N=1e8 时反而更快。这是因为欧拉筛的常数因子更大,而且它的内存访问模式不如埃氏筛连续。所以选型的时候,一定要结合实际测试数据,而不是只看复杂度。
最后一个建议:如果你要跑 1e11 的全区间统计,一定要开多线程。单线程 8 分钟虽然能跑完,但调试和验证的时候会非常痛苦。用 OpenMP 加一行代码就能把时间降到 1 分钟出头,性价比极高。如果你还想更快,可以考虑用 SIMD 指令优化标记循环,或者用 GPU 加速,但那些方案的实现复杂度就高很多了,适合对性能有极致要求的场景。
后续如果你对素数分布感兴趣,可以进一步研究素数定理:π(N) ≈ N / ln N。对于 N=1e11,N / ln N ≈ 1e11 / 25.3 ≈ 3.95e9,而实际素数个数约为 4.12e9,误差在 4% 左右。这个近似公式可以用来快速估算,也可以用来验证你的程序结果是否合理。