ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++验证哥德巴赫猜想:从试除法到埃氏筛性能优化

C++验证哥德巴赫猜想:从试除法到埃氏筛性能优化 简介一份面向C初学者与算法爱好者的哥德巴赫猜想验证程序项目。以VC6.0工程形式提供完整源码演示质数判断、遍历偶数并输出质数分解的核心思路帮助理解哥德巴赫猜想“任一大于2的偶数可表示为两个质数之和”的数值验证过程。压缩包共9个文件约11KB包含两个cpp源文件、一个头文件以及dsp、dsw等工程配置文件和txt说明文档结构简洁便于直接打开编译运行。已有254人学习浏览适合用于C循环、函数与数论入门练习。通过阅读和运行读者能掌握试除法判断质数、循环遍历及程序优化方向并可进一步扩展为预生成质数表如埃拉托斯特尼筛法或并行计算版本是兼顾编程基础与数学探索的实用小型案例。 看到验证哥德巴赫的验证这个标题我第一反应是愣了下以为打字重复了。但转念一想这个说法其实把这道题的内核概括得很准第一个验证是我们要写的程序行为第二个验证指的是哥德巴赫猜想本身。说白了这就是让你用 C 写一段程序去检查任何一个大于 2 的偶数都能拆成两个素数之和这个猜想在给定范围内到底成不成立。哥德巴赫猜想的内容本身不难理解。1742 年提出至今没被严格证明但计算机早就能在很大范围内验证它。比如 8 3 530 7 23 11 19 13 17100 更是有一堆分解。这道题最迷人的地方在于你不需要去解决数学难题只需要把逐个检查这件事写成代码跑起来。它非常适合刚学完 C 循环和函数、想找个综合练习的初学者也适合备战算法竞赛、想复习素数筛法和性能优化的选手。这篇文章我会从最直观的试除法开始一步步走到带剪枝的埃氏筛完整实现把边界问题、性能瓶颈和排查方法都过一遍。1. 先把验证翻译成程序逻辑题目到底要输出什么1.1 从数学定义到可执行的检查程序要做的事情非常具体。输入一个偶数 n输出所有满足 p q n 的素数对 (p, q)并且约定 p q 避免重复。举个例子输入 100程序应该输出100 3 97 100 11 89 100 17 83 100 29 71 100 41 59 100 47 53这就是验证一个偶数的最基本形态。但验证哥德巴赫猜想通常不止验证一个数。很多题目会升级成输入一个上界 N检查 4 到 N 之间所有偶数是否都能分解成两个素数之和。如果是这种形式你面对的不再是站在一个数面前猜而是连续检查成千上万个偶数。这一步差别直接决定了后面选用什么算法单个数验证试除法足够区间批量验证得上筛法。1.2 输入输出边界先定好后面少踩坑编程题最怕输入输出边界含糊不清。我写这类题时固定了一套处理方式输入后先判合法性n 必须是大于 2 的偶数奇数和 n 2 直接提示非法输入。输出格式统一为n p q每个分解占一行。如果没有找到任何分解理论上只有 n 2 时会出现输出提示信息。多个分解之间 p 按升序输出这样结果可读性最好也方便和别人程序对比答案。这些看起来琐碎实际上决定了程序能不能在判题系统里拿到满分。很多新手程序逻辑完全正确但输出了 (p, q) 和 (q, p) 两行重复内容判题直接给错。做这类题先把输入输出规则钉死比急着写核心逻辑更重要。2. 第一版能跑的代码试除法判定素数枚举配对找分解2.1 isPrime 的最朴素写法与两个边界细节最直接的素数判定方式是试除法判断 x 是否为素数就从 2 一直试到 sqrt(x)看有没有因子能整除。bool isPrime(int x) { if (x 2) return false; for (int i 2; i * i x; i) { if (x % i 0) return false; } return true; }这个函数有两个细节值得停下来看。第一边界判断必须写在最前面。x 2直接返回 false因为 0 和 1 都不是素数。新手最容易在这里翻车如果漏掉x 2isPrime(1) 会因为 for 循环一次都不执行而返回 true整个程序从根上就错了。第二循环终点用i * i x而不是i x / 2或者i x。i * i x等价于i sqrt(x)但避免了调用 sqrt 函数带来的浮点误差。这个写法很常见不过第 5 章我会专门讲一个隐患当 x 很大时i * i可能会溢出 int更稳的是写成i x / i。2.2 枚举配对时怎么避免 (p, q) 和 (q, p) 重复有了 isPrime主程序的枚举逻辑其实只需要一个循环不需要嵌套两层。int n; cin n; if (n % 2 ! 0 || n 2) { cout 请输入大于2的偶数 endl; return 0; } for (int p 2; p n / 2; p) { int q n - p; if (isPrime(p) isPrime(q)) { cout n p q endl; } }很多初学者会写成双重循环p 从 2 到 nq 从 2 到 n先判断 p q n再判断两个是不是素数。这种写法也能跑出正确结果但做了大量无用功而且输出会包含 (3, 97) 和 (97, 3) 这种重复项。正确做法是用约束条件 p q n 直接把 q 算出来再用p n / 2保证 p 是较小的一方这样枚举量直接砍半也不会重复。2.3 这个版本的性能极限在哪里单个数 n 100000 时上面的代码要调用 isPrime 约 50000 次每次最坏要循环到 i 316总操作量大约一千多万次现代 CPU 能在一秒内跑完。但如果题目改成验证 4 到 1000000 之间所有偶数每个偶数都跑一遍这个逻辑总操作量会膨胀到几十亿甚至上百亿次完全不可行。所以我的第一判断是如果只是验证单个偶数试除法够用一旦进入区间验证场景就得换思路了。3. 换用埃氏筛一次预处理把逐个判定变成查表3.1 筛法的核心思想划掉所有合数试除法的痛点是每个数都要从头试一遍因子而筛法干脆换了个思路一次性把 2 到 N 范围内所有素数挑出来后面全部变成 O(1) 查表。你可以想象一排从 2 到 N 的号码牌。从最小的数开始每遇到一个没被划掉的数它就是素数然后把它所有的倍数划掉。2 是素数划掉 4、6、8、10……接着看 3没被划过是素数划掉 9、12、15……4 已经被划掉了跳过5 没被划掉是素数划掉 25、30、35……扫完这一遍剩下没被划掉的全是素数。这个过程就是埃拉托斯特尼筛法简称埃氏筛。3.2 实现细节为什么我不用 vectorvectorchar isPrime(N 1, 1); isPrime[0] isPrime[1] 0; for (int i 2; i * i N; i) { if (isPrime[i]) { for (int j i * i; j N; j i) { isPrime[j] 0; } } }两个细节值得讲。第一内层循环从j i * i开始而不是从i * 2开始。因为i * 2、i * 3一直到i * (i - 1)这些数在更小的素数处理时已经被划过了。从i * i开始可以避免大量重复操作i 越大省下的操作越多。第二我特意用了vectorchar而不是vectorbool。这里有个 C 的经典坑vectorbool是模板特化每个元素被压缩成 1 个 bit 存储虽然省内存但operator[]返回的是代理对象而不是真正的bool读写速度反而比普通的 char 数组慢还经常引发一些诡异的类型问题。对于竞赛和工程场景vectorchar或者vectorint8_t是更稳妥的选择。3.3 为什么批量验证场景下筛法完胜埃氏筛的时间复杂度是 O(N log log N)N 取 10^7 时也只有大约三千万次操作。相比之下试除法对每个偶数要重复做约五万次判定累积起来完全没法看。我实测验证 4 到 200000 之间所有偶数时试除法版本要跑三秒多筛法版本二十毫秒以内完成差距就是这么大。但要注意筛法只是把 isPrime 变成了 O(1) 查表。真正验证区间内所有偶数时每个偶数还要枚举若干个 p。如果每个偶数都要求输出所有分解且不提前退出总枚举量依然巨大。所以筛法必须搭配后续的剪枝策略才实用。4. 性能再压榨奇偶剪枝、枚举边界、提前退出4.1 只检查奇数 p特殊处理 p2两个素数相加为偶数如果不考虑特殊素数 2那两个素数必然都是奇数和才能是偶数。2 是唯一的偶素数所以唯一包含 2 的分解是2 (n - 2)前提是 n - 2 也是素数。于是主循环可以拆成两部分if (isPrime[n - 2]) { cout n 2 n - 2 endl; } for (int p 3; p n / 2; p 2) { int q n - p; if (isPrime[p] isPrime[q]) { cout n p q endl; } }这一步把主循环的枚举量又减了一半。不要小看这个优化在 N 达到百万级别时少跑一半循环意味着节省几千万次访问数组的时间。4.2 对称性剪枝p 最多枚举到 n/2p n / 2这个边界就是对称性剪枝。因为 p q n如果 p 超过 n/2那 q 就小于 p一定会在之前某个 p 的枚举中被覆盖。前面第 2 章已经提过这个思路这里再强调一遍剪枝的本质不是炫技是利用问题本身的结构特性。类似的思路还有如果 x 是偶数且大于 2那么它一定不是素数可以在 isPrime 里提前返回 false。不过既然用了筛法这个优化在查表场景下意义不大但写在试除法版本里能明显提速。4.3 只需要一个解时方向和退出策略如果题目只要求输出一组分解找到第一个满足条件的 p 就可以 break。但 break 的方向有讲究从小到大枚举 p会输出 p 最小的解比如 100 输出3 97。从大到小枚举 pp 从 n/2 往下会输出两个素数最接近的解比如 100 输出47 53。两种结果都正确但判题系统到底接受哪种取决于题目要求。所以做这类题第一步就是看清题面要的是 p 最小还是 p 最大还是任意一组。我习惯的做法是看题目而不是想当然。实践上搜索方向的选择对找到第一个解的速度影响不大因为无论是小的 p 还是接近 n/2 的 p都相对容易碰到素数。真正影响速度的是你有没有及时 break。4.4 不同数据规模下的方案选择我把我的实测经验整理成一个表供参考数据规模试除法埃氏筛推荐单个偶数 n 10^6毫秒级可接受需要建表反而慢试除法单个偶数 n 10^9约 0.1 秒数组开不下试除法 优化区间 [4, N], N 10^6明显卡顿几十毫秒筛法区间 [4, N], N 10^7不可行1 秒左右筛法 剪枝核心判断依据就一句话单个验证用试除法简单直接还不用建表批量验证必须上筛法否则数据量稍微上来就扛不住。5. 踩坑实录从答案少了一行到程序一秒才出结果5.1 边界数字的坑1 和 2最容易被忽略的是数字 1。isPrime(1) 的 for 循环条件一次都不满足如果没有x 2的保护它会被当成素数整个验证逻辑就崩了。数字 2 则是另一个方向的坑。很多人在筛法初始化时想当然把偶数全部标记为 false想着偶数不可能是素数嘛结果把 2 也误杀了。于是验证到 n 4 时唯一分解4 2 2消失程序输出空结果。如果题目测试用例里恰好有 4你连哪儿错了都找不到。这类问题最好的排查方式就是打表把前 20 个素数打出来一眼就能看到 2 在不在里面。5.2 int 乘法溢出的隐形炸弹i * i n这个写法在 n 接近 2^31 - 1 时会出大问题。i 大约到 50000 的时候i * i就已经超过 int 上限变成负数循环条件直接错乱程序要么死循环要么漏判。所以更稳妥的写法是for (int i 2; i n / i; i) { // 等价于 i * i n但不会溢出 }筛法内层循环里j i * i同样有溢出风险。如果 N 达到十亿级别i * i在 i 到 46340 时刚好接近 int 上限再往上就溢出了。稳妥起见这里可以用 long long或者把 N 限制在 int 安全范围内。5.3 优化前先测量用 chrono 定位真正的瓶颈我以前经常凭直觉优化代码改了半天发现瓶颈根本不在我以为的地方。后来养成的习惯是先加计时器把每段操作分开测量。#include chrono auto start chrono::high_resolution_clock::now(); // 待测代码 auto end chrono::high_resolution_clock::now(); cout chrono::duration_castchrono::milliseconds(end - start).count() ms endl;实测结果经常和直觉相反。比如验证 N 10^6 的区间时筛表只花了几毫秒真正耗时的大头在后面的枚举阶段。如果你不知道这一点大概率会在筛法的细节里白白抠半天而枚举部分反而没做优化。5.4 对照验证怎么确认程序没写错程序写完后我会拿已知结果做对照4 2 28 3 510 3 7 5 5100 的六组分解人工核对一遍。另外再写一个小脚本把试除法和筛法两个版本的输出做 diff随机跑几百个偶数输出完全一致再放心。这个习惯帮我挡掉了很多低级 bug。尤其是当你调整了枚举策略或者退出条件后很容易出现看起来快了但漏了某些分解的情况。没有对照测试这种错误根本发现不了。6. 综合版代码与延伸从一个偶数到全区间验证6.1 一个兼顾可读性和性能的完整实现下面这段代码综合了前面所有的优化筛法预计算、奇偶剪枝、对称性边界、找到一组解即退出。它的用途是验证 [4, N] 区间内所有偶数是否都能分解并输出每个偶数的第一组解。#include iostream #include vector using namespace std; int main() { int N; cin N; vectorchar isPrime(N 1, 1); isPrime[0] isPrime[1] 0; for (int i 2; i N / i; i) { if (isPrime[i]) { for (int j i * i; j N; j i) { isPrime[j] 0; } } } bool allOK true; for (int n 4; n N; n 2) { bool found false; if (isPrime[n - 2]) { cout n 2 n - 2 endl; found true; } else { for (int p 3; p n / 2; p 2) { if (isPrime[p] isPrime[n - p]) { cout n p n - p endl; found true; break; } } } if (!found) { cout 反例: n endl; allOK false; break; } } if (allOK) { cout 验证通过 endl; } return 0; }代码里筛法用i N / i避免溢出用vectorchar而不是vectorbool。每个偶数只输出一组解并 break因为验证的目的是检查存在性不是列出全部分解。如果题目要求输出所有分解把 break 去掉并且把 p 2 的情况也并入主循环即可。6.2 如果题目变成统计分解种数或输出所有分解不同的题目形态改动量其实很小输出 n 的所有分解去掉 breakp 走满到 n/2。统计每个偶数有多少种分解加一个计数器每找到一组解就累加。寻找最小的 p 使 p 和 n - p 都是素数从小到大枚举找到即 break就是默认行为。寻找 p 最大的解从 n/2 往下枚举找到第一个就 break输出结果就是 p 最大的一组。这些变体的核心逻辑都一样唯一要留神的是输出量。所有分解的数量级可能非常大用 cout 一行行刷屏会严重拖慢程序。可以先把结果缓存到一个 string 或 vector 里最后一次性输出或者直接输出到文件。6.3 程序验证的边界为什么我们只能验证不能证明最后想聊聊这个验证的边界。程序跑完 N 10^7输出验证通过完全不代表哥德巴赫猜想被证明了。它只是在有限范围内没找到反例。数学猜想需要的是严格证明而程序能做的是有限枚举这两件事的性质完全不同。写这类题时要注意措辞输出验证通过就没有问题写猜想成立或者已证明就属于过度推断严谨性上站不住脚。我自己写这类代码时会刻意在注释里写清楚本程序仅验证有限范围既是提醒自己也是给看代码的人一个明确的边界。这道题真正有价值的地方不在于那一行验证通过而在于从试除法到筛法、从暴力枚举到剪枝优化的完整路径。你相当于把算法优化的典型过程亲手走了一遍。我每次遇到这种老题目都会再问自己一句如果数据范围再大一个数量级我会怎么改用线性筛去做还是用分段筛这种把基础题往深了想一层的习惯帮我应对过不少真正复杂的数论问题。如果你刚开始学 C建议从第一版试除法写起跑通之后再加上优化那种看着程序一点点变快的感觉本身就是学习算法最有意思的部分。本文还有配套的精品资源点击获取
返回列表