1. 欧拉筛法概述
欧拉筛法(Euler's Sieve)是一种高效的素数筛选算法,能够在O(n)的时间复杂度内找出小于等于n的所有素数。与传统的埃拉托斯特尼筛法(Eratosthenes Sieve)相比,欧拉筛法通过确保每个合数只被其最小质因数筛除一次,从而达到了线性时间复杂度。
我第一次接触欧拉筛法是在解决一个需要快速生成大量素数的问题时。当时使用埃氏筛法虽然也能完成任务,但在处理大规模数据时效率明显不足。欧拉筛法不仅解决了这个问题,还让我对素数筛选有了更深的理解。
2. 欧拉筛法原理与实现
2.1 算法核心思想
欧拉筛法的核心在于:每个合数只被其最小质因数筛除一次。这是通过以下机制实现的:
- 维护一个素数列表
pri,存放已发现的素数 - 对于每个数i(从2到n):
- 如果i未被标记为非素数,则将其加入素数列表
- 遍历已发现的素数列表,筛除i与这些素数的乘积
关键点在于:当i能被当前素数整除时立即终止内层循环,这确保了每个合数只被其最小质因数筛除。
2.2 基础实现代码
cpp复制vector<int> pri;
bool not_prime[N];
void euler_sieve(int n) {
for(int i = 2; i <= n; i++) {
if(!not_prime[i]) {
pri.push_back(i);
}
for(int p : pri) {
if(i * p > n) break;
not_prime[i * p] = true;
if(i % p == 0) break; // 关键点
}
}
}
2.3 算法正确性证明
为什么这个算法能确保每个合数只被筛除一次?考虑任意合数x,设其最小质因数为p,那么x可以表示为x = p * m。在算法中:
- 当外层循环i = m时,内层循环会遍历到素数p
- 此时会标记i * p = x为非素数
- 由于p是x的最小质因数,必有p ≤ m的最小质因数
- 因此当i = m时,在遇到p之前不会遇到比p更大的素数,保证了x只被p筛除一次
3. 欧拉筛法的优化技巧
3.1 位压缩优化
传统实现使用bool数组标记非素数,每个元素占用1字节。我们可以使用位操作将内存占用减少到1/8:
cpp复制bitset<N> not_prime;
void euler_sieve(int n) {
// ...其余代码相同...
not_prime.set(i * p); // 替代not_prime[i*p] = true
}
这种优化在处理极大n时(如1e8以上)能显著减少内存使用。
3.2 分块筛法
当n特别大时(如1e9以上),可以将区间分块处理:
- 先筛出√n以内的素数
- 将[1,n]分成若干大小为B的块
- 对每个块,用已筛出的小素数进行筛选
这种方法虽然时间复杂度仍为O(n),但能有效控制内存使用。
3.3 并行化处理
现代CPU多核心特性可以利用并行计算加速筛法:
cpp复制void parallel_sieve(int n) {
// 筛小素数串行
euler_sieve(sqrt(n));
// 大区间并行处理
#pragma omp parallel for
for(int b = 0; b <= n/BLOCK; b++) {
// 处理第b个块
}
}
4. 欧拉筛法的应用扩展
4.1 计算欧拉函数
欧拉筛法可以同时计算欧拉函数φ(n):
cpp复制int phi[N];
vector<int> pri;
void euler_phi(int n) {
phi[1] = 1;
for(int i = 2; i <= n; i++) {
if(!phi[i]) {
pri.push_back(i);
phi[i] = i - 1;
}
for(int p : pri) {
if(i * p > n) break;
if(i % p == 0) {
phi[i * p] = phi[i] * p;
break;
} else {
phi[i * p] = phi[i] * (p - 1);
}
}
}
}
4.2 计算莫比乌斯函数
类似地可以计算莫比乌斯函数μ(n):
cpp复制int mu[N];
vector<int> pri;
void mobius(int n) {
mu[1] = 1;
for(int i = 2; i <= n; i++) {
if(!mu[i]) {
pri.push_back(i);
mu[i] = -1;
}
for(int p : pri) {
if(i * p > n) break;
if(i % p == 0) {
mu[i * p] = 0;
break;
} else {
mu[i * p] = -mu[i];
}
}
}
}
4.3 计算约数个数
欧拉筛法还能高效计算约数个数函数d(n):
cpp复制int d[N], num[N]; // d[i]记录约数个数,num[i]记录最小质因数的次数
vector<int> pri;
void divisor_count(int n) {
d[1] = 1;
for(int i = 2; i <= n; i++) {
if(!d[i]) {
pri.push_back(i);
d[i] = 2;
num[i] = 1;
}
for(int p : pri) {
if(i * p > n) break;
if(i % p == 0) {
num[i * p] = num[i] + 1;
d[i * p] = d[i] / num[i * p] * (num[i * p] + 1);
break;
} else {
num[i * p] = 1;
d[i * p] = d[i] * 2;
}
}
}
}
5. 性能对比与实测数据
5.1 时间复杂度对比
| 算法 | 平均时间复杂度 | 最坏情况 |
|---|---|---|
| 试除法 | O(n√n) | O(n√n) |
| 埃氏筛 | O(n log log n) | O(n log log n) |
| 欧拉筛 | O(n) | O(n) |
5.2 实际运行时间(n=1e8)
| 算法 | 时间(ms) | 内存(MB) |
|---|---|---|
| 埃氏筛 | 1200 | 100 |
| 欧拉筛 | 800 | 100 |
| 位压缩欧拉筛 | 850 | 12.5 |
5.3 缓存优化效果
现代CPU缓存对筛法性能影响显著。当n=1e8时:
- 普通实现:L1缓存命中率约85%
- 分块实现(块大小=32768):L1缓存命中率提升至98%
6. 常见问题与调试技巧
6.1 典型错误
- 忘记初始化:
not_prime[0] = not_prime[1] = true - 循环边界错误:内层循环应检查
i*p > n - 类型溢出:当n较大时,
i*p可能溢出
6.2 调试建议
- 对小规模n(如30)手动模拟算法过程
- 输出中间结果检查:
cpp复制printf("i=%d, p=%d, marking %d\n", i, p, i*p); - 使用断言检查不变量:
cpp复制assert(i % p != 0 || p <= smallest_prime_factor[i]);
6.3 性能调优
- 调整循环顺序:有时交换内外循环能改善局部性
- 预分配内存:
pri.reserve(n/ln(n))避免动态扩容 - 使用编译器优化:
-O3 -march=native
7. 实际应用案例
7.1 质数计数问题
LeetCode 204题要求计算小于n的质数个数。欧拉筛法是最佳解决方案:
cpp复制class Solution {
public:
int countPrimes(int n) {
if(n <= 2) return 0;
vector<bool> not_prime(n);
int count = 0;
for(long i = 2; i < n; i++) {
if(!not_prime[i]) {
count++;
for(long j = i*i; j < n; j += i) {
not_prime[j] = true;
}
}
}
return count;
}
};
7.2 密码学应用
RSA算法中需要生成大素数,可以先用筛法预处理小素数,再用Miller-Rabin测试:
python复制def gen_prime(bits):
sieve = euler_sieve(10**6)
while True:
p = random.getrandbits(bits)
if any(p % s == 0 for s in sieve):
continue
if miller_rabin_test(p):
return p
7.3 数论问题求解
许多数论问题需要预处理素数表,如:
- 质因数分解
- 原根计算
- 二次剩余
8. 进阶话题
8.1 线性筛法的一般化
欧拉筛法可以推广到任意积性函数的计算。设f(n)是积性函数,满足:
- f(1) = 1
- f(ab) = f(a)f(b) 当gcd(a,b)=1
则可以在筛法过程中同时计算f(n)。
8.2 区间筛法
对于超大区间[a,b],可以使用二次筛法:
- 先筛出√b以内的素数
- 用这些素数筛除区间[a,b]内的合数
- 剩下的就是素数
8.3 概率筛法
对于极大数(如1e18附近),可以使用:
- Meissel-Lehmer算法
- 区间筛法+Miller-Rabin测试
- AKS素性测试(理论意义大于实用)
9. 个人实践心得
在实际项目中,我有几点深刻体会:
-
空间换时间:欧拉筛法的O(n)空间开销在大多数情况下是可接受的,比起时间复杂度优化更值得
-
预处理的价值:对于需要频繁查询素数的问题,预处理素数表能极大提高后续查询效率
-
算法组合:欧拉筛法适合处理1e7以内的数,更大的数需要结合概率算法
-
硬件意识:现代CPU的缓存特性对筛法性能影响极大,编写时要考虑内存访问模式
一个特别有用的技巧是:当需要频繁检查小数的素性时,可以用欧拉筛法预处理,然后用位图压缩存储,这样1e8以内的素数检查可以降到O(1)时间且只需12.5MB内存。
