每日算法 — 使用java实现色彩密码:Knuth最小最大算法与信息熵最优猜测策略

色彩密码(Mastermind,又称 Bulls and Cows)是一款经典的逻辑推理桌游。游戏规则简洁:一方设定一个由若干颜色组成的秘密序列,另一方通过逐轮猜测并接收反馈来破解它。每次反馈告知猜测中有多少个颜色位置完全正确(公牛),以及有多少个颜色正确但位置错误(奶牛)。本文将用 Java 完整实现这一游戏,并重点讲解 Donald Knuth 提出的最小最大算法(Minimax Algorithm),以及基于信息熵的最优猜测策略。

一、游戏规则与问题建模

在标准色彩密码中,假设有 6 种不同颜色,秘密序列长度为 4,且颜色允许重复。所有可能的秘密序列总数为:

$$
6^4 = 1296
$$

每次猜测后,系统返回一个 (bulls, cows) 反馈:
bulls(公牛):颜色和位置都正确的数量
cows(奶牛):颜色正确但位置错误的数量

例如,秘密序列是 [红, 蓝, 绿, 黄],猜测为 [红, 绿, 蓝, 黄],则反馈为 bulls=2(红和黄位置对),cows=2(蓝和绿颜色对但位置错)。

核心算法问题

给定当前所有可能的候选答案集合,如何选择下一轮的最优猜测,使得在最坏情况下所需的猜测次数最少?这正是 Knuth 最小最大算法要解决的核心问题。

二、Knuth 最小最大算法原理

Knuth 于 1976 年在《计算机科学与博弈论》中证明:对于标准 6 色 4 位的 Mastermind,存在一个确定性策略,保证在 5 次猜测内破解任何秘密序列。

算法思想

设当前候选集为 $S$(所有尚未被排除的可能秘密)。对于每一个可能的猜测 $g$(通常遍历全部 $1296$ 种组合,不限于候选集),我们模拟:如果真实秘密是 $S$ 中的某个元素,对猜测 $g$ 会产生什么反馈?根据反馈,$S$ 会被划分为若干子集。算法选择使得最大子集最小的猜测 $g$。

形式化描述:

$$
g^* = \arg\min_{g} \max_{(bulls, cows)} |S_{(bulls, cows)}^g|
$$

其中 $S_{(bulls, cows)}^g$ 表示候选集 $S$ 中,对猜测 $g$ 产生反馈 $(bulls, cows)$ 的所有秘密。

与信息熵的联系

最小最大算法侧重于最坏情况优化。另一个视角是信息论:每次猜测应最大化期望信息增益(即最小化反馈的不确定性)。Shannon 信息熵定义为:

$$
H = -\sum_{i} p_i \log_2 p_i
$$

选择猜测 $g$ 使得反馈分布的熵最大,意味着平均而言每次猜测获得的信息量最多。实际工程中,熵策略往往比纯最小最大策略的平均步数更优,但最小最大策略能保证最坏情况下步数的上界。

三、核心算法模块设计

我们采用模块化设计,将游戏拆分为以下核心组件:

  1. Code:表示一个颜色序列,提供 Bulls/Cows 计算
  2. Feedback:封装反馈结果
  3. KnuthSolver:核心求解器,实现最小最大算法
  4. MastermindGame:游戏主控,串联人机交互

3.1 颜色序列与反馈计算

计算 Bulls 和 Cows 是算法的基础。关键在于正确处理重复颜色:先统计位置完全匹配的 Bulls,再对剩余颜色统计 Cows。

/**
 * 表示一个色彩密码序列
 */
public class Code {
    private final int[] colors; // 颜色编码,0~5 分别代表六种颜色
    private final int length;
    private final int colorRange;

    public Code(int[] colors, int colorRange) {
        this.colors = colors.clone();
        this.length = colors.length;
        this.colorRange = colorRange;
    }

    /**
     * 计算当前序列与目标序列的 Bulls 和 Cows
     * bulls: 位置和颜色都正确
     * cows: 颜色正确但位置错误
     */
    public Feedback compareTo(Code other) {
        int bulls = 0;
        int cows = 0;
        int[] freqSelf = new int[colorRange];
        int[] freqOther = new int[colorRange];

        // 第一轮:统计 bulls
        for (int i = 0; i < length; i++) {
            if (this.colors[i] == other.colors[i]) {
                bulls++;
            } else {
                freqSelf[this.colors[i]]++;
                freqOther[other.colors[i]]++;
            }
        }

        // 第二轮:统计 cows(取两种颜色剩余数量的最小值)
        for (int c = 0; c < colorRange; c++) {
            cows += Math.min(freqSelf[c], freqOther[c]);
        }

        return new Feedback(bulls, cows);
    }

