资讯动态

C语言循环结构求π:避开整数除法与精度陷阱

发布时间:2026/10/15 7:43:47 来源:尧图企业网站定制
刷《算法笔记》循环这一章的练习时有一道题我前前后后改了三次才通过就是 2.4 节问题 G例题5-7 求圆周率pi的近似值。题目本身一句话就能说完用交错级数 π/4 1 - 1/3 1/5 - 1/7 ... 计算圆周率 pi 的近似值直到某一项的绝对值小于 10^-6然后输出保留 6 位小数的结果。听起来就是给循环套个壳但对初学者来说这道题的坑密度高得离谱整数除法、符号翻转、终止条件、输出格式哪一个都能让你对着屏幕怀疑人生。如果你正准备提交这道题或者想搞明白为什么自己写的就是不对这篇记录应该能帮你把每个坑都填平。1. 题目的真实考点从公式到代码的第一次翻译1.1 题干到底在考什么先别急着看代码把题目拆开看。这道题没有输入不需要写任何 scanf 语句所有工作都在一个公式里π/4 1 - 1/3 1/5 - 1/7 ...。翻译成人话就是不断把奇数分之一加起来一会加一会减直到某个分数的绝对值小于 10^-6 时停手最后把这个和乘以 4 就是 π 的近似值。题目背景是《算法笔记》2.4 节这一节主攻循环结构所以核心考点不是数学而是“求和公式”的代码化翻译。每次循环你都需要维护三个状态累加器 pi4、分母 den、符号 sign。循环体内部要做四件事算出当前项 term、把 term 累加进 pi4、把 sign 取反、把 den 加 2。很多同学死记模板却不知道为什么要做后两步——符号取反是为了实现“一正一负”分母加 2 是为了拿到“下一个奇数”。把这题和同一章其他练习比较一下会发现它不考输入解析不考数组也不考复杂算法纯粹聚焦“循环 浮点数”。但这种小题目恰恰最能暴露基本功问题你是真的理解了循环控制流还是只会照着模板敲。我见过太多人 for 循环写得飞起一遇到 while(true) break 这种组合就发怵这题正好补上这一课。1.2 为什么偏偏选这个公式教材选莱布尼茨级数不是因为它算得快而是因为它足够简单。每一项只是一个整数分之一没有阶乘、没有幂运算新手也能直接写出来。想想看如果换成 sinx 的泰勒展开或者连乘公式光处理阶乘和幂就能劝退一半人。选它当习题就是想把“循环语法”和“复杂数学”解耦让你专心看控制流。从数学角度说这个公式是反正切函数在 x1 处泰勒展开的结果属于交错级数判断收敛很容易后一项的绝对值比前一项小而且一正一负相互抵消。这带来两个直接后果第一级数本身一定收敛不会出现加着加着就炸掉的情况第二截断误差大约等于第一个被丢弃项的绝对值所以想要 10^-6 级别的精度就需要分母跑到 10^6 级别累计约五十万次循环。这个收敛速度在工程上根本没法用但在教学上却是宝贝。五十万次循环对计算机就是一瞬间的事既能让初学者感受到“计算机擅长重复劳动”又不会因为运行时间太长让人失去耐心。可以说这道题是“以数学为壳、考循环语法”的标准模板理解这一点做题时才会把重点放在代码细节上而不是死磕级数公式本身。2. 实现思路与三个关键细节符号、终止、类型2.1 符号交替的三种写法核心逻辑写出来很短但符号处理有三种思路取舍完全不一样。第一种int sign 1然后 sum sign / den最后 sign -sign。这是最经典的错误写法。原因在于 C 语言里两个整数做除法结果还是整数当 den 大于 1 时sign / den 的结果恒为 0。也就是说除了第一项 1/1 1其他项全部丢失最后输出 4 * 1 4完美错过正确答案。这个坑我在评论区见过无数次。第二种int sign 1然后 sum 1.0 * sign / den。这里用 1.0 做乘法强制把表达式提升为浮点运算结果对了。sign 还是整数取反逻辑清晰只是需要记得乘 1.0 这步。如果你喜欢显式控制类型可以选这种。第三种是我现在最喜欢的写法double sign 1.0。直接把符号变量定义成浮点数sum sign / den 天然就是浮点除法没有任何隐式转换的烦恼。取反仍然写 sign -sign。这段代码干净、直观新手照着写也不容易出错。还有同学会用 pow(-1, n1) 来生成符号虽然数学上没毛病但每轮循环都调用一次 pow开销大不说可读性也差。一个循环求和的题目没必要引入数学库函数去做符号除非你在写那种符号累计特别复杂的题目否则直接用变量翻转是更清爽的方案。2.2 终止条件差一个等号结果差一个量级题目原文写的是“直到某一项的绝对值小于 10^-6”。注意这包含两层意思第一判断的是“当前这一项的绝对值”不是累加和第二小于阈值的这一项本身不应该加入累加和。很多人栽在第二层理解上。对应到代码最贴合题意的写法是这样的进入循环后先算出当前项 term判断 fabs(term) 1e-6 是否成立成立就 break不成立才累加。换句话说循环要继续的条件是“当前项还没小到阈值以下”也就是 fabs(term) 1e-6。如果你用 do-while 先累加再判断那么最后那个绝对值已经小于阈值的项也会被加进去末位小数通常就会差出那么一两个数字。为什么强调这个细节因为交错级数的截断误差大约就是第一个被丢弃项的大小。你多丢一项或者少丢一项误差的符号和大小都会变反映在输出上就是 3.141591 和 3.141592 的区别。OJ 如果按样例字符串比对这一个数字就能让你卡题。有些人还会在这里犯一个低级错误把 fabs 写成 abs。abs 针对 intfabs 针对 double如果 term 是浮点数却用了 abs有的编译器会隐式截断成整数结果变成 0整个判断就废了。老老实实写 fabs从 math.h 引入这没什么商量余地。2.3 精度与输出乘 4 这件事不能忘公式给出的是 π/4输出的却是 π。于是“忘了乘 4”成为这道题出镜率第二高的错误。避免这个坑有个小技巧把累加变量直接命名为 pi4 而不是 sum。变量名一旦暗示了“这是四分之一π”看到 printf 里的 4 * pi4 就不会觉得突兀。输出格式固定是保留 6 位小数printf(%.6f\n, 4 * pi4) 一行搞定。很多人喜欢用 %lf 输出 double虽然现代编译器也能处理但规范写法就是 %fprintf 对 float 会自动提升为 double不需要你操这个心。再说精度。建议全程用 double不要用 float。float 有效位数只有 7 位左右这个任务要累加五十万次中途舍入误差会累计到末位导致输出不稳定。double 的有效位数约 15 位应付这种精度绰绰有余。我在本地用 double 跑eps 取 1e-6break 版本输出约是 3.141591如果改成先加后判结果会跳到 3.141592 或 3.141593。这说明终止时机对末位的影响是真实存在的。3. 两版可提交代码从 while 到 do-while逐行拆解3.1 推荐解法while break严格贴合题意我最推荐下面这个版本原因只有一个它严格按“这一项小于阈值就不算”的语义执行。#include stdio.h #include math.h int main() { double pi4 0.0; double sign 1.0; int den 1; double term; while (1) { term sign / den; if (fabs(term) 1e-6) { break; } pi4 term; sign -sign; den 2; } printf(%.6f\n, 4 * pi4); return 0; }逐块解释。pi4 初始化为 0.0sign 初始化为 1.0den 初始化为 1这个组合代数意义上就是第一项 1/1。进入循环后首先算 term这一项在计算时已经用到了当前 sign 和当前 den算完先别急着加要过一遍阈值判断。如果 term 绝对值小于 1e-6说明题目要求停手的时候到了直接 break 跳出循环。这里 break 会让整个 while(1) 结束程序继续往下走执行输出语句。如果 term 还是“够大”的那就累加进 pi4翻转符号分母加 2继续下一轮。为什么用 while(1) 而不是 do-while因为 do-while 至少会执行一次循环体你没法在“计算 term 之后、累加之前”这个精确时机跳出。while(1) break 则可以在任意位置退出语义上和中文表述“直到……”完美对应。我第一次提交用的就是 do-while 版本结果因为多加了末尾那一项末位一直对不上样例后来改成这个版本就一路通畅了。如果你想验证累加次数可以在循环里加一个计数器跑完会发现循环体实际执行了大约 50 万次。den 从 1 开始一直跳到 1000000 附近才停这个数字和之前分析的收敛速度对上了。3.2 另一种常见写法do-while 先加后判下面是很多人会第一时间想到的 do-while 版本看起来也符合“直到”的语气但执行逻辑有细微差别#include stdio.h #include math.h int main() { double pi4 0.0; double sign 1.0; int den 1; double term; do { term sign / den; pi4 term; sign -sign; den 2; } while (fabs(term) 1e-6); printf(%.6f\n, 4 * pi4); return 0; }这段代码的问题在于判断发生在累加之后。假设当前 term 正好小于 1e-6循环体还是会把这一项加进去然后才靠 while 条件发现已经不需要继续了。换句话说它把“第一个小于阈值的项”也算进了求和结果。从这个角度说它和题意的符合度略差但由于这一项本身绝对值只有约 1e-6对保留 6 位小数的输出影响只可能在最后一位很多 OJ 的数据甚至可能不区分这两种写法。那为什么还有人用它因为它结构简单不用考虑 break 的位置。如果你在自己刷题或做实验不追求和样例精确一致用 do-while 完全没问题。但如果你明确看到样例输出是 3.141591而你的 do-while 版本输出 3.141592不用怀疑别的就差在末尾这项上。这时候果断换成 while break 版本基本一次就过。3.3 调试手法如何快速确认算法没有跑偏这类数值题肉眼很难看出结果对不对。我的做法是先打印前几项手算验证。第一轮 term 应为 1第二轮应为 -0.333333第三轮应为 0.2第四轮应为 -0.142857。如果前几项就和这个序列对不上多半是符号翻转写错或者整数除法捣乱。然后可以临时把 eps 调成 1e-2跑一个粗糙结果。比如 4 * (1 - 0.333 0.2) 大约等于 3.466这个明显离 π 近但不精确的值能告诉你整体框架没问题。确认框架正确后再把 eps 调回 1e-6。我还会加一个循环计数变量提交前删掉。如果计数结果不是大约 50 万次而是几百万次或者只有几十次那就要检查终止条件是不是写反了。比如有人把 while(fabs(term) 1e-6) 写成 while(fabs(term) 1e-6)第一次判断 term1 不小于阈值条件为假循环体一次都不执行输出 0程序倒是不会死但结果完全错误。也有人把分母递增忘写term 永远 1程序会无限循环这时计数变量能救你一命——设置一个最大循环次数超过就打印错误并退出。4. 常见问题与排查技巧我在这道题上踩过的坑4.1 输出 4.000000、1.000000、0.785398逐个定位这三个输出各有各的原因我按概率排序。输出 4.000000几乎一定是在累加时用了整数除法。比如 sum sign / densign 和 den 都是 int第二项开始全部等于 0只剩第一项 1最后乘 4 就是 4。解决办法是把 sign 定义成 double或者写成 1.0 * sign / den。输出 0.785398说明累加本身是对的算出来的是 π/4 的近似值但最后输出时忘了乘以 4。这个错误很隐蔽因为 0.785398 仍然是 6 位小数形式上完全符合输出要求只有数值不对。建议把求和变量命名成 pi4形成条件反射。输出 1.000000我见过的场景是有人把 pi4 初始值误设为 1或者在循环外提前给 pi4 赋了一个非零值。还有一种可能是 for 循环条件写错循环体没执行初始化值直接被输出。排查方法很简单打印前几项 term如果第一项就不是 1说明初始状态有问题。这几个问题的共同点都是“程序能跑、格式好看、数值离谱”。与其盯着屏幕发愣不如加一行 printf(term%.6f\n, term)真相会立刻浮出水面。4.2 程序卡死或运行时间异常如果本地跑起来后光标一直闪不退出多半是进入死循环。最常见的原因有两个分母 den 忘记自增或者符号 sign 忘记翻转。前者会让 term 永远等于同一个值循环条件永远成立后者虽然不至于死循环但累计的错误方向会让结果偏离很远而且由于不是交错级数收敛判定会变得很奇怪。死循环的另一个隐蔽来源是精度问题。有人把判断条件写成了 while(fabs(term) 1e-6)但 term 是用 float 算的。float 有效位数有限累加到后期舍入误差会被放大可能出现本该低于阈值的项因为浮点误差仍然略高于阈值导致多跑很多轮。这种问题在本地可能不明显提交到 OJ 后因为环境不同偶尔会出现超时或答案错误。老老实实用 double 能消掉大部分这类问题。调试死循环最有效的办法是设置“安全阀”。比如 int cnt 0; while (1) { cnt; if (cnt 1000000) { printf(loop error\n); break; } ... }本地跑一遍如果触发错误提示立刻检查 den 和 sign 的更新逻辑。提交前把安全阀删掉留着自己思考也行。4.3 末位小数总是差了 1 或 2 怎么办这是最让人抓狂的逻辑看起来全对输出也是 3.141590 附近但就是和样例对不上。这个问题九成出在终止条件的时机。先做一件小事把输出格式临时改成 printf(%.10f\n, 4 * pi4)看更多位有效数字。如果多出来的位数显示你的级数停得太早或太晚说明阈值判断的位置不对。具体来说如果你用的是 do-while 先加后判结果通常会比 break 版本多一项反映在最后一位上一般是大一号或小一号。还有一个容易忽略的点eps 的值。题目说的是 1e-6 就写 1e-6不要写成 1e-5也不要写成 0.0000001。有人为了保险想提高精度把阈值改成 1e-7结果虽然理论上更接近真实值但和题目要求的截断方式不同样例比对反而可能失败。以题意为准不要自我发挥。如果两种写法都试了还是对不上那就要怀疑编译环境的浮点舍入了。不同平台对 double 的中间舍入处理不完全一致这种差异通常只影响最后一位。OJ 如果是特殊判题会允许微小误差如果是字符串比对那只能尽量复现出样例的数据。就这道题而言我自己的经验是 break 版本更容易命中样例优先尝试它。5. 从这道题想开去收敛速度与更实用的 π 算法5.1 莱布尼茨级数到底有多慢做这题的过程里最让我震惊的不是算法本身而是它的收敛速度。为了把误差压到 1e-6程序要跑大约五十万次循环。这是什么概念如果我想让结果精确到 1e-9项数就要扩大到 5 亿普通 OJ 必然超时继续加码到 1e-12任何桌面级程序都会让人等到怀疑人生。这种收敛速度在工程计算里基本不可用。你可以想象成用一把刻度为毫米的尺子去量纳米级的薄膜厚度——理论上可以无限靠近但实际上又慢又折磨。莱布尼茨级数作为数学表达式简洁、对称、优美作为工程计算工具却非常糟糕。但话说回来它作为教学材料真的很合适。五十万次循环让初学者切身感受到“计算机擅长海量重复劳动”又不会真的让人等到崩溃。如果换成弧长积分或者蒙特卡洛方法就会引入随机数或者数值积分等更复杂的主题抢了循环结构本身的风头。所以教材选这个公式是有道理的但你不能把它带进生产环境。5.2 一个快速估 π 的实用方向换个展开点既然问题出在级数衰减太慢那解决思路也很直接找收敛更快的展开式。莱布尼茨公式本质是 arctan(1) 的泰勒展开x1 离展开中心太远每一项衰减慢得令人发指。如果改用更小的 x比如 arctan(1/5) 和 arctan(1/239)配合下面的恒等式就能用极少的项换回高精度。π 16 · arctan(1/5) - 4 · arctan(1/239)这就是历史上很出名的 Machin 公式它在 17 世纪被用于计算 π 到小数点后一百多位。用泰勒级数分别求两个反正切每一项只需要做乘法和除法收敛速度比莱布尼茨级数快了好几个数量级。示范代码大概长这样#include stdio.h #include math.h double atan_series(double x) { double sum 0.0; double term x; int k 0; while (fabs(term) 1e-15) { sum term; term * -x * x * (2.0 * k 1) / (2.0 * k 3); k; } return sum; } int main() { double pi 16.0 * atan_series(1.0 / 5.0) - 4.0 * atan_series(1.0 / 239.0); printf(%.15f\n, pi); return 0; }这个版本里 atan_series 用递推的方式生成泰勒级数的下一项迭代几十次就能把 π 算到十几位小数。如果把循环条件改成 fabs(term) 1e-17甚至能跑到 double 能表示的极限精度。这是我在刷完原题后顺手做的对照实验感受特别直观。当然我不建议为了过这一道填空题去研究 Machin 公式。但当你理解了原题的慢再看更快的公式就很容易明白“选对算法”比“优化循环”重要得多。这也是刷题最有价值的副产品之一。5.3 这类题对后续学习的真正意义简单题的价值不是过题而是踩坑。如果这道题你一次就过反而建议故意改错几处比如把整数除法改出来把分母递增删掉把 eps 调错一位然后观察输出会发生什么变化。把这些反例跑一遍你对循环和浮点的理解会比听十遍课都深。之后学到函数时可以把这题的求和过程封装成一个函数 double pi_series(double eps)传入不同阈值就能复用。这个封装过程本身也是练习还能顺便体会“参数化”给代码带来的灵活性。往后无论学数组还是学结构体遇到类似的级数求和题目你都会条件反射地思考终止条件的位置和浮点类型的问题而不是一头扎进代码里瞎调。做完这道题之后我给自己定了个规矩凡是浮点除法先检查参与运算的变量有没有整型凡是遇到“直到”这种描述先问自己最后一项到底算不算。这两个习惯帮我避掉了后面很多相似题目的坑。如果你正在 2.4 节里挣扎别急着看下一题先把 4 * pi4 和终止项的先后顺序搞明白这道题就算没白刷。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