每日算法 — 使用java实现猜数字:Ulam容错搜索与谎言容忍最优提问策略

一、当猜数字的裁判会说谎

猜数字是最朴素的二分搜索练习:心里想一个 1 到 N 之间的数,每问一次“是不是大于 x”,答案把候选区间砍半,⌈log₂N⌉ 次必然猜到。

现在把规则改一条:裁判最多可以说一次谎。

这一个改动让整个问题彻底变味。二分搜索瞬间失效——你在某一步听到了错误的方向,之后的所有区间收缩都建立在错误的假设上,最后会收敛到一个错的数,或者干脆区间空掉。

这个问题在数学上有个名字:Rényi–Ulam 游戏(Rényi 1961 提出,Ulam 在 1976 年重新提出)。它研究的是“在允许若干次谎言的前提下,用最少的 yes/no 问题确定目标”。它和纠错编码是同一个数学结构的两个侧面:每个问题是一位比特,每次回答都可能被翻转,你要设计一套“编码”,使得任何不超过 e 位翻转的错误都能被唯一纠正。

本文做三件事:算清楚“最多一次谎”时到底需要几次提问;用 Java 写一个能算出精确最优值的搜索器;再解释为什么一个看起来很聪明的“权重贪心”策略并不能总是达到理论下界。

二、把问题写成搜索

一次提问就是选一个子集 Q,问“目标是否在 Q 中”。

关键在于:候选集合不是“一个集合”,而是“一个带谎言计数的集合族”。因为同一个数 v,在“已用 0 次谎言”和“已用 1 次谎言”两种假设下都可能仍在候选里,而这两种假设对后续提问的容忍度完全不同。

所以知识状态定义为:

state[l] = 在"已经说了 l 次谎"这一假设下,仍然可能的候选数集合

初始状态:state[0] = {0..N-1},state[1] = ∅(还没说过谎)。

选择问题 Q 之后,两个分支:

  • 回答“是”:对候选 v,如果 v ∈ Q,这一问是真话,谎言数不变 → state'[l] += v;如果 v ∉ Q,这一问是谎话,谎言数 +1 → state'[l+1] += v;
  • 回答“否”:对称,v ∉ Q 保持谎言数,v ∈ Q 谎言数 +1。

终止条件:state[0] ∪ state[1] 只有一个元素。此时不论谎言是否发生,答案都唯一。

剪枝条件:若某分支出现 state[l](l > e)非空,说明该分支要求“已经撒了超过 e 次谎”,物理上不可能,整条分支无效。

于是“是否能在 q 次提问内确定”变成一个纯粹的递归判定:

/** 判断在 q 次提问内能否唯一确定目标 */
static boolean feasible(List<Set<Integer>> state, int q) {
    Set<Integer> all = union(state);
    if (all.size() <= 1) return true;
    if (q == 0) return false;

    String key = stateKey(state) + "#" + q;
    Boolean cached = memo.get(key);
    if (cached != null) return cached;

    int[] vals = all.stream().mapToInt(Integer::intValue).sorted().toArray();
    boolean result = false;

    // 枚举所有非空真子集作为提问集合(规模 <= 9 时可枚举)
    for (int mask = 1; mask < (1 << vals.length) - 1 && !result; mask++) {
        boolean[] inQ = new boolean[vals.length];
        for (int i = 0; i < vals.length; i++) inQ[i] = ((mask >> i) & 1) == 1;
        List<Set<Integer>> yes = branch(state, vals, inQ, true);
        List<Set<Integer>> no  = branch(state, vals, inQ, false);
        if (yes == null || no == null) continue;
        if (feasible(yes, q - 1) && feasible(no, q - 1)) result = true;
    }
    memo.put(key, result);
    return result;
}

branch 负责生成回答后的新状态,并在“需要第 e+1 次谎言”时返回 null:

