每日算法 — 使用java实现投壶:抛物线运动学与最优投射角度

引言

投壶是中国古代一种雅致的礼仪性游戏,起源可追溯至春秋战国时期。参与者手持箭矢,站在一定距离外,将箭矢投入壶中以决胜负。这一看似简单的动作背后,蕴含着丰富的物理学原理与优化问题:给定壶的位置、箭的初速度范围,如何找到最佳投射角度,使箭准确落入壶中?本文将用Java实现投壶的物理模拟系统,讲解抛物线运动学建模,并通过三分搜索算法求解最优投射角度,帮助读者在经典游戏场景中理解运动学与数值优化的结合。

算法原理

抛物线运动学模型

忽略空气阻力时,抛体运动可分解为水平方向的匀速直线运动和竖直方向的匀加速直线运动。设箭的初速度为 $v_0$,投射角度为 $\theta$,重力加速度为 $g$:

  • 水平位移:$x(t) = v_0 \cdot \cos(\theta) \cdot t$
  • 竖直位移:$y(t) = v_0 \cdot \sin(\theta) \cdot t – \frac{1}{2} g t^2$

从水平位移公式解出时间 $t = \frac{x}{v_0 \cdot \cos(\theta)}$,代入竖直位移公式得到轨迹方程:

$$y(x) = x \cdot \tan(\theta) – \frac{g \cdot x^2}{2 v_0^2 \cdot \cos^2(\theta)}$$

最优角度搜索

对于固定的初速度 $v_0$ 和目标水平距离 $D$,我们需要找到角度 $\theta$ 使得箭在 $x=D$ 处的竖直位置恰好等于壶的高度。由于轨迹方程关于 $\theta$ 是单峰的,可以使用三分搜索高效地找到最优解。

三分搜索的核心思想:在单峰函数的区间 $[l, r]$ 内取两个三分点 $m_1$ 和 $m_2$,比较函数值后缩小区间,每次迭代将搜索范围缩小约 $1/3$,时间复杂度为 $O(\log(\frac{r-l}{\epsilon}))$。

Java实现

1. 物理引擎核心类

/**
 * 投壶物理引擎:模拟箭矢的抛物线运动
 */
public class PitchPotPhysics {
    // 物理常量
    private static final double G = 9.8;           // 重力加速度 (m/s^2)
    private static final double EPSILON = 1e-9;    // 数值精度

    private final double distance;    // 壶的水平距离 (m)
    private final double potHeight;   // 壶口高度 (m)
    private final double potRadius;   // 壶口半径 (m)

    public PitchPotPhysics(double distance, double potHeight, double potRadius) {
        this.distance = distance;
        this.potHeight = potHeight;
        this.potRadius = potRadius;
    }

    /**
     * 计算给定角度和初速度下,箭在目标水平距离处的竖直高度
     * @param angle 投射角度(弧度)
     * @param velocity 初速度 (m/s)
     * @return 目标距离处的竖直高度
     */
    public double calculateHeight(double angle, double velocity) {
        double cosTheta = Math.cos(angle);
        double sinTheta = Math.sin(angle);
        // 计算到达目标水平距离所需时间
        double time = distance / (velocity * cosTheta);
        // 竖直位移 = v0*sin(theta)*t - 0.5*g*t^2
        return velocity * sinTheta * time - 0.5 * G * time * time;
    }

    /**
     * 计算箭矢的完整飞行轨迹
     * @param angle 投射角度(弧度)
     * @param velocity 初速度 (m/s)
     * @param steps 采样点数
     * @return 轨迹点数组,每个元素为 [x, y]
     */
    public double[][] calculateTrajectory(double angle, double velocity, int steps) {
        double cosTheta = Math.cos(angle);
        double sinTheta = Math.sin(angle);
        // 总飞行时间(落地时间)
        double totalTime = 2 * velocity * sinTheta / G;
        double[][] trajectory = new double[steps + 1][2];
        for (int i = 0; i <= steps; i++) {
            double t = totalTime * i / steps;
            trajectory[i][0] = velocity * cosTheta * t;  // x
            trajectory[i][1] = velocity * sinTheta * t - 0.5 * G * t * t;  // y
        }
        return trajectory;
    }

