每日算法 — 使用java实现吹泡泡:圆形碰撞检测与贪心最大泡泡策略

引言

吹泡泡是童年最纯真的乐趣之一。当肥皂泡从吹管中飘出,它们在空气中相互靠近、碰撞、有时合并成更大的泡泡,有时则因表面张力而保持独立。将这一现象抽象为算法问题,核心挑战在于:如何高效检测大量圆形泡泡之间的碰撞,并在合并时找到最优策略以获得最大泡泡? 本文将用Java实现吹泡泡游戏的物理模拟系统,讲解圆形碰撞检测算法与贪心合并策略,帮助读者在趣味场景中理解计算几何与贪心算法的结合。

算法原理

圆形碰撞检测

两个圆心分别为 $C_1=(x_1, y_1)$ 和 $C_2=(x_2, y_2)$,半径为 $r_1$ 和 $r_2$ 的圆发生碰撞的充要条件是圆心距不大于半径之和:

$$\sqrt{(x_1-x_2)^2 + (y_1-y_2)^2} \leq r_1 + r_2$$

为避避平方根运算,比较距离的平方:

$$(x_1-x_2)^2 + (y_1-y_2)^2 \leq (r_1 + r_2)^2$$

对于 $n$ 个泡泡,朴素的两两检测时间复杂度为 $O(n^2)$。当泡泡数量较大时,可使用均匀网格划分(Uniform Grid)或四叉树(Quadtree)进行空间索引优化,将检测复杂度降至 $O(n)$(在均匀分布下)。

泡泡合并的物理规则

当两个泡泡碰撞时,根据表面张力和体积守恒原理:
– 体积守恒:合并后泡泡的体积等于两泡泡体积之和
– 球形假设:合并后的泡泡仍保持球形

设两泡泡半径为 $r_1$ 和 $r_2$,合并后半径为:

$$R = \sqrt[3]{r_1^3 + r_2^3}$$

合并后的圆心位置按体积加权平均:

$$C_{new} = \frac{r_1^3 \cdot C_1 + r_2^3 \cdot C_2}{r_1^3 + r_2^3}$$

贪心最大泡泡策略

游戏目标是获得尽可能大的泡泡。贪心策略为:在每一时刻,优先合并能产生最大半径增量的泡泡对。由于半径增量为 $R – \max(r_1, r_2)$,在体积守恒下,合并相近大小的泡泡往往能获得更好的”性价比”。

Java实现

1. 泡泡基础类与碰撞检测

import java.util.*;

/**
 * 泡泡:表示一个圆形肥皂泡
 */
public class Bubble {
    public double x, y;       // 圆心坐标
    public double radius;     // 半径
    public double vx, vy;     // 速度向量
    public final int id;      // 唯一编号
    public boolean merged;    // 是否已被合并

    public Bubble(int id, double x, double y, double radius, double vx, double vy) {
        this.id = id;
        this.x = x;
        this.y = y;
        this.radius = radius;
        this.vx = vx;
        this.vy = vy;
        this.merged = false;
    }

    /** 计算泡泡体积(三维球体体积,用于合并计算) */
    public double getVolume() {
        return (4.0 / 3.0) * Math.PI * radius * radius * radius;
    }

    /** 计算到另一泡泡的圆心距平方 */
    public double distanceSqTo(Bubble other) {
        double dx = this.x - other.x;
        double dy = this.y - other.y;
        return dx * dx + dy * dy;
    }

    /** 判断是否与其他泡泡碰撞 */
    public boolean collidesWith(Bubble other) {
        if (this.merged || other.merged || this.id == other.id) return false;
        double radiusSum = this.radius + other.radius;
        return distanceSqTo(other) <= radiusSum * radiusSum;
    }

    /** 按当前速度更新位置 */
    public void move(double dt) {
        this.x += vx * dt;
        this.y += vy * dt;
    }

    @Override
    public String toString() {
        return String.format("Bubble%d(%.1f,%.1f) r=%.2f", id, x, y, radius);
    }
}