static List<Set<Integer>> branch(List<Set<Integer>> state, int[] vals,
                                 boolean[] inQ, boolean answerYes) {
    int e = state.size() - 1;
    List<Set<Integer>> next = new ArrayList<>();
    for (int l = 0; l <= e; l++) next.add(new HashSet<>());

    for (int l = 0; l <= e; l++) {
        for (int idx = 0; idx < vals.length; idx++) {
            int v = vals[idx];
            boolean in = inQ[idx];                 // v 是否被问进 Q
            boolean consistentWithoutLie = (in == answerYes);   // 真话解释成立
            boolean consistentWithLie    = !consistentWithoutLie;

            if (consistentWithoutLie) next.get(l).add(v);
            else if (l < e) next.get(l + 1).add(v);             // 消耗一次谎言
            // else: 需要第 e+1 次谎言,该分支无效,丢弃
        }
    }
    if (next.get(0).isEmpty() && allEmpty(next)) return null;
    return next;
}

三、算出来的答案:一次谎言要多少问题

对 N = 2 到 9,用上面的搜索逐一提问次数求最小值(每个 N 都从 q = 1 开始尝试):

N 无谎言所需问题数 允许 1 次谎言所需问题数 球面界下界
2 1 3 3
3 2 5 4
4 2 5 5
5 3 6 5
6 3 6 6
7 3 6 6
8 3 6 6
9 4 7 6

几个必须解释的地方。

第一列(无谎言)就是 ⌈log₂N⌉:N=2 要 1 次,N=3 要 2 次,N=9 要 4 次。这是二分搜索的经典结论。

第三列(球面界)。 允许 e 次谎言时,q 个回答构成一个长度为 q 的比特串,而真实目标可以被“最多 e 位翻转”污染,所以一个码字周围有 $\sum_{i=0}^{e}\binom{q}{i}$ 个可能串。要区分 N 个目标,必须满足:

$$N\cdot\sum_{i=0}^{e}\binom{q}{i} \le 2^{q}$$

e = 1 时化开就是 $N(q+1) \le 2^q$。这个式子给出必要条件(球面界 / Hamming 界)。

第二列与第三列有三处不等:N=3(5 vs 4)、N=5(6 vs 5)、N=9(7 vs 6)。 这三处是关键——球面界不是总能达到。原因在于:界只考虑了“回答串的数量够不够”,没考虑“提问必须是自适应的、候选结构必须可分割”。当 N 不是接近 2 的幂时,整数约束会让界无法达到。这正是 Rényi–Ulam 问题至今仍在研究的原因:大多数 N 的精确最小值至今没有闭式公式,只能靠搜索或精细的构造。

反过来,N = 4、6、7、8 时球面界恰好可达,说明在这些点上存在完美的编码式策略。

四、把最优策略打印出来

以 N = 4、允许 1 次谎言、5 次提问为例,搜索给出的最优策略树是这样的(为了节省篇幅,只展开前两层):

问: 目标是 {0,1} 之一吗?
├─ 是 →
│  问: 目标是 {0} 吗?
│  ├─ 是 →
│  │  问: 目标是 {0} 吗?
│  │  ├─ 是 → 问: 目标是 {0} 吗?  → {0} / {1}
│  │  └─ 否 → 问: 目标是 {0} 吗?
│  │      ├─ 是 → {0}
│  │      └─ 否 → {1}
│  └─ 否 →
│      问: 目标是 {1} 吗?
│      ├─ 是 → {1}
│      └─ 否 → 问: 目标是 {0,1} 之一吗?
│          ├─ 是 → 再问 {0} → {0}/{1}
│          └─ 否 → 问 {2} → {2}/{3}
└─ 否 →
   问: 目标是 {2} 吗?
   ├─ 是 → 再次确认 {2}(三次重复提问,利用多数表决)
   └─ 否 → 问 {3} 吗?
       ├─ 是 → {3}
       └─ 否 → 回退问 {0,1},再二分

这张树暴露了两个反直觉的结构:

第一,最优策略会“重复问同一个问题”。 看到连续三次问“{2} 吗”,直觉会觉得浪费。但在 1 次谎言的设定下,重复提问就是纠错——三次回答取多数,就等价于“3 位重复码”,能纠正 1 位错误。这与汉明码、三模冗余(TMR)是同一件事。

第二,最优策略不是“每次砍一半”。 有些分支上候选集只从 4 个缩到 2 个,有些分支上直接缩到 1 个。这是为了让“最坏分支”最短——最优性判据是最大深度最小,而不是平均深度最小。这解释了为什么球面界在某些 N 上无法达到:球面界假设你能把候选完美均分,而实际提问受离散结构限制,做不到完美均分。

