数值模拟:微分方程求解 (Numerical Simulation)


章节概述

科学计算的核心任务之一是求解微分方程——从牛顿力学到量子力学,从电路分析到流行病模型,微分方程无处不在。本章从常微分方程(ODE)的 scipy.integrate.solve_ivp 入手,覆盖 Runge-Kutta 方法家族;随后使用 NumPy 有限差分法求解一维热方程(PDE)实例;最后介绍蒙特卡洛模拟(π 估计、期权定价)作为随机模拟的入门。我们将对比 C 语言中手写 RK4 的代码量与 SciPy 一行调用的差距,并简要提及可视化输出的去向。

核心理念:数值模拟是”理论”与”实验”之外的第三条科研道路。Python 科学栈让你在几分钟内搭建起在 C 中需要几天才能写好的仿真原型——先理解你的模型行为,再决定是否需要 C 级别的性能优化。


第一节:常微分方程(ODE)数值解

1.1 solve_ivp 基础

SciPy 的 solve_ivp 求解初值问题 dy/dt = f(t, y),提供多种 Runge-Kutta 方法。

import numpy as np
from scipy.integrate import solve_ivp
import matplotlib.pyplot as plt
 
# 经典 ODE:指数衰减 dy/dt = -k*y, y(0) = y0
def decay(t, y, k):
 return -k * y
 
k, y0 = 0.5, 10.0
sol = solve_ivp(decay, [0, 10], [y0], args=(k,),
 method='RK45', dense_output=True)
 
print(f"求解状态: {sol.message}")
print(f"时间点数: {len(sol.t)}")
print(f"y(5) ≈ {sol.sol(5)[0]:.4f}") # 解析解: 10*exp(-0.5*5) ≈ 0.8208
 
# 设置固定的评估点
t_eval = np.linspace(0, 10, 100)
sol = solve_ivp(decay, [0, 10], [y0], args=(k,),
 t_eval=t_eval, method='RK45')

1.2 Runge-Kutta 方法家族

方法类型精度适用场景
RK45(默认)显式,自适应步长5(4) 阶非刚性 ODE
RK23显式,自适应步长3(2) 阶低精度需求
DOP853显式,8 阶高精度高精度需求
Radau隐式5 阶刚性 ODE
BDF隐式,多步可变阶严重刚性 ODE
LSODA自动切换可变未知刚性时

刚性(stiffness) 是数值 ODE 求解的核心挑战:当系统中存在差异巨大的时间尺度时,显式方法必须使用极小的步长才能稳定。隐式方法(Radau, BDF)通过求解非线性方程组来保持稳定性,虽然每步更贵但总体更高效。

1.3 刚性 ODE 示例

import numpy as np
from scipy.integrate import solve_ivp
import time
 
# Van der Pol 振荡器(在 μ 很大时刚性)
def vanderpol(t, y, mu):
 return [y[1], mu * (1 - y[0]**2) * y[1] - y[0]]
 
mu = 1000 # 大 μ → 刚性
y0 = [2, 0]
 
# 显式 RK45 — 极慢(步长被限制)
t0 = time.perf_counter()
sol_rk45 = solve_ivp(vanderpol, [0, 3000], y0, args=(mu,),
 method='RK45')
t1 = time.perf_counter()
print(f"RK45: {t1-t0:.2f}s, 步数: {len(sol_rk45.t)}")
 
# 隐式 Radau — 快很多
t0 = time.perf_counter()
sol_radau = solve_ivp(vanderpol, [0, 3000], y0, args=(mu,),
 method='Radau')
t1 = time.perf_counter()
print(f"Radau: {t1-t0:.2f}s, 步数: {len(sol_radau.t)}")
# Radau 步数远少于 RK45,整体时间大幅缩短

1.4 事件(Events)——提前终止

import numpy as np
from scipy.integrate import solve_ivp
 
# 抛体运动:y'' = -g,求解何时落地
def projectile(t, state):
 x, y, vx, vy = state
 return [vx, vy, 0, -9.81]
 
# 定义事件:y = 0(触地),检测下降过程中穿过零点
def hit_ground(t, state):
 return state[1] # y 坐标
 
hit_ground.terminal = True # 触地时停止积分
hit_ground.direction = -1 # 仅在下降时检测
 
sol = solve_ivp(projectile, [0, 10], [0, 0, 10, 20],
 events=hit_ground, max_step=0.01)
 
print(f"飞行时间: {sol.t[-1]:.3f} s")
print(f"落地点 x: {sol.y[0, -1]:.3f} m")