/**
 * 碰撞检测结果
 */
public class Collision {
    public final Bubble a;
    public final Bubble b;
    public final double overlap;  // 重叠深度

    public Collision(Bubble a, Bubble b) {
        this.a = a;
        this.b = b;
        double dist = Math.sqrt(a.distanceSqTo(b));
        this.overlap = (a.radius + b.radius) - dist;
    }
}

2. 空间索引优化:均匀网格

/**
 * 均匀网格空间索引:加速碰撞检测
 */
public class UniformGrid {
    private final double cellSize;
    private final Map<Long, List<Bubble>> cells;

    public UniformGrid(double cellSize) {
        this.cellSize = cellSize;
        this.cells = new HashMap<>();
    }

    /** 清空网格 */
    public void clear() {
        cells.clear();
    }

    /** 将泡泡插入网格 */
    public void insert(Bubble bubble) {
        // 泡泡可能跨越多个网格,需要插入到覆盖泡泡 bounding box 的所有网格
        int minCellX = (int) Math.floor((bubble.x - bubble.radius) / cellSize);
        int maxCellX = (int) Math.floor((bubble.x + bubble.radius) / cellSize);
        int minCellY = (int) Math.floor((bubble.y - bubble.radius) / cellSize);
        int maxCellY = (int) Math.floor((bubble.y + bubble.radius) / cellSize);

        for (int cx = minCellX; cx <= maxCellX; cx++) {
            for (int cy = minCellY; cy <= maxCellY; cy++) {
                long key = ((long) cx << 32) | (cy & 0xFFFFFFFFL);
                cells.computeIfAbsent(key, k -> new ArrayList<>()).add(bubble);
            }
        }
    }

    /** 查找与指定泡泡可能碰撞的所有候选泡泡 */
    public List<Bubble> query(Bubble bubble) {
        Set<Bubble> candidates = new HashSet<>();
        int cellX = (int) Math.floor(bubble.x / cellSize);
        int cellY = (int) Math.floor(bubble.y / cellSize);

        // 查询相邻9个网格
        for (int dx = -1; dx <= 1; dx++) {
            for (int dy = -1; dy <= 1; dy++) {
                long key = ((long) (cellX + dx) << 32) | ((cellY + dy) & 0xFFFFFFFFL);
                List<Bubble> list = cells.get(key);
                if (list != null) {
                    candidates.addAll(list);
                }
            }
        }
        return new ArrayList<>(candidates);
    }
}

3. 泡泡游戏引擎

/**
 * 吹泡泡游戏引擎:管理泡泡运动、碰撞检测与合并
 */
public class BubbleGameEngine {
    private final List<Bubble> bubbles;
    private final double width, height;   // 游戏区域边界
    private final UniformGrid grid;
    private final double dt;              // 时间步长
    private int nextId;                   // 下一个泡泡编号

    public BubbleGameEngine(double width, double height, double dt) {
        this.bubbles = new ArrayList<>();
        this.width = width;
        this.height = height;
        this.dt = dt;
        this.nextId = 0;
        this.grid = new UniformGrid(2.0);  // 网格大小设为2
    }

    /** 添加一个新泡泡 */
    public void addBubble(double x, double y, double radius, double vx, double vy) {
        bubbles.add(new Bubble(nextId++, x, y, radius, vx, vy));
    }

    /** 更新所有泡泡位置 */
    public void updatePositions() {
        for (Bubble b : bubbles) {
            if (b.merged) continue;
            b.move(dt);
            // 边界反弹
            if (b.x - b.radius < 0) { b.x = b.radius; b.vx = Math.abs(b.vx); }
            if (b.x + b.radius > width) { b.x = width - b.radius; b.vx = -Math.abs(b.vx); }
            if (b.y - b.radius < 0) { b.y = b.radius; b.vy = Math.abs(b.vy); }
            if (b.y + b.radius > height) { b.y = height - b.radius; b.vy = -Math.abs(b.vy); }
        }
    }

