埃拉托斯特尼筛法(Sieve of Eratosthenes)——原理、复杂度与多种 C++ 实现
目录
1. 算法简介
2. 正确性与复杂度分析
3. 常见优化与变体
4. 经典实现(可读、适合教学)
6. 高性能实现(位压缩 + 只处理奇数)
7. 分段筛(Segmented Sieve)
8. 应用与注意事项
9. 小结
本文目标:用通俗但严谨的语言介绍埃拉托斯特尼筛法的原理、时间与空间复杂度、常见优化手段以及若干实用的 C++ 实现(含注释与使用示例)。文章适合算法学习者、竞赛选手与需要在工程中高效生成素数表的开发者。
1. 算法简介
埃拉托斯特尼筛法是一种古老且高效的找出区间 [2, n]
内所有素数的方法。基本思想:
-
从最小的素数 2 开始,将其所有倍数(大于自身的)标记为合数。
-
找到下一个未被标记的数,将其视为素数,继续标记其倍数。
-
重复直到当前素数的平方大于
n
(因为如果p * p > n
,那么对于任何k >= p
的倍数p*k
要么已经被比p
小的素数标记,要么超出范围)。
因此,只需对 p
遍历到 sqrt(n)
。
2. 正确性与复杂度分析
-
正确性:任何合数
m
都有一个不超过sqrt(m)
的素因子,因此在遍历到小于等于sqrt(n)
的素数时,m
会被某个素因子标记为合数。 -
时间复杂度:经典的分析表明,埃筛的时间复杂度约为
O(n log log n)
。直观上,对每个素数p
,我们标记大约n/p
个数,所以总工作量是n * (1/2 + 1/3 + 1/5 + 1/7 + ...)
,由素数倒数和约等于log log n
。 -
空间复杂度:需要
O(n)
的额外空间来存放标记(布尔数组或 bitset)。通过一些技巧(只处理奇数)可将空间减半。
3. 常见优化与变体
-
只存储奇数:除了 2 之外的所有偶数都不是素数。把原问题映射到表示奇数的索引上,可将空间与工作量大致减半。
-
使用位集合(bitset)或压缩存储:用位而不是
bool
(或char
)来存储标记,能进一步节省内存并提升缓存效率。 -
跳过已知非必要的标记起点:当用奇数映射时,标记
p*p
开始的倍数,并以2*p
为步长跳过偶数。 -
线性筛(Euler 筛):复杂度更好,能在
O(n)
时间内生成素数列表,但实现和常数因子不同。适合需要线性复杂度的场景(另外会产生每个数的最小质因子)。 -
分段筛(Segmented Sieve):当
n
很大(比如n
无法一次性装入内存或n
超过内存限制)时,分段处理区间[L, R]
。需要首先用常规埃筛得到sqrt(n)
范围内的素数,然后用这些小素数在每个段上标记合数。常用于生成高达10^12
或更大的素数区间。 -
轮子筛(Wheel Factorization):通过剔除 2、3、5 等小素数的倍数来减少标记次数,是更高级的常数级优化。
4. 经典实现(可读、适合教学)
// basic_sieve.cpp
// 使用 vector<bool> 的经典实现,适合 n 不超过 ~1e8(视机器内存而定)
#include <bits/stdc++.h>
using namespace std;vector<int> simple_sieve(int n) {if (n < 2) return {};vector<bool> is_prime(n + 1, true);is_prime[0] = is_prime[1] = false;int limit = floor(sqrt(n));for (int p = 2; p <= limit; ++p) {if (is_prime[p]) {for (long long m = 1LL * p * p; m <= n; m += p)is_prime[m] = false;}}vector<int> primes;for (int i = 2; i <= n; ++i)if (is_prime[i]) primes.push_back(i);return primes;
}int main() {int n = 100;auto primes = simple_sieve(n);for (int p : primes) cout << p << " ";cout << '\n';return 0;
}
说明:vector<bool>
在 C++ 标准库中被特化为位容器,但其引用代理类型在某些情形下会带来小的性能负担;对于高性能应用,建议使用 std::vector<char>
/std::string
(每个元素一个字节)或手工位操作的 vector<uint64_t>
。
5. 优化实现:只处理奇数 + 使用 vector<char>
// odd_sieve.cpp
#include <bits/stdc++.h>
using namespace std;// 将序号 i (从0开始) 映射到实际数 2*i+1(只表示奇数)
// 我们不存储 2,单独处理。
vector<int> odd_sieve(int n) {if (n < 2) return {};vector<int> primes;primes.push_back(2);if (n == 2) return primes;int m = (n - 1) / 2; // 表示的奇数个数,例如 n=10 时 m=4 表示 1,3,5,7,9 共4个(但1不是素数)vector<char> is_composite(m, 0);int limit = (int)floor((sqrt(n) - 1) / 2);for (int i = 1; i <= limit; ++i) {if (!is_composite[i]) {int p = 2 * i + 1;// p*p 对应的索引:int start = (p * p - 1) / 2;for (int j = start; j < m; j += p) is_composite[j] = 1;}}for (int i = 1; i < m; ++i) if (!is_composite[i]) primes.push_back(2 * i + 1);return primes;
}int main() {int n = 100;auto primes = odd_sieve(n);for (int p : primes) cout << p << " ";cout << '\n';return 0;
}
说明:该实现将空间和一部分工作量降低约 2 倍,并且通常在实际运行中更快速、更节省内存。
6. 高性能实现(位压缩 + 只处理奇数)
下面给出将布尔数组压缩到 64 位块(uint64_t
)中的实现。适合想在大 n
下优化内存与缓存利用的人。实现涉及位操作,代码更复杂,但性能较好。
// bit_sieve.cpp
#include <bits/stdc++.h>
using namespace std;
using u64 = unsigned long long;vector<int> bit_sieve(int n) {if (n < 2) return {};vector<int> primes;primes.push_back(2);int m = (n - 1) / 2; // 只表示奇数size_t blocks = (m + 63) / 64;vector<u64> bits(blocks, 0);int limit = (int)floor((sqrt(n) - 1) / 2);for (int i = 1; i <= limit; ++i) {if ( (bits[i >> 6] >> (i & 63)) & 1ULL ) continue; // 已标记int p = 2 * i + 1;int start = (p * p - 1) / 2;for (int j = start; j < m; j += p) bits[j >> 6] |= (1ULL << (j & 63));}for (int i = 1; i < m; ++i) if ( ((bits[i >> 6] >> (i & 63)) & 1ULL) == 0 ) primes.push_back(2 * i + 1);return primes;
}int main() {int n = 10000000; // 示例:生成 1e7 内的素数auto primes = bit_sieve(n);cout << "count = " << primes.size() << '\n';// 输出前 20 个做示例for (size_t i = 0; i < primes.size() && i < 20; ++i) cout << primes[i] << " ";cout << '\n';return 0;
}
7. 分段筛(Segmented Sieve)
当 n
很大时(例如 n
达到 10^12
或更高)无法一次性分配 O(n)
内存来做标准埃筛,这时使用分段筛:
-
先用简单的埃筛生成
sqrt(n)
以内的全部素数(这是内存可承受的,因为sqrt(1e12)=1e6
)。 -
将区间
[2, n]
划分为若干长度为segment_size
(例如1e6
)的小段[L, R]
。 -
对每个小段,初始化一个布尔数组,再用先前获得的小素数标记该段内的合数(计算在段内每个小素数的第一个倍数的起点),之后收集该段中的素数并处理或输出。
分段筛的优点是只需要 O(segment_size)
的额外内存,并且可以在线输出素数、不需要一次性保存全部素数。
下面是一个简单的分段筛实现:
// segmented_sieve.cpp
#include <bits/stdc++.h>
using namespace std;vector<int> simple_sieve_for_limit(int limit) {vector<char> is_prime(limit + 1, 1);is_prime[0] = is_prime[1] = 0;for (int p = 2; p * p <= limit; ++p) if (is_prime[p]) {for (int q = p * p; q <= limit; q += p) is_prime[q] = 0;}vector<int> primes;for (int i = 2; i <= limit; ++i) if (is_prime[i]) primes.push_back(i);return primes;
}vector<int> segmented_sieve(long long n) {if (n < 2) return {};long long limit = floor(sqrt(n));vector<int> primes = simple_sieve_for_limit((int)limit);long long segment_size = max(limit, (long long)32768); // 选择段大小:至少 limit,适合缓存vector<int> result;for (long long low = 2; low <= n; low += segment_size) {long long high = min(n, low + segment_size - 1);vector<char> is_prime(high - low + 1, 1);for (int p : primes) {if (1LL * p * p > high) break;long long start = max(1LL * p * p, ((low + p - 1) / p) * 1LL * p);for (long long j = start; j <= high; j += p) is_prime[j - low] = 0;}for (long long i = low; i <= high; ++i) if (is_prime[i - low]) result.push_back((int)i);}return result;
}int main() {long long n = 1000000000LL; // 示例:生成到 1e9(实际可能较慢,视机器而定)auto primes = segmented_sieve(n);cout << "count = " << primes.size() << '\n';// 这里不输出全部素数return 0;
}
说明与建议:实际使用中,将段大小调整为适合 L1/L2 缓存的值(例如 32768
~ 1e6
)常能获得较好性能;对于极大 n
,建议把分段的结果写到文件或逐段处理以避免内存占用过高。
8. 应用与注意事项
-
常见应用:质因子分解的预处理(用素数表做 trial division)、寻找素数区间、生成测试数据、数论研究、密码学中的素数测试的预筛等。
-
边界与陷阱:
-
使用
int
存储p * p
可能溢出(当p
接近sqrt(INT_MAX)
时)。在循环中请用long long
计算或检查溢出。 -
vector<bool>
的特殊化在并行或一些索引中会带来开销,必要时用vector<char>
或手工位压缩替代。 -
当
n
很大时,注意内存和 IO 瓶颈,分段筛和按需输出是常见策略。
-
9. 小结
埃拉托斯特尼筛法是产生素数的经典方法。对于常见的范围(比如 n <= 1e8
),经过适当优化(只处理奇数 + 位压缩)可以在常见竞赛与工程场景中高效运行。对于非常大的 n
,分段筛能够把内存需求控制在合理范围,同时保持接近原始筛法的时间复杂度。
如果你需要我:
-
把某个实现改成并行版本(例如用 OpenMP)来提高速度;
-
提供可直接运行的测试用例或性能基准;
-
把代码封装成可复用的类或库接口(例如
class Sieve { ... }
);
告诉我你的目标(例如 n
的典型大小、是否需要低内存实现、是否要并行化),我可以基于此做进一步优化或生成可编译的项目模板。
作者备注:本文中的示例代码均为自包含的 C++ 源文件,使用常见的 GCC/Clang 编译器(例如 g++ -O2 file.cpp -o file
)即可编译运行。