1. 素数筛法入门:从暴力判断到高效筛选
在编程竞赛和算法学习中,素数筛法是一个绕不开的重要话题。我第一次接触筛法时,完全无法理解为什么需要这么"复杂"的方法来判断素数——毕竟用简单的除法判断看起来已经足够简单了。直到遇到需要处理十万甚至百万级别数据的问题时,我才真正体会到筛法的威力。
1.1 为什么需要筛法?
传统判断素数的方法是对每个数字n,检查2到√n之间是否有其因数。这种方法的时间复杂度是O(n√n),当n=10^6时,计算量将达到10^9级别,这在竞赛中绝对会超时。
而埃氏筛法的时间复杂度是O(nloglogn),线性筛更是达到了惊人的O(n)。举个例子,当n=10^6时:
- 暴力方法:约10^9次操作
- 埃氏筛:约3×10^6次操作
- 线性筛:约10^6次操作
这种效率差距在实际应用中意味着"能解"与"不能解"的区别。
1.2 埃氏筛的基本原理
埃氏筛(埃拉托斯特尼筛法)的核心思想是"标记非素数":
- 初始化一个布尔数组isPrime[],假设所有数都是素数
- 从2开始,如果当前数是素数,则标记它的所有倍数为非素数
- 重复这个过程直到√n,剩下的未标记数就是素数
关键优化点:
- 从i*i开始标记,因为更小的倍数已经被更小的素数标记过了
- 外层循环只需到√n,因为更大的数的倍数会超出n的范围
cpp复制// 典型埃氏筛实现
const int N = 1e6 + 10;
bool isPrime[N];
void eratosthenes(int n) {
fill(isPrime, isPrime + n + 1, true);
isPrime[0] = isPrime[1] = false;
for (int i = 2; i * i <= n; ++i) {
if (isPrime[i]) {
for (int j = i * i; j <= n; j += i) {
isPrime[j] = false;
}
}
}
}
2. 线性筛:更高效的素数筛选算法
2.1 线性筛的优势
埃氏筛虽然高效,但它有个明显的缺点——会重复标记某些合数。比如数字12会被2和3都标记一次。线性筛(欧拉筛)通过确保每个合数只被它的最小质因数筛掉,实现了真正的线性时间复杂度。
线性筛的两个核心特点:
- 时间复杂度严格O(n)
- 可以同时记录每个数的最小质因数
2.2 线性筛的实现细节
cpp复制const int N = 1e6 + 10;
int prime[N], cnt; // 存储素数
bool vis[N]; // 标记是否为合数
void euler(int n) {
for (int i = 2; i <= n; ++i) {
if (!vis[i]) prime[cnt++] = i;
for (int j = 0; j < cnt && i * prime[j] <= n; ++j) {
vis[i * prime[j]] = true;
if (i % prime[j] == 0) break; // 关键优化
}
}
}
关键点解析:
if (i % prime[j] == 0) break:这是线性筛的灵魂所在。它确保每个合数只被其最小质因数筛掉- 内层循环的条件
i * prime[j] <= n:控制筛的范围不超过n - 外层循环从2到n:确保所有数都被处理
3. 十道经典筛法题目详解
3.1 输出1~n所有素数(埃氏筛基础题)
这是最基础的筛法应用。直接使用埃氏筛标记后输出即可。注意几个细节:
- 数组大小要足够(通常设为n+10)
- 输出时从2开始,避免输出0和1
- 末尾不要有多余空格
常见错误:数组开太小导致越界;忘记初始化isPrime数组;输出格式不正确
3.2 统计素数个数
在筛法完成后,遍历isPrime数组统计true的个数。可以优化为在筛的过程中直接计数:
cpp复制int count = 0;
for (int i = 2; i <= n; ++i) {
if (isPrime[i]) ++count;
}
3.3 判断多个数字是否为素数
当需要判断多个数字时,先筛出最大数以内的所有素数,然后直接查询:
cpp复制int max_num = *max_element(nums.begin(), nums.end());
eratosthenes(max_num);
for (int num : nums) {
cout << (isPrime[num] ? "Yes" : "No") << endl;
}
3.4 输出第k个素数(线性筛应用)
这是线性筛的典型应用场景,因为我们需要按顺序记录素数:
cpp复制euler(MAX); // 预先筛出足够多的素数
cout << prime[k-1]; // 注意数组从0开始
3.5 求1~n所有素数的和
在筛法过程中或之后累加素数。注意使用long long防止溢出:
cpp复制long long sum = 0;
for (int i = 2; i <= n; ++i) {
if (isPrime[i]) sum += i;
}
4. 筛法的高级应用与优化技巧
4.1 区间筛法
当需要处理超大区间(如[1e9, 1e9+1e6])时,常规筛法内存不够。这时可以使用区间筛法:
- 先筛出√R以内的素数
- 用这些素数去筛区间[L,R]内的合数
- 剩下的就是区间内的素数
cpp复制void segment_sieve(ll L, ll R) {
vector<bool> isPrime(R - L + 1, true);
if (L == 1) isPrime[0] = false;
for (ll p : primes) { // primes是预筛的√R以内素数
for (ll j = max(p * p, (L + p - 1) / p * p); j <= R; j += p) {
isPrime[j - L] = false;
}
}
// 输出区间素数
for (ll i = L; i <= R; ++i) {
if (isPrime[i - L]) cout << i << " ";
}
}
4.2 筛法预处理最小质因数
线性筛的一个强大功能是可以记录每个数的最小质因数(LPF),这在质因数分解时非常有用:
cpp复制int lpf[N]; // 最小质因数数组
void preprocess_lpf() {
for (int i = 2; i < N; ++i) {
if (lpf[i] == 0) { // i是素数
lpf[i] = i;
for (int j = 2 * i; j < N; j += i) {
if (lpf[j] == 0) lpf[j] = i;
}
}
}
}
4.3 筛法的时间与空间优化
-
位压缩:用bitset代替bool数组,可以节省8倍空间
cpp复制bitset<N> isPrime; isPrime.set(); // 全部置1 -
分段处理:大数组分块处理,减少缓存未命中
-
轮式筛法:跳过偶数进一步优化,但代码复杂度增加
5. 实战经验与常见错误
5.1 我踩过的坑
-
数组越界:筛法时循环条件写错,如
i*i可能溢出,应该用i <= n/i -
1不是素数:经常忘记特判,导致输出错误
-
多组数据未重置:在多次调用筛法时,忘记重置标记数组
-
内存限制:开太大数组导致MLE,应该根据题目限制合理设置N
5.2 性能对比测试
在我的测试中(n=1e7,i5-10300H):
- 暴力判断:约45秒
- 埃氏筛:约0.15秒
- 线性筛:约0.08秒
- bitset优化的埃氏筛:约0.12秒
5.3 如何选择筛法
根据需求选择:
- 埃氏筛:代码简单,适合一次性获取所有素数
- 线性筛:需要记录素数表或最小质因数时使用
- 区间筛:处理超大区间问题
- 位压缩筛:内存紧张时使用
6. 筛法在竞赛中的扩展应用
6.1 欧拉函数计算
利用线性筛可以高效计算欧拉函数φ(n):
cpp复制int phi[N];
void euler_phi() {
for (int i = 1; i < N; ++i) phi[i] = i;
for (int i = 2; i < N; ++i) {
if (phi[i] == i) { // i是素数
for (int j = i; j < N; j += i) {
phi[j] = phi[j] / i * (i - 1);
}
}
}
}
6.2 莫比乌斯函数计算
同样可以用筛法预处理:
cpp复制int mu[N];
void mobius() {
fill(mu, mu + N, 1);
for (int i = 2; i < N; ++i) {
if (mu[i] == 1) { // i是素数
for (int j = i; j < N; j += i) {
if (j % (i * i) == 0) mu[j] = 0;
else mu[j] = -mu[j];
}
}
}
}
6.3 素数密度问题
利用筛法可以验证素数定理:当n→∞时,π(n) ~ n/ln(n)
测试数据:
- n=10^6:π(n)=78498,n/ln(n)≈72382
- n=10^7:π(n)=664579,n/ln(n)≈620420
7. 进阶挑战与思考题
-
超大范围筛法:如何筛出10^18以内的素数?提示:可能需要概率性测试如Miller-Rabin
-
并行筛法:如何利用多线程加速筛法过程?
-
GPU加速:如何用CUDA实现并行筛法?
-
函数式实现:用Python生成器实现惰性筛法
python复制def eratosthenes():
D = {}
q = 2
while True:
p = D.pop(q, None)
if p:
x = p + q
while x in D: x += p
D[x] = p
else:
D[q*q] = q
yield q
q += 1
8. 从筛法看算法优化思维
筛法的演进过程体现了算法设计的几个核心思想:
- 空间换时间:用额外数组存储中间结果
- 预处理思想:提前计算并存储可能用到的信息
- 避免重复计算:线性筛通过最小质因数避免重复标记
- 渐进式优化:从暴力→埃氏筛→线性筛的逐步改进
在实际编程中,这种思维方式同样适用。当我面对一个需要频繁查询的问题时,第一反应往往是:能否预处理某些信息?能否用空间换时间?能否避免重复计算?
9. 推荐学习资源
-
书籍:
- 《算法竞赛入门经典》第5章
- 《具体数学》数论章节
-
在线评测:
- LeetCode 204. Count Primes
- SPOJ PRIME1
- Project Euler 第7、10题
-
可视化工具:
- 埃氏筛可视化:https://visualgo.net/en/eratosthenes
- 线性筛动画演示:https://www.cs.usfca.edu/~galles/visualization/Sieve.html
10. 总结与个人心得
素数筛法是我算法学习路上的一个重要里程碑。从最初的不理解,到后来的熟练应用,再到能够根据具体问题选择合适的筛法变种,这个过程让我深刻体会到算法思维的魅力。
在实际比赛中,我的经验是:
- 对于简单问题,直接用埃氏筛
- 需要素数表或处理数论函数时,用线性筛
- 注意数组大小,防止RE
- 多组数据时记得初始化
- 遇到TLE时考虑bitset优化
最后分享一个实用技巧:在竞赛中,我通常会预先写好筛法的模板代码,包含常见的变种(如欧拉函数、莫比乌斯函数等),这样在比赛中可以快速调用,节省编码时间。