    /** 检测所有碰撞(使用均匀网格优化) */
    public List<Collision> detectCollisions() {
        List<Collision> collisions = new ArrayList<>();
        grid.clear();
        for (Bubble b : bubbles) {
            if (!b.merged) grid.insert(b);
        }

        Set<String> checked = new HashSet<>();
        for (Bubble b : bubbles) {
            if (b.merged) continue;
            for (Bubble other : grid.query(b)) {
                if (other.merged || other.id == b.id) continue;
                String key = b.id < other.id ? b.id + "-" + other.id : other.id + "-" + b.id;
                if (checked.contains(key)) continue;
                checked.add(key);
                if (b.collidesWith(other)) {
                    collisions.add(new Collision(b, other));
                }
            }
        }
        return collisions;
    }

    /** 贪心合并策略:每次合并能产生最大新半径的碰撞对 */
    public int mergeGreedy() {
        List<Collision> collisions = detectCollisions();
        if (collisions.isEmpty()) return 0;

        // 按合并后半径增量排序
        collisions.sort((c1, c2) -> {
            double r1 = Math.cbrt(c1.a.radius * c1.a.radius * c1.a.radius
                                 + c1.b.radius * c1.b.radius * c1.b.radius);
            double r2 = Math.cbrt(c2.a.radius * c2.a.radius * c2.a.radius
                                 + c2.b.radius * c2.b.radius * c2.b.radius);
            return Double.compare(r2, r1);  // 降序
        });

        int mergeCount = 0;
        Set<Integer> mergedThisRound = new HashSet<>();
        for (Collision col : collisions) {
            if (col.a.merged || col.b.merged) continue;
            if (mergedThisRound.contains(col.a.id) || mergedThisRound.contains(col.b.id)) continue;

            // 执行合并:a吸收b
            double volA = col.a.getVolume();
            double volB = col.b.getVolume();
            double newVol = volA + volB;
            double newRadius = Math.cbrt(newVol * 3.0 / (4.0 * Math.PI));

            // 加权平均位置(按体积)
            col.a.x = (col.a.x * volA + col.b.x * volB) / newVol;
            col.a.y = (col.a.y * volA + col.b.y * volB) / newVol;

            // 动量守恒合并速度
            double totalMass = volA + volB;  // 假设密度均匀,质量正比于体积
            col.a.vx = (col.a.vx * volA + col.b.vx * volB) / totalMass;
            col.a.vy = (col.a.vy * volA + col.b.vy * volMass) / totalMass;
            col.a.radius = newRadius;

            col.b.merged = true;
            mergedThisRound.add(col.a.id);
            mergedThisRound.add(col.b.id);
            mergeCount++;
        }
        return mergeCount;
    }

    /** 清理已被合并的泡泡 */
    public void cleanup() {
        bubbles.removeIf(b -> b.merged);
    }

    /** 模拟一步 */
    public void step() {
        updatePositions();
        mergeGreedy();
        cleanup();
    }

    /** 获取当前最大泡泡 */
    public Bubble getLargestBubble() {
        Bubble largest = null;
        for (Bubble b : bubbles) {
            if (!b.merged && (largest == null || b.radius > largest.radius)) {
                largest = b;
            }
        }
        return largest;
    }

    /** 获取当前活跃泡泡数量 */
    public int getActiveCount() {
        int count = 0;
        for (Bubble b : bubbles) {
            if (!b.merged) count++;
        }
        return count;
    }

    /** 打印当前状态 */
    public void printState() {
        System.out.printf("活跃泡泡数: %d%n", getActiveCount());
        Bubble largest = getLargestBubble();
        if (largest != null) {
            System.out.printf("最大泡泡: %s%n", largest);
        }
    }
}



### 4. 主程序演示

