3263 字
16 分钟
算法基础之埃氏筛
2026-05-09 22:10:16
无标签

算法基础之埃氏筛:从古老智慧到现代优化的素数筛法#

一、引言#

素数在数论中占据着核心地位,而如何高效地找出一定范围内的所有素数,是算法学习中绕不开的经典话题。逐个判断每个数是否为素数的方法时间复杂度高达 (O(nn))(O(n\sqrt{n})),当 (n) 达到 (10^6) 甚至更大时,这样的算法显然不可接受。两千多年前,古希腊数学家埃拉托斯特尼(Eratosthenes)提出了一种巧妙的思路——不是逐个”判断”素数,而是批量”筛除”合数。这种被称为埃氏筛法(Sieve of Eratosthenes)的算法,至今仍是算法竞赛和实际开发中最常用的素数筛选工具之一。

二、算法原理#

2.1 核心思想:一个朴素而深刻的事实#

埃氏筛的核心思想极其简洁:一个素数的倍数一定不是素数。具体来说,如果我们从最小的素数 2 开始,把 2 的所有倍数都标记为合数,然后找到下一个未被标记的数(它就是素数),再将其所有倍数标记为合数,如此反复,最终剩下的未被标记的数就是素数。

用一个生活化的比喻来帮助理解:就像筛选班级里的”好学生”,先假设所有人都是好学生(质数),然后从第一个人开始,把每个”坏学生”(合数)逐个找出来标注,最后剩下的就是真正的好学生(质数)。

2.2 筛选过程可视化#

以筛选 1 到 30 之间的素数为例,整个过程如下:

初始状态(假设所有 ≥2 的数都是素数,用□表示,■表示合数):
2 3 4 5 6 7 8 9 10
□ □ □ □ □ □ □ □ □
11 12 13 14 15 16 17 18 19 20
□ □ □ □ □ □ □ □ □ □
21 22 23 24 25 26 27 28 29 30
□ □ □ □ □ □ □ □ □ □

第一轮(用 2 筛):2 是素数,从 (2×2=4)(2 \times 2 = 4) 开始,把 2 的倍数(4、6、8、…)全部标记为合数:

被筛除:4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30

第二轮(用 3 筛):3 是素数,从 (3×3=9)(3 \times 3 = 9) 开始,把 3 的倍数(9、12、15、…)全部标记为合数。注意,12 已经被 2 筛过了,这里会重复标记——这就是埃氏筛的缺陷所在:

被筛除:9, 12(重复), 15, 18(重复), 21, 24(重复), 27, 30(重复)

第三轮(用 5 筛):5 是素数,从 (5×5=25)(5 \times 5 = 25) 开始筛选:

被筛除:25, 30(重复)

终止条件:此时 (7×7=49>30)(7 \times 7 = 49 > 30),所以 7 及更大的素数无需再筛——因为任何 ≤30 的合数,其最小质因子一定 ≤ (30)(\sqrt{30}),已经被之前的素数筛过了。

最终未被标记的数即为素数:

2 3 5 7 11 13 17 19 23 29 ← 1~30 之间的全部素数

2.3 关键优化:为何从 (i^2) 开始?#

细心的读者可能注意到,上面筛选时并不是从 (i×2)(i \times 2) 开始,而是从 (i×i)(i \times i)(即 (i^2))开始。这是因为:当一个素数 (p) 的倍数 (p×k)(p \times k) 中 (k < p) 时,(k) 一定已经被更小的素数筛过了。例如,筛 5 时,(5×2=10)(5 \times 2 = 10)(5×3=15)(5 \times 3 = 15) 已经被 2 和 3 分别筛除,所以只需从 (5×5=25)(5 \times 5 = 25) 开始。这一优化极大地减少了重复操作,也是算法能拥有优秀时间复杂度的关键。

三、代码实现#

3.1 基础版本#

以下是最简洁的埃氏筛 C++ 实现:

const int MAXN = 1e6 + 5;
bool is_prime[MAXN]; // true 表示素数,false 表示合数
void eratosthenes(int n) {
// 初始化:0 和 1 不是素数,其余默认为素数
is_prime[0] = is_prime[1] = false;
for (int i = 2; i <= n; i++) {
is_prime[i] = true;
}
for (int i = 2; i * i <= n; i++) {
if (is_prime[i]) {
// 从 i*i 开始,步长为 i,标记所有 i 的倍数
for (int j = i * i; j <= n; j += i) {
is_prime[j] = false;
}
}
}
}

3.2 常用版本(边筛边收集素数)#

在实际应用中,我们通常希望同时获得素数列表。以下版本在筛选的同时将素数收集起来,并采用 bitset 优化内存:

const int MAXN = 1e8 + 10;
bitset<MAXN> is_prime; // 使用 bitset 代替 bool 数组,节省 8 倍空间
int primes[MAXN]; // 存储所有素数
int cnt = 0; // 素数个数
void get_primes(int n) {
is_prime.set(); // 全部置为 true
is_prime[0] = is_prime[1] = false; // 0 和 1 不是素数
for (int i = 2; i <= n; i++) {
if (is_prime[i]) {
primes[++cnt] = i; // 发现素数,记录下来
}
// 注意:这里筛倍数时仍然从 i*i 开始
// 但外层循环遍历到 n 而不是 sqrt(n),是为了顺便收集素数
for (int j = i * i; j <= n; j += i) {
is_prime[j] = false;
}
}
}

需要注意的是,这个版本中筛除操作仍然从 i*i 开始,当 i 较大时 i*i 可能超过 n,此时内层循环自动跳过,不影响正确性。

四、时间复杂度分析#

4.1 直观分析#

埃氏筛的两层循环看起来像 (O(n^2)),但实际远非如此。对于每个素数 (p),我们标记的次数大约是 (n/p) 次。因此总操作次数约为:

pn, p 为素数np=npn1p\sum_{p \leq n,\ p\text{ 为素数}} \frac{n}{p} = n \cdot \sum_{p \leq n} \frac{1}{p}

根据数论中的重要结论,素数倒数和渐近于 (\log \log n):

pn1ploglogn+M\sum_{p \leq n} \frac{1}{p} \approx \log \log n + M

其中 (M) 为 Meissel–Mertens 常数(约 0.2615)。由此可得:

T(n)nloglognT(n) \approx n \cdot \log \log n

4.2 时间复杂度结论#

埃氏筛的时间复杂度为 (O(nloglogn))(O(n \log \log n)),这在理论上已被严格证明。当 (n = 10^6) 时,(loglogn2.5)(\log \log n \approx 2.5),意味着实际常数非常小;当 (n=109)(n = 10^9) 时,(loglogn3.0)(\log \log n \approx 3.0),增长极其缓慢。因此在实际应用中,埃氏筛的性能几乎接近于线性,远优于朴素方法的 (O(nn))(O(n\sqrt{n}))

4.3 空间复杂度#

埃氏筛使用一个长度为 (n+1) 的布尔数组来标记每个数的素数状态,空间复杂度为 (O(n))(O(n))。对于 (n=108)(n = 10^8) 的范围,使用 bool 数组约需 100MB 内存,而换用 bitset 后仅需约 12.5MB,大大降低了对内存的要求。

五、算法优化#

埃氏筛虽然已经足够高效,但仍有许多可优化之处。下面介绍几种常用的优化技巧。

5.1 从 i*i 开始筛(已在基础版本中体现)#

这是最基础也最重要的优化。将内层循环的起点从 2*i 改为 i*i,可以避免对已经被更小素数标记过的数进行重复操作,能减少约 30% 的无效标记。

5.2 只筛奇数#

除 2 以外,所有偶数都不是素数。因此可以预先排除偶数,将存储需求和操作次数减半:

void sieve_odd_only(int n) {
is_prime[2] = true;
// 只处理奇数,索引映射:下标 i 对应数字 2*i+1
for (int i = 3; i <= n; i += 2) {
is_prime[i] = true;
}
for (int i = 3; i * i <= n; i += 2) {
if (is_prime[i]) {
// 步长为 2*i,因为 i 是奇数,i*i 是奇数
// 加 i 后变为偶数(已排除),再加 i 才回到奇数
for (int j = i * i; j <= n; j += 2 * i) {
is_prime[j] = false;
}
}
}
}

这一优化在实测中可将运行时间缩短约 40%。

5.3 使用 bitset 替代 bool 数组#

C++ 标准库中的 bitset 将每个布尔值压缩为 1 个比特(而非 1 个字节),使内存占用减少为原来的 1/8。更重要的是,更紧凑的内存布局带来了更好的缓存命中率。

有趣的是,测试表明:时间复杂度 (O(nloglogn))(O(n \log \log n)) 的埃氏筛在使用 bitset 优化后,其实际运行速度甚至能超过时间复杂度 (O(n)) 的欧拉筛。这是因为埃氏筛的常数因子较小,且 bitset 对缓存更友好。

5.4 分块 / 区间筛法#

当需要筛选的范围非常大(如 (n1012)(n \geq 10^{12}))而内存不足以容纳整个数组时,可以采用分块策略:先用小范围的埃氏筛筛出所有 (n)(\leq \sqrt{n})的素数(”基础素数”),然后将 ([L, R]) 分成若干块,逐块用基础素数进行筛选。每块大小可根据内存限制灵活设定,这一技巧常用于高难度算法竞赛题目。