    /**
     * 评估投射质量:距离壶口中心的偏差
     * @param angle 投射角度(弧度)
     * @param velocity 初速度 (m/s)
     * @return 竖直方向偏差(越小越好)
     */
    public double evaluateShot(double angle, double velocity) {
        double heightAtTarget = calculateHeight(angle, velocity);
        return Math.abs(heightAtTarget - potHeight);
    }

    /**
     * 判断箭是否能落入壶中
     * @param angle 投射角度(弧度)
     * @param velocity 初速度 (m/s)
     * @return true 表示箭落入壶口范围内
     */
    public boolean isInPot(double angle, double velocity) {
        double heightAtTarget = calculateHeight(angle, velocity);
        // 箭在目标处的竖直速度
        double cosTheta = Math.cos(angle);
        double time = distance / (velocity * cosTheta);
        double vy = velocity * Math.sin(angle) - G * time;
        // 落入壶口的条件:高度在壶口范围内,且竖直方向向下
        return Math.abs(heightAtTarget - potHeight) <= potRadius && vy < 0;
    }
}

2. 最优角度求解器

/**
 * 最优投射角度求解器:使用三分搜索找到最佳角度
 */
public class OptimalAngleSolver {
    private final PitchPotPhysics physics;
    private final double velocity;     // 固定初速度

    public OptimalAngleSolver(PitchPotPhysics physics, double velocity) {
        this.physics = physics;
        this.velocity = velocity;
    }

    /**
     * 使用三分搜索求解最优投射角度
     * @return 最优角度(弧度)
     */
    public double solve() {
        double low = Math.toRadians(10);   // 最小角度 10度
        double high = Math.toRadians(80);  // 最大角度 80度

        for (int i = 0; i < 100; i++) {    // 100次迭代足够达到双精度精度
            double m1 = low + (high - low) / 3.0;
            double m2 = high - (high - low) / 3.0;

            double f1 = physics.evaluateShot(m1, velocity);
            double f2 = physics.evaluateShot(m2, velocity);

            if (f1 < f2) {
                high = m2;  // 最优解在左侧
            } else {
                low = m1;   // 最优解在右侧
            }
        }
        return (low + high) / 2.0;
    }

    /**
     * 搜索所有可行角度范围
     * @return 可行角度区间的列表 [startRad, endRad]
     */
    public java.util.List<double[]> findFeasibleAngles() {
        java.util.List<double[]> ranges = new java.util.ArrayList<>();
        double step = Math.toRadians(0.5);  // 0.5度步长搜索
        double start = -1;

        for (double angle = Math.toRadians(10); angle <= Math.toRadians(80); angle += step) {
            boolean feasible = physics.isInPot(angle, velocity);
            if (feasible && start < 0) {
                start = angle;
            } else if (!feasible && start >= 0) {
                ranges.add(new double[]{start, angle - step});
                start = -1;
            }
        }
        if (start >= 0) {
            ranges.add(new double[]{start, Math.toRadians(80)});
        }
        return ranges;
    }
}

3. 主程序与可视化

import java.util.List;

/**
 * 投壶游戏主程序:演示物理模拟与最优角度计算
 */
public class PitchPotGame {