    public int[] getColors() {
        return colors.clone();
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder("[");
        String[] colorNames = {"红", "橙", "黄", "绿", "蓝", "紫"};
        for (int i = 0; i < length; i++) {
            sb.append(colorNames[colors[i]]);
            if (i < length - 1) sb.append(", ");
        }
        sb.append("]");
        return sb.toString();
    }
}

/**
 * 封装猜测反馈结果
 */
public class Feedback {
    public final int bulls;
    public final int cows;

    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 this.bulls == other.bulls && this.cows == other.cows;
    }

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

    @Override
    public String toString() {
        return String.format("(bulls=%d, cows=%d)", bulls, cows);
    }
}

3.2 Knuth 最小最大求解器

求解器维护一个候选集 candidates,每次根据反馈过滤不可能的答案,然后用最小最大原则选择下一轮最优猜测。

import java.util.*;

/**
 * Knuth 最小最大算法求解器
 * 核心思想:选择使得最坏情况下剩余候选数最少的猜测
 */
public class KnuthSolver {
    private final int codeLength;     // 序列长度(默认 4)
    private final int colorRange;     // 颜色种类(默认 6)
    private List<Code> allCodes;      // 所有可能的序列(6^4 = 1296)
    private List<Code> candidates;    // 当前候选集

    public KnuthSolver(int codeLength, int colorRange) {
        this.codeLength = codeLength;
        this.colorRange = colorRange;
        generateAllCodes();
        this.candidates = new ArrayList<>(allCodes);
    }

    /**
     * 生成所有可能的色彩序列
     * 使用递归回溯生成笛卡尔积
     */
    private void generateAllCodes() {
        allCodes = new ArrayList<>();
        int[] current = new int[codeLength];
        backtrack(0, current);
    }

    private void backtrack(int pos, int[] current) {
        if (pos == codeLength) {
            allCodes.add(new Code(current.clone(), colorRange));
            return;
        }
        for (int c = 0; c < colorRange; c++) {
            current[pos] = c;
            backtrack(pos + 1, current);
        }
    }

    /**
     * 根据猜测和反馈过滤候选集
     * 仅保留那些对当前猜测能产生相同反馈的秘密序列
     */
    public void filterCandidates(Code guess, Feedback feedback) {
        List<Code> newCandidates = new ArrayList<>();
        for (Code candidate : candidates) {
            if (candidate.compareTo(guess).equals(feedback)) {
                newCandidates.add(candidate);
            }
        }
        candidates = newCandidates;
    }

    /**
     * 使用最小最大算法选择最优猜测
     * 对每个可能的猜测,计算在所有反馈下最坏情况的剩余候选数
     * 选择使该最大值最小的猜测
     */
    public Code findBestGuess() {
        if (candidates.size() == 1) {
            return candidates.get(0);
        }

        Code bestGuess = null;
        int minMaxRemaining = Integer.MAX_VALUE;

        // 遍历所有可能的猜测(不限于候选集,Knuth 证明这更优)
        for (Code guess : allCodes) {
            // 按反馈分组,统计每组的大小
            Map<Feedback, Integer> feedbackCount = new HashMap<>();
            for (Code candidate : candidates) {
                Feedback fb = candidate.compareTo(guess);
                feedbackCount.merge(fb, 1, Integer::sum);
            }

            // 找出最坏情况(最大分组)
            int maxRemaining = 0;
            for (int count : feedbackCount.values()) {
                maxRemaining = Math.max(maxRemaining, count);
            }

            // 平局优先策略:
            // 1. 优先选择 maxRemaining 更小的
            // 2. 若相同,优先选择属于候选集的猜测
            // 3. 若仍相同,保持第一个
            boolean isInCandidates = candidates.contains(guess);
            boolean better = false;

            if (maxRemaining < minMaxRemaining) {
                better = true;
            } else if (maxRemaining == minMaxRemaining) {
                if (bestGuess == null) {
                    better = true;
                } else {
                    boolean bestInCandidates = candidates.contains(bestGuess);
                    if (isInCandidates && !bestInCandidates) {
                        better = true;
                    }
                }
            }

            if (better) {
                minMaxRemaining = maxRemaining;
                bestGuess = guess;
            }
        }

        return bestGuess;
    }