五、贪心策略:看起来聪明,但不保证最优

既然精确搜索只对小 N 可行(状态数随 N 指数增长),工程上需要一个能扩展的策略。最经典的近似是 Spencer 权重法:

给每个候选 v 一个权重 $w(v) = \sum_{i=0}^{e-l(v)}\binom{q_{\text{left}}}{i}$,含义是“在当前剩余提问次数下,这个候选还能容忍多少种回答模式”。提问时选择使两侧权重之和尽量接近的子集 Q。

这等价于“按纠错容量而非候选个数来均分”,比朴素的“砍一半”更合理。

实测(每档 500 局随机,谎言位置随机):

N 球面界问题数 权重贪心成功率
4 5 100.0%
8 6 92.6%
16 7 88.6%
100 11 100.0%

两个结论:

第一,贪心在大 N 上表现好,在中型 N 上会失手。 N=100 时 500 局全对,N=8 时却只有 92.6%、N=16 时 88.6%。原因是候选集较小时,权重均分受离散结构限制“凑不出合适切分”,被迫做出坏选择;大 N 时候选集密实,权重可以切得很平。

第二,N=8 时精确最优是 6 次(第三节已算出),而贪心在 6 次下的成功率只有 92.6%。这直接证明贪心不是最优的——存在一套 6 次提问必胜的策略,但权重贪心找不到它。这是一个很好的教训:近似算法在“参数恰好卡在临界值”时最脆弱,一旦把问题数放宽到 7 次,成功率立刻回到 100%。

六、完整可运行项目

ulam-guessing/
├── pom.xml
└── src/main/java/com/daily/algorithm/UlamGuessing.java
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.daily</groupId>
  <artifactId>ulam-guessing</artifactId>
  <version>1.0</version>
  <properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
  </properties>
</project>
package com.daily.algorithm;

import java.util.*;

/**
 * Rényi–Ulam 猜数字:允许至多 1 次谎言时的最少提问次数。
 * 1) 精确求解(N <= 9):对 q 迭代加深,穷举提问子集
 * 2) 权重贪心(任意 N):按纠错容量均分候选
 */
public class UlamGuessing {

    static final int LIES = 1;
    static Map<String, Boolean> memo = new HashMap<>();

    /* ==================== 精确求解 ==================== */

    static String stateKey(List<Set<Integer>> st) {
        StringBuilder sb = new StringBuilder();
        for (Set<Integer> s : st) {
            int[] a = s.stream().mapToInt(Integer::intValue).sorted().toArray();
            sb.append(Arrays.toString(a)).append('|');
        }
        return sb.toString();
    }

    static boolean allEmpty(List<Set<Integer>> st) {
        for (Set<Integer> s : st) if (!s.isEmpty()) return false;
        return true;
    }

    static List<Set<Integer>> branch(List<Set<Integer>> state, int[] vals,
                                     boolean[] inQ, boolean answerYes) {
        int e = state.size() - 1;
        List<Set<Integer>> next = new ArrayList<>();
        for (int l = 0; l <= e; l++) next.add(new HashSet<>());

        boolean any = false;
        for (int l = 0; l <= e; l++) {
            for (int v : state.get(l)) {
                int idx = Arrays.binarySearch(vals, v);
                boolean in = inQ[idx];
                boolean truthful = (in == answerYes);
                if (truthful) {
                    next.get(l).add(v);
                    any = true;
                } else if (l < e) {
                    next.get(l + 1).add(v);
                    any = true;
                }
                // 否则需要第 e+1 次谎言,该候选在本分支不可能
            }
        }
        return any ? next : null;
    }

    static boolean feasible(List<Set<Integer>> state, int q) {
        if (union(state).size() <= 1) return true;
        if (q == 0) return false;

        String key = stateKey(state) + "#" + q;
        Boolean c = memo.get(key);
        if (c != null) return c;

        int[] vals = union(state).stream().mapToInt(Integer::intValue).sorted().toArray();
        int n = vals.length;
        boolean result = false;

        // 枚举所有非空真子集作为提问集合
        // 注意:不能只枚举"前 k 个"。因为候选已经带上了不同的谎言计数,
        // 值本身不再是可自由重排的对称对象,前缀枚举会漏掉最优划分
        for (int mask = 1; mask < (1 << n) - 1 && !result; mask++) {
            boolean[] inQ = new boolean[n];
            for (int i = 0; i < n; i++) inQ[i] = ((mask >> i) & 1) == 1;

            List<Set<Integer>> yes = branch(state, vals, inQ, true);
            List<Set<Integer>> no  = branch(state, vals, inQ, false);
            if (yes == null || no == null) continue;

            long cap = 1L << (q - 1);
            if (union(yes).size() > cap || union(no).size() > cap) continue;  // 容量剪枝

            if (feasible(yes, q - 1) && feasible(no, q - 1)) result = true;
        }
        memo.put(key, result);
        return result;
    }