第二节:偏微分方程(PDE)— 热方程

2.1 一维热方程的有限差分解

热方程 ∂u/∂t = α ∂²u/∂x² 使用显式 Euler 时间推进 + 中心差分空间离散:

import numpy as np
 
# 参数
alpha = 0.01 # 热扩散系数
L = 1.0 # 杆的长度
nx = 50 # 空间网格点
dx = L / (nx - 1)
dt = 0.001 # 时间步长
nt = 5000 # 时间步数
 
# 稳定性条件(显式格式的 CFO 条件)
cfl = alpha * dt / dx**2
print(f"CFL = {cfl:.4f} (必须 < 0.5 以保证稳定)")
 
# 初始条件:高斯热脉冲
x = np.linspace(0, L, nx)
u = np.exp(-((x - 0.5)**2) / 0.01)
 
# 时间推进 — 向量化(无 Python 循环)
for n in range(nt):
 # 二阶中心差分:∂²u/∂x² ≈ (u[i+1] - 2*u[i] + u[i-1]) / dx²
 u[1:-1] += alpha * dt / dx**2 * (u[2:] - 2*u[1:-1] + u[:-2])
 
 # Dirichlet 边界条件:两端固定为 0
 u[0] = u[-1] = 0.0
 
 if n % 1000 == 0:
 print(f"步骤 {n:5d}, 总热量: {u.sum() * dx:.6f}")
 
print(f"最终总热量: {u.sum() * dx:.6f}")
# 热量应几乎完全耗散(因两端边界为 0)

CFL 稳定性条件:显式格式要求 α·dt/dx² < 0.5,否则数值解振动发散。隐式格式(如 Crank-Nicolson)无条件稳定,但每步需解线性方程组。

2.2 与 C 实现的对比

对比同样用 C 语言实现显式热方程求解器(伪代码):

// C 语言手写热方程(约 40 行,需管理内存、边界、CFL 检查)
double *u = malloc(nx * sizeof(double));
double *u_new = malloc(nx * sizeof(double));
 
// 初始化...
for (int n = 0; n < nt; n++) {
 for (int i = 1; i < nx-1; i++) {
 u_new[i] = u[i] + alpha * dt / (dx*dx)
 * (u[i+1] - 2*u[i] + u[i-1]);
 }
 // 交换指针、处理边界...
 double *tmp = u; u = u_new; u_new = tmp;
}
// free / 错误检查 ...

NumPy 版本的代码行数约为 C 版本的 1/5,且无需管理内存、边界检查。但 C 版本在编译器 -O3 优化下可能比纯 NumPy 向量化还快(因为无需 Python 对象)。权衡:开发速度 vs 运行时性能

关于性能对比的深入讨论,参见 性能对比章节


第三节:蒙特卡洛模拟

3.1 π 估计

蒙特卡洛方法的核心思想是用随机采样逼近确定性量:

import numpy as np
 
N = 1_000_000
 
# 在 [-1,1] 正方形中均匀采样
x = np.random.uniform(-1, 1, N)
y = np.random.uniform(-1, 1, N)
 
# 点在单位圆内的比例 → π/4
inside = (x**2 + y**2) <= 1
pi_estimate = 4 * inside.sum() / N
 
print(f"π 估计值: {pi_estimate:.6f}")
print(f"真实值: {np.pi:.6f}")
print(f"误差: {abs(pi_estimate - np.pi):.6f}")
# 蒙特卡洛误差 ∝ 1/√N,约 0.001 量级

3.2 欧式期权定价

Black-Scholes 模型中,股票价格遵循几何布朗运动:

ST = S0 * exp((r - σ²/2)*T + σ*√T*ε) 其中 ε ~ N(0,1)
import numpy as np
 
S0, K, r, sigma, T = 100, 105, 0.05, 0.2, 1.0
N = 500_000
 
np.random.seed(42)
Z = np.random.randn(N)
ST = S0 * np.exp((r - 0.5 * sigma**2) * T + sigma * np.sqrt(T) * Z)
 
# 看涨期权 payoff = max(ST - K, 0)
payoff = np.maximum(ST - K, 0)
 
# 现值贴现
option_price = np.exp(-r * T) * payoff.mean()
std_error = np.exp(-r * T) * payoff.std() / np.sqrt(N)
 
print(f"期权价格: {option_price:.4f}")
print(f"95% 置信区间: [{option_price - 1.96*std_error:.4f}, {option_price + 1.96*std_error:.4f}]")
 
