
这道题在各类在线判题平台的 C 语言入门题单里出现频率极高题干往往就两三行用格里高利公式求 π 的近似值给定精度 eps当最后一项的绝对值小于 eps 时停止累加。公式本身不难记π/4 1 - 1/3 1/5 - 1/7 …交替加减奇数分之一。可就是这个“看起来一个 while 循环就能收工”的题目几乎每一批初学者里都有人卡住而且卡的方式高度相似——不是不会写循环也不是不会敲公式而是压根不知道自己错在哪一位。我前后帮人看过十几次这道题印象最深的一次是对方把代码贴过来我编译运行输出 3.1416而题目样例要 3.1412他硬是改了三天变量名把 sum 改成 s把 n 改成 i结果当然毫无变化。问题出在临界项加不加这件事上跟变量名一个字符的关系都没有。还有个更典型的例子有人把1.0 / (2 * n - 1)写成了1 / (2 * n - 1)程序只输出 4.0他还以为是编译器出了问题。这篇我按判题反馈、循环骨架、边界归属、隐蔽写法、性能与封装这个顺序把这道题彻底拆一遍。不管你是第一次写、卡在最后一位小数上还是已经通过但想弄明白为什么这么写照着顺序走一遍基本不会再在这道题上丢分。1. 判题反馈拆解三类提示对应三套排查路径在线判题系统给出的反馈通常就几句话答案错误、格式错误、运行超时。很多人一看到“答案错误”就往公式上找反复核对 π/4 1 - 1/3 1/5 - … 有没有抄错。实际上这道题的公式几乎没有写错的空间真正出问题的是数据的组织方式和边界的处理方式。1.1 答案错误先看偏差量级再看偏差方向同样是“答案错误”结果偏差的大小基本能锁定原因区间。我在帮人排查时习惯先问一句你跑出来多少跟样例差多少这一步比看代码快得多。偏差表现最可能的原因优先检查只差最后一位小数如 3.1416 对 3.1412临界项加不加、eps 的实际类型循环停止的时点结果是一个整数或只有 4.0整数除法把每一项都截断成 0除法两边的类型结果明显偏大或偏小但量级对符号没有翻转或者分母没有递推循环体内两行更新结果固定不变无论输入多少循环条件写反或提前退出while 的判断表达式程序不结束eps 为 0 或条件恒真自测输入偏差只落在最后一位是这道题最折磨人的情况。因为代码在语法上完全正确逻辑上也“看起来对”编译没有任何警告运行也不崩溃就是判不过。这类问题百分之百出在一边你对“最后一项”的理解和出题人的理解差了半拍。后面第 3 节会专门拆这一点。1.2 格式错误与运行超时两个被低估的信号格式错误反而是最好解决的因为它的成因非常单一输出和标准答案逐字符比较对不上。PI 3.1416和PI3.1416差两个空格pi 3.1416差大小写末尾多一个换行在部分系统上也可能被判错。还有一种隐蔽情况题目要求输出%.4f你顺手写了%.5f多出来的那一位直接导致不匹配。这类问题的处理方式很土但很有效——把样例输出复制到记事本里逐字符和自己的 printf 格式串对一遍包括等号两边有没有空格。运行超时在这道题上不算常见但一旦出现就很有代表性。最常见的原因是循环体里调用了pow(-1, n - 1)来生成符号。pow是通用幂函数内部要走浮点运算和分支判断单次调用开销是普通乘法的几十倍。题目在 eps 取 1e-6 时需要五十万次循环每次一次 pow累计开销足以让程序从几十毫秒涨到几秒卡在时限边缘。符号翻转明明一个sign -sign;就能解决没必要动用 pow。另一个会导致超时的原因是 eps 取得过小这一点跟算法本身无关而是格里高利级数的收敛速度决定的第 5 节会给出项数估算公式。2. 循环骨架分母、符号、通项三件事各归各位把级数翻译成代码本质上是把数学上的通项 aₙ (-1)ⁿ⁻¹ / (2n-1) 拆成三个可以独立维护的量。拆得好循环体只有三行拆不好就会写出又长又容易错的表达式。我的习惯是分母单独用一个整型变量递推符号单独用一个浮点变量翻转通项每次现算。2.1 分母从 1 开始每轮加 2分母序列是 1, 3, 5, 7, 9…这是一个公差为 2 的等差数列。最直观的写法是每轮用2 * n - 1现算n 从 1 开始递增。这种写法没错但多了一次乘法和一次减法而且 n 的语义和分母的语义混在一起读代码时要在脑子里做一次换算。更清爽的做法是直接维护分母int den 1; ... den 2;好处是语义直白den就是当前项的分母不需要任何心算。而且整数加法比乘法快虽然在现代机器上这点差异可以忽略但可读性的收益是实打实的。代价是要多一个变量不过这道题的变量本来就不多完全放得下。需要提醒的是den用 int 就够。eps 取到 1e-8 时分母最大约 1e8仍在 32 位 int 的范围内约 21 亿。真要把 eps 压到 1e-10分母会逼近 1e10这时候 int 会溢出成负数除法结果完全错乱——这种极端情况虽然题目不会给但自己玩的时候容易踩。2.2 符号翻转一个变量比一个函数便宜得多正负交替是这道题唯一需要一点技巧的地方。常见的三种写法用if (n % 2 1)判断奇偶决定正负用pow(-1, n - 1)直接算用一个double sign 1.0;每轮sign -sign;。第一种写法要看 n 的起始值起手是正是负容易搞反而且每次循环多一次取模。第二种写法前面说过性能代价大。第三种是我一直用的初值 1.0每轮翻转一次逻辑上等价于乘 -1一次浮点取负操作几乎没有成本。用sign还有一个额外好处它和分母组合成项的时候可以写成term sign / den;因为sign是 double整个除法自动按浮点算天然避开了整数除法的坑。这一行同时解决了符号和类型两个问题是我认为这道题里最划算的一行代码。2.3 通项的表达顺序先算项再累加再更新循环体里三件事的先后顺序很容易写乱。我的固定套路是term sign / den; /* 先算出当前项 */ sum term; /* 累加 */ den 2; /* 分母推进 */ sign -sign; /* 符号翻转 */这样写的好处是term始终代表“刚刚被加进去的那一项”后面写循环条件时直接拿它做判断语义非常清晰。如果顺序颠倒比如先更新分母再算项就会漏掉第一项或者多算一项而且这种错误在输入较大时看不出明显异常只有在小 eps 下才会暴露。还有一处细节最后输出的是4.0 * sum而不是在循环里每项都乘 4。前者只做一次乘法后者做五十万次虽然结果相同但没有理由多花这个开销。从数值角度说先累加再乘 4 也略微更稳因为累加的中间值始终在 0.78 附近远离浮点数的精度边界。3. 临界项的归属加与不加结果差 4 倍 eps这是整道题最容易翻车的地方也是我在各种问答里见到频率最高的困惑。题干说“当最后一项的绝对值小于 eps 时停止”这句话在自然语言里有歧义那一项到底算不算进总和3.1 两种循环骨架的语义完全不同看两段代码它们只差一个判断时点。先判断、后累加临界项不计入double sum 0.0, term 1.0, sign 1.0; int den 1; while (fabs(term) eps) { sum term; den 2; sign -sign; term sign / den; }先累加、后判断临界项计入double sum 0.0, term 1.0, sign 1.0; int den 1; do { term sign / den; sum term; den 2; sign -sign; } while (fabs(term) eps);第一段里term一旦小于 eps循环不再进入这一项永远加不到 sum 里。第二段里term被加进去之后才做判断所以那个“小于 eps 的项”确实参与了求和。两者的差值恰好是临界项的值而这个值的绝对值小于 eps乘上最后的 4就是小于 4×eps 的偏差。别小看这 4×epseps 取 1e-4 时偏差是 0.0004而输出要求保留 4 位小数0.0004 正好是最后一位的一个单位——足以让 3.1412 变成 3.1416判题系统当然判你错。3.2 用一个能手算的 eps 把差异看清楚光看代码不容易建立直觉我建议拿一个很大的 eps 手动跑一遍。设 eps 0.15项序列是轮次分母符号项的值绝对值 ≥ 0.15111.000000是23--0.333333是350.200000是47--0.142857否第 4 项绝对值 0.142857 小于 0.15按题意应该在这里停下。不计入临界项时sum 1 - 1/3 1/5 0.866667乘 4 得 3.466667。计入临界项时sum 再减去 1/7得 0.723810乘 4 得 2.895238。两个结果差了整整 0.57肉眼可见。用这个小例子跑一遍自己的代码你立刻就能判断出手上的实现属于哪一种。这比盯着样例输出猜要可靠得多因为样例的 eps 通常很小差异被压缩到最后一位看不出来。3.3 以题目样例为准不要以“我觉得”为准两种写法在工程上都说得通具体用哪个唯一的标准是题目给的样例。做法很简单写一个临时版本输入样例里的 eps看输出是否和样例逐字符一致如果不一致换成另一种骨架再试。这个过程最多两分钟比在论坛上发帖等人回复快得多。需要说明的是这两种写法在数学上都是对 π 的近似都不算“错”区别只在于对题面文字的理解。所以别在这上面纠结哪个更“正确”对齐样例才是唯一目标。我在实际做题时养成了一个习惯凡是涉及“直到……为止”的循环题先用两个版本各跑一次样例确认边界语义之后再动别的。4. 四个让程序算出错误结果的隐蔽写法有些错误编译器不会报警运行时也不崩溃但结果就是不对。这类问题最耗时间因为它们看起来“什么都没错”。下面四个是我见过最多的。4.1 整数除法把每一项都压成零term 1 / (2 * n - 1);这行代码在 n 等于 1 时得到 1n 大于 1 时全部得到 0因为两边都是 int做的是截断除法。程序最终输出 4.0看起来像是公式完全没生效。修改方式有三种效果一样1.0 / (2 * n - 1)字面量写成浮点(double)1 / (2 * n - 1)强制转换sign / den让分子本身是 double。我倾向第三种因为它顺手把符号也带上了一行解决两件事。第二种写法最显式适合给代码加注释说明意图。第一种最简短但如果哪天把 1.0 手滑改成 1错误会悄无声息地回来。顺带说一句相关的坑scanf(%f, eps)配 double 变量也是同一类问题。%f期待的是 float 指针传 double 指针进去读取的字节数和解释方式都不对eps 会变成一个莫名其妙的数。double 必须配%lf输入输出都一样这条规则没有例外。4.2 eps 为 0 时的死循环题目一般保证 eps 是正数但自己本地测试时手滑输入 0程序就可能永远不结束。原因在于fabs(term) 0恒为真循环条件永远成立。理论上 term 会随着分母增大不断变小最终在浮点下溢时变成 0此时0 0依然为真循环还是不停。这种死循环在判题系统上表现为运行超时在自己机器上表现为终端卡住。排查时可以先在循环里加一个计数器跑十万次就打印当前 term 和 sum 然后退出一眼就能看出是不是分母没推进或者条件写错。这个技巧在处理任何“疑似死循环”的题目时都好用比单步调试快。4.3 float 和 double 的取舍这道题用 float 能不能过eps 取 1e-4 时可以因为 float 有大约 7 位有效十进制数字累加到 0.78 附近时精度还够。但 eps 一旦小到 1e-6 以下float 的精度就开始吃紧每次加上一个 1e-6 量级的项到一个 0.78 量级的和上低位直接被舍掉累加结果可能停滞不前循环条件判断也会失真。double 有大约 15 到 16 位有效数字应对 1e-8 以内的 eps 完全够用。这道题没有任何理由用 float统一用 double 是最省心的选择。顺带把fabs也用上它对应 double如果变量是 float应该用fabsf混用虽然多数情况下能编译通过但类型转换的隐式行为不值得赌。另外别忘了#include math.h。在老一些的编译环境里用了 math.h 里的函数还需要在编译命令后面加-lm链接数学库否则会报 undefined reference。在线判题系统通常已经配好但本地手动编译时遇到这个报错不要以为是代码写错了。4.4 输出格式要一个字符一个字符地对这道题的标准输出往往是PI 3.1416这种形式等号两边各一个空格字母全大写末尾换行。任何一个字符对不上都是格式错误。我吃过的一次亏是题目样例里写的是PI 我照着敲成了PI本地看着差不多交上去直接格式错误。后来学乖了直接从题面复制那段前缀粘贴到 printf 里不再手打。小数点位数同理题目说保留 4 位就用%.4f不要因为“多一位更精确”就写%.5f判题系统不认精确只认字符。还有一个更细的点%.4f在遇到刚好落在 0.00005 边界上的值时输出结果取决于该值在二进制下的真实大小可能出现“手算该进位、程序没进位”的情况。这道题一般碰不到这么巧的值但如果你的输出和预期差最后一位且怎么改都不对可以往这个方向想一想。5. 通过之后项数估算、函数封装与公式替换代码能过题只是第一步。这道题真正值得琢磨的是它背后的收敛性质——为什么 eps 稍微调小一点程序就跑得明显变慢以及如果以后要在别的场景里算 π这段代码该怎么改造成可复用的形式5.1 项数与 eps 成反比这是级数的固有性质格里高利级数是条件收敛的误差量级和下一项的绝对值相当。粗略估算要让误差小于 eps需要累加的项数大约是 0.5/eps。这个公式很好记也很好用eps预估项数循环量级1e-250几十次1e-45,000几千次1e-6500,000几十万次1e-850,000,000几千万次可以看出eps 每缩小十倍循环次数就翻十倍。这就是为什么 eps 取 1e-8 时程序会明显变慢五千万次循环即使每次只有几条指令总时间也到了几百毫秒的量级放在时限严格的题目里就有风险。理解了这一点你就不会误以为是自己的代码写得低效——是公式本身的收敛速度决定的。也正因为收敛慢累加过程中的浮点误差反而不太需要担心。误差随项数增长是缓慢的在 double 的精度范围内五千万次累加的误差远小于 eps 本身没必要上 Kahan 求和这类补偿技巧。真要用高精度换公式比改求和方式划算得多。5.2 把这段逻辑封装成一个函数如果以后要在别的程序里算 π把主函数里的循环原样复制过去是不合适的。稍微改一下就能得到一个干净的接口#include stdio.h #include math.h /* 用格里高利级数求 pi 的近似值误差小于 eps */ double gregory_pi(double eps) { double sum 0.0; double term 1.0; double sign 1.0; int den 1; while (fabs(term) eps) { sum term; den 2; sign -sign; term sign / den; } return 4.0 * sum; } int main(void) { double eps; if (scanf(%lf, eps) ! 1 || eps 0.0) { return 1; } printf(PI %.4f\n, gregory_pi(eps)); return 0; }封装之后有几个好处。第一主函数只负责输入输出逻辑和界面分开了调试时可以单独测函数。第二输入校验放进了主函数eps 非正数时直接返回避免死循环。第三函数名本身就说明了算法来源以后回头看代码不用再猜。加输入校验这个习惯值得养成。在线判题系统一般保证输入合法但自己写工具时一个 0 或者一个负数就能让程序卡死排查起来很烦。5.3 想要高精度换个收敛更快的公式格里高利级数的优点是形式简单、易于讲解缺点是收敛慢到令人发指。同样求 1e-10 的精度用它需要五百亿次循环任何机器都扛不住。如果实际场景里需要一个高精度的 π常见的替代思路是用收敛更快的展开式比如 Machin 类公式把 π/4 表示成几个反正切项的组合每一项的收敛速度都是指数级的几十项就能达到十几位精度。不过就这道题而言没必要舍近求远。它的训练目标是循环控制、浮点类型和边界判断不是数值计算本身。把格里高利级数写对、写清楚、边界搞明白这道题的价值就已经拿到了。等你以后真的需要高精度常数直接查表或者用库函数远比现算靠谱。最后分享一个我自己做题时的小习惯凡是这种“直到满足某条件为止”的循环题我会在草稿纸上先画一张表列出前三四轮的分母、符号、项值和累加和然后对着表写代码。表画完循环的初值、更新顺序、终止条件基本就定死了写出来的代码一遍过的概率比直接敲要高得多。这道题用这个方法前四行表格画完临界项归属的问题也就自然浮现出来了根本不用等到提交之后才发现偏差。