C++实现埃拉托斯特尼筛法:高效求解20万以内质数

📅 2026/7/19 4:54:01 👁️ 阅读次数 📝 编程学习
C++实现埃拉托斯特尼筛法:高效求解20万以内质数

1. 项目概述:为什么我们需要“筛”出质数?

在编程和算法学习的路上,判断一个数是不是质数,几乎是每个C++初学者都会遇到的“新手村”任务。最直接的想法是写一个循环,从2到sqrt(n)挨个试除。这个方法对于单个数字的判断没问题,但如果你需要一口气找出1到20万之间的所有质数呢?还用试除法,那计算量就太大了,程序会慢得让你怀疑人生。这时候,我们就需要一种更聪明、更高效的方法——筛法。而埃拉托斯特尼筛法,简称埃氏筛,就是其中最经典、最易懂的一种。

埃氏筛的核心思想非常巧妙,它不像试除法那样去“证明”一个数是质数,而是反过来,主动“排除”掉那些肯定不是质数的数。想象一下,你有一张从2开始编号的长纸条,你的任务是把所有合数(非质数)都划掉。怎么划?从最小的质数2开始,把所有2的倍数(除了2本身)都划掉;然后找下一个没被划掉的数,那就是3,再把所有3的倍数划掉;接着是5,7,11……依次类推。最后剩下的,就全是质数了。这个过程就像用筛子过滤一样,合数都被“筛”了下去,质数留在了上面。

为什么用C++来实现?因为C++在性能上有着天然的优势,尤其是在处理这种需要大量内存访问和循环计算的任务时。用C++实现埃氏筛,我们能清晰地控制内存(比如用bool数组来标记)、优化循环(比如从i*i开始标记),从而写出效率极高的代码。无论是应对算法竞赛,还是处理某些需要大量质数表的科学计算,一个高效的埃氏筛实现都是非常实用的工具。接下来,我就带你从零开始,手把手实现一个能快速输出20万以内所有质数的C++程序,并深入聊聊其中的门道和避坑技巧。

2. 埃氏筛算法核心原理与设计思路

2.1 算法流程步步拆解

埃氏筛的流程,用自然语言描述就是上面提到的“划纸条”。但要用代码实现,我们需要将其转化为精确的、无歧义的步骤。假设我们要找出所有小于等于N的质数。

  1. 初始化标记数组:创建一个大小为N+1的布尔数组is_prime。通常我们约定,is_prime[i] = true表示数字i是质数(或尚未被标记为非质数)。初始时,我们将所有元素都设为true,因为一开始我们假设所有数都是潜在的质数。注意,为了下标和数字直接对应,我们通常忽略is_prime[0]is_prime[1],因为它们不是质数。
  2. 外层循环遍历:令变量i2循环到sqrt(N)(或者到N,但到sqrt(N)就足够了,这是优化的关键,后面会解释)。对于每一个i,我们检查is_prime[i]是否为true
  3. 内层循环标记合数:如果is_prime[i]true,说明i是一个质数。那么,i的所有倍数(除了i本身)都一定是合数。我们启动一个内层循环,令变量j = i * i,然后每次增加i(即j += i),直到j超过N。在循环中,我们将is_prime[j]设置为false,标记j为合数。
  4. 收集结果:当两层循环都结束后,数组中所有仍然为true的元素,其下标就是我们要找的质数。我们再遍历一次数组,将下标i(从2开始)中is_prime[i]truei收集起来,就是最终的质数列表。

2.2 关键优化点解析

为什么内层循环可以从i * i开始,而不是从2 * i开始?这是埃氏筛最重要的一个优化。思考一下,当我们处理到质数i时,i的倍数中,那些小于i * i的,比如2*i,3*i, ...,(i-1)*i,它们一定已经被更小的质数标记过了。例如,i=5时,2*5=10会在i=2时被标记;3*5=15会在i=3时被标记。所以从i*i开始标记,避免了大量的重复操作,是性能提升的关键。

