每日算法 — 使用java实现称球问题:信息论三分搜索与最优称重决策树

引言

称球问题是一道经典的信息论谜题:给定 $n$ 个外观相同的小球,其中恰好有一个是次品(可能更重或更轻),使用天平最少需要称几次才能找出次品并确定其轻重?本文将用Java实现称球问题的最优求解算法,核心结合信息论三分搜索与最优决策树构建。

问题分析与信息论下界

天平每次称重有3种可能结果:左重、平衡、右重。因此 $k$ 次称重最多可区分 $3^k$ 种情况。

对于 $n$ 个球,每个球都可能是重球或轻球,共 $2n$ 种可能。因此信息论下界为:
$$3^k \geq 2n \Rightarrow k \geq \lceil \log_3(2n) \rceil$$

经典结论:12球问题只需3次称重即可解决。

状态空间建模

每个球有三种状态:可能重(H)、可能轻(L)、正常(N)。初始时所有球都是 H 或 L 状态。

import java.util.*;

/**
 * 称球问题求解器:信息论三分搜索 + 最优决策树
 */
public class BallWeighingSolver {

    // 球的状态
    enum Status { HEAVY, LIGHT, NORMAL }

    static class Ball {
        int id;
        Status status;
        Ball(int id) { this.id = id; this.status = Status.NORMAL; }
    }

    // 当前信息状态:记录每个球是否可能为H/L
    static class State {
        Set<Integer> possibleHeavy;  // 可能重的球
        Set<Integer> possibleLight;  // 可能轻的球
        int weighCount;              // 已称重次数
        List<String> history;        // 称重历史

        State(int n) {
            possibleHeavy = new HashSet<>();
            possibleLight = new HashSet<>();
            for (int i = 1; i <= n; i++) {
                possibleHeavy.add(i);
                possibleLight.add(i);
            }
            weighCount = 0;
            history = new ArrayList<>();
        }

        State(State other) {
            possibleHeavy = new HashSet<>(other.possibleHeavy);
            possibleLight = new HashSet<>(other.possibleLight);
            weighCount = other.weighCount;
            history = new ArrayList<>(other.history);
        }

        // 获取当前总可能性数
        int getPossibilityCount() {
            return possibleHeavy.size() + possibleLight.size();
        }

        // 是否已确定唯一解
        boolean isSolved() {
            return getPossibilityCount() == 1;
        }

        // 获取唯一解
        String getSolution() {
            if (possibleHeavy.size() == 1) {
                int id = possibleHeavy.iterator().next();
                return "球" + id + "是重的";
            }
            if (possibleLight.size() == 1) {
                int id = possibleLight.iterator().next();
                return "球" + id + "是轻的";
            }
            return "未确定";
        }
    }

称重操作与状态转移

    /**
     * 执行一次称重操作,根据结果更新状态
     * @param state 当前信息状态
     * @param left  左盘球编号集合
     * @param right 右盘球编号集合
     * @param result 称重结果:1=左重, 0=平衡, -1=右重
     * @return 更新后的状态
     */
    public State weigh(State state, Set<Integer> left, Set<Integer> right, int result) {
        State newState = new State(state);
        newState.weighCount++;
        String record = String.format("第%d次: 左%s vs 右%s -> %s", 
            newState.weighCount, left, right,
            result == 1 ? "左重" : (result == 0 ? "平衡" : "右重"));
        newState.history.add(record);

        Set<Integer> notWeighed = new HashSet<>();
        for (int i = 1; i <= 12; i++) {
            if (!left.contains(i) && !right.contains(i)) {
                notWeighed.add(i);
            }
        }

        if (result == 1) { // 左重
            // 左盘的球可能重,右盘的球可能轻
            // 未称的球和之前排除的球都是正常的
            newState.possibleHeavy.retainAll(left);
            newState.possibleLight.retainAll(right);
        } else if (result == -1) { // 右重
            newState.possibleHeavy.retainAll(right);
            newState.possibleLight.retainAll(left);
        } else { // 平衡
            // 所有参与称重的球都是正常的
            newState.possibleHeavy.removeAll(left);
            newState.possibleHeavy.removeAll(right);
            newState.possibleLight.removeAll(left);
            newState.possibleLight.removeAll(right);
        }

        return newState;
    }

最优称重方案生成:三分搜索策略

