这道题在各类在线判题平台的 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和PI=3.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×eps:eps 取 1e-4 时,偏差是 0.0004,而输出要求保留 4 位小数,0.0004 正好是最后一位的一个单位——足以让 3.1412 变成 3.1416,判题系统当然判你错。
3.2 用一个能手算的 eps 把差异看清楚
光看代码不容易建立直觉,我建议拿一个很大的 eps 手动跑一遍。设 eps = 0.15,项序列是:
| 轮次 | 分母 | 符号 | 项的值 | 绝对值 ≥ 0.15? |
|---|---|---|---|---|
| 1 | 1 | + | +1.000000 | 是 |
| 2 | 3 | - | -0.333333 | 是 |
| 3 | 5 | + | +0.200000 | 是 |
| 4 | 7 | - | -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 时得到 1,n 大于 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-2 | 50 | 几十次 |
| 1e-4 | 5,000 | 几千次 |
| 1e-6 | 500,000 | 几十万次 |
| 1e-8 | 50,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 表示成几个反正切项的组合,每一项的收敛速度都是指数级的,几十项就能达到十几位精度。
不过就这道题而言,没必要舍近求远。它的训练目标是循环控制、浮点类型和边界判断,不是数值计算本身。把格里高利级数写对、写清楚、边界搞明白,这道题的价值就已经拿到了。等你以后真的需要高精度常数,直接查表或者用库函数远比现算靠谱。
最后分享一个我自己做题时的小习惯:凡是这种“直到满足某条件为止”的循环题,我会在草稿纸上先画一张表,列出前三四轮的分母、符号、项值和累加和,然后对着表写代码。表画完,循环的初值、更新顺序、终止条件基本就定死了,写出来的代码一遍过的概率比直接敲要高得多。这道题用这个方法,前四行表格画完,临界项归属的问题也就自然浮现出来了,根本不用等到提交之后才发现偏差。