    /**
     * 基于信息熵的策略:选择使反馈熵最大的猜测
     * 信息熵越大,平均每次猜测获得的信息量越多
     */
    public Code findBestGuessByEntropy() {
        if (candidates.size() == 1) {
            return candidates.get(0);
        }

        Code bestGuess = null;
        double maxEntropy = -1.0;

        for (Code guess : allCodes) {
            Map<Feedback, Integer> feedbackCount = new HashMap<>();
            for (Code candidate : candidates) {
                Feedback fb = candidate.compareTo(guess);
                feedbackCount.merge(fb, 1, Integer::sum);
            }

            // 计算熵: H = -sum(p_i * log2(p_i))
            double entropy = 0.0;
            int total = candidates.size();
            for (int count : feedbackCount.values()) {
                if (count > 0) {
                    double p = (double) count / total;
                    entropy -= p * (Math.log(p) / Math.log(2));
                }
            }

            boolean isInCandidates = candidates.contains(guess);
            boolean better = false;

            if (entropy > maxEntropy + 1e-9) {
                better = true;
            } else if (Math.abs(entropy - maxEntropy) < 1e-9) {
                if (bestGuess == null || (isInCandidates && !candidates.contains(bestGuess))) {
                    better = true;
                }
            }

            if (better) {
                maxEntropy = entropy;
                bestGuess = guess;
            }
        }

        return bestGuess;
    }

    public List<Code> getCandidates() {
        return candidates;
    }

    public void reset() {
        this.candidates = new ArrayList<>(allCodes);
    }
}

3.3 游戏主控与自动破解演示

import java.util.*;

/**
 * Mastermind 游戏主控类
 * 支持人机对战和 AI 自动破解两种模式
 */
public class MastermindGame {
    private static final int CODE_LENGTH = 4;
    private static final int COLOR_RANGE = 6;
    private static final String[] COLOR_NAMES = {"红", "橙", "黄", "绿", "蓝", "紫"};

    public static void main(String[] args) {
        System.out.println("=== 色彩密码 (Mastermind) ===");
        System.out.println("颜色: 红(0) 橙(1) 黄(2) 绿(3) 蓝(4) 紫(5)");
        System.out.println("规则: 猜一个 4 位颜色序列,颜色可重复");
        System.out.println();

        // 演示模式:AI 自动破解随机生成的秘密序列
        demoAutoSolve(10);
    }

    /**
     * 演示 AI 自动破解多个随机秘密
     */
    private static void demoAutoSolve(int rounds) {
        Random random = new Random(42); // 固定种子保证可复现
        KnuthSolver solver = new KnuthSolver(CODE_LENGTH, COLOR_RANGE);
        int totalSteps = 0;
        int maxSteps = 0;

        System.out.println("--- AI 自动破解演示 ---\n");

        for (int r = 0; r < rounds; r++) {
            // 生成随机秘密
            int[] secretColors = new int[CODE_LENGTH];
            for (int i = 0; i < CODE_LENGTH; i++) {
                secretColors[i] = random.nextInt(COLOR_RANGE);
            }
            Code secret = new Code(secretColors, COLOR_RANGE);
            solver.reset();

            System.out.printf("第 %d 轮 - 秘密序列: %s%n", r + 1, secret);
            int steps = autoSolve(solver, secret);
            totalSteps += steps;
            maxSteps = Math.max(maxSteps, steps);
            System.out.println();
        }

        System.out.println("--- 统计 ---");
        System.out.printf("总轮数: %d%n", rounds);
        System.out.printf("平均步数: %.2f%n", (double) totalSteps / rounds);
        System.out.printf("最大步数: %d%n", maxSteps);
        System.out.println("(Knuth 算法保证在 5 步内破解任何序列)");
    }