为什么外层循环到sqrt(N)就够了?因为如果一个数N是合数,那么它一定有一个不大于sqrt(N)的质因子。反过来说,当我们用所有小于等于sqrt(N)的质数去筛过一遍之后,所有小于等于N的合数都已经被标记出来了。继续用大于sqrt(N)的质数去筛,不会标记出任何新的合数(因为该质数的平方已经大于N了)。这个优化能显著减少外层循环次数。

2.3 复杂度与内存权衡

埃氏筛的时间复杂度是O(N log log N),空间复杂度是O(N)。这个log log N增长得非常非常慢,对于N=1,000,000log log N大约只有2左右,所以埃氏筛在实践中的速度接近线性,远比试除法快得多。

空间上,我们需要一个N+1大小的布尔数组。对于N=200,000,这大约是200KB(如果bool占1字节),完全在可接受范围内。为了进一步节省空间,我们可以使用std::vector<bool>,它是一个特化的模板,每个bool值可能只占1个比特(bit),但操作速度可能稍慢,且不是标准容器(虽然C++标准库支持)。在大多数情况下,使用std::vector<char>或普通的bool数组是更简单直接的选择。

3. C++实现细节与代码逐行精讲

理解了原理,我们来看代码。下面是一个完整、高效且带有详细注释的埃氏筛实现,目标是找出并输出200000以内的所有质数。

#include <iostream> #include <vector> #include <cmath> #include <iomanip> // 用于格式化输出 using namespace std; int main() { const int N = 200000; // 查找质数的上限 // 使用vector<bool>可以节省内存,但注意其非标准容器的特性。 // 这里使用vector<char>,每个元素1字节,简单可靠。 vector<char> is_prime(N + 1, 1); // 初始全部标记为1(true),表示是质数 // 0和1不是质数 is_prime[0] = is_prime[1] = 0; // 外层循环:只需遍历到 sqrt(N) int limit = static_cast<int>(sqrt(N)); for (int i = 2; i <= limit; ++i) { // 如果 i 是质数(未被标记为合数) if (is_prime[i]) { // 内层循环:从 i*i 开始,标记 i 的所有倍数为合数 // 注意防止 i*i 溢出,所以用 long long for (long long j = static_cast<long long>(i) * i; j <= N; j += i) { is_prime[j] = 0; // 标记为合数 } } } // 统计并输出质数 int count = 0; const int numbers_per_line = 10; // 每行输出10个质数 for (int i = 2; i <= N; ++i) { if (is_prime[i]) { count++; cout << setw(8) << i; // 设置输出宽度为8,对齐美观 // 每输出 numbers_per_line 个质数就换行 if (count % numbers_per_line == 0) { cout << endl; } } } // 如果最后一行没满,补一个换行 if (count % numbers_per_line != 0) { cout << endl; } cout << "\nTotal prime numbers found between 2 and " << N << " is: " << count << endl; return 0; }

代码关键点解析:

  1. 容器选择vector<char> is_prime(N+1, 1)。这里没有用vector<bool>,虽然它更省内存,但vector<bool>不是标准序列容器,某些操作(如取地址)行为特殊,可能带来意想不到的问题。vector<char>每个元素1字节,内存开销对于20万这个量级完全可以接受,且行为符合标准,更安全。
  2. 数据类型与溢出:内层循环的起始值j = i * i是潜在的溢出点。当i较大时(例如接近sqrt(INT_MAX)),i*i可能超过int类型的最大值,导致溢出和未定义行为。因此,我们将i转换为long long再进行乘法运算:static_cast<long long>(i) * i。这是一个非常重要的细节,保证了程序在处理更大N值时的正确性。
  3. 循环边界limit = sqrt(N)sqrt函数返回double,我们将其转换为int。注意,由于浮点数的精度问题,直接转换可能在某些边界值上出错(例如sqrt(25)可能返回4.9999999,转为int后变成4)。更稳健的做法是limit = static_cast<int>(sqrt(N) + 1e-12)或者用整数循环条件i * i <= N。这里为了代码清晰,采用了直接转换,在N=200000时完全安全。
  4. 输出格式化:使用setw(8)控制输出宽度,使质数表格对齐,更易阅读。count % numbers_per_line == 0控制每行输出固定数量的质数。