六、埃氏筛 vs 欧拉筛(线性筛)#

6.1 埃氏筛的缺陷#

埃氏筛的一个明显问题是重复标记:同一个合数可能被多个素因数重复筛除。例如合数 12 既会被 2 筛除(2×6=12)(2 \times 6 = 12),又会被 3 筛除(3×4=12)(3 \times 4 = 12)。合数 39 会被 3 和 13 两数重复筛去。当 (n) 很大时,这种重复带来的额外开销不可忽视。

6.2 欧拉筛的核心改进#

欧拉筛(也称线性筛)基于一个关键思想:每个合数只被它最小的质因数筛掉。它能确保每个合数恰好被标记一次,从而实现严格的 (O(n)) 线性时间复杂度。

// 欧拉筛(线性筛)核心代码
void euler_sieve(int n) {
for (int i = 2; i <= n; i++) {
if (!is_prime[i]) primes[++cnt] = i; // 初始化时 is_prime 数组全部为 false,表示“暂时都假设是素数”。
for (int j = 1; j <= cnt && i * primes[j] <= n; j++) {
is_prime[i * primes[j]] = true; // 标记为合数
if (i % primes[j] == 0) break; // 关键:保证最小质因数
}
}
}

其中 if (i % primes[j] == 0) break; 这一行是关键:当 primes[j]i 的最小质因数时立即跳出循环,确保了后续标记的合数都以当前质数为最小质因数,从而杜绝重复。

6.3 对比总结#

特性埃氏筛欧拉筛(线性筛)
时间复杂度(O(nloglogn))(O(n \log \log n))(O(n))(O(n))
空间复杂度(O(n))(O(n))(O(n))(O(n))
重复标记存在
代码复杂度简单中等
实际常数较小较大(含取模运算)
适用场景(n108)(n \leq 10^8) 的一般场景需要严格线性的场景

有意思的是,在实际测试中,由于埃氏筛没有取模运算,常数因子更小,配合 bitset 优化后运行速度甚至能反超欧拉筛。因此,在实际工程和竞赛中,应根据具体需求选择合适的算法,不必盲目追求”理论上最优”。

七、应用场景#

埃氏筛不仅用于求素数表,在许多实际问题中也有广泛应用:

  • LeetCode 204. 计数质数:给定整数 n,统计小于 n 的质数数量,是埃氏筛最经典的应用入门题。
  • 孪生素数求解:利用埃氏筛快速生成素数表,再筛选出间距为 2 的素数对。
  • 密码学基础:RSA 等算法需要生成大素数,埃氏筛是生成候选素数池的高效工具。
  • 数论函数预处理:求欧拉函数 φ(n)、莫比乌斯函数 μ(n) 等积性函数时,可基于埃氏筛思想批量预处理。

八、总结#

埃氏筛法是一个历经两千年而不衰的经典算法。它以”素数的倍数不是素数”这一简单事实为基础,用空间换时间,将批量求素数的时间复杂度从 (O(nn))(O(n\sqrt{n})) 降至 (O(nloglogn))(O(n \log \log n)),在绝大多数实际场景中表现优异。

理解埃氏筛,不仅是为了掌握一个筛选素数的工具,更是为了体会算法设计中的核心思维:利用已知结果,批量排除无效选项。这种”筛”的思想在算法竞赛和实际开发中随处可见——当需要从大规模数据中找出满足某种性质的元素时,与其逐个检查,不如想办法成批过滤。

从埃氏筛出发,还可以继续深入了解更多高级筛法,如欧拉线性筛、杜教筛、Min-25 筛等,它们各自在不同的场景下展现出独特的优势。但无论走多远,埃氏筛作为入门的第一把”筛子”,都值得我们反复品味其精巧与高效。

参考资料

[1] CSDN. 算法:素数筛和线性筛(埃氏筛,欧拉筛). 2020.

[2] 腾讯云开发者社区. 素数筛. 2024.

[3] 腾讯云. 狂热算法篇:解锁筛法密码——埃氏筛与线性筛(欧拉筛)的深度剖析. 2025.

[4] Luogu. 线性筛法与求欧拉函数. 2026.

[5] CSDN. 关于埃式筛法的详解——需一次求解大量素数时必备的算法. 2020.

[6] CSDN. 高效素数筛选:埃氏筛法与区间筛法的 C++ 实战解析. 2026.

[7] AcWing. 速度比欧拉筛还快的埃氏筛. 2023.

[8] CSDN. 埃氏筛(质数筛)详细讲解 + 完整可运行代码(适合新手/蓝桥杯). 2026.

算法基础之埃氏筛
https://fuwari.wisansiiz.top/posts/eratosthenes/
作者
Wisansiiz
发布于
2026-05-09
许可协议
CC BY-NC-SA 4.0