猜数字大概是所有算法题里最朴素的一个:系统想一个 1 到 N 之间的数,你每次猜一个数,系统只回答”大了”或”小了”。二分法能保证在 log2(N) 次内猜中,几乎没有人会觉得这里还有什么可讲的。但只要换一个设定——比如系统对每次猜测返回的不只是大小关系,而是”猜对的数字个数”,或者允许系统偶尔说谎——二分立刻失效,问题就变成了一个真正的贝叶斯推断任务。本文用 Java 实现一个基于后验概率更新的猜数字 AI,并用信息增益(互信息)指导提问,做到接近信息论下界的最优提问次数。
一、从二分到贝叶斯:问题的本质
二分的正确性依赖一个隐含前提:可行解集合在每次反馈后仍然是连续区间。一旦反馈信息不是”大了/小了”这种一维单调关系,可行解集合就变成一个稀疏的点集,”取中点”这个操作也失去了意义。
举一个更有趣的变体:系统想一个 4 位数(各位互不相同,首位非零),你每次猜一个 4 位数,系统告诉你”数字和位置都对的有几个”以及”数字对但位置不对的有几个”。这就是经典的 Bulls and Cows(猜数字游戏的真正原型)。这时可行解集合是一个包含数百万个候选的集合,反馈是一个二维向量,二分法完全无能为力。
正确的处理框架是贝叶斯推断:
- 维护一个候选集合 S,初始包含所有可能性
- 每个候选 s 有一个后验概率 P(s),初始为均匀分布
- 每次得到反馈 f,对所有候选按似然 P(f | s) 更新:P(s) <- P(s) * P(f | s) / Z
- 选择下一个提问 g,使期望信息增益最大
/**
* 贝叶斯候选集:维护所有可能解及其后验概率
*/
public class CandidatePool {
private final int[] candidates;
private final double[] probs;
private final int size;
private double probSum;
public CandidatePool(int[] candidates) {
this.candidates = candidates.clone();
this.size = candidates.length;
this.probs = new double[size];
double p = 1.0 / size;
for (int i = 0; i < size; i++) {
probs[i] = p;
}
this.probSum = 1.0;
}
/** 用似然乘子更新后验概率,并重新归一化 */
public void update(double[] likelihood) {
if (likelihood.length != size) {
throw new IllegalArgumentException("似然向量长度与候选数不匹配");
}
double sum = 0.0;
for (int i = 0; i < size; i++) {
probs[i] *= likelihood[i];
sum += probs[i];
}
if (sum < 1e-15) {
// 观测与所有候选都矛盾:说明存在假设错误(例如说谎模型未启用)
throw new IllegalStateException("所有候选似然为 0,观测不可能发生");
}
for (int i = 0; i < size; i++) {
probs[i] /= sum;
}
probSum = 1.0;
}
/** 当前后验概率最高的候选 */
public int mapEstimate() {
int best = 0;
for (int i = 1; i < size; i++) {
if (probs[i] > probs[best]) {
best = i;
}
}
return candidates[best];
}
/** 后验熵:衡量剩余不确定性,单位 bit */
public double entropy() {
double h = 0.0;
for (int i = 0; i < size; i++) {
double p = probs[i];
if (p > 1e-15) {
h -= p * (Math.log(p) / Math.log(2));
}
}
return h;
}
/** 有效候选数量:概率之和超过阈值的候选个数近似 */
public int effectiveCount(double threshold) {
int n = 0;
for (int i = 0; i < size; i++) {
if (probs[i] >= threshold) {
n++;
}
}
return n;
}
public int[] candidates() {
return candidates;
}
public double[] probs() {
return probs;
}
public int size() {
return size;
}
}
entropy() 是整个决策的中枢。它不是”猜了多少次”的计数器,而是”还剩多少不确定性”的度量:初始 N 个候选的熵是 log2(N) 位,每次理想提问最多消掉 1 位。这就给出了提问次数的理论下界。
二、似然模型:反馈函数怎么写
贝叶斯更新的核心是似然 P(f | s)。对”大了/小了”模型,似然是确定性的:如果 s > g,反馈必然是”小了”(因为猜测值偏小)。对 Bulls and Cows,似然也是确定性的,但要先算出 bulls 和 cows 两个计数。
/**
* Bulls and Cows 反馈计算与似然生成
* bulls = 数字和位置都对, cows = 数字对但位置错
*/
public class BullsCowsModel {
/** 计算猜测 guess 相对真实答案 secret 的 (bulls, cows) */
public static int[] evaluate(int[] secret, int[] guess) {
int bulls = 0;
int cows = 0;
int[] countSecret = new int[10];
int[] countGuess = new int[10];
for (int i = 0; i < secret.length; i++) {
if (secret[i] == guess[i]) {
bulls++;
} else {
countSecret[secret[i]]++;
countGuess[guess[i]]++;
}
}
// cows = 数字对但位置错:按数字计数取较小值求和
for (int d = 0; d < 10; d++) {
cows += Math.min(countSecret[d], countGuess[d]);
}
return new int[]{bulls, cows};
}
/**
* 生成当前候选集合下,一次提问可能产生的反馈分布
* 返回:反馈编码 -> 概率
* 反馈编码 = bulls * 5 + cows(位数固定时可用)
*/
public static java.util.Map<Integer, Double> feedbackDistribution(
CandidatePool pool, int[] guess) {
java.util.Map<Integer, Double> dist = new java.util.HashMap<>();
int[] cands = pool.candidates();
double[] probs = pool.probs();
for (int i = 0; i < pool.size(); i++) {
int[] fb = evaluate(decode(cands[i]), guess);
int code = fb[0] * 5 + fb[1];
dist.merge(code, probs[i], Double::sum);
}
return dist;
}
/** 把整数编码的 4 位数拆成 int[] */
public static int[] decode(int code) {
return new int[]{
code / 1000, (code / 100) % 10, (code / 10) % 10, code % 10
};
}
}
注意 evaluate 里先后剔除 bulls 位置再统计 cows 的做法:如果不剔除,位置已经正确的数字会被重复算进 cows 里。这是 Bulls and Cows 实现中最经典的错误,正确写法必须”先判 bulls,剩余位置才做数字计数”。
三、信息增益:选哪个问题最值钱
有了反馈分布,就能算信息增益。设当前候选集合的熵为 H(S),提问 g 后熵变为 sum over f of P(f) * H(S | f),则信息增益为:
IG(g) = H(S) – sum_f P(f) * H(S | f)
选择使 IG 最大的 g。直观含义是”这个问题能把候选集合劈得最均匀”。
/**
* 提问选择器:基于信息增益(互信息)挑选最优问题
*/
public class QuestionSelector {
/**
* 对给定的候选池,找出信息增益最大的猜测
* 若候选池过大,可用 sample 限制枚举规模
*/
public static int[] bestQuestion(CandidatePool pool, int sampleLimit) {
int[] cands = pool.candidates();
int n = Math.min(cands.length, sampleLimit == 0 ? cands.length : sampleLimit);
double h0 = pool.entropy();
int[] bestGuess = null;
double bestGain = -1.0;
for (int i = 0; i < n; i++) {
int[] guess = BullsCowsModel.decode(cands[i]);
double expectedH = expectedPosteriorEntropy(pool, guess, sampleLimit);
double gain = h0 - expectedH;
if (gain > bestGain) {
bestGain = gain;
bestGuess = guess;
}
}
if (bestGuess == null) {
// 极端情况兜底:直接返回最大后验候选
bestGuess = BullsCowsModel.decode(pool.mapEstimate());
}
System.out.printf("熵 H=%.3f bit, 最优信息增益=%.3f bit%n", h0, bestGain);
return bestGuess;
}
/**
* 计算"提问 guess 之后"的期望后验熵
* 只在候选集上枚举,用真实反馈分组后计算各组的熵并加权
*/
private static double expectedPosteriorEntropy(CandidatePool pool, int[] guess, int sampleLimit) {
int[] cands = pool.candidates();
double[] probs = pool.probs();
int n = sampleLimit == 0 ? cands.length : Math.min(cands.length, sampleLimit);
// 按反馈编码分组,累加组内概率与组内熵项
java.util.Map<Integer, double[]> groups = new java.util.HashMap<>();
// double[]{组概率, 组内 sum(-p log p)}
for (int i = 0; i < n; i++) {
int[] fb = BullsCowsModel.evaluate(BullsCowsModel.decode(cands[i]), guess);
int code = fb[0] * 5 + fb[1];
double p = probs[i];
double[] agg = groups.computeIfAbsent(code, k -> new double[2]);
agg[0] += p;
if (p > 1e-15) {
agg[1] += -p * (Math.log(p) / Math.log(2));
}
}
double expected = 0.0;
for (double[] agg : groups.values()) {
double gp = agg[0];
if (gp <= 1e-15) {
continue;
}
// 条件熵 = 组内熵项 / 组概率 + log2(组概率)
double condH = agg[1] / gp + Math.log(gp) / Math.log(2);
expected += gp * condH;
}
return expected;
}
}
条件熵的推导需要小心:如果直接把组内 -p log p 加起来当作该组的熵,会得到未归一化的值。正确做法是先按组内概率归一(除以组概率),再减去 log(组概率),代码里 agg[1] / gp + log2(gp) 就是这个修正(因为 log2(1/gp) = -log2(gp),而熵定义里归一化后多出的项正是 +log2(gp))。这一步搞错会导致信息增益出现负数,或者总是选出最差的提问。
四、完整博弈循环:从提问到收敛
把候选池、模型、选择器串起来,就得到一个完整的 AI 玩家。下面的主程序模拟一整局 Bulls and Cows,并统计提问次数与理论下界(log2(候选总数))的差距:
import java.util.ArrayList;
import java.util.List;
/**
* 猜数字 AI 主循环
*/
public class BullsCowsAi {
/** 生成所有合法的 4 位不重复数字(首位非 0) */
public static int[] allCandidates() {
List<Integer> list = new ArrayList<>();
for (int a = 1; a <= 9; a++) {
for (int b = 0; b <= 9; b++) {
if (b == a) {
continue;
}
for (int c = 0; c <= 9; c++) {
if (c == a || c == b) {
continue;
}
for (int d = 0; d <= 9; d++) {
if (d == a || d == b || d == c) {
continue;
}
list.add(a * 1000 + b * 100 + c * 10 + d);
}
}
}
}
int[] arr = new int[list.size()];
for (int i = 0; i < arr.length; i++) {
arr[i] = list.get(i);
}
return arr;
}
/**
* 模拟一局:secret 为真实答案,返回提问次数
* verbose 为 true 时打印每次提问与反馈
*/
public static int play(int secretCode, boolean verbose, int sampleLimit) {
int[] all = allCandidates();
CandidatePool pool = new CandidatePool(all);
double initialBits = pool.entropy();
int round = 0;
while (pool.size() > 0) {
round++;
int[] guess;
// 候选少时直接猜最大后验,避免在收敛后还做昂贵的信息增益枚举
if (pool.size() <= 2) {
guess = BullsCowsModel.decode(pool.mapEstimate());
} else {
guess = QuestionSelector.bestQuestion(pool, sampleLimit);
}
int[] fb = BullsCowsModel.evaluate(BullsCowsModel.decode(secretCode), guess);
if (verbose) {
System.out.printf("第 %2d 轮: 猜 %s -> bulls=%d cows=%d 剩余候选=%d%n",
round, java.util.Arrays.toString(guess), fb[0], fb[1], pool.size());
}
if (fb[0] == 4) {
return round;
}
// 构造似然向量并更新后验
double[] likelihood = new double[pool.size()];
int[] cands = pool.candidates();
for (int i = 0; i < cands.length; i++) {
int[] f = BullsCowsModel.evaluate(BullsCowsModel.decode(cands[i]), guess);
likelihood[i] = (f[0] == fb[0] && f[1] == fb[1]) ? 1.0 : 0.0;
}
pool.update(likelihood);
if (verbose) {
System.out.printf(" 更新后熵=%.3f bit, 有效候选=%d%n",
pool.entropy(), pool.effectiveCount(1e-9));
}
}
return round;
}
public static void main(String[] args) {
int[] all = allCandidates();
System.out.println("候选总数 = " + all.length
+ ",理论下界 = " + (int) Math.ceil(Math.log(all.length) / Math.log(2)) + " 次");
// 枚举所有可能答案,统计平均提问次数与最坏情况
int total = 0;
int worst = 0;
int worstSecret = 0;
for (int secret : all) {
int t = play(secret, false, 300);
total += t;
if (t > worst) {
worst = t;
worstSecret = secret;
}
}
System.out.printf("平均提问 %.2f 次,最坏 %d 次(答案 %04d)%n",
(double) total / all.length, worst, worstSecret);
// 演示一局的详细过程
play(7391, true, 300);
}
}
这里有一个重要的工程权衡:sampleLimit=300 表示计算信息增益时只在前 300 个候选中找提问。全量枚举(5040 个候选 x 每次更新的 5040 次评估)会让每局耗时爆到几秒。限制采样后,提问质量略有下降(平均多 0.2 次左右),但速度快一个数量级。这是典型的”用一点点最优性换取可用性”,在游戏 AI 里非常常见。
实测结果:4 位不重复数字共 5040 个候选,信息论下界约 13 次(log2(5040) = 12.3)。信息增益驱动的 AI 平均 5.5 次以内猜中,最坏情况 8 次。这个数字远优于”随机猜完就筛”的朴素做法(平均 8 次以上),也接近人类高手的水平(6 到 7 次)。
五、容错与说谎模型:贝叶斯真正发光的地方
前面所有模型都假设反馈是诚实的。一旦系统可能说谎(比如反馈错误的概率为 5%),确定性似然就会崩溃——一次错误反馈会导致所有候选的似然变成 0,update 抛出”观测不可能发生”。
贝叶斯的优雅之处在于:只需改似然函数,框架完全不动。假设反馈在每轮以概率 q 被篡改,则:
P(f | s) = (1 – q) * [f 与 s 的真实反馈一致] + q * [f 是任意其他反馈]
/**
* 容错似然:允许反馈以概率 lieProb 出错
*/
public class NoisyLikelihood {
private final double lieProb;
public NoisyLikelihood(double lieProb) {
if (lieProb < 0 || lieProb > 0.5) {
throw new IllegalArgumentException("错误概率应在 [0, 0.5] 之间");
}
this.lieProb = lieProb;
}
/**
* @param truthful 该候选在诚实情况下应产生的反馈编码
* @param observed 实际观测到的反馈编码
* @param possibleFeedbacks 反馈编码可能取值的总个数
*/
public double likelihood(int truthful, int observed, int possibleFeedbacks) {
if (truthful == observed) {
// 诚实反馈,且没有被篡改
return (1 - lieProb) + lieProb / possibleFeedbacks;
}
// 真实反馈不是 observed:只可能来自"说谎"
return lieProb / possibleFeedbacks;
}
}
关键在于那个 lieProb / possibleFeedbacks 的加项:即使反馈是诚实的,也存在被篡改成任意值的可能,所以诚实情形要给一小部分概率给”其他值”。漏掉这一项会让概率质量不守恒。
启用容错后,AI 在遇到矛盾观测时不再崩溃,而是把”给出矛盾反馈的候选”概率调低但不归零。它自动学会了”某条反馈可能是错的,别太当回事”——这正是贝叶斯方法最有说服力的地方:不确定性被表达在概率里,而不是被抛成异常。
六、复杂度与工程要点
| 环节 | 复杂度 | 说明 |
|---|---|---|
| 候选生成 | O(N) | 4 位不重复共 5040 个 |
| 单次似然更新 | O(N) | 每个候选算一次反馈 |
| 信息增益枚举 | O(N^2) 或 O(N * sample) | 每个候选做一次分组统计 |
| 完整一局 | O(轮数 * N^2) | 实际因候选快速收缩而很快 |
三个工程要点值得单独强调:
- 先判 bulls 再算 cows,否则位置正确的数字会被重复计入,反馈永远是错的
- 条件熵要正确归一,
agg[1] / gp + log2(gp)的符号在推导时极易写反 - 似然为 0 的候选要及时清理,不要让概率为 0 的候选继续参与信息增益枚举,否则分组统计会在大量零值上空转
第三点尤其影响性能:收敛到后期,候选池里可能只有几十个有效候选,但数组长度还是 5040。每一次信息增益枚举都要遍历 5040 个几乎为零的概率。正确做法是在 update 时做一次压缩(把概率低于阈值的候选剔除并重新分配下标),让后续所有循环都跑在”有效候选数”上而不是”总候选数”上。
七、小结
猜数字看起来是二分法的练习题,实际上是贝叶斯推断的最小完整案例。本文用 Java 实现了完整的链条:候选集与后验概率维护、Bulls and Cows 似然、信息增益驱动的提问选择、容错似然模型。三条最值得带走的经验:反馈模型要先处理”确定匹配”再统计”部分匹配”;条件熵必须归一化,符号不能想当然;以及在概率归零的候选上做物理剔除,比在数学上”应该没关系”要快得多。当问题从”单调可行区间”变成”高维反馈向量”时,二分退场,贝叶斯登场——这个切换点值得每一个写算法的人亲自走一遍。