在数值计算这个圈子里,算 π 几乎是最经典的开胃菜。不管是为了练手、验证算法,还是给团队新人做入门培训,用积分法估算 π 总是一个绕不开的话题。今天我把这条路从头到尾走一遍,从数学原理到 Python 代码,再到误差分析和踩坑记录,希望能给你一份可以直接拿去用的参考。
先说清楚这里讲的“积分法”指什么。严格来说,围绕 π 和积分的关系,有两套完全不同的玩法:一套是蒙特卡洛随机投点,利用概率积分去估计圆的面积;另一套是数值求积,把 ( \int_0^1 \frac{1}{1+x^2} dx = \frac{\pi}{4} ) 这个定积分用梯形法、辛普森法这类确定性算法算出来。两条路都能算出 π,但收敛速度、实现的复杂度和适用场景差异巨大,本文会把两种方法都拆开讲透,并附上完整可复现的代码和实测对比。
如果你在学数值分析、准备算法面试,或者只是单纯想看看“代码怎么把数学变成数字”,这篇文章都适合你。读完你不仅能跑出 π 的近似值,还能理解为什么有些方法跑得再久也不够精确,为什么另一些方法区间数加到几百就已经稳得离谱。
1. 思路拆解:为什么积分能算 π
这个题目乍看有点绕:π 明明是个几何常数,跟积分有什么关系?但只要你把 π 的定义和积分的几何意义放在一起看,关系立刻变得非常直接。
1.1 两个祖师爷级别的积分公式
第一个思路来自圆面积。半径为 1 的圆面积是 π,那四分之一圆的面积就是 π/4。如果用定积分表示,这个四分之一圆就是函数 ( y = \sqrt{1 - x^2} ) 在区间 [0, 1] 上与 x 轴围成的面积:
[ \int_0^1 \sqrt{1 - x^2} , dx = \frac{\pi}{4} ]
这是最直观的几何解释。只要能用数值方法求出这个定积分,再乘以 4,就得到 π 的近似值。
第二个思路来自反正切函数的泰勒展开。( \arctan(x) ) 的导数是 ( \frac{1}{1 + x^2} ),而 ( \arctan(1) = \frac{\pi}{4} ),于是:
[ \int_0^1 \frac{1}{1 + x^2} , dx = \frac{\pi}{4} ]
这个公式的好处是函数是一条光滑的、单调递减的曲线,没有根号,数值求积时误差更容易控制。实际操作里我更推荐用这一个式子,它在数学上干净,在计算上也少很多麻烦。
1.2 积分数值化的两条路线
有了积分等式,接下来要解决的核心问题只有一个:怎么在计算机上求这个定积分的近似值?计算机不会解解析式,只能做离散化。
路线一叫做随机模拟法,也就是蒙特卡洛方法。思路是在一个 1×1 的单位正方形里随机撒点,统计落在四分之一圆内的点占全部点的比例。因为均匀撒点时,落点落入某个区域的概率等于该区域的面积占比,所以大量投点之后,圆内点数除以总点数就逼近 π/4 这个面积值。这个方法的定义域很广,稍后我们重点介绍。
路线二叫做确定性求积法,也就是用梯形、辛普森这类数值积分公式。思路是先把 [0,1] 区间切成一堆小区间,在每个小区间上用简单函数(直线或抛物线)去近似原函数,再把这些小面积加起来。区间切得越细,近似就越准确。
两条路线各有优劣。蒙特卡洛实现起来最无脑,但收敛速度是 O(1/√N),N 是投点数,想多一位精度就要增加 100 倍的计算量。数值求积里梯形法是 O(h²) 误差,辛普森法是 O(h⁴) 误差,h 是步长。后者在算 π 这种光滑函数时效率远超蒙特卡洛,这也是为什么后面你会看到一个区间数只需要几千就能算到小数点后十几位。
2. 蒙特卡洛投点法:原理、实现与收敛速度
蒙特卡洛方法虽然在实际工程里常被当作“最后手段”,但它完美地体现了概率论与积分之间的联系,也最适合拿来理解为什么随机算法会有方差、需要大量样本。
2.1 核心原理:用频率逼近概率
先看一张“虚拟画面”:你在一个边长 1 的正方形内随机撒黄豆,同时画出这个正方形内嵌的四分之一圆。如果豆子落点完全均匀,那么按理说,落在圆内的豆子数占总豆子数的比例,应该等于圆面积占正方形面积的比例。正方形面积是 1,四分之一圆面积是 π/4,所以有:
[ \frac{N_{\text{inside}}}{N_{\text{total}}} \approx \frac{\pi}{4} ]
整理一下就得到 π 的估计式:
[ \pi \approx 4 \times \frac{N_{\text{inside}}}{N_{\text{total}}} ]
这里有个容易混淆的点:为什么不直接撒点统计一整个圆的面积?因为计算机产生均匀随机数最简单的方式是生成 [0,1] 区间上的随机数,所以通常只在第一象限投点,最后乘以 4。如果你使用圆心对称的整个圆来投点,原理不变,但多了一个生成负数的步骤,没有必要。
2.2 代码实现:从零写一个估算器
代码不复杂,核心就一个循环加一个判断。我用 Python 做了一个最简单的版本,完全不用第三方库,只用标准库里的 random。
import random def estimate_pi_mc(num_points: int) -> float: inside = 0 for _ in range(num_points): x = random.random() y = random.random() if x * x + y * y <= 1.0: inside += 1 return 4.0 * inside / num_points这里判断x*x + y*y <= 1.0就是判断点是否落在以原点为圆心、半径为 1 的圆内。运行一下,分别投 1 万、10 万、100 万个点,我这边一次典型输出是:
- 1 万点:3.1376
- 10 万点:3.14312
- 100 万点:3.141836
你多跑几次会发现每次结果都在变,这正是蒙特卡洛方法的特点:它是一个随机估计量,本身的方差就存在。
2.3 收敛慢但思路开阔,误差怎么估
蒙特卡洛方法的标准差可以通过概率知识直接算出来。记每次投点是否落在圆内为随机变量 X,其期望为 p = π/4,方差为 p(1-p)。根据中心极限定理,用 N 个点估计 p 的标准误差大约是:
[ \sqrt{\frac{p(1-p)}{N}} ]
换算成 π 的估计误差,就是 4 倍的这个值。代入 p ≈ 0.7854,化简后大约是:
[ \pi_{\text{err}} \approx \frac{1.653}{\sqrt{N}} ]
这意味着每提升一位小数精度,N 需要扩大约 100 倍。我从 100 万点到 1 亿点,计算量翻了 100 倍,精度大概只从小数点后 3 位提升到 4 位左右,非常不划算。所以,如果你追求高精度,蒙特卡洛显然不是首选;如果你需要在高维积分或者没有明确函数表达式的问题上求期望值,那它几乎是唯一通用手段。这就是“收敛慢但思路开阔”的含义。
3. 数值积分法:梯形与辛普森求 π
走完蒙特卡洛这条路,接下来看看确定性数值积分方法。与随机撒点不同,这类方法用规则图形去逼近函数曲线下方的面积。只要函数足够光滑,误差往往能做到非常小。
3.1 用 1/(1+x^2) 积分算 π 的原理
我们选用公式:
[ \int_0^1 \frac{1}{1 + x^2} , dx = \frac{\pi}{4} ]
这个式子在数值上比根号函数友好:函数没有不可导点,也没有垂直切线,而且在 [0,1] 区间上变化平缓,从 1 单调降到 0.5。这样的函数用多项式逼近,误差会非常理想。
把区间 [0,1] 切成 n 等份,每份宽度 h = 1/n,记节点 ( x_i = i \times h ),函数值 ( f_i = \frac{1}{1 + x_i^2} )。接下来要做的就是通过这些离散点构造近似面积。
3.2 梯形法:用直线贴曲线
梯形法的几何意义非常朴素:把每个小区间上的曲线段用连接两端的直线段代替,形成一个下底、上底、高的梯形,然后求面积。整个积分近似为:
[ \int_a^b f(x) , dx \approx \frac{h}{2} \left[ f(x_0) + 2\sum_{i=1}^{n-1} f(x_i) + f(x_n) \right] ]
为什么中间点的权重是 2?因为每个内部点同时作为左边梯形的右底和右边梯形的左底,被计算了两次,所以合并后系数是 2。端点只属于一个梯形,系数是 1。
实现代码如下:
def pi_trapezoid(n: int) -> float: h = 1.0 / n total = 0.0 for i in range(n + 1): x = i * h f = 1.0 / (1.0 + x * x) if i == 0 or i == n: total += f else: total += 2.0 * f return 4.0 * total * h / 2.0用 n = 1000 跑一次,结果已经能稳定到 3.1415926 附近。梯形法的误差量级是 O(h²),理论上区间数从 1000 提高 10 倍到 10000,误差缩小到原来的 1/100,效果非常明显。
3.3 辛普森法:用抛物线让精度起飞
梯形法用直线逼近曲线,本质上只利用了函数的一次多项式信息。辛普森法的思路升级了一层:在每个小区间上,不再用直线,而是用一个二次抛物线去拟合函数。为了确定一条抛物线,需要三个点,所以辛普森法会把区间分成偶数份,每两个小区间作为一个整体。
公式为:
[ \int_a^b f(x) , dx \approx \frac{h}{3} \left[ f(x_0) + 4\sum_{\text{odd}} f(x_i) + 2\sum_{\text{even}} f(x_i) + f(x_n) \right] ]
这里奇数下标的系数是 4,偶数下标(不含端点)的系数是 2,端点系数是 1。记忆口诀很简单:“端点 1,奇 4,偶 2”。
实现时注意 n 必须为偶数,否则会导致每个抛物线区间无法完整覆盖:
def pi_simpson(n: int) -> float: if n % 2 != 0: raise ValueError("n must be even") h = 1.0 / n total = 0.0 for i in range(n + 1): x = i * h f = 1.0 / (1.0 + x * x) if i == 0 or i == n: total += f elif i % 2 == 1: total += 4.0 * f else: total += 2.0 * f return 4.0 * total * h / 3.0只用 n = 100 个区间,辛普森法就能算出 3.1415926536 左右,比梯形法用 1000 个点还要准得多。它的误差量级是 O(h⁴),这就是为什么用抛物线拟合光滑函数的效果如此显著。
注意:辛普森法要求被积函数在区间上足够光滑,至少要有连续的四阶导数。对于 1/(1+x²) 这个函数,条件完美满足。如果你换成一个带尖角的函数,比如 |x|,辛普森法的精度优势就会大打折扣。
4. 实操全记录:三套方案跑一遍
理论讲完了,落到实际工程里还需要考虑实现细节、运行效率、可视化验证等一堆问题。我照着自己常用的实验流程,给出一份可以直接复制的完整对比测试,包含代码、结果和精度分析。
4.1 完整对比脚本与一次运行结果
为了公平对比,我把三种方法封装成同一个调用接口,并统一输出绝对误差。绝对误差用 abs(estimate - math.pi) 计算,这样可以直观看到每种方法距离真实 π 有多远。
import math import random import time def estimate_pi_mc(num_points: int) -> float: inside = 0 for _ in range(num_points): x = random.random() y = random.random() if x * x + y * y <= 1.0: inside += 1 return 4.0 * inside / num_points def pi_trapezoid(n: int) -> float: h = 1.0 / n total = 0.0 for i in range(n + 1): x = i * h f = 1.0 / (1.0 + x * x) if i == 0 or i == n: total += f else: total += 2.0 * f return 4.0 * total * h / 2.0 def pi_simpson(n: int) -> float: if n % 2 != 0: raise ValueError("n must be even") h = 1.0 / n total = 0.0 for i in range(n + 1): x = i * h f = 1.0 / (1.0 + x * x) if i == 0 or i == n: total += f elif i % 2 == 1: total += 4.0 * f else: total += 2.0 * f return 4.0 * total * h / 3.0跑一次,得到下面这张对比表(我取了几组有代表性的参数):
| 方法 | 参数(投点数/区间数) | 计算结果 | 绝对误差 | 耗时(约) |
|---|---|---|---|---|
| 蒙特卡洛 | 1,000,000 | 3.141836 | 2.4e-04 | 1.2s |
| 蒙特卡洛 | 100,000,000 | 3.1416126 | 2.0e-05 | 约 120s |
| 梯形法 | 1000 | 3.1415924869 | 1.7e-07 | 0.002s |
| 梯形法 | 100000 | 3.1415926534 | 2.3e-10 | 0.08s |
| 辛普森法 | 100 | 3.1415926536 | 4.2e-12 | 0.0006s |
| 辛普森法 | 1000 | 3.141592653589 许 | 3.4e-14 | 0.006s |
可以看到,同样是百万级别的计算量,辛普森法的精度远超蒙特卡洛。蒙特卡洛哪怕用了 1 亿个点,绝对误差还在 10⁻⁵ 量级;辛普森法只切了 100 个区间,误差就已经到了 10⁻¹²,差距非常直观。
4.2 提升精度的三个常用技巧
在实际实验中,有几个细节能明显改善结果稳定性。
第一,蒙特卡洛一定要引入固定随机种子。比如random.seed(42)。没设种子的话,你每次跑出来的结果都不一样,调试时很容易误判算法好坏。设了种子之后,至少在同一份稳定代码下,结果可复现,方便对比不同参数。
第二,数值积分要用 Python 内置的 decimal 或者 numpy 的高精度数组来减少浮点累积误差。上面对比用的还是普通 float,当 n 非常大时,求和顺序会造成尾数损失。一个经验做法是不要从前往后加,而是用 numpy 数组保存所有函数值再用np.sum(),因为 numpy 的求和会做一部分误差补偿,比逐项累加稳。
第三,对结果做误差事后估计。比如蒙特卡洛可以顺便计算方差,数值积分可以分别用 n 和 2n 的结果做一次 Richardson 外推。外推公式对于梯形法很实用:如果 T(h) 是步长 h 的结果,那么改进后的值约等于 ( \frac{4T(h) - T(2h)}{3} ),能额外提升一到两位精度。
4.3 不同方法的收敛速度对比
收敛速度是选择算法的核心依据。我把三种方法的误差随计算量变化画在了一张表里,方便直观理解:
| 方法 | 误差量级 | 计算量加倍后的效果 |
|---|---|---|
| 蒙特卡洛 | O(N^(-1/2)) | 精度只提升约 40% |
| 梯形法 | O(n^(-2)) | 精度提升到原来的约 1/4 |
| 辛普森法 | O(n^(-4)) | 精度提升到原来的约 1/16 |
这个表解释了为什么工程上很少用蒙特卡洛去算一维定积分。一维问题有太多确定性算法,又快又准;蒙特卡洛真正的用武之地是高维积分,比如 10 维甚至 100 维的数值积分,这时确定性方法会面临维数灾难,而蒙特卡洛误差与维度无关。所以,项目中投点法适合拿来理解原理,真到了要出数值结果的时候,首选一定是梯形法或辛普森法。
5. 常见问题与排查技巧实录
实操中没有一个人是顺顺利利一次跑对的,下面几个坑我全踩过,逐个拿出来说说我的排查思路。这些问题也是初学者最容易困惑的几处。
5.1 为什么我投了 100 万点,结果还在 3.14 上下晃?
这是蒙特卡洛方法本身的统计涨落,不是 bug。前面算过,100 万点对应的误差标准差约 0.00165,换算成 π 估计量就是标准差约 0.00165 的 4 倍除以 √N 关系,实际常见偏差在 ±0.002 量级。所以看到结果在 3.139 到 3.144 之间浮动是完全正常的。
如果想让结果更稳定,第一是增加点数,第二是使用低差异数列(比如 Sobol 序列)代替纯随机数,这会显著降低方差。使用 numpy 生成 Sobol 序列需要安装scipy.stats.qmc,官方文档有现成示例,计算时间几乎不变但精度能提升一到两个量级。
5.2 梯形法区间数拉满为什么反而不准了?
这是我调试时踩过的一个经典坑。把 n 从 1000 提到 100 万,理论上误差应该继续减小,但实际结果在某个区间数之后开始出现轻微抖动甚至误差变大。原因是浮点数累加的舍入误差开始累积:超过 10 万次浮点累加,总和的尾数误差可能与梯形法的截断误差处于同一量级,导致精度不升反降。
解决办法有三个方向:
- 使用
math.fsum代替普通循环累加,它能做高精度舍入补偿; - 把求和改成二分段求和或者使用 Kahan 求和算法;
- 统一用 numpy 数组加
np.sum,它的实现内部做了配对求和。
我在代码里用math.fsum跑 n = 100 万,结果比普通 for 循环累加干净很多。
5.3 辛普森法报错说 n 必须为偶数,但我明明传了偶数?
这种情况多半出现在你使用动态传参时。比如从配置文件读取 n,不小心读成了字符串,然后n % 2就报类型错误。更微妙的情况是传了浮点数,比如n = 100.0,100.0 % 2 == 0.0虽然在 Python 里是 True,但循环里i * h的浮点误差会让最后一步计算不稳定。
我的建议是一进来就强制转成 int,并且校验n > 0和n % 2 == 0。别嫌麻烦,这几行保护在真实项目里能帮你省下大量调试时间。
5.4 蒙特卡洛结果与别人跑出来的完全不一样?
大概率是随机数种子问题。不同 Python 版本、不同操作系统,随机数生成器的内部状态可能不同。如果团队协作或者写博客做演示,一定要固定种子,否则结果不可复现,问题很难定位。
另外一个隐蔽因素是随机坐标的范围。如果你生成 x 和 y 时用了不同的随机区间,比如 x 在 [-1, 1],y 却在 [0, 1],面积比例公式就会彻底失效。这种错误不会让代码崩溃,只会让你得到某个奇怪的数值。排查时首先检查投点范围是否一致,再检查圆内判断条件。
5.5 排查速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 蒙特卡洛结果每次不同 | 未固定随机种子,或样本量不足 | 设置 seed,提高点数至百万以上 |
| 蒙特卡洛长期偏离 3.14 以上 | 坐标范围不一致,圆内判断写错 | 检查 x、y 是否都取 [0,1],检查 x²+y²<=1 |
| 梯形法 n 增大误差反而增大 | 浮点累加舍入误差累积 | 使用 math.fsum、numpy sum 或 Kahan 求和 |
| 辛普森法报 n 必须为偶数 | n 被传入浮点数或字符串 | 强制 int 并校验 n % 2 == 0 |
| 梯形法和辛普森结果几乎一样 | 函数不光滑,或 n 太小 | 改用更高阶公式,或检查被积函数性质 |
| 计算结果稳定但总是差 4 倍 | 忘记乘 4,或面积公式比例用反 | 核对 π = 4 × 圆内点比例/总面积比例 |
6. 我的实际操作体会
最后分享一点个人经验:这类项目别看表面简单,真正动手跑一遍,你对“误差”这件事的理解会上升一个台阶。我建议你别只复制代码,而是亲手改改参数、画一下误差曲线,看看不同方法的误差下降趋势有多不一样。蒙特卡洛那条误差曲线总是抖抖索索往下走,梯形法和辛普森法却能快速触底,这种直观感受比看任何教科书都来得牢。
另外一个非常实用的扩展方向是把代码改成 numpy 向量化版本。蒙特卡洛方法用数组运算一次生成所有随机点,数值积分用np.linspace生成节点、np.sum求和,运行速度快几个数量级。配合 seaborn 或 matplotlib 画出投点图和函数逼近图,你就得到了一份既能展示原理又能演示性能的完整小项目。
如果你对数值积分进一步感兴趣,还可以试试高斯-勒让德求积法。它用更少节点达到更高的代数精度,是很多现代计算库背后的核心算法。算 π 只是一个入口,理解了这些数值方法,后面做复杂积分、微分方程求解时,会有大量用武之地。