    /**
     * AI 自动破解指定秘密,返回所用步数
     */
    private static int autoSolve(KnuthSolver solver, Code secret) {
        int steps = 0;
        Code guess;

        // 经典开局:选择两个颜色各重复两次的序列,信息量大
        // 如 [红, 红, 橙, 橙],这在 Knuth 算法中通常会被自动选中
        while (true) {
            guess = solver.findBestGuess();
            steps++;
            Feedback fb = secret.compareTo(guess);
            System.out.printf("  第 %d 步: 猜测 %s -> 反馈 %s", steps, guess, fb);

            if (fb.bulls == CODE_LENGTH) {
                System.out.println(" ✅ 破解成功!");
                break;
            } else {
                System.out.printf(" (剩余候选: %d)%n", solver.getCandidates().size());
            }

            solver.filterCandidates(guess, fb);
        }

        return steps;
    }

    /**
     * 交互模式:人类设密,AI 破解
     */
    private static void interactiveMode() {
        Scanner scanner = new Scanner(System.in);
        KnuthSolver solver = new KnuthSolver(CODE_LENGTH, COLOR_RANGE);

        System.out.println("请在心里想一个 4 位颜色序列(如: 0 1 2 3 表示 [红, 橙, 黄, 绿])");
        System.out.println("每次我会猜测,你输入反馈: 公牛数 奶牛数(如: 1 2)");
        System.out.println();

        int steps = 0;
        while (true) {
            Code guess = solver.findBestGuess();
            steps++;
            System.out.printf("第 %d 步猜测: %s%n", steps, guess);
            System.out.print("请输入反馈 (公牛 奶牛): ");
            int bulls = scanner.nextInt();
            int cows = scanner.nextInt();

            if (bulls == CODE_LENGTH) {
                System.out.println("破解成功!");
                break;
            }

            solver.filterCandidates(guess, new Feedback(bulls, cows));
            if (solver.getCandidates().isEmpty()) {
                System.out.println("候选集为空,请检查反馈是否输入正确!");
                break;
            }
        }
    }
}

四、算法复杂度分析

时间复杂度

设颜色种类为 $C$,序列长度为 $L$,总组合数为 $N = C^L$。

  • 生成所有代码:$O(N \times L)$,即 $O(C^L \times L)$
  • 单次 Bulls/Cows 计算:$O(L + C)$,使用频率数组优化后
  • 单次最小最大评估:对 $N$ 个猜测,每个猜测需要与 $|S|$ 个候选比较,共 $O(N \times |S| \times L)$
  • 完整求解:最坏情况下每轮都需重新计算,总体约为 $O(k \times N^2 \times L)$,其中 $k$ 是步数(标准 Mastermind 中 $k \leq 5$)

对于标准参数 $C=6, L=4$,$N=1296$,单次选 guess 约需 $1296 \times 1296 \times 4 \approx 670$ 万次操作,在现代 CPU 上毫秒级完成。

空间复杂度

  • 存储所有代码:$O(N \times L)$
  • 候选集:最坏 $O(N \times L)$
  • 反馈分组映射:$O(K)$,$K$ 是不同反馈组合数(标准 Mastermind 中 $K \leq 14$)

五、扩展与优化方向

  1. 熵策略对比实验:实现两种策略(最小最大 vs 信息熵),对全部 1296 个秘密做批量测试,统计平均步数和最坏步数。通常熵策略的平均步数更优(约 4.1 步),但最小最大策略的最坏步数更优(保证 5 步)。

  2. 更大规模问题:当颜色数 $C$ 或长度 $L$ 增大时,$C^L$ 指数增长。此时可采用蒙特卡洛采样缩小候选评估范围,或使用遗传算法近似求解最优 guess。

  3. 人类心理模型:如果对手并非均匀随机选秘密,可以引入贝叶斯更新,根据历史数据调整先验分布,使猜测更贴合对手偏好。

六、总结

色彩密码是一个将组合数学信息论博弈论完美融合的经典问题。Knuth 最小最大算法的精妙之处在于:它不是在猜测”最可能”的答案,而是在系统地缩小可能性空间。每一次选择都在问:”如果我的运气最差,哪种猜测能让我剩下的工作最少?”这种最坏情况最优化的思想,在密码学、决策树设计和对抗性 AI 中都有广泛应用。

通过本文的 Java 实现,你可以看到:
– 如何用频率数组高效计算 Bulls/Cows
– 如何用候选集过滤实现确定性的状态空间搜索
– 如何用最小最大原则做出最优决策
– 如何用信息熵量化一次猜测的信息价值

完整代码可直接编译运行,尝试修改 COLOR_RANGECODE_LENGTH,观察算法在不同规模问题上的表现。