每日算法 — 使用java实现猜数字:决策树最优分割与信息熵最小化策略

猜数字(Bulls and Cows,又称 Mastermind 的简化版本)是经典的逻辑推理游戏:系统随机生成一个不重复数字的秘密序列,玩家每次猜测后获得”位置数字都对(A)”和”数字对位置不对(B)”的反馈。本文用 Java 实现基于决策树的最优猜测策略,讲解信息熵计算、最优属性选择与决策树剪枝的核心思想,使平均猜测次数逼近理论下界。

一、问题分析与信息论建模

1.1 游戏规则与状态空间

以 4 位不重复数字(0-9)为例,秘密数字共有 P(10,4) = 5040 种可能。每次猜测后,根据反馈 (A,B) 可以将候选集合大幅缩小。最优策略的目标是:每次猜测后,无论得到什么反馈,剩余候选集合的期望大小最小。

1.2 信息熵与最优猜测

信息熵衡量系统的不确定性。对于候选集合 S,猜测 g 的信息增益为:

Entropy(S) = -Σ p(c) × log₂(p(c))
Gain(S, g) = Entropy(S) - Σ (|S_v| / |S|) × Entropy(S_v)

其中 S_v 是得到反馈 v 后的候选子集。最优猜测是使信息增益最大(或等价地,使期望剩余候选数最小)的那个猜测。

1.3 决策树构建

将每次猜测视为决策树的一个节点,每个分支对应一种反馈结果。决策树的深度即为猜测次数。构建最优决策树等价于在每一步选择信息熵最小的分割点。

二、Java 实现

2.1 核心数据结构与工具类

import java.util.*;

/**
 * 猜数字游戏:决策树最优策略实现
 * 规则:4位不重复数字,反馈格式为 xAyB
 */
public class GuessNumberGame {

    private static final int NUM_DIGITS = 4;
    private static final int NUM_CHOICES = 10; // 0-9

    /**
     * 表示一次猜测的反馈结果
     */
    public static class Feedback {
        final int bulls;  // 位置和数字都对(A)
        final int cows;   // 数字对位置不对(B)

        public Feedback(int bulls, int cows) {
            this.bulls = bulls;
            this.cows = cows;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Feedback)) return false;
            Feedback other = (Feedback) o;
            return bulls == other.bulls && cows == other.cows;
        }

        @Override
        public int hashCode() {
            return bulls * 10 + cows;
        }

        @Override
        public String toString() {
            return bulls + "A" + cows + "B";
        }
    }

    /**
     * 计算猜测 guess 对于秘密 secret 的反馈
     */
    public static Feedback getFeedback(int[] secret, int[] guess) {
        int bulls = 0, cows = 0;
        boolean[] secretUsed = new boolean[NUM_DIGITS];
        boolean[] guessUsed = new boolean[NUM_DIGITS];

        // 先计算 bulls
        for (int i = 0; i < NUM_DIGITS; i++) {
            if (guess[i] == secret[i]) {
                bulls++;
                secretUsed[i] = true;
                guessUsed[i] = true;
            }
        }

        // 再计算 cows
        for (int i = 0; i < NUM_DIGITS; i++) {
            if (guessUsed[i]) continue;
            for (int j = 0; j < NUM_DIGITS; j++) {
                if (secretUsed[j]) continue;
                if (guess[i] == secret[j]) {
                    cows++;
                    secretUsed[j] = true;
                    break;
                }
            }
        }

        return new Feedback(bulls, cows);
    }

    /**
     * 生成所有可能的候选数字(4位不重复)
     */
    public static List<int[]> generateAllCandidates() {
        List<int[]> candidates = new ArrayList<>();
        int[] digits = new int[NUM_DIGITS];
        generateCandidatesHelper(candidates, digits, 0, new boolean[NUM_CHOICES]);
        return candidates;
    }

    private static void generateCandidatesHelper(List<int[]> candidates, int[] digits, int pos, boolean[] used) {
        if (pos == NUM_DIGITS) {
            candidates.add(digits.clone());
            return;
        }
        for (int d = 0; d < NUM_CHOICES; d++) {
            if (used[d]) continue;
            used[d] = true;
            digits[pos] = d;
            generateCandidatesHelper(candidates, digits, pos + 1, used);
            used[d] = false;
        }
    }

    /**
     * 将数字数组转换为整数表示,便于显示
     */
    public static int digitsToInt(int[] digits) {
        int result = 0;
        for (int d : digits) {
            result = result * 10 + d;
        }
        return result;
    }

    public static void main(String[] args) {
        // 测试反馈计算
        int[] secret = {1, 2, 3, 4};
        int[] guess1 = {1, 2, 3, 4};
        int[] guess2 = {5, 6, 7, 8};
        int[] guess3 = {1, 3, 2, 4};

        System.out.println("秘密: 1234");
        System.out.println("猜测 1234 -> " + getFeedback(secret, guess1));
        System.out.println("猜测 5678 -> " + getFeedback(secret, guess2));
        System.out.println("猜测 1324 -> " + getFeedback(secret, guess3));
    }
}

2.2 信息熵最优策略引擎

import java.util.*;