    public static void main(String[] args) {
        // 游戏场景参数
        double distance = 5.0;      // 壶距离 5米
        double potHeight = 0.8;     // 壶口高度 0.8米
        double potRadius = 0.15;    // 壶口半径 15厘米
        double velocity = 8.0;      // 初速度 8m/s

        PitchPotPhysics physics = new PitchPotPhysics(distance, potHeight, potRadius);
        OptimalAngleSolver solver = new OptimalAngleSolver(physics, velocity);

        System.out.println("=== 投壶游戏物理模拟 ===");
        System.out.printf("场景参数: 距离=%.1fm, 壶高=%.1fm, 壶口半径=%.2fm%n", distance, potHeight, potRadius);
        System.out.printf("箭矢初速度: %.1fm/s%n%n", velocity);

        // 求解最优角度
        double optimalAngle = solver.solve();
        double optimalDegree = Math.toDegrees(optimalAngle);
        double bestHeight = physics.calculateHeight(optimalAngle, velocity);
        double deviation = Math.abs(bestHeight - potHeight);

        System.out.printf("最优投射角度: %.4f度%n", optimalDegree);
        System.out.printf("目标处实际高度: %.4fm (壶高: %.4fm)%n", bestHeight, potHeight);
        System.out.printf("竖直偏差: %.6fm%n", deviation);
        System.out.printf("是否落入壶中: %s%n%n", physics.isInPot(optimalAngle, velocity) ? "是" : "否");

        // 展示可行角度范围
        List<double[]> ranges = solver.findFeasibleAngles();
        System.out.println("=== 可行角度范围 ===");
        if (ranges.isEmpty()) {
            System.out.println("当前初速度下无可行角度,请增加初速度");
        } else {
            for (int i = 0; i < ranges.size(); i++) {
                double[] r = ranges.get(i);
                System.out.printf("范围 %d: %.2f° ~ %.2f°%n", i + 1, Math.toDegrees(r[0]), Math.toDegrees(r[1]));
            }
        }

        // 展示最优角度下的轨迹
        System.out.println("\n=== 最优角度轨迹采样 ===");
        double[][] trajectory = physics.calculateTrajectory(optimalAngle, velocity, 10);
        System.out.printf("%-8s %-10s %-10s%n", "Step", "X(m)", "Y(m)");
        System.out.println("-".repeat(30));
        for (int i = 0; i < trajectory.length; i++) {
            System.out.printf("%-8d %-10.3f %-10.3f%n", i, trajectory[i][0], trajectory[i][1]);
        }

        // 不同初速度下的最优角度分析
        System.out.println("\n=== 不同初速度下的最优角度 ===");
        System.out.printf("%-10s %-12s %-12s %-10s%n", "初速度", "最优角度", "偏差(m)", "是否入壶");
        System.out.println("-".repeat(50));
        for (double v = 5.0; v <= 12.0; v += 1.0) {
            OptimalAngleSolver s = new OptimalAngleSolver(physics, v);
            double angle = s.solve();
            double deg = Math.toDegrees(angle);
            double dev = physics.evaluateShot(angle, v);
            boolean inPot = physics.isInPot(angle, v);
            System.out.printf("%-10.1f %-12.4f %-12.6f %-10s%n", v, deg, dev, inPot ? "是" : "否");
        }
    }
}

复杂度分析

操作 时间复杂度 空间复杂度 说明
单点高度计算 $O(1)$ $O(1)$ 直接公式计算
轨迹采样 $O(n)$ $O(n)$ $n$ 为采样点数
三分搜索 $O(\log(1/\epsilon))$ $O(1)$ $\epsilon$ 为精度要求
可行角度扫描 $O(k)$ $O(r)$ $k$ 为扫描步数,$r$ 为可行区间数

扩展思考

  • 风速影响:在水平方向加入风速修正项 $v_{wind} \cdot t$,需要重新建立轨迹方程并调整搜索策略。
  • 初速度优化:若允许同时优化初速度和角度,可将问题转化为二维搜索,或使用梯度下降法。
  • 多目标评估:除精准度外,可加入”优雅度”评分,如轨迹最高点、入射角度等,构建多目标优化函数。

总结

本文通过Java实现了投壶游戏的物理模拟系统,核心要点包括:

  • 抛物线运动学模型将抛体运动分解为水平和竖直分量,通过轨迹方程精确预测箭矢落点
  • 三分搜索算法高效求解单峰优化问题,快速找到使偏差最小的最优投射角度
  • 可行域分析通过扫描法找出所有能使箭落入壶中的角度范围,为玩家提供操作宽容度

投壶将古老游戏与现代优化算法结合,展示了数值方法在物理模拟中的实用价值。理解这些原理不仅能帮助你在游戏中”百发百中”,也是学习计算物理和数值优化的绝佳入门案例。