Sieve
质数筛法详解:从埃氏筛到欧拉筛的优化之路
在信息学竞赛中,高效获取一定范围内的质数是解决数论问题的基础。筛法(Sieve)是实现这一目标的核心技术,本文将详细讲解两种经典筛法 —— 埃拉托斯特尼筛法(Eratosthenes Sieve)和欧拉筛(Euler Sieve,又称线性筛)的原理、实现及优化思路。
一、埃拉托斯特尼筛法(Eratosthenes Sieve)
1.1 基本原理
埃氏筛的核心思想是 「标记合数」:从最小的质数 2 开始,将每个质数的所有倍数标记为合数,未被标记的数即为质数。
具体步骤:
- 初始化一个布尔数组 \(vis\),\(vis[i]\) 表示 \(i\) 是否为合数(初始全为 \(false\),即默认都是质数);
- 从 2 开始遍历到 \(n\):
- 若 \(i\) 未被标记(即 \(vis[i] = false\)),则 \(i\) 是质数;
- 将 \(i\) 的所有倍数(从 \(2i\) 开始,步长为 \(i\))标记为合数;
- 遍历结束后,所有未被标记的数即为 \([2, n]\) 范围内的质数。
1.2 代码实现
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10;
int n;
bool vis[N]; // vis[i] = true 表示i是合数
void Eratosthenes_Sieve(int n) {
vis[1] = true; // 1不是质数
for (int i = 2; i <= n / i; i ++ ) // 外层循环到√n即可
if (!vis[i]) // i是质数
// 标记i的所有倍数为合数
for (int j = i + i; j <= n; j += i)
vis[j] = true;
}
int main() {
cin >> n;
Eratosthenes_Sieve(n);
// 输出所有质数(未被标记的数)
for (int i = 2; i <= n; i ++ )
if (!vis[i])
cout << i << " ";
return 0;
}
1.3 关键优化解析
- 外层循环到 \(\sqrt{n}\):对于大于 \(\sqrt{n}\) 的合数 \(x\),其最小质因子一定小于 \(\sqrt{n}\),因此在处理小于 \(\sqrt{n}\) 的质数时,\(x\) 已被标记。例如 \(x=25\),其最小质因子是 5(\(<\sqrt{25}=5\)),在 \(i=5\) 时会被标记。
- 从 \(i \times i\) 开始标记:进一步优化可将内层循环起点从 \(i+i\) 改为 \(i \times i\)(因为 \(2i, 3i, \dots, (i-1)i\) 已被更小的质数标记过)。例如 \(i=5\) 时,\(5 \times 2=10\)(被 2 标记)、\(5 \times 3=15\)(被 3 标记),从 \(5 \times 5=25\) 开始标记即可。
1.4 时间复杂度
埃氏筛的时间复杂度为 \(O(n \log \log n)\),接近线性。证明涉及数论中的调和级数,直观理解:每个质数 \(p\) 的标记次数为 \(n/p\),总操作数约为 \(n(1/2 + 1/3 + 1/5 + \dots + 1/p)\)(质数倒数和),而该和的极限为 \(\log \log n\)。
二、欧拉筛(Euler Sieve,线性筛)
埃氏筛虽高效,但存在重复标记问题(例如 12 会被 2 和 3 分别标记)。欧拉筛通过保证每个合数只被其最小质因子标记一次,将时间复杂度优化至严格线性 \(O(n)\)。
2.1 核心改进:最小质因子标记
欧拉筛引入一个数组 \(primes\) 存储已找到的质数,通过以下规则避免重复标记:
对于每个数 \(i\)(从 2 到 \(n\)):
- 若 \(i\) 是质数(未被标记),加入 \(primes\) 数组;
- 遍历 \(primes\) 中已有的质数 \(p\),标记 \(i \times p\) 为合数;
- 当 \(i \% p == 0\) 时,\(p\) 是 \(i\) 的最小质因子,因此也是 \(i \times p\) 的最小质因子,此时停止遍历(避免用更大的质数标记 \(i \times p\))。
2.2 代码实现
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10;
int n, cnt; // cnt为质数个数
int primes[N]; // 存储质数
bool vis[N]; // 标记合数
void Euler_Sieve(int n) {
vis[1] = true; // 1不是质数
for (int i = 2; i <= n; i ++ ) {
// i是质数,加入primes
if (!vis[i]) primes[ ++ cnt] = i;
// 用已找到的质数标记i的倍数
for (int j = 1; primes[j] <= n / i; j ++ ) {
vis[i * primes[j]] = true; // 标记i*primes[j]为合数
// 关键:primes[j]是i的最小质因子
if (i % primes[j] == 0) break;
}
}
}
int main() {
cin >> n;
Euler_Sieve(n);
// 输出所有质数
for (int i = 1; i <= cnt; i ++ )
cout << primes[i] << " ";
return 0;
}
2.3 去重原理详解
为什么 \(i \% primes[j] == 0\) 时要 break?
- 当 \(i \% primes[j] == 0\),说明 \(primes[j]\) 是 \(i\) 的因子,且是最小质因子(因为 \(primes\) 中的质数递增);
- 此时 \(i \times primes[j+1]\) 的最小质因子仍是 \(primes[j]\)(而非 \(primes[j+1]\)),若继续循环,会用更大的质数标记 \(i \times primes[j+1]\),导致重复标记;
- 例如 \(i=6\)(质因子 2、3),\(primes[j]=2\) 时 \(6\%2=0\),此时 \(6 \times 3=18\) 的最小质因子是 2(而非 3),应在 \(j=1\) 时 break,避免 \(j=2\) 时用 3 标记 18(18 会在 \(i=9\)、\(j=1\) 时被 2 标记)。
2.4 时间复杂度
由于每个合数恰好被标记一次(被其最小质因子),总操作数为 \(O(n)\),因此欧拉筛是严格线性的筛法。
三、两种筛法的对比与应用场景
特性 | 埃氏筛(Eratosthenes) | 欧拉筛(Euler) |
---|---|---|
时间复杂度 | \(O(n \log \log n)\) | \(O(n)\) |
空间复杂度 | \(O(n)\) | \(O(n)\)(需额外存储质数) |
重复标记 | 存在 | 无 |
适用场景 | 中小范围(\(n \leq 10^7\)) | 大范围(\(n > 10^7\)) |
竞赛中的选择建议:
- 当 \(n > 2 \times 10^6\)时,欧拉筛的线性复杂度优势明显。
- 竞赛中几乎不可能只让你筛出 \(1\sim n\) 的质数,但是埃氏筛不能额外储存质因子,而且遍历还需要 \(O(n)\) 的额外时间,在竞赛中极少使用。
- 强烈推荐欧拉筛。
四、扩展:筛法的实际应用
- 质因数分解加速:结合欧拉筛记录的最小质因子,可在 \(O(\log n)\) 时间内分解任意数;
- 数论函数预处理:如欧拉函数 \(\varphi(n)\)、莫比乌斯函数 \(\mu(n)\) 等,可通过筛法线性预处理;
- 区间质数筛选:对于 \([L, R]\)(\(L\) 较大),可先用筛法处理 \([2, \sqrt{R}]\),再标记区间内的合数。