    /**
     * 生成当前状态下的最优称重方案
     * 核心策略:将可能状态尽可能均匀地分配到3种结果中
     * @param state 当前状态
     * @param totalBalls 总球数
     * @return 最优的左右盘球集合
     */
    public WeighingPlan findBestWeighing(State state, int totalBalls) {
        Set<Integer> candidates = new HashSet<>();
        candidates.addAll(state.possibleHeavy);
        candidates.addAll(state.possibleLight);

        // 已知的正常球(可用于平衡配重)
        Set<Integer> normalBalls = new HashSet<>();
        for (int i = 1; i <= totalBalls; i++) {
            if (!candidates.contains(i)) normalBalls.add(i);
        }

        WeighingPlan bestPlan = null;
        double bestEntropy = Double.MAX_VALUE;

        // 枚举左右盘的分配方案(只从候选球和已知正常球中选择)
        // 策略:左右盘数量相等,且尽量将候选球均匀分配
        List<Integer> candidateList = new ArrayList<>(candidates);
        int maxPerSide = Math.min(4, candidateList.size() / 2 + 1);

        for (int size = 1; size <= maxPerSide; size++) {
            List<Set<Integer>> leftCombinations = generateCombinations(candidates, normalBalls, size);

            for (Set<Integer> left : leftCombinations) {
                // 右盘从剩余候选球中选择同样数量
                Set<Integer> remaining = new HashSet<>(candidates);
                remaining.removeAll(left);

                List<Set<Integer>> rightCombinations = generateCombinations(remaining, normalBalls, size);

                for (Set<Integer> right : rightCombinations) {
                    if (left.equals(right)) continue;

                    // 计算该方案的信息熵(三种结果的可能性分布)
                    double entropy = calculateEntropy(state, left, right);

                    if (entropy < bestEntropy) {
                        bestEntropy = entropy;
                        bestPlan = new WeighingPlan(left, right);
                    }
                }
            }
        }

        return bestPlan;
    }

    /**
     * 计算给定称重方案的信息熵
     * 熵越小表示信息增益越大(三种结果越均匀)
     */
    private double calculateEntropy(State state, Set<Integer> left, Set<Integer> right) {
        // 模拟三种结果,统计各自的可能性数量
        State s1 = weigh(state, left, right, 1);
        State s0 = weigh(state, left, right, 0);
        State s_1 = weigh(state, left, right, -1);

        int c1 = s1.getPossibilityCount();
        int c0 = s0.getPossibilityCount();
        int c_1 = s_1.getPossibilityCount();
        int total = c1 + c0 + c_1;

        if (total == 0) return Double.MAX_VALUE;

        // 香农熵: -sum(p_i * log(p_i))
        double entropy = 0;
        for (int c : new int[]{c1, c0, c_1}) {
            if (c > 0) {
                double p = (double) c / total;
                entropy -= p * Math.log(p) / Math.log(3);
            }
        }
        return entropy;
    }

    /**
     * 生成指定大小的球集合组合(从候选球+正常球中选取)
     */
    private List<Set<Integer>> generateCombinations(Set<Integer> candidates, 
                                                     Set<Integer> normalBalls, 
                                                     int size) {
        List<Set<Integer>> results = new ArrayList<>();
        List<Integer> all = new ArrayList<>();
        all.addAll(candidates);
        all.addAll(normalBalls);

        combinationsHelper(all, 0, size, new HashSet<>(), results);
        return results;
    }

    private void combinationsHelper(List<Integer> list, int start, int size,
                                     Set<Integer> current, List<Set<Integer>> results) {
        if (size == 0) {
            results.add(new HashSet<>(current));
            return;
        }
        for (int i = start; i <= list.size() - size; i++) {
            current.add(list.get(i));
            combinationsHelper(list, i + 1, size - 1, current, results);
            current.remove(list.get(i));
        }
    }

    static class WeighingPlan {
        Set<Integer> left, right;
        WeighingPlan(Set<Integer> l, Set<Integer> r) { left = l; right = r; }
        public String toString() {
            return "左盘" + left + " vs 右盘" + right;
        }
    }

完整求解与验证