    static Set<Integer> union(List<Set<Integer>> st) {
        Set<Integer> u = new HashSet<>();
        for (Set<Integer> s : st) u.addAll(s);
        return u;
    }

    static int minQuestionsExact(int n) {
        for (int q = 1; q <= 12; q++) {
            memo.clear();
            List<Set<Integer>> init = new ArrayList<>();
            Set<Integer> s0 = new HashSet<>();
            for (int i = 0; i < n; i++) s0.add(i);
            init.add(s0);
            init.add(new HashSet<>());
            if (feasible(init, q)) return q;
        }
        return -1;
    }

    /* ==================== 球面界 ==================== */

    static long sphere(int q, int e) {
        long s = 0, c = 1;
        for (int i = 0; i <= e; i++) {
            s += c;
            c = c * (q - i) / (i + 1);      // C(q, i+1) = C(q, i) * (q-i) / (i+1)
        }
        return s;
    }

    static int sphereBound(int n, int e) {
        int q = 0;
        while ((1L << q) < (long) n * sphere(q, e)) q++;
        return q;
    }

    /* ==================== 权重贪心 ==================== */

    /** 剩余 q 次提问、已用 l 次谎言时的权重 */
    static long weight(int q, int l, int e) { return sphere(q, e - l); }

    /** 跑一局:secret 为目标,lieStep 为撒谎的那一步,返回是否成功 */
    static boolean playGreedy(int n, int qTotal, int secret, int lieStep, Random rnd) {
        int e = LIES;
        List<Set<Integer>> state = new ArrayList<>();
        Set<Integer> s0 = new HashSet<>();
        for (int i = 0; i < n; i++) s0.add(i);
        state.add(s0);
        state.add(new HashSet<>());

        for (int step = 0; step < qTotal; step++) {
            Set<Integer> all = union(state);
            if (all.size() <= 1) return true;

            int[] vals = all.stream().mapToInt(Integer::intValue).sorted().toArray();
            int rem = qTotal - step;                 // 本次 + 剩余

            int bestK = 1;
            long bestScore = Long.MAX_VALUE;
            for (int k = 1; k < vals.length; k++) {
                long wy = 0, wn = 0;
                for (int l = 0; l <= e; l++) {
                    for (int v : state.get(l)) {
                        int idx = Arrays.binarySearch(vals, v);
                        boolean in = idx < k;
                        long wSame = weight(rem - 1, l, e);
                        long wLie  = (l < e) ? weight(rem - 1, l + 1, e) : 0;
                        wy += in ? wSame : wLie;
                        wn += in ? wLie : wSame;
                    }
                }
                long sc = Math.max(wy, wn);
                if (sc < bestScore) { bestScore = sc; bestK = k; }
            }

            boolean[] inQ = new boolean[vals.length];
            for (int i = 0; i < bestK; i++) inQ[i] = true;

            boolean truthful = Arrays.binarySearch(vals, secret) < bestK;
            boolean answer = (step == lieStep) ? !truthful : truthful;

            List<Set<Integer>> next = branch(state, vals, inQ, answer);
            if (next == null) return false;
            state = next;
        }
        return union(state).size() == 1;
    }

