在分子动力学的数值方法模拟中,经常使用Leapfrog算法来“追踪”每个粒子的速度以及位置。Leapfrog的最简单总结就是,其在时间上把对速度求解和对位置求解(即对速度积分)分离开,在此刻算速度,在下一刻算位置,循环往复。

那么,为什么要这样做呢?为什么Euler方法的「速度和位置同时算」就会带来能量增长以及精度下降?一个可行的分析角度是从哈密顿力学的相空间出发。

一、Leapfrog算法简述

对于一个只考虑静电场的数值程序,标准的staggered Leapfrog模式为:

  • 位置 $\mathbf{x}$ 定义在整数时间步 $t^n$;
  • 速度 $\mathbf{v}$ 定义在半整数时间步 $t^{n+1/2}$;
  • 电场由 $t^n$ 时刻的位置/电荷密度计算。

时间推进循环为:

$$
\mathbf{v}^{n+1/2} = \mathbf{v}^{n-1/2} + \frac{q}{m} \mathbf{E}(\mathbf{x}^n) \Delta t
$$

$$
\mathbf{x}^{n+1} = \mathbf{x}^n + \mathbf{v}^{n+1/2} \Delta t
$$

这个模式满足时间反演对称性,当把$\Delta t$换以$-\Delta t$,公式的形式完全不变。这意味着:将同一物理过程向前推进100步再向后推进100步,理论上将精确回到起点。

而在实际数值方法实现上(即 离散化后),上述时间反演对称性仍然将被以一种振荡的方式满足。为了在数学上更清晰的理解这件事情,我们需要借助相空间这一工具来看。

二、相空间面积及刘维尔定理

在由位置-动量组成的坐标系,即相空间中,每个相点$(q, p)$的轨迹称为相曲线。对于保守哈密顿系统(无耗散,$\mathcal{H}$二阶连续可微),刘维尔定理断言这个相曲线为一封闭曲线,并且其围成的面积不变。或者其规范表述:

保守哈密顿系统的相流在演化中保持相空间体积(测度)不变,等价于‌分布函数沿相轨迹的随体导数为零。

2.1 辛结构与体积守恒

设相空间坐标 $\mathbf{z} = (q_1, \ldots, q_n, p_1, \ldots, p_n)$,哈密顿方程为:

$$
\dot{\mathbf{z}} = \mathbf{J} \nabla \mathcal{H}
$$

其中 $\mathbf{J}$ 是辛矩阵(symplectic matrix):

$$
\mathbf{J} = \begin{pmatrix} \mathbf{0} & \mathbf{I} \ -\mathbf{I} & \mathbf{0} \end{pmatrix}
$$

相流 $\phi_t$ 将初始点 $\mathbf{z}_0$ 映射到 $t$ 时刻的 $\mathbf{z}(t)$。其雅可比矩阵 $\mathbf{M}(t) = \partial\mathbf{z}(t)/\partial\mathbf{z}_0$ 满足辛条件

$$
\mathbf{M}^\top \mathbf{J} \mathbf{M} = \mathbf{J}
$$

取行列式:

$$
\det(\mathbf{M}^\top) \det(\mathbf{J}) \det(\mathbf{M}) = \det(\mathbf{J})
$$

由于 $\det(\mathbf{J}) \neq 0$ 且 $\det(\mathbf{M}^\top) = \det(\mathbf{M})$:

$$
\det(\mathbf{M})^2 = 1 \quad \Rightarrow \quad \det(\mathbf{M}) = \pm 1
$$

对于从恒等映射连续演化的相流,$\det(\mathbf{M}(0)) = +1$,故对所有 $t$ 有 $\det(\mathbf{M}(t)) = +1$。这意味着相空间体积元 $dV = dq_1 \wedge dp_1 \wedge \cdots \wedge dq_n \wedge dp_n$ 在演化中严格守恒。

2.2 面积与能量的关系

对于一维简谐振子,哈密顿量为:

$$
\mathcal{H} = \frac{p^2}{2m} + \frac{1}{2}kq^2 = E
$$

等能面是相空间中的椭圆,半轴分别为:

$$
a = \sqrt{\frac{2E}{k}}, \quad b = \sqrt{2mE}
$$

椭圆面积为:

$$
A = \pi ab = \pi \sqrt{\frac{2E}{k}} \sqrt{2mE} = 2\pi E \sqrt{\frac{m}{k}} = \frac{2\pi E}{\omega}
$$

其中 $\omega = \sqrt{k/m}$ 是振子角频率。面积与能量成正比:$A \propto E$。

因此,若数值方法导致相空间面积膨胀($\det \mathbf{M} > 1$),则等效于系统能量被人工放大;若面积收缩($\det \mathbf{M} < 1$),则能量被人工耗散。只有 $\det \mathbf{M} = 1$ 时,能量才不会出现系统性漂移。

2.3 离散映射的体积变化

将连续相流替换为离散映射 $\mathbf{z}_{n+1} = \mathbf{F}(\mathbf{z}_n)$ 时,每一步的雅可比矩阵 $\mathbf{M}_n = \partial\mathbf{F}/\partial\mathbf{z}_n$ 决定了体积变化:

$$
\Delta V_{n+1} = \det(\mathbf{M}_n) \cdot \Delta V_n
$$

  • 若 $\det(\mathbf{M}_n) > 1$(如 Euler 方法),体积指数膨胀,能量单调增长;
  • 若 $\det(\mathbf{M}_n) < 1$,体积指数收缩,能量单调衰减;
  • 若 $\det(\mathbf{M}_n) = 1$(如 Leapfrog 方法),体积守恒,能量围绕真实值振荡。

三、Euler方法的相空间面积不守恒

以简谐振子为例:
$$
H = \frac{p^2}{2m} + \frac{1}{2}kq^2
$$

运动方程:
$$
\dot{q} = \frac{p}{m}, \quad \dot{p} = -kq
$$

前向 Euler 的离散映射为(整步推进):
$$
q_{n+1} = q_n + \frac{p_n}{m}\Delta t
$$
$$
p_{n+1} = p_n – kq_n\Delta t
$$

写成矩阵:

计算这个矩阵的行列式:
$$
\det(M_{\text{Euler}}) = 1 \cdot 1 – \left(\frac{\Delta t}{m}\right)(-k\Delta t) = 1 + \frac{k(\Delta t)^2}{m}
$$

因为 $k, m, \Delta t$ 都是正的,所以
$$
\det(M_{\text{Euler}}) = 1 + \underbrace{\frac{k(\Delta t)^2}{m}}_{> 0} > 1
$$

行列式 > 1 意味着,相空间中任何微小面积 $dA = dq \times dp$,经过 Euler 一步映射后:
$$
dA_{n+1} = \det(M) \cdot dA_n = \left(1 + \frac{k(\Delta t)^2}{m}\right) dA_n
$$

可见,每步面积都在膨胀。那么例如1000步后:
$$
dA_{1000} = \left(1 + \frac{k(\Delta t)^2}{m}\right)^{1000} dA_0 \approx e^{1000 \cdot \frac{k(\Delta t)^2}{m}} dA_0
$$

这就是指数增长的来源。Euler 把相空间“拉伸”了,并且只拉伸不压缩。因此在长步数下计算得到的结果难以满足能量守恒等的物理先验。

四、Leapfrog方法的相空间面积不变

Leapfrog对简谐振子模型可以写成:

$$
p_{n+1/2} = p_{n-1/2} – kq_n\Delta t
$$
$$
q_{n+1} = q_n + \frac{p_{n+1/2}}{m}\Delta t
$$

或者更清楚地看成两个正则变换的复合:

Step 1: Kick(只动量变,位置不变):

矩阵:
$$
M_{\text{kick}} = \begin{pmatrix} 1 & 0 \ -\frac{k\Delta t}{2} & 1 \end{pmatrix}, \quad \det = 1
$$

Step 2: Drift(只位置变,动量不变)

矩阵:
$$
M_{\text{drift}} = \begin{pmatrix} 1 & \frac{\Delta t}{m} \ 0 & 1 \end{pmatrix}, \quad \det = 1
$$

Step 3: 再 Kick

完整的 Leapfrog 映射:
$$
M_{\text{Leapfrog}} = M_{\text{kick}} \cdot M_{\text{drift}} \cdot M_{\text{kick}}
$$

注意到,每个子步的映射都是辛映射,矩阵行列式都为1,3次或者有限次矩阵相乘,结果行列式仍为1:
$$
\det(M_{\text{Leapfrog}}) = 1 \times \ldots \times 1 = 1
$$

所以,Leapfrog推进的相空间面积严格守恒。

在代码实现上,需要对上述计算公式进行离散化,此时Leapfrog推进的相空间面积将不是定值,但仍然将在初始面积的附近振荡。这在一定精度下确保了能量守恒的物理先验。

如图是一个以Python程序实现的两种方法对比,分别绘制了两种方法运行10步、100步后的相空间面积。可见,Leapfrog相比于Euler算法更好地贴切了初始状态的相空间面积。

五、总结

Euler 的离散映射不是正则变换($\det M \neq 1$),它在每一步“放大”了相空间面积,而面积 $\propto$ 能量,所以能量指数增长。

Leapfrog 的离散映射是正则变换($\det M = 1$),它只“拧转”相空间(剪切变形),面积绝对值不变,所以能量不会系统性漂移,只会围绕真实值振荡。

附:Python程序

import numpy as np
import matplotlib.pyplot as plt

# ========== 参数设置 ==========
m = 1.0
k = 1.0
dt = 0.3
n_points = 200  # 圆盘上的点数
r = 1.0

# 生成初始圆盘
theta = np.linspace(0, 2*np.pi, n_points, endpoint=False)
q0 = r * np.cos(theta)
p0 = r * np.sin(theta)

def polygon_area(q, p):
    """鞋带公式计算多边形面积"""
    return 0.5 * abs(np.sum(q[:-1]*p[1:] - q[1:]*p[:-1]) + q[-1]*p[0] - q[0]*p[-1])