/**
 * 基于信息熵的最优猜测策略
 * 每次选择使期望剩余候选集合最小的猜测
 */
public class EntropyStrategy {

    private static final int NUM_DIGITS = 4;
    private List<int[]> allCandidates;
    private Random random;

    public EntropyStrategy() {
        this.allCandidates = GuessNumberGame.generateAllCandidates();
        this.random = new Random();
        System.out.println("候选空间大小: " + allCandidates.size());
    }

    /**
     * 根据当前候选集合,计算每个可能猜测的信息熵
     * 返回使期望剩余候选数最小的猜测
     */
    public int[] findBestGuess(List<int[]> candidates) {
        if (candidates.size() <= 1) {
            return candidates.get(0);
        }

        double minExpectedSize = Double.MAX_VALUE;
        int[] bestGuess = null;
        int evaluated = 0;

        // 评估所有可能的猜测(包括不在候选集中的猜测,称为"试探性猜测")
        List<int[]> guessPool = allCandidates;

        for (int[] guess : guessPool) {
            // 统计该猜测下各种反馈对应的候选子集大小
            Map<GuessNumberGame.Feedback, Integer> feedbackCount = new HashMap<>();

            for (int[] candidate : candidates) {
                GuessNumberGame.Feedback fb = GuessNumberGame.getFeedback(candidate, guess);
                feedbackCount.merge(fb, 1, Integer::sum);
            }

            // 计算期望剩余候选数
            double expectedSize = 0;
            for (int count : feedbackCount.values()) {
                double p = (double) count / candidates.size();
                expectedSize += p * count;
            }

            // 优先选择候选集中的猜测,若期望大小相同
            boolean inCandidates = false;
            for (int[] c : candidates) {
                if (Arrays.equals(c, guess)) {
                    inCandidates = true;
                    break;
                }
            }

            // 选择期望剩余候选数最小的猜测
            // 若相同,优先选择仍在候选集中的猜测
            if (expectedSize < minExpectedSize || 
                (expectedSize == minExpectedSize && inCandidates)) {
                minExpectedSize = expectedSize;
                bestGuess = guess;
            }

            evaluated++;
        }

        return bestGuess;
    }

    /**
     * 计算信息熵(用于分析)
     */
    public double calculateEntropy(List<int[]> candidates) {
        if (candidates.isEmpty()) return 0;
        // 均匀分布的熵
        return Math.log(candidates.size()) / Math.log(2);
    }

    /**
     * 使用最优策略解决一局游戏
     * @param secret 秘密数字(用于模拟)
     * @return 猜测历史记录
     */
    public List<String> solve(int[] secret) {
        List<int[]> candidates = new ArrayList<>(allCandidates);
        List<String> history = new ArrayList<>();
        int attempts = 0;
        final int MAX_ATTEMPTS = 10;

        // 初始猜测:信息论研究表明 0123 是一个较好的初始猜测
        int[] guess = {0, 1, 2, 3};

        while (attempts < MAX_ATTEMPTS) {
            attempts++;
            GuessNumberGame.Feedback fb = GuessNumberGame.getFeedback(secret, guess);
            history.add(String.format("第%d次: %04d -> %s (候选数: %d)", 
                attempts, GuessNumberGame.digitsToInt(guess), fb, candidates.size()));

            if (fb.bulls == NUM_DIGITS) {
                history.add("破解成功!共猜测 " + attempts + " 次");
                break;
            }

            // 根据反馈过滤候选集合
            List<int[]> newCandidates = new ArrayList<>();
            for (int[] candidate : candidates) {
                if (GuessNumberGame.getFeedback(candidate, guess).equals(fb)) {
                    newCandidates.add(candidate);
                }
            }
            candidates = newCandidates;

            // 选择下一个最优猜测
            guess = findBestGuess(candidates);
        }

        return history;
    }

    /**
     * 大规模统计:测试所有可能的秘密数字,计算平均猜测次数
     */
    public void benchmark() {
        int totalAttempts = 0;
        int maxAttempts = 0;
        Map<Integer, Integer> distribution = new TreeMap<>();

        for (int[] secret : allCandidates) {
            List<String> result = solve(secret);
            int attempts = result.size() - 1; // 减去最后的成功消息
            totalAttempts += attempts;
            maxAttempts = Math.max(maxAttempts, attempts);
            distribution.merge(attempts, 1, Integer::sum);
        }

        double average = (double) totalAttempts / allCandidates.size();
        System.out.println("\n=== 策略性能统计 ===");
        System.out.printf("平均猜测次数: %.3f%n", average);
        System.out.println("最大猜测次数: " + maxAttempts);
        System.out.println("猜测次数分布:");
        for (Map.Entry<Integer, Integer> entry : distribution.entrySet()) {
            System.out.printf("  %d 次: %d 个 (%.2f%%)%n", 
                entry.getKey(), entry.getValue(), 
                100.0 * entry.getValue() / allCandidates.size());
        }
    }