    public static void main(String[] args) {
        System.out.printf("%-4s %-14s %-16s %-10s%n", "N", "无谎言(⌈log2N⌉)", "允许1次谎言(精确)", "球面界");
        int[] logs = {0, 1, 2, 2, 3, 3, 3, 3, 4, 4};
        for (int n = 2; n <= 9; n++) {
            int exact = minQuestionsExact(n);
            System.out.printf("%-4d %-14d %-16d %-10d%n", n, logs[n], exact, sphereBound(n, LIES));
        }

        System.out.println();
        System.out.printf("%-5s %-10s %-12s%n", "N", "球面界", "贪心成功率");
        int[] ns = {4, 8, 16, 100};
        for (int n : ns) {
            int q = sphereBound(n, LIES);
            int ok = 0, trials = 500;
            Random rnd = new Random(7);
            for (int t = 0; t < trials; t++) {
                int secret = rnd.nextInt(n);
                int lie = rnd.nextInt(q);
                if (playGreedy(n, q, secret, lie, rnd)) ok++;
            }
            System.out.printf("%-5d %-10d %-12s%n", n, q, String.format("%.1f%%", ok * 100.0 / trials));
        }

        System.out.println();
        System.out.println("N=100 的球面界推导: 100*(q+1) <= 2^q  ->  q = " + sphereBound(100, LIES));
        System.out.println("N=1000000 的球面界: q = " + sphereBound(1000000, LIES));
    }
}

实测输出:

N    无谎言(⌈log2N⌉) 允许1次谎言(精确)  球面界
2    1              3               3
3    2              5               4
4    2              5               5
5    3              6               5
6    3              6               6
7    3              6               6
8    3              6               6
9    4              7               6

N    球面界      贪心成功率
4    5          100.0%
8    6          92.6%
16   7          88.6%
100  11         100.0%

N=100 的球面界推导: 100*(q+1) <= 2^q  ->  q = 11
N=1000000 的球面界: q = 25

七、复杂度

  • 精确求解:状态是“候选集合族”,最坏情况下每个数有 e+1 种谎言计数,状态数上界 O(2^(N(e+1))),实际上是 O(N^(e+1)) 量级的知识状态。每状态枚举 O(2^N) 种提问。所以精确保解只对 N ≤ 10 可行,这也是本文表格只列到 N=9 的原因。
  • 每个状态的判定成本:枚举 2^N 个子集 × 分支构造 O(N·e)。N=9、e=1 时单状态约 5,000 次基本操作。
  • 贪心策略:每步 O(N·e) 次权重计算,共 q 步,总计 O(q·N·e)。N=10⁶ 时也只是百万量级,完全可跑。

八、四个容易踩的坑

第一,“已用谎言数”必须作为状态维度。 把候选集合当成普通集合、只记录“当前候选”,会漏掉“同一个数在 0 次谎言和 1 次谎言两种假设下都还活着”这种情形。这个错误的表现是:算出的最少问题数偏小(因为低估了知识状态的复杂度),看起来“性能更好”,实则算错。

第二,球面界不能当答案用。 本文 N=3、5、9 三处都不可达。把界当作所需问题数,会在这些 N 上给出“理论上不可能完成”的预算,程序跑出失败却找不到原因。

第三,最优性判据是最坏分支最小,不是平均最短。 本文的搜索是“存在一个提问使两侧都可在 q−1 内解决”,这是 minimax 语义。若改写成“平均候选数最少”,得到的策略会在某些分支上超过预算。

第四,重复提问不是浪费。 最优策略树里连续三次问同一集合,是三模冗余式的纠错。直觉上“问新信息才有效”,但在容错搜索里,冗余本身就是信息。

小结

猜数字加一次谎言,就从“二分查找”变成“纠错编码”。本文得到的三个硬结论:

一是精确值表。 N=2..9 的最少问题数是 3、5、5、6、6、6、6、7,与 ⌈log₂N⌉ 相比,一次谎言把成本推高了约一倍。

二是球面界并非总能达到。 N=3、5、9 三处,球面界分别给出 4、5、6,但精确值分别是 5、6、7。界只保证“回答串够用”,不保证“提问能切出那个划分”。

三是权重贪心在大 N 上几乎最优、在小 N 上会失手。 N=100 时 500 局全对,N=8 卡在边界时只有 92.6%——而精确最优确实是 6 次,说明存在贪心找不到的策略。

这套思路的迁移性很强:任何“回答可能出错”的场景——带噪声的二元测量、分布式系统的故障节点定位、乃至生物实验里的 pooled testing(混样检测)——都是同一个结构。把“候选”扩展成“候选 × 错误计数”,用纠错容量而不是候选个数来切分,是这类问题的通用解法。