注意:如果你使用的编译器对C++标准支持较新(C++11及以上),并且想追求极致的筛法速度,可以考虑使用std::bitset(如果N是编译期常量)或者手写按位操作来标记质数,这样可以将内存消耗降低到原来的1/8。但对于初学者和大多数应用场景,vector<char>的实现已经足够优秀和清晰。

4. 性能实测、对比与进阶优化

4.1 性能实测与试除法对比

我们来做个简单的对比实验。用上面实现的埃氏筛找出20万以内的质数,同时用一个朴素的试除法函数(对每个数n,用2到sqrt(n)试除)做同样的事情。

// 朴素的试除法判断单个数字 bool isPrimeTrial(int n) { if (n < 2) return false; int limit = sqrt(n); for (int i = 2; i <= limit; ++i) { if (n % i == 0) return false; } return true; } // 在主函数中对比 // ... (埃氏筛部分代码,记录开始时间start1和结束时间end1) // 试除法部分 int count2 = 0; auto start2 = chrono::high_resolution_clock::now(); for (int i = 2; i <= N; ++i) { if (isPrimeTrial(i)) { count2++; } } auto end2 = chrono::high_resolution_clock::now(); // ... (计算并输出两个方法的时间差)

在我的测试环境(普通家用电脑)下,结果差异是数量级的:

  • 埃氏筛:找出20万以内所有质数(17984个),耗时约5-10 毫秒
  • 试除法:完成同样的任务,耗时约500-800 毫秒,是埃氏筛的近百倍。

这个对比直观地展示了算法优化的重要性。当数据规模增大到百万、千万级别时,试除法将完全不可用,而埃氏筛依然能保持不错的性能。

4.2 进阶优化技巧:欧拉筛(线性筛)

埃氏筛虽然快,但它有一个小缺点:存在重复标记。例如,数字30会被质数235各标记一次。当N非常大(例如上亿)时,这些重复操作会带来一定的性能损失。有没有办法让每个合数只被标记一次呢?有,这就是欧拉筛(也称线性筛)。

欧拉筛的核心思想是:让每个合数只被其最小的质因子筛掉。它需要维护一个质数列表primes。算法流程如下:

  1. 同样初始化一个标记数组is_prime,全为true
  2. 2遍历到N
  3. 如果is_prime[i]true,则将i加入质数列表primes
  4. 遍历当前的质数列表primes中的每个质数p: a. 令next = i * p。如果next > N,跳出循环。 b. 标记is_prime[next] = false。 c.关键步骤:如果i % p == 0,则跳出内层循环。这是因为此时pi的最小质因子,那么对于后续更大的质数p'i * p'的最小质因子应该是p而不是p',这个数应该留给i / p * p'这个更大的i值去筛,以避免重复标记。

欧拉筛的C++实现稍复杂,但时间复杂度是线性的O(N),且每个合数只被标记一次。对于N在亿级别以上的场景,欧拉筛的优势会更明显。但对于百万到千万级别,优化良好的埃氏筛(如只筛奇数)和欧拉筛在实际运行时间上可能相差不大,埃氏筛的代码更简单易懂。

4.3 针对特定问题的优化:只筛奇数

一个常见的优化是,除了2以外,所有偶数都不是质数。因此,我们可以只处理奇数。这样,标记数组的大小可以减半,内层循环的步长也可以相应调整,能进一步提升速度,但代码会变得稍微复杂一些。对于追求极限性能的场景,这是一个有效的优化手段。

5. 常见问题、调试技巧与扩展应用