    public static void main(String[] args) {
        EntropyStrategy strategy = new EntropyStrategy();

        // 演示一局
        int[] secret = {5, 7, 2, 9};
        System.out.println("秘密数字: " + GuessNumberGame.digitsToInt(secret));
        System.out.println("\n解题过程:");
        List<String> history = strategy.solve(secret);
        for (String line : history) {
            System.out.println("  " + line);
        }

        // 完整基准测试(可选,运行时间较长)
        // strategy.benchmark();
    }
}

2.3 决策树节点与可视化

import java.util.*;

/**
 * 决策树节点:用于构建和展示最优猜测策略树
 */
public class DecisionTreeNode {
    int[] guess;  // 当前节点的猜测
    GuessNumberGame.Feedback feedback; // 到达该节点的反馈(根节点为null)
    List<DecisionTreeNode> children;
    boolean isLeaf;
    int[] answer; // 若是叶子节点,记录确定的答案
    int depth;    // 节点深度

    public DecisionTreeNode(int[] guess, int depth) {
        this.guess = guess;
        this.depth = depth;
        this.children = new ArrayList<>();
        this.isLeaf = false;
    }

    /**
     * 递归构建决策树
     * @param candidates 当前候选集合
     * @param allPossible 所有可能的数字组合
     */
    public static DecisionTreeNode buildTree(List<int[]> candidates, 
                                              List<int[]> allPossible, 
                                              int depth) {
        if (candidates.size() == 1) {
            DecisionTreeNode leaf = new DecisionTreeNode(candidates.get(0), depth);
            leaf.isLeaf = true;
            leaf.answer = candidates.get(0);
            return leaf;
        }

        // 使用信息熵策略选择最优猜测
        EntropyStrategy strategy = new EntropyStrategy();
        int[] bestGuess = strategy.findBestGuess(candidates);

        DecisionTreeNode node = new DecisionTreeNode(bestGuess, depth);

        // 按反馈分组
        Map<GuessNumberGame.Feedback, List<int[]>> groups = new HashMap<>();
        for (int[] candidate : candidates) {
            GuessNumberGame.Feedback fb = GuessNumberGame.getFeedback(candidate, bestGuess);
            groups.computeIfAbsent(fb, k -> new ArrayList<>()).add(candidate);
        }

        // 为每种反馈构建子树
        for (Map.Entry<GuessNumberGame.Feedback, List<int[]>> entry : groups.entrySet()) {
            DecisionTreeNode child = buildTree(entry.getValue(), allPossible, depth + 1);
            child.feedback = entry.getKey();
            node.children.add(child);
        }

        return node;
    }

    /**
     * 打印决策树结构(简化版)
     */
    public void printTree(String indent) {
        if (isLeaf) {
            System.out.println(indent + "[叶子] 答案: " + GuessNumberGame.digitsToInt(answer));
            return;
        }
        System.out.println(indent + "[节点] 猜测: " + GuessNumberGame.digitsToInt(guess));
        for (DecisionTreeNode child : children) {
            System.out.println(indent + "  -> 反馈 " + child.feedback + ":");
            child.printTree(indent + "    ");
        }
    }

    /**
     * 计算树的最大深度和平均深度
     */
    public int getMaxDepth() {
        if (isLeaf) return depth;
        int max = 0;
        for (DecisionTreeNode child : children) {
            max = Math.max(max, child.getMaxDepth());
        }
        return max;
    }

    public static void main(String[] args) {
        List<int[]> all = GuessNumberGame.generateAllCandidates();
        // 构建完整的决策树需要大量内存和时间,这里仅演示小规模
        List<int[]> subset = all.subList(0, 24);
        System.out.println("构建决策树(24个候选的子集)...");
        DecisionTreeNode root = DecisionTreeNode.buildTree(subset, all, 0);
        System.out.println("树的最大深度: " + root.getMaxDepth());
        System.out.println("\n决策树结构:");
        root.printTree("");
    }
}

三、复杂度分析

操作 时间复杂度 空间复杂度 说明
生成候选空间 O(P(10,4)) = O(5040) O(5040) 一次性预处理
计算反馈 O(k²) = O(16) O(1) k=4 位数字
单次最优猜测 O(|S| × 5040) O(反馈种类数) |S| 为当前候选数
完整解题 O( attempts × |S| × 5040 ) O(|S|) 通常 attempts ≤ 7
构建完整决策树 O(5040² × 5040) O(树节点数) 理论上,实际需剪枝优化

四、策略对比与优化

策略 平均猜测次数 最大猜测次数 计算开销
随机猜测 ~5.2 >10 极低
简单启发式 ~4.8 8 低
信息熵最小化 ~4.34 6-7 高
最小最大(Knuth) ~4.47 5 极高

信息熵策略的平均表现最优,但最小最大策略能保证最坏情况下不超过 5 次猜测。实际工程中可根据场景在平均性能和最坏保证之间取舍。

五、总结

本文从信息论视角出发,将猜数字游戏建模为决策树构建问题。通过信息熵量化不确定性,每次选择使期望剩余候选集合最小的猜测,实现了接近理论最优的求解策略。Java 实现涵盖候选空间生成、反馈计算、信息熵评估和决策树递归构建等完整模块,读者可以在此基础上扩展到 5 位数字、含重复数字等变体场景,或引入并行计算加速大规模策略评估。