def run_euler(q, p, n_steps, dt, m, k):
    """前向Euler推进"""
    q_e, p_e = q.copy(), p.copy()
    for _ in range(n_steps):
        q_new = q_e + (p_e / m) * dt
        p_new = p_e - k * q_e * dt
        q_e, p_e = q_new, p_new
    return q_e, p_e

def run_leapfrog(q, p, n_steps, dt, m, k):
    """Leapfrog推进,返回整数时间层的(q, p)"""
    q_l = q.copy()
    # 初始化半步速度: v^{-1/2} = v^0 - 0.5*a^0*dt
    p_half = p.copy() - 0.5 * (-k * q_l) * dt
    
    for _ in range(n_steps):
        q_l = q_l + (p_half / m) * dt      # Drift
        p_half = p_half + (-k * q_l) * dt   # Kick
    
    # 最后半步kick回到整数时间层: p^N = p^{N+1/2} - 0.5*a^N*dt
    p_l = p_half - 0.5 * (-k * q_l) * dt
    return q_l, p_l

# ========== 运行模拟 ==========
steps_list = [10, 100]
results = {}
for n in steps_list:
    q_e, p_e = run_euler(q0, p0, n, dt, m, k)
    q_l, p_l = run_leapfrog(q0, p0, n, dt, m, k)
    results[n] = {
        'euler': (q_e, p_e, polygon_area(q_e, p_e)),
        'leapfrog': (q_l, p_l, polygon_area(q_l, p_l)),
        'det_theory': (1 + k*dt**2/m)**n
    }

area0 = polygon_area(q0, p0)

# ========== 绘图 ==========
fig, axes = plt.subplots(2, 3, figsize=(14, 9))
fig.suptitle('Phase Space Area Conservation: Euler vs Leapfrog', 
             fontsize=14, fontweight='bold', y=0.98)

c_euler, c_leapfrog, c_init = '#E74C3C', '#3498DB', '#2C3E50'

# 第一行:10步
for col, (title, color, data_key) in enumerate([
    ('Initial State', c_init, None),
    ('Forward Euler (10 steps)', c_euler, 'euler'),
    ('Leapfrog (10 steps)', c_leapfrog, 'leapfrog')
]):
    ax = axes[0, col]
    if data_key is None:
        q, p, area = q0, p0, area0
    else:
        q, p, area = results[10][data_key]
    ax.fill(q, p, color=color, alpha=0.15)
    ax.plot(q, p, 'o', color=color, markersize=2, alpha=0.6)
    ax.set_title(f'{title}\nArea = {area:.3f}', fontsize=11, 
                 color=color if data_key else c_init)
    ax.set_ylabel('p')
    ax.set_aspect('equal')
    ax.grid(True, alpha=0.3)
    ax.set_xlim(-4, 4)
    ax.set_ylim(-4, 4)

# 第二行:100步
for col, (title, color, data_key) in enumerate([
    ('Initial State', c_init, None),
    ('Forward Euler (100 steps)', c_euler, 'euler'),
    ('Leapfrog (100 steps)', c_leapfrog, 'leapfrog')
]):
    ax = axes[1, col]
    if data_key is None:
        q, p, area = q0, p0, area0
    else:
        q, p, area = results[100][data_key]
    ax.fill(q, p, color=color, alpha=0.15)
    ax.plot(q, p, 'o', color=color, markersize=1.5, alpha=0.5)
    
    if data_key == 'euler':
        det_th = results[100]['det_theory']
        ax.set_title(f'{title}\nArea = {area:.0f}\n(expansion factor: {area/area0:.0f}×)', 
                     fontsize=11, color=color)
        max_c = max(np.abs(q).max(), np.abs(p).max()) * 1.1
        ax.set_xlim(-max_c, max_c)
        ax.set_ylim(-max_c, max_c)
    else:
        ax.set_title(f'{title}\nArea = {area:.3f}', fontsize=11, 
                     color=color if data_key else c_init)
        ax.set_xlim(-4, 4)
        ax.set_ylim(-4, 4)
    
    ax.set_xlabel('q')
    ax.set_ylabel('p')
    ax.set_aspect('equal')
    ax.grid(True, alpha=0.3)

plt.tight_layout(rect=[0, 0, 1, 0.96])
plt.savefig('phase_space_demo.png', dpi=200, bbox_inches='tight', 
            facecolor='white', edgecolor='none')
plt.show()

# ========== 打印总结 ==========
print("=" * 60)
print("相空间面积守恒演示结果")
print("=" * 60)
print(f"参数: m={m}, k={k}, dt={dt}, 初始圆盘半径 r={r}")
print(f"Euler行列式: det = 1 + k·dt²/m = {1 + k*dt**2/m:.4f}")
for n in steps_list:
    _, _, area_e = results[n]['euler']
    _, _, area_l = results[n]['leapfrog']
    print(f"\n{n} 步后:")
    print(f"  Euler:    面积 = {area_e:10.2f}  (膨胀 {area_e/area0:.0f}×)")
    print(f"  Leapfrog: 面积 = {area_l:10.4f}  (守恒)")

备注:①代码由Kimi生成。②实务中,考虑计算速度,通常使用C++程序实现,此处只做效果演示。