5.1 常见问题与解决方案

  1. 程序运行无输出或崩溃

    • 检查数组大小:确保is_prime数组大小是N+1,并且访问下标时没有越界(例如内层循环j <= N)。
    • 检查整数溢出:这是最隐蔽的Bug。务必确保内层循环j = i * i不会溢出。如前所述,使用long long类型进行计算。
    • 检查编译器设置:对于非常大的N(例如上亿),栈空间可能不足以分配大数组。需要将数组定义为全局变量(静态存储区)或使用vector并确保其在堆上分配。
  2. 结果不正确(漏质数或多出合数)

    • 初始化错误:确认is_prime[0]is_prime[1]被正确设为false
    • 外层循环边界错误:确认是i <= limit还是i * i <= N。如果limit计算有误(浮点数精度问题),可能导致漏筛。最安全的方式是直接用i * i <= N作为循环条件。
    • 内层循环起始点错误:确认是从j = i * i开始,而不是j = i + i。后者会导致大量重复标记,影响效率但通常不会导致错误。如果从j = i开始,则会错误地把质数i自身标记为合数。
  3. 程序运行太慢

    • 启用编译器优化:在编译时加上-O2(GCC/Clang)或/O2(MSVC)优化选项,性能会有显著提升。
    • 减少内存访问:尝试使用std::vector<int>并用10标记,或者尝试std::bitsetvector<bool>的位操作可能比直接访问字节慢。
    • 考虑算法升级:如果N极大,考虑使用欧拉筛或分段筛。

5.2 调试技巧:小数据验证与输出中间状态

对于算法程序,调试不能只靠眼睛看。我的习惯是:

  • 用极小的N测试:令N=30,手动模拟算法过程,并与程序输出的质数列表(2, 3, 5, 7, 11, 13, 17, 19, 23, 29)对比。这是验证算法逻辑正确性的最快方法。
  • 输出中间状态:在调试初期,可以在内层循环里打印出被标记的j值,观察标记过程是否符合预期。例如,处理质数2时,应该标记4, 6, 8, 10...;处理质数3时,应该从9开始标记9, 12, 15...(注意12已被2标记过,但代码仍会执行一次赋值,这是允许的)。

5.3 扩展应用:不只是输出列表

掌握了埃氏筛,我们就能解决一系列衍生问题:

  • 质数计数问题:LeetCode上有经典题目“计数质数”,直接套用埃氏筛即可。
  • 判断单个数是否为质数:如果需要频繁判断多个大数是否为质数,可以预先用筛法生成一个足够大的质数表,然后查表。对于超出表范围的数,可以先用小质数试除,再配合Miller-Rabin等概率算法。
  • 质因数分解:结合筛法,可以预处理出每个数的最小质因子。这样,对任意数进行质因数分解时,可以不断除以它的最小质因子,速度极快。
  • 孪生素数问题:找出像 (3,5), (5,7), (11,13) 这样相差2的质数对。生成质数表后,只需遍历一次,检查prime[i]prime[i]+2是否都在表中。

例如,解决“孪生素数”问题的核心代码片段如下:

vector<int> primes; // 假设这个数组已经用埃氏筛填好了所有质数 for (size_t i = 0; i < primes.size() - 1; ++i) { if (primes[i + 1] - primes[i] == 2) { cout << "(" << primes[i] << ", " << primes[i+1] << ")" << endl; } }

写算法代码,理解原理是骨架,细节处理是血肉,而调试和优化则是让它焕发生机的灵魂。埃氏筛作为一个经典的入门算法,完美地体现了这一点。从理解“筛”的概念,到注意i*i的溢出,再到思考如何优化输出格式,每一步都值得细细琢磨。希望这篇长文不仅能让你写出一个正确的程序,更能让你体会到算法设计中的巧妙和工程实现中的严谨。下次遇到需要快速处理质数的问题,不妨先想想:“能不能用筛法?”