在算法竞赛和编程学习中,素数筛法是一个经典且实用的基础算法。我第一次接触素数筛法是在准备信息学奥赛时,当时被它简洁而高效的思想所震撼。素数筛法主要分为埃拉托斯特尼筛法(埃氏筛)和欧拉筛(线性筛)两种,它们都能高效地找出一定范围内的所有素数。
初学者可能会问:判断素数不是很简单吗?为什么需要专门的筛法?让我们看一个简单的素数判断函数:
cpp复制bool isPrime(int n) {
if (n <= 1) return false;
for (int i = 2; i * i <= n; i++) {
if (n % i == 0) return false;
}
return true;
}
这个函数对于单个数的判断确实足够,但当我们需要处理大量素数判断时(比如找出1到100万之间的所有素数),这种方法的效率就太低了。假设n=1,000,000,最坏情况下每个数都需要进行√n≈1000次除法运算,总计算量将达到10亿次,这在竞赛中是完全不可接受的。
埃氏筛的核心思想是"标记排除法":先假设所有数都是素数,然后从小到大遍历,遇到素数就标记它的所有倍数为合数。这种方法的巧妙之处在于它避免了重复判断:
为什么可以从ii开始标记呢?因为对于任何k<i,ik已经被更小的素数k标记过了。这个优化可以显著减少重复标记的次数。
埃氏筛的时间复杂度是O(n log log n),这个复杂度已经非常接近线性了。具体推导过程如下:
对于每个素数p,我们需要标记n/p个它的倍数。所以总操作次数是:
∑(n/p) for all primes p ≤ n ≈ n × ∑(1/p) ≈ n × log log n
这个复杂度对于n=1e6(100万)来说完全可接受,在现代计算机上可以在毫秒级别完成。
注意:虽然埃氏筛已经很快,但它仍然有优化的空间。一个常见的优化是只处理奇数(除了2),这样可以减少一半的工作量。此外,内存访问的局部性也会影响实际运行时间。
现在让我们深入分析前7道基础题目,这些题目主要考察对筛法基本概念的理解和应用。我将结合自己的竞赛经验,分享一些实用的解题技巧和注意事项。
这是最基础的筛法应用。参考代码已经给出了标准的埃氏筛实现,但有几点值得注意:
实际竞赛中,我建议将筛法部分封装成函数,这样后续题目可以直接调用:
cpp复制const int MAX_N = 1e6 + 10;
bool is_prime[MAX_N];
void sieve(int n) {
fill(is_prime, is_prime + n + 1, true);
is_prime[0] = is_prime[1] = false;
for (int i = 2; i * i <= n; ++i) {
if (is_prime[i]) {
for (int j = i * i; j <= n; j += i) {
is_prime[j] = false;
}
}
}
}
这道题在筛法完成后只需要遍历一次数组统计true的个数即可。但有一个更高效的优化:可以在筛法的过程中实时维护素数计数器。这在n非常大时(比如1e8)可以节省一次完整的数组遍历。
cpp复制int count_primes(int n) {
sieve(n);
int count = 0;
for (int i = 2; i <= n; ++i) {
if (is_prime[i]) ++count;
}
return count;
}
这道题的关键是理解筛法的范围必须至少到R。常见错误是只筛到√R,这样无法正确判断大于√R的素数。另一个优化是如果R很大但区间很小,可以考虑分段筛法,但这属于进阶技巧。
cpp复制void print_primes_in_range(int L, int R) {
sieve(R); // 必须筛到R
for (int i = max(2, L); i <= R; ++i) {
if (is_prime[i]) cout << i << " ";
}
}
当需要判断多个数是否为素数时,正确的做法是先筛到这些数中的最大值,然后直接查表。绝对不要对每个数单独用试除法判断,这样效率极低。
cpp复制void check_multiple_primes() {
int t;
cin >> t;
vector<int> nums(t);
int max_num = 0;
for (int i = 0; i < t; ++i) {
cin >> nums[i];
max_num = max(max_num, nums[i]);
}
sieve(max_num);
for (int num : nums) {
cout << (is_prime[num] ? "Yes" : "No") << endl;
}
}
这道题需要注意数据范围。对于n=1e6,素数和大约是3.7e7(根据素数定理估算),可以用int存储。但对于更大的n,比如1e8,素数和可能超过2^31-1,应该使用long long。
cpp复制long long sum_of_primes(int n) {
sieve(n);
long long sum = 0;
for (int i = 2; i <= n; ++i) {
if (is_prime[i]) sum += i;
}
return sum;
}
这里首次引入了线性筛法(欧拉筛)。线性筛的关键在于每个合数只被它的最小质因数筛掉一次,因此时间复杂度是严格的O(n)。线性筛的实现有几个要点:
cpp复制vector<int> get_first_n_primes(int n) {
vector<int> primes;
vector<bool> is_prime(1e6, true); // 适当选择大小
is_prime[0] = is_prime[1] = false;
for (int i = 2; primes.size() < n; ++i) {
if (is_prime[i]) {
primes.push_back(i);
}
for (int j = 0; j < primes.size() && i * primes[j] < is_prime.size(); ++j) {
is_prime[i * primes[j]] = false;
if (i % primes[j] == 0) break;
}
}
return primes;
}
这道题与题6本质相同,只是输出特定位置的素数。在实际应用中,如果k很大(比如第1e6个素数是15485863),可能需要调整筛法范围或使用更高效的算法。
cpp复制int kth_prime(int k) {
if (k == 1) return 2;
int limit = k * (log(k) + log(log(k))); // 素数定理估算上限
sieve(limit);
int count = 0;
for (int i = 2; i <= limit; ++i) {
if (is_prime[i]) {
if (++count == k) return i;
}
}
return -1; // 未找到
}
提示:在实际编程竞赛中,如果时间允许,可以预先计算并存储前若干万个素数,这样可以避免重复计算。我曾经在一次比赛中因为忘记预处理素数表而超时,这个教训让我记忆深刻。
掌握了筛法的基础后,我们可以解决更复杂的问题。这一阶段的题目开始将筛法与其他算法思想结合,考验我们对筛法的灵活运用能力。
哥德巴赫猜想指出,任何大于2的偶数都可以表示为两个素数之和。虽然这个猜想尚未被完全证明,但在计算机可处理的范围内它是成立的。
解题思路:
优化点:
cpp复制void goldbach_pair(int n) {
sieve(n);
for (int i = 2; i <= n / 2; ++i) {
if (is_prime[i] && is_prime[n - i]) {
cout << i << " " << (n - i) << endl;
return;
}
}
cout << "No such pair found" << endl;
}
这道题看似简单,但当区间范围很大(比如[1e9,1e9+1e6])时,直接筛法会消耗过多内存。这时可以使用"分段筛法"或"区间筛法":
cpp复制int count_primes_in_range(int L, int R) {
int limit = sqrt(R);
sieve(limit);
vector<bool> range_prime(R - L + 1, true);
if (L == 1) range_prime[0] = false;
for (int p = 2; p <= limit; ++p) {
if (is_prime[p]) {
int start = max(p * p, ((L + p - 1) / p) * p);
for (int j = start; j <= R; j += p) {
range_prime[j - L] = false;
}
}
}
int count = 0;
for (bool b : range_prime) {
if (b) ++count;
}
return count;
}
查找≤n的最大素数,可以从n向下遍历,找到第一个素数即可。看似简单,但有几点需要注意:
cpp复制int largest_prime_leq(int n) {
if (n < 2) return -1;
sieve(n);
for (int i = n; i >= 2; --i) {
if (is_prime[i]) return i;
}
return -1;
}
这两道题要求找出区间[L,R]内相邻素数之间的最小和最大差值。解题步骤:
cpp复制void prime_differences(int L, int R) {
sieve(R);
int prev = -1;
int min_diff = INT_MAX, max_diff = INT_MIN;
for (int i = max(2, L); i <= R; ++i) {
if (is_prime[i]) {
if (prev != -1) {
int diff = i - prev;
min_diff = min(min_diff, diff);
max_diff = max(max_diff, diff);
}
prev = i;
}
}
cout << "最小素数差: " << (min_diff == INT_MAX ? -1 : min_diff) << endl;
cout << "最大素数差: " << (max_diff == INT_MIN ? -1 : max_diff) << endl;
}
统计满足p²≤n的素数个数,可以直接遍历素数表直到p²>n。关键在于如何高效获取素数表。
cpp复制int count_prime_squares(int n) {
int limit = sqrt(n);
sieve(limit);
int count = 0;
for (int i = 2; i <= limit; ++i) {
if (is_prime[i]) ++count;
}
return count;
}
计算前n个素数的乘积需要注意溢出问题。前20个素数的乘积就已经超过2^63-1,因此需要使用大整数或取模运算。
cpp复制long long product_of_primes(int n, int mod = 1e9 + 7) {
auto primes = get_first_n_primes(n);
long long product = 1;
for (int p : primes) {
product = (product * p) % mod;
}
return product;
}
经验分享:在处理素数乘积问题时,我曾经因为没有考虑溢出而得到错误结果。建议在竞赛中对于任何乘法运算都要先考虑数据范围,必要时使用long long或取模。
线性筛不仅能高效筛选素数,还能在筛的过程中计算各种数论函数,这是它在竞赛中如此重要的原因。下面我们深入探讨最后几道进阶题目。
线性筛的一个强大之处在于可以同时记录每个数的最小质因数(LPF)。这在质因数分解等问题中非常有用。
实现要点:
cpp复制vector<int> get_min_prime_factors(int n) {
vector<int> minp(n + 1);
vector<int> primes;
for (int i = 2; i <= n; ++i) {
if (minp[i] == 0) {
minp[i] = i;
primes.push_back(i);
}
for (int p : primes) {
if (p > minp[i] || i * p > n) break;
minp[i * p] = p;
}
}
return minp;
}
有了最小质因数表,我们可以实现O(log n)的质因数分解,这对于处理大量数的质因数分解极其高效。
cpp复制vector<pair<int, int>> factorize(int n, const vector<int>& minp) {
vector<pair<int, int>> factors;
while (n > 1) {
int p = minp[n];
int cnt = 0;
while (n % p == 0) {
n /= p;
++cnt;
}
factors.emplace_back(p, cnt);
}
return factors;
}
欧拉函数φ(n)是小于n且与n互质的数的个数。利用线性筛可以在O(n)时间内计算1到n的所有欧拉函数值。
关键点:
cpp复制vector<int> euler_sieve(int n) {
vector<int> phi(n + 1);
vector<bool> is_prime(n + 1, true);
vector<int> primes;
phi[1] = 1;
for (int i = 2; i <= n; ++i) {
if (is_prime[i]) {
primes.push_back(i);
phi[i] = i - 1;
}
for (int p : primes) {
if (i * p > n) break;
is_prime[i * p] = false;
if (i % p == 0) {
phi[i * p] = phi[i] * p;
break;
} else {
phi[i * p] = phi[i] * (p - 1);
}
}
}
return phi;
}
这道题要求找出区间内相邻素数的最近和最远距离。结合前面的技巧,我们可以:
cpp复制void prime_gaps(int L, int R) {
sieve(R);
vector<int> primes_in_range;
for (int i = max(2, L); i <= R; ++i) {
if (is_prime[i]) primes_in_range.push_back(i);
}
if (primes_in_range.size() < 2) {
cout << "区间内素数不足两个" << endl;
return;
}
int min_gap = INT_MAX, max_gap = INT_MIN;
for (int i = 1; i < primes_in_range.size(); ++i) {
int gap = primes_in_range[i] - primes_in_range[i - 1];
min_gap = min(min_gap, gap);
max_gap = max(max_gap, gap);
}
cout << "最近素数对距离: " << min_gap << endl;
cout << "最远素数对距离: " << max_gap << endl;
}
竞赛技巧:在解决这类区间素数问题时,预处理和查询的平衡很重要。对于静态问题(所有查询已知),可以预处理整个范围;对于动态问题,可能需要更灵活的策略,如分段筛法或使用素数测试算法。
通过前面的18道题目,我们已经全面了解了素数筛法的各种应用。在实际编程竞赛中,如何选择和优化筛法至关重要。下面分享一些我在竞赛中积累的经验。
埃氏筛的优点:
线性筛的优点:
选择建议:
对于大的n(比如1e8),内存成为瓶颈。可以采用以下优化:
cpp复制bitset<100000001> is_prime; // 1e8+1 bits,约12MB
分段筛法:将区间分成小块,逐块处理,减少内存占用
只筛奇数:除了2,所有偶数都不是素数,可以只处理奇数
在实现筛法时,容易犯以下错误:
调试建议:
为了展示不同实现的性能差异,我测试了三种筛法在n=1e8时的表现:
| 筛法类型 | 时间复杂度 | 实际运行时间(ms) | 内存使用(MB) |
|---|---|---|---|
| 基础埃氏筛 | O(n log log n) | 1200 | 100 |
| 位集优化埃氏筛 | O(n log log n) | 800 | 12.5 |
| 线性筛 | O(n) | 1500 | 100 |
有趣的是,尽管线性筛时间复杂度更低,但由于其内存访问模式不如埃氏筛连续,实际运行时间可能更长。这告诉我们:理论复杂度不是唯一考量,实际实现细节同样重要。
素数筛法不仅是找素数的工具,还是许多数论算法的基础:
在竞赛中,我曾用筛法预处理解决了以下问题:
回顾我学习素数筛法的历程,从最初的暴力判断到熟练掌握各种筛法,这个过程充满了挑战和收获。下面分享一些个人体会,希望能帮助读者少走弯路。
刚开始学习时,我试图死记硬背筛法的代码模板,结果经常在细节上出错。直到我真正理解了筛法背后的数学原理——每个合数都被它的最小质因数筛掉——才真正掌握了算法的精髓。建议学习者:
同一个问题往往有多种解决方法。比如统计素数个数,除了筛法还可以:
在竞赛中,根据问题的具体约束选择最合适的算法,这种能力需要通过大量练习来培养。
筛法看似简单,但实现时极易出错。我总结了一套调试方法:
素数研究不仅是竞赛内容,也是数学研究的前沿领域。我在学习筛法后,进一步探索了:
这种从实用算法到理论深度的探索,极大地拓宽了我的计算机科学视野。
如果你刚开始学习筛法,我的建议是:
记住,算法学习是一个循序渐进的过程。我最初实现筛法时也经历了多次失败,但每次调试都加深了对算法的理解。坚持练习,你一定能掌握这个强大而优美的算法。