# Black-Scholes 解析解(对比用)
from scipy.stats import norm
d1 = (np.log(S0/K) + (r + 0.5*sigma**2)*T) / (sigma*np.sqrt(T))
d2 = d1 - sigma*np.sqrt(T)
bs_price = S0*norm.cdf(d1) - K*np.exp(-r*T)*norm.cdf(d2)
print(f"BS 解析价格: {bs_price:.4f}")

蒙特卡洛的收敛速度是 O(1/√N)——要想提高一位小数精度,需要 100 倍的计算量。对于低维问题,确定性积分方法(如 Simpson 规则)更高效;蒙卡的优势在于高维积分(> 4 维),其中确定性方法遭遇”维数诅咒”。

3.3 方差缩减技巧

import numpy as np
 
# 对偶变量法:用一对相反的随机数,降低方差
N_half = N // 2
Z = np.random.randn(N_half)
ST1 = S0 * np.exp((r - 0.5*sigma**2)*T + sigma*np.sqrt(T)*Z)
ST2 = S0 * np.exp((r - 0.5*sigma**2)*T + sigma*np.sqrt(T)*(-Z))
 
payoff_avg = 0.5 * (np.maximum(ST1 - K, 0) + np.maximum(ST2 - K, 0))
price_antithetic = np.exp(-r*T) * payoff_avg.mean()
print(f"对偶变量法价格: {price_antithetic:.4f}")

第四节:C 实现对比

4.1 手写 RK4 对比

以下是在 C 中手写一个单步的经典四阶 Runge-Kutta(RK4)所需的核心函数:

// C: 手写 RK4 的 dydt(导数函数)
void dydt(double t, double *y, double *dydt_out, void *params) {
 double k = *(double*)params;
 dydt_out[0] = -k * y[0]; // dy/dt = -k*y
}
 
// C: 通用 RK4 一步
void rk4_step(void (*f)(double, double*, double*, void*),
 double t, double *y, double h, int n_eq,
 double *y_new, void *params) {
 double *k1 = malloc(n_eq * sizeof(double));
 double *k2 = malloc(n_eq * sizeof(double));
 double *k3 = malloc(n_eq * sizeof(double));
 double *k4 = malloc(n_eq * sizeof(double));
 double *ytmp = malloc(n_eq * sizeof(double));
 
 f(t, y, k1, params);
 
 for (int i = 0; i < n_eq; i++)
 ytmp[i] = y[i] + 0.5 * h * k1[i];
 f(t + 0.5*h, ytmp, k2, params);
 
 for (int i = 0; i < n_eq; i++)
 ytmp[i] = y[i] + 0.5 * h * k2[i];
 f(t + 0.5*h, ytmp, k3, params);
 
 for (int i = 0; i < n_eq; i++)
 ytmp[i] = y[i] + h * k3[i];
 f(t + h, ytmp, k4, params);
 
 for (int i = 0; i < n_eq; i++)
 y_new[i] = y[i] + (h/6.0) * (k1[i] + 2*k2[i] + 2*k3[i] + k4[i]);
 
 free(k1); free(k2); free(k3); free(k4); free(ytmp);
}

对比 Python 等价代码:

from scipy.integrate import solve_ivp
 
def dydt(t, y, k):
 return -k * y
 
sol = solve_ivp(dydt, [0, 10], [10.0], args=(0.5,), method='RK45')

Python 版本不仅更短,而且具有:

  • 自适应步长(C 版本需要额外数百行代码实现)
  • 事件检测
  • 刚性自动检测(LSODA)
  • 稠密插值输出

4.2 何时该用 C

场景推荐
原型开发、实验Python(SciPy)
单次运行、低维Python
大规模 Monte CarloPython + Numba 或 C
实时嵌入式系统C(无 Python 解释器可用)
大规模 PDE(如 CFD)C/Fortran/C++(或 Python 调用 C 后端)
参数扫描(同一模型多次运行)C(或 Python + 多进程/MPI)

关于 Python → C 性能桥接的完整讨论,见 性能对比pybind11 与 Cython


练习

以下题目用于验证本章所学内容:

题号题目链接涉及知识点
50Pow(x, n)https://leetcode.cn/problems/powx-n/数值计算、迭代
69x 的平方根https://leetcode.cn/problems/sqrtx/牛顿迭代、数值方法
509斐波那契数https://leetcode.cn/problems/fibonacci-number/递推、数值序列