    /**
     * 针对12球问题的完整求解流程演示
     * 已知:12个球中有一个次品,不知轻重
     */
    public void solve12Balls() {
        System.out.println("=== 12球称球问题求解 ===\n");
        System.out.println("信息论下界: ceil(log3(24)) = " + 
            (int)Math.ceil(Math.log(24) / Math.log(3)) + " 次\n");

        State state = new State(12);

        // 第一次称重:最优策略是将12球分4组,每组3个
        // 1,2,3,4 vs 5,6,7,8
        Set<Integer> left1 = new HashSet<>(Arrays.asList(1, 2, 3, 4));
        Set<Integer> right1 = new HashSet<>(Arrays.asList(5, 6, 7, 8));

        System.out.println("第一次称重方案: " + new WeighingPlan(left1, right1));
        System.out.println("(理论最优:将24种可能性尽量均匀分为3组)\n");

        // 模拟所有可能的结果
        for (int result : new int[]{1, 0, -1}) {
            State s = weigh(state, left1, right1, result);
            String resultStr = result == 1 ? "左重" : (result == 0 ? "平衡" : "右重");
            System.out.println("若结果=" + resultStr + ": 剩余可能性=" + s.getPossibilityCount());
            System.out.println("  可能重: " + s.possibleHeavy);
            System.out.println("  可能轻: " + s.possibleLight);

            if (!s.isSolved()) {
                WeighingPlan plan2 = findBestWeighing(s, 12);
                System.out.println("  第二次称重: " + plan2);
            }
            System.out.println();
        }
    }

    /**
     * 针对已知次品轻重的情况求解(简化版)
     * @param n 球的数量
     * @param heavy 是否已知次品更重(false则已知更轻)
     * @return 最少称重次数及方案
     */
    public int solveKnownWeight(int n, boolean heavy) {
        // 此时每次称重也是3种结果,但只有n种可能性
        // 信息论下界: ceil(log3(n))
        return (int) Math.ceil(Math.log(n) / Math.log(3));
    }

    public static void main(String[] args) {
        BallWeighingSolver solver = new BallWeighingSolver();
        solver.solve12Balls();

        System.out.println("=== 已知轻重的情况 ===");
        System.out.println("27球已知次品更重: 最少需要 " + solver.solveKnownWeight(27, true) + " 次");
        System.out.println("解释: 3^3 = 27,每次将球分为3组,可逐次缩小到1/3");
    }
}

决策树构建与可视化

/**
 * 决策树节点:记录每个称重步骤
 */
class DecisionNode {
    Set<Integer> left, right;     // 本次称重的左右盘
    DecisionNode leftHeavy;       // 左重分支
    DecisionNode balance;         // 平衡分支
    DecisionNode rightHeavy;      // 右重分支
    String solution;              // 叶子节点的解(若已确定)

    boolean isLeaf() { return solution != null; }
}

/**
 * 构建完整决策树(针对小规模问题)
 */
public class DecisionTreeBuilder {

    /**
     * 递归构建决策树
     * @param state 当前信息状态
     * @param totalBalls 总球数
     * @param maxDepth 最大深度限制
     * @return 决策树根节点
     */
    public DecisionNode buildTree(BallWeighingSolver.State state, 
                                   int totalBalls, int maxDepth) {
        DecisionNode node = new DecisionNode();

        if (state.isSolved() || state.weighCount >= maxDepth) {
            node.solution = state.getSolution();
            return node;
        }

        BallWeighingSolver solver = new BallWeighingSolver();
        BallWeighingSolver.WeighingPlan plan = solver.findBestWeighing(state, totalBalls);

        if (plan == null) {
            node.solution = "无法确定";
            return node;
        }

        node.left = plan.left;
        node.right = plan.right;

        node.leftHeavy = buildTree(solver.weigh(state, plan.left, plan.right, 1), 
                                    totalBalls, maxDepth);
        node.balance = buildTree(solver.weigh(state, plan.left, plan.right, 0), 
                                  totalBalls, maxDepth);
        node.rightHeavy = buildTree(solver.weigh(state, plan.left, plan.right, -1), 
                                     totalBalls, maxDepth);

        return node;
    }
}

复杂度分析

  • 信息论下界:$k = \lceil \log_3(2n) \rceil$,这是理论最优解
  • 方案枚举:每次称重需要从 $n$ 个球中选择左右盘,复杂度为 $O(C(n, k)^2)$,实际中通过剪枝和限制盘大小,效率可接受
  • 决策树空间:完整决策树有 $3^k$ 个节点,空间复杂度 $O(3^k)$
  • 求解时间:对于12球问题,构建完整决策树的时间为 $O(3^3 \cdot poly(n))$,即常数时间

总结

本文通过Java实现了称球问题的完整求解框架:

  1. 信息论分析:利用香农熵计算理论下界,证明12球问题只需3次称重
  2. 状态空间建模:用 H/L/N 三态追踪每个球的可能性
  3. 三分搜索策略:每次称重将可能性集合尽量均匀分为3份
  4. 决策树构建:递归生成完整的称重决策流程

读者可以进一步扩展:实现N球通用求解器、引入未知次品数量的情况、或构建交互式称重模拟器验证决策树正确性。