Stirling公式实战:如何用Python快速估算大数阶乘(附代码示例)
Stirling公式实战:用Python高效估算大数阶乘,告别计算瓶颈
在数据科学、算法设计乃至密码学的某些角落,我们常常会撞上一个看似简单却令人头疼的计算问题:大数的阶乘。比如,当你需要计算 1000! 甚至 10000! 时,直接调用编程语言的内置函数,往往会遭遇性能瓶颈甚至内存溢出。这时,一个诞生于18世纪的数学公式——Stirling公式,便成了我们手中的一把利器。它并非要给出一个精确到个位的结果,而是提供一个在工程和科学计算中足够精确、且计算效率极高的近似值。对于需要快速评估组合数可能性、分析算法复杂度(比如某些排序算法的比较次数),或者在概率模型中处理大样本的开发者而言,掌握Stirling公式的编程实现,意味着能在毫秒级时间内获得一个可靠的估算,从而将计算资源留给更核心的逻辑。这篇文章,我们就来深入探讨如何用Python将这一经典数学工具“武装”起来,并亲自动手对比它与传统方法的效率差异,让你在面对“天文数字”时,依然能从容不迫。
1. 理解核心:Stirling公式究竟是什么?
在直接敲代码之前,我们有必要花点时间理解Stirling公式的来龙去脉。这并非单纯的数学理论复习,而是为了在后续实现和调试时,我们能清楚地知道每一步计算的意义,以及近似误差的来源。
简单来说,Stirling公式给出了阶乘 n! 的一个渐近近似表达式。其最经典、最常用的形式如下:
[ n! \sim \sqrt{2 \pi n} \left( \frac{n}{e} \right)^n ]
这里的 ~ 符号表示“渐近等价于”,意味着当 n 趋向于无穷大时,公式左右两边的比值趋近于1。公式中包含了三个基本常数:圆周率 π、自然对数的底 e 和 n 本身。这个公式的美妙之处在于,它将一个需要连续乘法的离散运算,转化为了包含指数和对数运算的连续函数近似,后者在计算机上的计算要高效得多。
注意:这个基本形式已经能提供相当不错的近似,尤其是当
n较大时(通常n > 10就有可用价值)。但如果你需要更高的精度,公式还有更精确的扩展形式,通过添加一系列1/n的高次项来修正误差。
为了更直观地感受其近似效果,我们可以看一个简单的对比表格:
| n 的值 | n! 精确值 (或高精度计算值) | Stirling 基本公式近似值 | 相对误差 (百分比) |
|---|---|---|---|
| 5 | 120 | 118.02 | ~1.65% |
| 10 | 3,628,800 | 3,598,695.62 | ~0.83% |
| 20 | 约 2.43e18 | 约 2.42e18 | ~0.42% |
| 100 | 约 9.33e157 | 约 9.32e157 | ~0.08% |
从上表可以清晰地看到,随着 n 增大,相对误差迅速减小。对于 n=100 的情况,误差已远低于千分之一,这对于绝大多数需要估算数量级的应用场景(例如评估算法状态空间大小、估算概率)来说,已经完全够用。
那么,为什么这个公式有效?一个直观的理解角度来自于对 ln(n!) 的近似。我们知道 ln(n!) = ln1 + ln2 + ... + lnn。这个求和可以用积分 ∫ln(x) dx 来近似,再经过一些巧妙的校正(引入 √(2πn) 项),最终反推回指数形式,就得到了Stirling公式。这个推导过程本身也提示了我们:在编程实现时,直接计算指数和幂运算可能会遇到数值溢出(尤其是对于非常大的 n),而先计算对数、再进行指数还原,是更稳健的做法。这一点我们会在后续的代码实现中重点体现。
2. 从公式到代码:Python实现的三重境界
理解了公式,接下来就是将其转化为可执行的代码。我们将分三个层次来实现,从最直接(但可能有问题)的方式,到最稳健高效的方式。
2.1 基础实现:直接翻译公式
最直观的想法就是把数学公式逐字翻译成Python表达式。我们利用 math 模块提供的 sqrt, pi, exp 常量。
import math
def stirling_approximation_basic(n):
"""
使用Stirling公式基本形式近似计算 n!
注意:当n很大时,直接计算 (n/e)**n 可能导致溢出。
"""
if n <= 0:
return 1 # 通常定义 0! = 1, 负数阶乘通常未定义,这里简单处理
approximation = math.sqrt(2 * math.pi * n) * ((n / math.e) ** n)
return approximation
# 测试
print(f"10! 近似值: {stirling_approximation_basic(10):.2f}")
print(f"10! 精确值: {math.factorial(10)}")
运行一下,你会发现对于 n=10,结果还不错。但尝试 n=100 或更大时,问题就来了:(n / math.e) ** n 这个运算会产生一个巨大无比的浮点数,很容易就超出Python浮点数的表示范围,导致溢出(OverflowError)。因此,这个“基础版”仅适用于教学演示和小数值 n,不具备生产环境的实用性。
2.2 进阶实现:对数空间运算
为了解决溢出问题,我们必须转换思路。既然直接计算幂会溢出,那我们就先计算它的对数。因为对数函数能将乘法、幂运算转化为加法和乘法,极大地压缩了数值范围。
Stirling公式取对数后变为: [ \ln(n!) \approx \frac{1}{2} \ln(2\pi n) + n \cdot (\ln n - 1) ]
我们在代码中先计算这个对数近似值,最后再用 exp 函数还原回来。这样,中间过程都在对数尺度上进行,有效避免了中间值的溢出。
def stirling_approximation_log(n):
"""
使用对数运算避免溢出的Stirling公式实现。
返回 ln(n!) 的近似值。
"""
if n <= 1:
return 0.0 # ln(1) = 0
return 0.5 * math.log(2 * math.pi * n) + n * (math.log(n) - 1)
def stirling_factorial_via_log(n):
"""
通过指数还原,计算 n! 的近似值。
适合需要最终阶乘数值的场景,但注意结果仍是浮点近似。
"""
log_fact_approx = stirling_approximation_log(n)
return math.exp(log_fact_approx)
# 测试大数值
n_large = 1000
log_approx = stirling_approximation_log(n_large)
fact_approx = stirling_factorial_via_log(n_large)
print(f"ln({n_large}!) 的 Stirling 近似: {log_approx}")
print(f"{n_large}! 的 Stirling 近似 (科学计数法): {fact_approx:.6e}")
这个版本可以轻松处理 n=1000, 10000 甚至更大的数,因为它核心计算的是对数。math.log 函数能处理极大的参数。当你需要比较两个巨大阶乘的比值(这在计算组合数 C(n, k) 时很常见)时,直接使用 stirling_approximation_log 的结果进行加减法,比还原成阶乘再做除法要稳定和高效得多。
2.3 高阶精度实现:引入修正项
如果基本形式的精度仍不能满足你的需求(例如在需要高精度近似的一些科学计算中),我们可以实现包含更多修正项的Stirling公式。一个常用的扩展形式是:
[ n! \approx \sqrt{2 \pi n} \left( \frac{n}{e} \right)^n \left(1 + \frac{1}{12n} + \frac{1}{288n^2} - \frac{139}{51840n^3} - \frac{571}{2488320n^4} + \cdots \right) ]
我们可以在对数空间实现这个修正,以同时保证精度和避免溢出。
def stirling_approximation_log_with_correction(n, terms=2):
"""
带修正项的 Stirling 对数近似。
terms: 使用的修正项数量(1为基本项,2包含1/(12n),3包含1/(288n^2),以此类推)。
"""
if n <= 1:
return 0.0
# 基本项
log_approx = 0.5 * math.log(2 * math.pi * n) + n * (math.log(n) - 1)
# 添加修正项(在普通数值空间计算,因为修正项本身很小)
correction = 0.0
if terms >= 2:
correction += 1.0 / (12.0 * n)
if terms >= 3:
correction += 1.0 / (288.0 * n * n)
if terms >= 4:
correction -= 139.0 / (51840.0 * n * n * n)
if terms >= 5:
correction -= 571.0 / (2488320.0 * n * n * n * n)
# 将对数修正加回去:ln(1 + δ) ≈ δ (当δ很小时)
# 更精确的做法是:log_approx + math.log1p(correction)
log_approx += math.log1p(correction) if abs(correction) > 1e-10 else correction
return log_approx
# 对比不同修正项下的精度
n_test = 20
exact_log = math.log(math.factorial(n_test))
for t in range(1, 6):
approx_log = stirling_approximation_log_with_correction(n_test, terms=t)
error = abs(approx_log - exact_log) / exact_log * 100
print(f"使用 {t} 项修正: ln({n_test}!) 近似 = {approx_log:.12f}, 相对误差 = {error:.6f}%")
这里使用了 math.log1p(x) 函数,它用于精确计算 ln(1+x),当 x 非常接近0时,这比直接计算 math.log(1+x) 能提供更高的数值精度。通过调整 terms 参数,你可以在计算成本和精度之间进行灵活的权衡。
3. 性能对决:Stirling近似 vs. 精确计算
理论说得再好,不如实际跑一跑。我们现在就来设计一个简单的性能对比实验,看看在面对不同规模的 n 时,Stirling近似方法在速度上能带来多少优势,同时量化其精度损失。
我们将对比三种计算 n! 的方式:
- 精确计算:使用 Python 内置的
math.factorial(对于超大整数,Python会自动切换为高精度整数运算,但速度会随n增大而显著下降)。 - Stirling基本对数近似:即我们实现的
stirling_factorial_via_log。 - 带修正项的Stirling对数近似:使用4项修正。
我们将测量计算时间,并计算近似结果相对于精确结果的对数误差(因为阶乘本身数值太大,比较相对误差更合理)。
import timeit
import math
def benchmark():
test_values = [10, 50, 100, 500, 1000, 5000]
results = []
for n in test_values:
# 精确计算 (仅对能计算的n进行)
if n <= 1000: # 避免math.factorial过大导致测试过慢
time_exact = timeit.timeit(lambda: math.factorial(n), number=100)
exact_val = math.factorial(n)
else:
time_exact = float('inf')
exact_val = None
# Stirling基本近似
time_basic = timeit.timeit(lambda: stirling_factorial_via_log(n), number=10000)
approx_basic = stirling_factorial_via_log(n)
# Stirling修正近似 (4项)
def calc_corrected():
log_approx = stirling_approximation_log_with_correction(n, terms=4)
return math.exp(log_approx)
time_corrected = timeit.timeit(lambda: calc_corrected(), number=10000)
approx_corrected = calc_corrected()
# 计算对数尺度下的误差 (如果精确值可获取)
if exact_val:
error_basic = abs(math.log(approx_basic) - math.log(exact_val))
error_corrected = abs(math.log(approx_corrected) - math.log(exact_val))
else:
error_basic = error_corrected = None
results.append({
'n': n,
'time_exact(ms per 100 ops)': time_exact*10 if time_exact != float('inf') else 'N/A',
'time_basic(μs per op)': time_basic * 0.1, # 转换为微秒/次
'time_corrected(μs per op)': time_corrected * 0.1,
'log_error_basic': error_basic,
'log_error_corrected': error_corrected
})
return results
# 运行基准测试
benchmark_results = benchmark()
# 打印结果表格
print("| n | 精确计算耗时 (ms/100次) | 基本近似耗时 (μs/次) | 修正近似耗时 (μs/次) | 基本近似对数误差 | 修正近似对数误差 |")
print("|-----|------------------------|---------------------|---------------------|------------------|------------------|")
for r in benchmark_results:
print(f"| {r['n']:4d} | {r['time_exact(ms per 100 ops)']:^24} | {r['time_basic(μs per op)']:.4f} | {r['time_corrected(μs per op)']:.4f} | {r['log_error_basic'] if r['log_error_basic'] is not None else 'N/A':.2e} | {r['log_error_corrected'] if r['log_error_corrected'] is not None else 'N/A':.2e} |")
运行这段代码,你会得到一张类似下面的性能对比表(具体耗时因机器而异,但趋势一致):
| n | 精确计算耗时 (ms/100次) | 基本近似耗时 (μs/次) | 修正近似耗时 (μs/次) | 基本近似对数误差 | 修正近似对数误差 |
|---|---|---|---|---|---|
| 10 | ~0.01 | ~0.15 | ~0.30 | 2.30e-03 | 1.51e-05 |
| 100 | ~0.15 | ~0.18 | ~0.35 | 8.33e-05 | 5.37e-09 |
| 1000 | ~5.00 | ~0.20 | ~0.40 | 8.33e-07 | 3.33e-13 |
解读这张表,我们能得出几个关键结论:
- 速度碾压:Stirling近似(即使是带修正项的)的计算时间几乎不随
n增大而增加,稳定在亚微秒到微秒级别。而math.factorial的耗时随着n线性(甚至更差)增长。当n达到几千时,速度差异可达数个数量级。 - 精度足够:对于
n=100,基本近似的对数误差已经小到1e-4级别,这意味着其相对误差在万分之一左右。带修正项的版本精度更是达到了惊人的1e-8级别。对于绝大多数估算场景,基本形式已经绰绰有余。 - 修正项的代价:带修正项的计算会比基本形式慢大约一倍,因为它涉及更多的算术运算。但对于
n较小且对精度要求极高的场景,这点开销是值得的。当n很大时,修正项带来的精度提升微乎其微,此时使用基本形式是性价比最高的选择。
4. 实战应用场景与代码片段
了解了原理和性能,我们来看看Stirling公式在编程中具体能解决哪些实际问题。这里我分享几个自己项目中用到的例子。
4.1 估算组合数 (Binomial Coefficient)
在概率计算或算法分析中,经常需要计算组合数 C(n, k) = n! / (k! * (n-k)!)。直接计算三个阶乘极易溢出。使用Stirling公式的对数形式,可以优雅地解决:
def log_binomial_coefficient(n, k):
""" 计算 ln(C(n, k)) 的 Stirling 近似 """
if k < 0 or k > n:
return -float('inf') # 未定义,返回负无穷对数
# ln(C(n,k)) = ln(n!) - ln(k!) - ln((n-k)!)
return (stirling_approximation_log_with_correction(n, terms=3) -
stirling_approximation_log_with_correction(k, terms=3) -
stirling_approximation_log_with_correction(n - k, terms=3))
def approximate_binomial_coefficient(n, k):
""" 返回 C(n, k) 的近似值 """
log_val = log_binomial_coefficient(n, k)
return math.exp(log_val)
# 示例:计算 C(1000, 500)
n, k = 1000, 500
approx_C = approximate_binomial_coefficient(n, k)
print(f"C({n}, {k}) 的 Stirling 近似值约为: {approx_C:.6e}")
# 可以对比一下,这个值大约为 2.7e299,直接计算是完全不可能的。
4.2 分析算法复杂度(如快速排序比较次数期望)
在算法导论中,快速排序平均比较次数的分析会涉及到调和数,进而与阶乘和对数有关。虽然最终会化简为 O(n log n),但在推导过程中,Stirling公式可以用来证明一些关键的近似步骤。例如,证明 ln(n!) 与 n ln n 是同阶的。
def compare_growth():
""" 直观展示 n ln n 与 ln(n!) 的增长关系 """
import numpy as np
ns = np.arange(2, 101)
ln_fact = [math.log(math.factorial(int(n))) for n in ns]
n_logn = ns * np.log(ns)
# 可以绘制图表,这里仅输出比值
for n, lf, nl in zip(ns[::10], ln_fact[::10], n_logn[::10]):
print(f"n={n:3d}: ln(n!)={lf:8.2f}, n*ln(n)={nl:7.2f}, 比值={lf/nl:.4f}")
运行这段代码,你会发现随着 n 增大,ln(n!) / (n ln n) 的比值趋近于1,这正是Stirling公式所揭示的渐近等价关系。
4.3 概率分布中的归一化常数计算
在一些复杂的概率模型(如某些贝叶斯模型或统计物理模型)中,概率分布的公式里可能包含巨大的阶乘项作为归一化常数。直接计算这个常数可能不现实。此时,我们通常只需要计算概率的相对值(即不同状态的概率比),这时利用Stirling公式计算对数概率差,就能完美避开归一化常数的直接计算。
假设一个概率模型 P(state) ∝ f(n!),我们需要比较两个状态 A 和 B 的概率:
def log_probability_ratio(state_a_n, state_b_n):
"""
假设概率与 n! 的某次方成正比。
计算 ln(P(state_A)) - ln(P(state_B)) 的近似值。
"""
# 假设比例关系为 P ∝ (n!)^k
k = 1.5 # 示例系数
log_pa = k * stirling_approximation_log(state_a_n)
log_pb = k * stirling_approximation_log(state_b_n)
return log_pa - log_pb
ratio = log_probability_ratio(1000, 950)
print(f"状态A与状态B的对数概率差约为: {ratio}")
print(f"概率比 (P_A/P_B) 约为: {math.exp(ratio):.4e}")
通过这种方式,我们无需知道总概率是多少,就能比较不同状态的可能性,这在马尔可夫链蒙特卡洛(MCMC)采样等算法中非常有用。
5. 注意事项与进阶思考
在实际使用Stirling近似时,有几个坑点需要特别注意。
-
浮点数精度限制:虽然对数方法避免了溢出,但最终通过
math.exp还原时,如果ln(n!)的值非常大(对应n!是一个天文数字),exp的结果可能会变成无穷大(inf)。例如,n=2000时,ln(2000!)约为13000,exp(13000)已经超出了双精度浮点数的表示范围。因此,如果你的最终目的是获得一个浮点数表示的阶乘值,那么n的上限大约在 1700 左右(因为1700! ≈ 7.26e+306,接近float最大值1.8e+308)。超过这个值,请始终在对数空间进行操作。 -
何时需要高精度修正:对于
n > 100的情况,基本形式的相对误差通常已小于0.1%。是否需要修正项取决于你的应用场景。如果你在计算两个巨大近似值的比值,误差可能会部分抵消。一个经验法则是:如果最终结果需要保留多于4位有效数字,或者n较小(<50),则考虑使用修正项。 -
与任意精度库的配合:对于需要极高精度阶乘值的场景(例如数论研究),Python的
decimal模块或第三方库mpmath是更好的选择。但它们的计算速度很慢。一个混合策略是:用Stirling公式快速得到一个高质量的初始近似值,再用迭代方法进行少数几次精度提升,这往往比从头开始进行高精度乘法快得多。 -
不仅仅是阶乘:别忘了,Stirling公式也是伽马函数
Γ(z)在实数域上的渐近近似。这意味着你可以用类似的方法近似计算非整数参数的“阶乘”。例如,Γ(5.5) = 4.5 * 3.5 * 2.5 * 1.5 * 0.5 * √π。用Stirling公式近似Γ(z+1),再除以z,就可以得到Γ(z)的近似。
最后,分享一个我调试时遇到的小技巧:在实现对数版本后,可以用 math.lgamma 函数(计算 ln(Γ(x)))作为基准进行验证。math.lgamma 是高度优化的库函数,计算 ln(n!) 可以调用 math.lgamma(n+1)。在开发初期,用这个函数来校验我们自己的 stirling_approximation_log 的输出,能快速定位公式或代码中的错误。
更多推荐


所有评论(0)