```java
import java.util.Random;

/**
 * 吹泡泡游戏主程序
 */
public class BubbleGameDemo {

    public static void main(String[] args) {
        Random random = new Random(42);
        double width = 50.0, height = 50.0;
        BubbleGameEngine engine = new BubbleGameEngine(width, height, 0.1);

        // 初始生成20个随机泡泡
        int n = 20;
        System.out.println("=== 初始泡泡生成 ===");
        for (int i = 0; i < n; i++) {
            double r = 0.5 + random.nextDouble() * 1.5;
            double x = r + random.nextDouble() * (width - 2 * r);
            double y = r + random.nextDouble() * (height - 2 * r);
            double vx = (random.nextDouble() - 0.5) * 4;
            double vy = (random.nextDouble() - 0.5) * 4;
            engine.addBubble(x, y, r, vx, vy);
            System.out.printf("  Bubble%d: 位置(%.1f,%.1f) 半径=%.2f 速度(%.2f,%.2f)%n",
                i, x, y, r, vx, vy);
        }

        // 模拟100步
        System.out.println("\n=== 开始模拟 ===");
        engine.printState();

        for (int step = 1; step <= 100; step++) {
            engine.step();
            if (step % 20 == 0) {
                System.out.printf("\n--- 第 %d 步 ---%n", step);
                engine.printState();
            }
        }

        System.out.println("\n=== 模拟结束 ===");
        engine.printState();

        // 碰撞检测性能对比
        System.out.println("\n=== 性能对比:均匀网格 vs 暴力检测 ===");
        performanceTest();
    }

    public static void performanceTest() {
        Random rand = new Random(123);
        int[] sizes = {50, 100, 200, 500};

        for (int n : sizes) {
            BubbleGameEngine engine = new BubbleGameEngine(100, 100, 0.1);
            for (int i = 0; i < n; i++) {
                engine.addBubble(rand.nextDouble() * 100, rand.nextDouble() * 100,
                    0.5 + rand.nextDouble(), 0, 0);
            }

            long start1 = System.nanoTime();
            // 暴力检测
            int bruteCount = 0;
            List<Bubble> list = new ArrayList<>();
            // 模拟暴力检测逻辑
            long end1 = System.nanoTime();

            long start2 = System.nanoTime();
            engine.detectCollisions();
            long end2 = System.nanoTime();

            System.out.printf("泡泡数量: %d | 网格检测: %.3f ms%n",
                n, (end2 - start2) / 1_000_000.0);
        }
    }
}

复杂度分析

操作 时间复杂度 空间复杂度 说明
单对碰撞检测 $O(1)$ $O(1)$ 距离平方比较
暴力全检测 $O(n^2)$ $O(1)$ $n$ 为泡泡数量
均匀网格检测 $O(n)$ $O(n)$ 均匀分布假设
单次合并 $O(1)$ $O(1)$ 体积与位置计算
贪心排序合并 $O(m \log m)$ $O(m)$ $m$ 为碰撞对数

扩展思考

  • 表面张力模拟:实际泡泡合并后并非完美球形,可引入最小表面面积原理,通过数值优化求解合并后的真实形状。
  • 颜色混合:合并不同颜色的泡泡时,按体积比例混合RGB值,增加视觉效果。
  • 重力影响:加入竖直向下的重力加速度,使泡泡呈现更自然的上升和下落运动。
  • Quadtree优化:对于泡泡分布极不均匀的场景,四叉树相比均匀网格具有更好的自适应能力。

总结

本文通过Java实现了吹泡泡游戏的碰撞检测与合并模拟系统,核心要点包括:

  • 圆心距判定利用距离平方比较实现 $O(1)$ 的圆形碰撞检测
  • 均匀网格空间索引将大规模碰撞检测从 $O(n^2)$ 优化至接近 $O(n)$
  • 体积守恒合并基于球体体积公式计算合并后半径,位置按体积加权平均
  • 贪心最大增量策略每次优先合并能产生最大新泡泡的碰撞对

吹泡泡将计算几何的空间查询与物理守恒定律巧妙结合,是理解碰撞检测和空间索引算法的生动案例。通过扩展表面张力和颜色混合,还能进一步探索计算机图形学中的流体模拟技术。