Задача N тел: гравитация каждого с каждым

Добавь третье тело — и точного решения больше нет; задача N тел живёт только в симуляции.

Задача N тел — расчёт движения системы из $N$ гравитирующих тел, каждое из которых притягивает каждое; для $N \ge 3$ общего аналитического решения не существует.

Почему N тел — это сложно

Две гравитирующие точки решаются аналитически — это орбиты Кеплера. Но стоит добавить третье тело, и замкнутого решения уже нет: система становится хаотической, чувствительной к начальным условиям. Зато численно задача N тел решается прямолинейно. Идея проста: ускорение каждого тела — это сумма притяжений со стороны всех остальных. Если тел $N$, то на каждом шаге для каждого тела складываем $N-1$ вкладов:

$$\vec a_i = \sum_{j \neq i} G\frac{m_j}{r_{ij}^2}\,\hat r_{ij},$$

где $r_{ij}$ — расстояние между телами $i$ и $j$, а $\hat r_{ij}$ — единичный вектор от $i$ к $j$. Это двойной цикл «каждый с каждым», стоимостью $O(N^2)$ операций на шаг.

Симуляция системы из трёх тел

Соберём звезду и две планеты. Звезду не закрепляем — пусть тоже движется (она чуть качается от притяжения планет, это и есть честная N-body постановка). Главный тест корректности — суммарный импульс системы: на него не действуют внешние силы, поэтому он обязан сохраняться.

import math
G = 4*math.pi**2
bodies = [
    {"m": 1.0,  "x": 0.0,  "y": 0.0, "vx": 0.0, "vy": 0.0},                # звезда
    {"m": 1e-3, "x": 1.0,  "y": 0.0, "vx": 0.0, "vy": math.sqrt(G/1.0)},   # планета A
    {"m": 1e-3, "x": -2.0, "y": 0.0, "vx": 0.0, "vy": -math.sqrt(G/2.0)},  # планета B
]
def accelerations(bodies):
    n = len(bodies)
    acc = [[0.0, 0.0] for _ in range(n)]
    for i in range(n):
        for j in range(n):
            if i == j:
                continue
            dx = bodies[j]["x"] - bodies[i]["x"]
            dy = bodies[j]["y"] - bodies[i]["y"]
            r = math.hypot(dx, dy)
            f = G*bodies[j]["m"]/r**3      # сила/масса_i на единицу dx,dy
            acc[i][0] += f*dx; acc[i][1] += f*dy
    return acc
def total_p(bodies):
    return (sum(b["m"]*b["vx"] for b in bodies),
            sum(b["m"]*b["vy"] for b in bodies))

dt = 0.001
for s in range(0, 2001):
    if s % 400 == 0:
        rA = math.hypot(bodies[1]["x"], bodies[1]["y"])
        rB = math.hypot(bodies[2]["x"], bodies[2]["y"])
        px, py = total_p(bodies)
        print(f"t={s*dt:.2f}  r_A={rA:.3f}  r_B={rB:.3f}  P=({px:+.6f}, {py:+.6f})")
    acc = accelerations(bodies)
    for i, b in enumerate(bodies):
        b["vx"] += acc[i][0]*dt; b["vy"] += acc[i][1]*dt
    for b in bodies:
        b["x"] += b["vx"]*dt; b["y"] += b["vy"]*dt

Вывод:

t=0.00  r_A=1.000  r_B=2.000  P=(+0.000000, +0.001840)
t=0.40  r_A=0.996  r_B=1.998  P=(+0.000000, +0.001840)
t=0.80  r_A=0.997  r_B=1.998  P=(-0.000000, +0.001840)
t=1.20  r_A=0.998  r_B=2.000  P=(+0.000000, +0.001840)
t=1.60  r_A=0.998  r_B=2.004  P=(+0.000000, +0.001840)
t=2.00  r_A=0.999  r_B=2.007  P=(+0.000000, +0.001840)

Обе планеты держат свои орбиты (радиусы около $1$ и $2$ а.е.), а суммарный импульс системы стоит как вкопанный — $P_y = 0.001840$ от начала до конца. Это бесценная проверка: если бы импульс «поплыл», в коде была бы ошибка (например, несимметричный учёт сил). Сохранение суммарного импульса — встроенный детектор багов любого N-body движка.

Как работает под капотом

Двойной цикл $O(N^2)$ хорош для десятков тел, но для миллионов звёзд в галактике он непосилен. Тогда применяют умные приближения: метод Барнса — Хатта группирует далёкие тела в «облака» и считает их как одну точку, снижая стоимость до $O(N\log N)$; particle-mesh методы раскладывают гравитацию через сетку. Но идея неизменна: ускорение — сумма вкладов. Ещё одна тонкость — смягчение (softening): при близком сближении $r \to 0$ сила $\frac{1}{r^2}$ взрывается и симуляция разлетается; поэтому в знаменатель добавляют малую константу $\varepsilon$, заменяя $r^2$ на $r^2 + \varepsilon^2$. Это убирает численные «выстрелы» при тесных контактах ценой крошечного искажения близкодействия.

Частые ошибки

  • Закреплять звезду без нужды. Для честной N-body все тела движутся; фиксация центра нарушает сохранение импульса.
  • Делить на $r^2$ без смягчения. При тесном сближении сила взрывается и тела «выстреливают» в бесконечность; нужен softening $\varepsilon$.
  • Не проверять сохранение импульса/энергии. Это главные индикаторы корректности; без них баг в суммировании сил незаметен.

Итог

  • Ускорение тела — сумма притяжений всех остальных ($O(N^2)$ двойной цикл).
  • Для $N \ge 3$ аналитики нет — задача живёт только в симуляции.
  • Сохранение суммарного импульса — встроенный тест корректности движка.
  • Для масштаба и устойчивости нужны приближения ($O(N\log N)$) и смягчение силы $\varepsilon$.
Проверьте себя
1. Как вычисляется ускорение тела в задаче N тел?
AБерётся только ближайшее тело
BСуммируются гравитационные вклады всех остальных тел
CИспользуется среднее всех масс
DУскорение всегда направлено к центру координат
2. Зачем в знаменатель силы добавляют малую константу ε (смягчение)?
AДля красоты
BЧтобы при тесном сближении (r→0) сила не взрывалась и тела не выстреливали
CЧтобы ускорить цикл
DЧтобы сохранить энергию точно
3. Почему сохранение суммарного импульса — хороший тест N-body движка?
AЕго легко напечатать
BНа замкнутую систему нет внешних сил, поэтому импульс обязан сохраняться; его дрейф выдаёт баг
CИмпульс всегда равен нулю
DЭто требование Pyodide