几何级数这个词听着像课本里压在最后一章的老古董,可只要写过一行处理衰减系数的代码、算过一次无限期的现金流、或者在代码评审里被人问过"你这个循环到底会不会收敛",其实就已经在跟它打交道了。它的定义朴素得不像话:每一项都是前一项乘上同一个固定的数,写成式子就是 a + ar + ar² + ar³ + …。但就是这么个简单结构,撑起了收敛条件这套判定逻辑,也撑起了大量工程里"无限求和"的落地方法。这篇文章不讲考试题,只讲三件事:几何级数为什么在 |r| < 1 时收敛、边界上那几个特殊值到底发生了什么、以及怎么用 Python 把它算准而不是算出一个似是而非的数。刚接触级数的读者能拿到完整的直觉和可复制的代码,做过数值计算的读者能在这里找到那几个容易翻车的浮点细节。
1. 几何级数到底在算什么:定义、三层直觉与部分和
1.1 一个固定倍数带来的结构
几何级数(也叫等比级数)的标准形式是 Σ_{k=0}^{∞} a·r^k,其中 a 是首项,r 是公比。这里有个坑必须先说清楚:很多教材从 k=1 开始写,形式变成 Σ a·r^{k-1},两者只差一个下标位移,但你在套公式的时候一旦搞混"首项到底算第 0 项还是第 1 项",结果就会整体差一个 r 倍。我自己习惯一律从 k=0 写起,这样第 n 项就是 a·r^n,无限和是 a/(1-r),有限和是 a(1-r^{n+1})/(1-r),首项和指数能对上,不容易错。
为什么这个结构值得单独拎出来讲?因为绝大多数能"手算出来"的无限求和,最后都归约到它。泰勒展开在某个点取值、概率里的几何分布、信号处理里的指数衰减、金融里的年金现值,表面看各不相干,落到式子上都是同一个形状:每次乘一个固定的比例。抓住这个共性,你就不需要记十来个公式,只需要记一个。
还有一个容易忽略的前提:讨论收敛性时默认 a ≠ 0。a = 0 时所有项都是 0,和恒为 0,收敛性当然成立,但这个平凡情形没有任何信息量,教材里通常直接跳过。写代码时倒是要单独处理,因为 0·r^k 在 r = 0 且 k = 0 时会碰到 0^0 的约定问题,Python 里 0**0 返回 1,正好和"首项是 a"的约定一致。
1.2 部分和公式是怎么推出来的
有限项的和记作 S_n = a + ar + ar² + … + ar^n,一共 n+1 项。推导只用一招:错位相减。把 S_n 乘上 r,得到 rS_n = ar + ar² + … + ar^{n+1},两个式子上下对齐相减,中间所有项两两抵消,只剩头尾:(1-r)S_n = a - a·r^{n+1} = a(1 - r^{n+1})。
于是当 r ≠ 1 时,S_n = a(1 - r^{n+1})/(1 - r);当 r = 1 时,这个式子分母为零失效,得回到原始定义,每一项都等于 a,共 n+1 项,S_n = (n+1)a。这一步看起来是废话,但它是后面代码里必须单独开分支的原因,我见过太多人直接在代码里写 a*(1-r**n)/(1-r) 然后在 r=1 时收获一个 ZeroDivisionError。
错位相减这个技巧的适用范围远不止几何级数。等差乘等比形成的级数(比如 Σ k·r^k)、某些差分方程的通解、生成函数的化简,用的都是同一套动作:乘一个因子、错位、相减、消掉中间项。所以这段推导别当成一次性公式背下来,它是你后面处理更复杂求和的基本功。
1.3 三种直觉:几何折叠、误差传递、Zeno 悖论
第一种直觉是几何的。把总和看成一条长度为 1 的线段,先取 r 倍长度,再从剩下的里面取 r 倍,如此反复。当 0 < r < 1 时,每一轮都只拿剩余部分的一个固定比例,剩余量按 (1-r) 的比例指数缩小,永远不会"取完",但取走的长度总和是有限的。这就是 Zeno 悖论的正解:走一半、再走剩下的一半、再走剩下的一半,无限步之后总路程恰好是 1,无限步不等于无限远。
第二种直觉是误差传递。设当前的部分和与真实和的差为 d_n,那么再补一项之后,误差变成 d_{n+1} = r·d_n + 尾部的残余,主导项就是乘上 r。所以误差每一轮都被压缩到原来的 r 倍,压缩因子小于 1,误差必然趋于零。这个视角比"公式取极限"更贴近工程:你在迭代算法里看到的收敛速度,几乎都能表述成"误差每轮乘一个小于 1 的因子"。
第三种直觉来自离散动力系统。把部分和序列 S_0, S_1, S_2, … 看成一个迭代过程,它满足 S_{n+1} = r·S_n + a,这是一个一阶线性递推。它的不动点解 S* = a/(1-r),收敛到不动点的条件正是 |r| < 1。换句话说,几何级数收敛这件事,和"线性迭代能不能稳定下来"是同一件事的两种说法。理解了这一层,你看到马尔可夫链的幂迭代、滤波器冲激响应、折现因子衰减,都会觉得眼熟。
2. 收敛条件不是一句 |r| < 1 的口诀
2.1 用部分和公式把收敛性彻底说透
判断收敛,本质上就是判断 lim_{n→∞} S_n 是否存在。把 S_n = a(1 - r^{n+1})/(1-r) 拆开看,a 和 (1-r) 都是常数,唯一会随 n 变化的是 r^{n+1}。所以问题被压缩成一句话:r^{n+1} 在 n 趋于无穷时有没有极限。
- 当 |r| < 1 时,r^n → 0,于是 S_n → a/(1-r),收敛。
- 当 r = 1 时,S_n = (n+1)a,随 n 线性增长,发散到无穷(a > 0 时)。
- 当 r = -1 时,S_n 在 a 和 0 之间来回跳,没有极限,属于"有界但发散"。
- 当 |r| > 1 时,|r|^n → ∞,S_n 的绝对值无界,发散。
|r| < 1 时 r^n → 0 这个结论需要一个像样的证明,不然就只是"课本这么说"。思路是把 1/|r| 写成 1 + h 的形式,其中 h > 0。由二项式展开或伯努利不等式,(1+h)^n ≥ 1 + nh > nh,取倒数得 |r|^n = 1/(1+h)^n < 1/(nh)。右边趋于 0,夹逼一下就得到 |r|^n → 0。这个证明只用到 h > 0 这一个条件,而 h > 0 恰好等价于 |r| < 1,所以收敛的充要条件就锁死了。
注意:这里的"收敛"指的是部分和序列有有限极限。r = -1 的情形常被误判,因为它的部分和有界、上下震荡,看起来"没跑远",但极限不存在,就是不收敛。判断标准是极限存在与否,不是有界与否。
2.2 边界上的几个特殊值,以及复数情形
r = 1 是最容易忽视的边界。它的有限和是 (n+1)a,无限和发散,但因为形式简单,很多人在写代码时直接用通用公式,结果除以零。正确做法是在函数入口就判断 r == 1,走单独分支。
r = -1 更有迷惑性。部分和序列是 a, 0, a, 0, a, 0, …,两个子列分别收敛到 a 和 0,整体不收敛。这类"子列极限不同"的振荡发散,在数值实验里也很难靠"多算几项"发现,因为你看到的数始终在 a 附近,只是永远定不下来。判断依据仍然是数学上的极限存在性,不是数值上的"看起来稳定"。
|r| = 1 但 r 不是实数,比如 r = i,情形也一样:r^n 在 i, -1, -i, 1 之间循环,部分和有界但无极限。所以完整的收敛条件是 |r| < 1,这里的绝对值是复数的模,不是实数的绝对值。写代码时用 abs(r) 就对,Python 的 abs 对 complex 返回模长,这点很省事。
|r| > 1 时也不是全无用处。有限项和公式依然成立,二叉树节点计数 1 + 2 + 4 + … + 2^h = 2^{h+1} - 1 就是 r = 2 的有限和。区别只在于:有限和随便算,无限和必须 |r| < 1。这个区分在工程里很关键,比如容量规划算的是有限和,而稳态误差分析要求无限和收敛。
|r| < 1 时无限和等于 a/(1-r),这个式子在 r 为复数时同样成立,结果是复数。比如 a = 1, r = i/2,和就是 1/(1 - i/2) = (1 + i/2)/(1.25) = 0.8 + 0.4i。这种复数几何级数在信号处理里很常见,Z 变换的收敛域讨论本质上就是在讨论这个。
2.3 收敛速度、截断误差与项数估算
知道收敛还不够,工程上真正关心的是"要算多少项才够"。设 |r| < 1,截断误差满足 |S - S_n| = |a|·|r|^{n+1}/(1-|r|)。这个式子可以直接反解出所需项数:要求误差不超过 ε,则
|r|^{n+1} ≤ ε(1-|r|)/|a|
两边取对数(注意 ln|r| < 0,除的时候不等号翻转):
n + 1 ≥ ln(ε(1-|r|)/|a|) / ln|r|
取上整再减一,就是最小项数。取 a = 1、ε = 1e-6,不同 r 的结果差异极大:
| 公比 r | 达到 1e-6 精度所需项数 n | 说明 |
|---|---|---|
| 0.5 | 20 | 收敛很快,日常够用 |
| 0.9 | 152 | 仍然可控 |
| 0.99 | 1832 | 开始变慢 |
| 0.999 | 20712 | 明显吃力 |
| -0.5 | 20 | 负公比不影响误差界 |
| -0.9 | 152 | 误差界由模长决定 |
这张表值得盯几秒。r 从 0.9 挪到 0.99,只变了 0.09,项数涨了十二倍;再挪到 0.999,又涨了十倍多。这就是"收敛"和"快速收敛"的区别,也是很多迭代算法明明理论收敛、实际跑不动的原因。模长越接近 1,收敛越慢,这条规律在矩阵情形下同样成立。
提示:这个误差界是上界,不是精确值。对 r > 0 基本贴紧,对 r < 0 时因为正负项交替,实际误差通常比界小很多。所以估算项数时可以放心用这个界,它是偏保守的。
还有一个隐藏前提:这个界只描述数学上的截断误差,不包含浮点运算本身的舍入误差。当 r 非常接近 1 时,项数会大到让浮点误差开始主导,那时候算出来的"精度"根本达不到理论值。这个坑在第 3 节详细说。
3. Python 实战:符号推导、数值验证与浮点陷阱
3.1 先用符号计算把公式坐实
动手写数值代码之前,先用 sympy 做一遍符号推导,好处是能把分支条件看清楚。下面这段代码求有限部分和的闭式表达:
from sympy import symbols, summation, oo, simplify, Rational, Sum k, n = symbols('k n', integer=True, nonnegative=True) a = symbols('a', nonzero=True) r = symbols('r') S_n = summation(a * r**k, (k, 0, n)) print(S_n)我这边的环境跑出来的结果是一个分段表达式,主分支是 a*(1 - r**(n + 1))/(1 - r),附加条件是 r ≠ 1,另一个分支是 a*(n + 1),对应 r = 1 的情形。这个输出恰好把第 1 节手推的结论原样复现了一遍,等于是一次自动校验。
求无限和把上限换成 oo:
S_inf = summation(a * r**k, (k, 0, oo)) print(S_inf)返回结果里会出现 a/(1 - r),并且带上 Abs(r) < 1 的收敛条件。如果你关心的只是实数情形,可以给 r 加一个 real=True 的假设,输出会稍微简化一些。符号计算的好处是你不需要记住条件,它会替你写上;坏处是有时候表达式太复杂反而不好读,所以通常只在小规模推导时用。
再补一个精确验证,用 Rational 避开浮点:
from fractions import Fraction partial = sum(Fraction(1, 2)**k for k in range(100)) print(partial) # 一个巨大的分数 print(float(partial)) # 1.0 print(2 - partial) # 2**-99结果非常干净:(1/2)^99 这一项被完整保留下来,两个分数相减得到的精确值就是 2^-99,大约 1.58e-30。这就是有理数运算的价值,它把"误差有多大"变成了一个可以精确写出来的数,而不是一句"大约很小"。
3.2 数值逼近:观察部分和序列怎么走向极限
符号结果确定之后,换成 numpy 做数值实验,用来观察收敛过程。先写一个最直接的部分和函数:
import numpy as np def geom_partial(a, r, n): k = np.arange(n + 1) return np.sum(a * r**k)对 a = 1、r = 0.5,真实和是 2。取不同 n 观察误差:
| n | 部分和 S_n | 误差 |S - S_n| | | --- | --- | --- | | 5 | 1.96875 | 3.125e-2 | | 10 | 1.9990234375 | 9.766e-4 | | 20 | 1.9999990463256836 | 9.537e-7 | | 30 | 1.9999999990686774 | 9.313e-10 | | 50 | 2.0 | 0(达到双精度分辨率) |
误差每一行都缩小到前一行的约 1/32,对应每加 5 项乘 0.5^5 = 1/32,和理论完全一致。到 n = 50 时误差已经在双精度机器精度以下,算出来就是 2.0,再增加项数没有意义。
对 r = 0.9 重做一遍,感受一下速度差异:n = 50 时误差约 0.9^51/0.1 ≈ 4.6e-3,n = 152 时误差约 1e-6,n = 500 时误差约 1e-23,已经到机器精度以下。同一个函数,换个公比,项数差了一个数量级。
再看负公比 r = -0.5,部分和在 2 上下跳动,n 为偶数时略小于 2,n 为奇数时略大于 2,跳跃幅度以 0.5 的幂衰减。这个振荡是数学上的必然,不是数值不稳定。很多人在调试时看到结果来回摆就怀疑代码有问题,其实是被负公比骗了。
3.3 浮点陷阱:三个真实的翻车场景
第一个陷阱是 r 极其接近 1 时的抵消误差。看这段代码:
r = 1 - 1e-16 print(r == 1.0) # True原因是双精度浮点的机器 epsilon 约 2.22e-16,1e-16 小于它的二分之一,1 - 1e-16 在舍入后就是 1.0,这个数根本没有被表示出来。既然 r 已经被舍入成 1,后面所有公式算出来的都是 r = 1 的结果,误差 100%。这个例子有点极端,但含义很重要:输入精度决定了输出的天花板,你不能指望一个已经被舍入掉的参数还能算出正确的和。
稍微退一步,取 r = 1 - 1e-12,能表示出来了,但 1 - r 这个减法会损失有效位。1 - 1e-12 的十进制表示大约有 12 个有效位被"用掉"来表达那个 1e-12 的差值,剩下的有效位才用来表达差值本身,相对误差被放大。用 Fraction 或者 Decimal 一试就能看出来:
from decimal import Decimal, getcontext getcontext().prec = 50 x = Decimal('1e-12') r = Decimal(1) - x one_minus_r = Decimal(1) - r print(one_minus_r) # 1E-12,在 Decimal 里是准的在 Decimal 里这个减法是精确的,因为十进制表示天然适合表达 10 的幂次差值。用 double 就会丢位。工程上的对策不是换 Decimal(太慢),而是从一开始就把 r 存成 1 - x 里的那个 x,别存 r 本身。需要算 1 - r 的时候直接用 x,需要算 r^k 的时候用 expm1/log1p 组合,把抵消误差绕过去。
第二个陷阱是大 n 累加的舍入。朴素循环 sum(...) 在 n 达到千万级时,每一项的舍入误差会累积,而且小项加到大的累加器上会被直接吞掉。对策是 math.fsum,它做精确舍入的求和,误差不随项数增长。或者用 numpy.sum,它内部是成对求和,误差增长是对数级的,比朴素循环好得多,但不如 fsum。实测下来,n = 1e7、r = 0.9 时朴素循环和 fsum 的结果能差到 1e-10 量级,对精度要求高的场景这个差值是致命的。
第三个陷阱是溢出和下溢。r > 1 且 n 很大时,rn 会抛 OverflowError,Python 的 float 类型不会静默返回 inf,而是直接报错。反过来 r 很小、n 很大时,rn 下溢到 0.0,这倒没什么危害,因为连续加 0 不影响结果。但如果你在算 a·r^n/(1-r) 这种表达式,下溢成 0 之后的除法会出现 0/0 或者得到一个错误的 0,需要提前判断。
注意:r**n 在 Python 里对 float 底数用的是 pow,精度比 exp(n*log(r)) 好,别为了"优化"手动改成 exp/log 组合,除非你就是为了绕开抵消误差,那也得配合 log1p 用。
3.4 写一个能直接抄作业的稳健函数
把前面的坑都兜住,给出一版可以放进工具箱的实现。核心思路是:分支处理 r = 0 和 r = 1,有限和用 fsum,无限和检查 |r| < 1,靠近 1 的时候走 expm1/log1p 路径。
import math def geom_sum(a, r, n=None): """几何级数求和。n 为 None 时求无限和,要求 abs(r) < 1。""" if r == 0: return a if n is None else a if n is None: if abs(r) >= 1: raise ValueError(f"无限和发散或未定义, r={r}") return a / (1 - r) if n < 0: raise ValueError("n 必须非负") if r == 1: return a * (n + 1) return math.fsum(a * r**k for k in range(n + 1))无限和那一支的 r == 0 判断其实可以省掉,1 - 0 = 1,a/1 = a,结果一样。但有限和的 r == 0 分支有价值,因为它避免了 0**0 的语义依赖,虽然 Python 返回 1 恰好是对的,但显式写出来更清楚。
针对 r 接近 1 的稳定版本,单独处理实数且 0 < r < 1 的情形:
def geom_partial_stable(a, r, n): """针对 r 接近 1 的部分和,减少 1-r 的抵消误差。""" if r == 1: return a * (n + 1) if r <= 0 or r >= 1: # r 为负或偏离 1 较远时,常规公式没有抵消问题 return a * (1 - r**(n + 1)) / (1 - r) x = 1 - r # 若能直接传入 x 更好 num = -math.expm1((n + 1) * math.log1p(-x)) return a * num / x这里的两个技巧值得展开。log1p(-x) 计算的是 ln(1-x),当 x 很小时它不丢失精度,而 math.log(1-x) 会先做 1-x 的减法再取对数,精度已经损失了。expm1(y) 计算的是 e^y - 1,当 y 接近 0 时同样能保持精度,而 math.exp(y) - 1 在 y 很小时会得到 0。两个函数配合,就把 1 - r^{n+1} 这个容易抵消的表达式换成了数值稳定的形式。
配套的项数估算函数也写出来,注意 log 那里同样要用 log1p:
def terms_for_tolerance(a, r, eps): """估算达到 eps 精度所需的项数 n。""" ra = abs(r) if a == 0: return 0 if not (0 < ra < 1): raise ValueError("仅对 0 < |r| < 1 有意义") target = eps * (1 - ra) / abs(a) # log(ra) 在 ra 接近 1 时用 log1p 更稳 log_r = math.log1p(ra - 1) if ra > 0.5 else math.log(ra) n = math.log(target) / log_r - 1 return max(0, math.ceil(n))对 a = 1、r = 0.9、eps = 1e-6 调用,返回 152,和手算一致;r = 0.99 返回 1832;r = 0.999 返回 20712。这三个数和我前面表格里的值完全对得上,说明估算逻辑没问题。
4. 工程里真正会用到几何级数的地方
4.1 现金流折现与等额本息贷款
先看一个最直观的场景。假设年贴现率 5%,每年末收到 100 元,一直收下去,现值是多少?第 k 年的现金流折现因子是 1/1.05^k = (1/1.05)^k,这是一个公比 r = 1/1.05 ≈ 0.95238 的几何级数,和是 100/(1 - 0.95238) = 100/0.04762 = 2100 元。换个算法,永续年金公式是 现金流/利率 = 100/0.05 = 2000 元。两个结果差了 100,原因是我这里用了"年末支付"和"年初支付"的区别,年初支付要多乘一个 1.05。这类首项错位在财务计算里极常见,本质就是第 1 节说的下标从 0 还是从 1 开始。
再看有限期。同样每年 100 元、共 20 年,现值是 100·(1 - 1.05^{-20})/0.05。1.05^20 = 2.6533,倒数是 0.37689,1 减它等于 0.62311,乘 100 再除以 0.05,得 1246.2 元。和无限期的 2000 元一比,能看出第 20 年之后的现金流折现后只剩 750 多元,年限拉长的边际贡献衰减得很快,这正好对应几何级数尾部项越来越小。
等额本息月供是同一套东西的变形。设贷款本金 P、月利率 i、期数 N,每月还款 M,那么所有还款的现值之和等于本金:M·Σ_{k=1}^{N} (1+i)^{-k} = P。里面的和是首项 (1+i)^{-1}、公比 (1+i)^{-1} 的几何级数,化简得 M = P·i/(1 - (1+i)^{-N})。
拿一笔 100 万、年利率 4.8%、20 年期的贷款算一遍。月利率 i = 0.048/12 = 0.004,N = 240。(1.004)^240 = e^{240×ln1.004} = e^{0.958085} = 2.60634,倒数是 0.38366,1 减它等于 0.61634。M = 1e6 × 0.004 / 0.61634 = 4000/0.61634 ≈ 6490 元。总还款约 155.8 万,其中利息约 55.8 万。这个数字你可以用任意贷款计算器核对,误差在几十元以内,差的那点来自四舍五入。
提示:这类计算里 i 通常很小(0.004 这种量级),(1+i)^{-N} 可以直接算,不会有抵消问题。真正要小心的是利率极低、期数极大同时出现的情形,那时候 1-(1+i)^{-N} 会接近 1,反而更稳,不用特殊处理。
4.2 算法分析里的几何分布与期望次数
几何分布是几何级数的概率版本:独立重复试验,每次成功概率 p,首次成功所需的试验次数 X 服从几何分布,P(X = k) = (1-p)^{k-1}·p。它的期望推导要用到几何级数的求导性质。
从 Σ_{k=0}^{∞} x^k = 1/(1-x) 出发,两边对 x 求导,得 Σ_{k=1}^{∞} k·x^{k-1} = 1/(1-x)²。把 x = 1-p 代进去,再乘上 p,就是 E[X] = p·1/(1-(1-p))² = 1/p。同理再求一次导能得到二阶矩,最终方差是 (1-p)/p²。
这个 1/p 在工程里出现的频率高得惊人。哈希表的开放寻址,负载因子 α 时成功查找的期望探测次数约为 (1/α)·ln(1/(1-α)),线性探测则近似 (1/2)(1 + 1/(1-α))。α = 0.75 时后者约 2.5 次,这就是为什么主流哈希表把负载因子上限设在 0.75 附近:再高一点,探测次数就按 1/(1-α) 的形态爆炸。
再举个重试策略的例子。某个远程调用单次成功率 0.8,失败就重试,最多重试 3 次。总尝试次数的期望可以按几何分布截断算:E = Σ_{k=1}^{3} k·0.2^{k-1}·0.8 + 4·0.2³。算一下,1×0.8 + 2×0.16 + 3×0.032 + 4×0.008 = 0.8 + 0.32 + 0.096 + 0.032 = 1.248 次。加上截断项之后期望只比 1/p = 1.25 小一点点,说明重试上限设 3 次基本够用,再往上加对成功率贡献很小,因为尾部是 0.2 的三次方量级。
二分查找的判定树节点数是 1 + 2 + 4 + … + 2^h = 2^{h+1} - 1,这是 r = 2 的有限几何级数。这里 r > 1,无限和发散,但有限和完全正常。这个对比说明一件事:发散不等于没用,发散只意味着你不能把"无限多项相加"当成一个有限数,而有限项求和在 r > 1 时照样是标准工具。
4.3 马尔可夫链、幂迭代与衰减系数
把标量换成矩阵,几何级数变成 Neumann 级数:Σ_{k=0}^{∞} P^k = (I - P)^{-1},收敛条件是 P 的谱半径小于 1。这个推广不是数学游戏,PageRank 的幂迭代就是这么工作的:每一轮把概率向量乘以转移矩阵,误差按主特征值之外的次大特征值的模长衰减。
如果次大特征值的模长是 0.85,那么迭代 100 轮之后误差约 0.85^100 = e^{100×ln0.85} = e^{-16.25} ≈ 8.7e-8。看着很小,但要注意这是从初始误差 1 开始算的,如果初始误差是 1e-3,最终只能到 1e-10 量级。想再降一个数量级,得再来 15 轮左右,因为 0.85^15 ≈ 0.087,差不多缩小一个数量级。这就是"收敛因子接近 1 时迭代成本急剧上升"的具体体感。
同样的分析适用于任何一个线性迭代 x_{k+1} = A x_k + b。收敛速度由 A 的谱半径决定,误差每一轮乘谱半径,累积 k 轮就是谱半径的 k 次方。你完全可以把谱半径理解成那个公比 r,把整个收敛过程理解成一个矩阵版的几何级数。这套语言在控制系统、强化学习的值迭代、数值线性代数的迭代解法里是通用的。
注意:谱半径小于 1 是充分必要条件,但数值上判断"是否真的收敛"还要看舍入误差。谱半径 0.9999 的迭代在数学上收敛,跑一百万轮才降一个数量级,实际工程里通常直接判定为不收敛,改用别的算法。
4.4 一阶滤波与动画缓动
信号处理里的一阶低通滤波:y[n] = α·y[n-1] + (1-α)·x[n],其中 0 < α < 1。它的冲激响应是 h[k] = (1-α)·α^k,正好是个几何级数。所有冲激响应之和 Σ h[k] = (1-α)/(1-α) = 1,这个"和为 1"保证了直流增益为 1,输入常量信号时输出稳态也是同一个常量。如果系数写错,比如写成 y[n] = α·y[n-1] + x[n],和就变成 1/(1-α),直流增益被放大,输出会缓慢爬升到错误的值。这个 bug 我在调参时见过不止一次。
阶跃响应的形式是 1 - α^{n+1},达到 99% 所需步数由 α^{n+1} ≤ 0.01 决定,n ≥ ln(0.01)/ln(α) - 1。α = 0.9 时,ln0.01 = -4.605,ln0.9 = -0.10536,比值 43.7,所以需要 44 步。α = 0.99 时需要 459 步。这和前面项数估算表是同一个公式,只是符号换了个名字。做前端动画的时候,缓动曲线的"手感"其实就是这个衰减系数:α 越大越"黏",收敛越慢。
再说一个概率上的用法:几何涂鸦、抛硬币直到出现正面,期望次数是 2。这个 2 就是 1/p 在 p = 0.5 时的值。如果你在做 A/B 测试的样本量估算,看到"需要多少次曝光才能观察到一次转化"这类问题,答案就是 1/转化率,背后的数学就是几何分布的期望,而几何分布的期望又来自几何级数求导。一层套一层,但根子是同一个。
5. 常见问题与排查清单
5.1 症状、根因与处理对照表
| 现象 | 可能根因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 结果恰好差一个 r 倍 | 首项下标从 0 还是从 1 开始搞混 | 手算前两项和代码输出对比 | 统一从 k=0 写,公式配套改 |
| r = 1 时抛 ZeroDivisionError | 通用公式分母 1-r 为零 | 打印 r 的实际值 | 入口处加 r == 1 分支 |
| r 接近 1 时结果偏差巨大 | 1-r 的抵消误差或 r 已被舍入为 1 | 用 Decimal 高精度复算 | 直接传 x = 1-r,用 expm1/log1p |
| 大 n 时结果与理论值差 1e-10 | 浮点累加舍入 | 换 math.fsum 对比 | 用 fsum 或 numpy.sum |
| 无限和返回 inf 或 nan | r | ≥ 1 导致 r**n 溢出 | |
| 负公比时结果来回摆 | 正常振荡,不是 bug | 观察偶数项和奇数项 | 确认是数学性质即可 |
| 复数公比报类型错误 | 用了 math 模块而非 cmath | 检查导入 | 用 cmath 或直接用 complex 运算 |
| 项数估算比实际需要的多很多 | 误差界是保守上界 | 实测截断误差 | 可接受,或改用实测值 |
这张表里最值得反复看的是第三行。r 接近 1 的偏差往往不是"算得不够多",而是"参数精度不够",多加项数完全无效。很多人第一反应是把 n 加大,结果算了十万项还是不对,因为瓶颈在输入而不是迭代。
5.2 几个我踩过的坑和对应的处理习惯
第一个坑是 0.999… 到底等不等于 1。写成代码是 sum(9 * 10**-k for k in range(1, 100)),用浮点算出来是 0.9999999999999999,不等于 1。换 Fraction 算,结果是精确的 1。这个差异不是数学问题,是表示问题:十进制小数 0.9 在二进制浮点里没有精确表示,累积误差让结果偏了一点。我的处理习惯是:涉及"精确相等"判断的场景,一律用 Fraction 或 Decimal,绝不拿浮点结果去和 1 比 ==。
第二个坑是 numpy 的整数溢出。写 np.arange(k) ** 64 或者对 int32 数组做大指数幂,会静默溢出成负数,不报错。我现在的习惯是任何幂运算前先把数组转成 float64 或者 object,宁可慢一点也不要静默错误。对几何级数来说,r 是整数的情况其实很少,但一旦出现(比如算 2 的幂次和),就是重灾区。
第三个坑是 math.log 在参数接近 1 时的精度。math.log(1 - 1e-15) 和 math.log1p(-1e-15) 的结果能差出好几个有效位,前者因为先做了减法,精度已经丢了。凡是形如 ln(1+x) 的表达式,只要 x 可能很小,一律用 log1p;凡形如 e^x - 1 的表达式,一律用 expm1。这两条规则我写在代码规范里,比事后调 bug 省事得多。
第四个坑是误用无限和公式。有人拿着 a/(1-r) 去算一个本该分期的现金流,忘了期限是有限的,结果把 20 年的现值算成了永续的,多出 50% 以上。判断方法很简单:问一句"这个流有没有终点",有终点就用有限和公式,没有才用无限和。有限和公式对任意 r ≠ 1 都成立,适用范围比无限和宽得多。
第五个坑是符号计算和数值计算混用。sympy 给的是精确表达式,numpy 给的是浮点近似,两者对比时不能用 ==。我一般的做法是把符号结果转成 float 再和数值结果比,容差设成 1e-12 量级(考虑累积误差),超过就说明有一边错了。这个对比步骤在写新的求和函数时几乎必做,是发现下标错位最快的手段。
最后再分享一个小习惯:每次写几何级数相关的函数,我都会在函数上方留一行注释,写清楚"k 从 0 开始,首项是 a"。看起来是废话,但半年后回来改代码,这一行能省掉十分钟的推导。数学上的下标约定,永远比你以为的更容易忘。