引言
“谁是卧底”是一款考验语言表达与逻辑推理的社交游戏:多数玩家拿到相同的平民词,少数卧底拿到相似但不同的词。玩家轮流描述自己的词,每轮投票淘汰最可疑的人,卧底需隐藏身份,平民需找出卧底。本文将用Java实现一个AI玩家,核心算法包括词向量相似度计算、贝叶斯概率更新与信息熵最优提问策略,让AI能够像人类一样进行推理和发言。
核心算法一:词向量相似度与语义空间建模
AI首先需要理解词语之间的语义关系。采用简化的字符特征向量方法:将词语映射到特征空间,通过特征重叠度衡量语义相似性。
特征提取策略
- 字符级特征:词语包含的汉字集合
- 语义类别特征:通过预定义词库标注类别(动物、食物、职业等)
- 属性特征:通过外部知识库获取的属性标签(大小、颜色、用途等)
import java.util.*;
/**
* 词语语义特征向量
*/
class WordFeature {
String word;
Set<String> chars; // 字符集合
Set<String> categories; // 语义类别
Set<String> attributes; // 属性标签
Map<String, Double> embedding; // 简化的特征权重向量
public WordFeature(String word) {
this.word = word;
this.chars = new HashSet<>();
this.categories = new HashSet<>();
this.attributes = new HashSet<>();
this.embedding = new HashMap<>();
extractChars(word);
}
private void extractChars(String w) {
for (char c : w.toCharArray()) {
chars.add(String.valueOf(c));
}
}
public void addCategory(String cat) { categories.add(cat); }
public void addAttribute(String attr) { attributes.add(attr); }
/**
* 设置特征权重(用于TF-IDF风格的加权)
*/
public void setFeatureWeight(String feature, double weight) {
embedding.put(feature, weight);
}
}
/**
* 语义相似度计算器
*/
class SemanticSimilarity {
/**
* Jaccard相似度:特征集合的交集/并集
*/
public static double jaccard(Set<String> a, Set<String> b) {
if (a.isEmpty() && b.isEmpty()) return 1.0;
Set<String> intersection = new HashSet<>(a);
intersection.retainAll(b);
Set<String> union = new HashSet<>(a);
union.addAll(b);
return (double) intersection.size() / union.size();
}
/**
* 综合语义相似度:融合字符、类别、属性三层特征
*/
public static double computeSimilarity(WordFeature w1, WordFeature w2) {
double charSim = jaccard(w1.chars, w2.chars);
double catSim = jaccard(w1.categories, w2.categories);
double attrSim = jaccard(w1.attributes, w2.attributes);
// 加权融合:类别相似度权重最高,属性次之,字符最低
return charSim * 0.2 + catSim * 0.5 + attrSim * 0.3;
}
/**
* 余弦相似度(用于稠密向量)
*/
public static double cosineSimilarity(Map<String, Double> v1,
Map<String, Double> v2) {
double dot = 0, norm1 = 0, norm2 = 0;
Set<String> allKeys = new HashSet<>(v1.keySet());
allKeys.addAll(v2.keySet());
for (String key : allKeys) {
double a = v1.getOrDefault(key, 0.0);
double b = v2.getOrDefault(key, 0.0);
dot += a * b;
norm1 += a * a;
norm2 += b * b;
}
if (norm1 < 1e-9 || norm2 < 1e-9) return 0;
return dot / (Math.sqrt(norm1) * Math.sqrt(norm2));
}
}
核心算法二:贝叶斯概率推理
AI需要根据其他玩家的发言不断更新对每个玩家身份的置信度。设 $H_i$ 表示”玩家i是卧底”的假设,$D$ 表示观测到的发言数据,则根据贝叶斯定理:
$$P(H_i | D) = \frac{P(D | H_i) \cdot P(H_i)}{P(D)}$$
似然函数设计
观测到玩家 $j$ 的发言 $s_j$ 后,计算该发言来自平民词 $w_{civilian}$ 或卧底词 $w_{undercover}$ 的可能性:
$$P(s_j | \text{player j is civilian}) \propto \text{sim}(s_j, w_{civilian})$$
$$P(s_j | \text{player j is undercover}) \propto \text{sim}(s_j, w_{undercover})$$
其中 $\text{sim}$ 为发言内容与词语的语义相似度。
/**
* 玩家状态与概率模型
*/
class Player {
int id;
String role; // "CIVILIAN" or "UNDERCOVER" or "UNKNOWN"
boolean alive;
List<String> statements; // 历史发言记录
double undercoverProb; // 当前被判定为卧底的后验概率
public Player(int id) {
this.id = id;
this.role = "UNKNOWN";
this.alive = true;
this.statements = new ArrayList<>();
this.undercoverProb = 0.5; // 先验概率
}
}
/**
* 贝叶斯推理引擎
*/
class BayesianReasoner {
private WordFeature civilianWord;
private WordFeature undercoverWord;
private List<Player> players;
private int undercoverCount;
public BayesianReasoner(String civilian, String undercover,
int playerCount, int undercoverCount) {
this.civilianWord = new WordFeature(civilian);
this.undercoverWord = new WordFeature(undercover);
this.undercoverCount = undercoverCount;
this.players = new ArrayList<>();
for (int i = 0; i < playerCount; i++) {
players.add(new Player(i));
}
// 设置初始先验
double prior = (double) undercoverCount / playerCount;
for (Player p : players) {
p.undercoverProb = prior;
}
}
/**
* 添加语义知识到词特征
*/
public void enrichWordKnowledge(String word, String category,
String... attributes) {
WordFeature target = word.equals(civilianWord.word) ?
civilianWord : undercoverWord;
target.addCategory(category);
for (String attr : attributes) {
target.addAttribute(attr);
}
}
/**
* 处理一轮新发言,更新所有玩家的概率
* @param speakerId 发言玩家ID
* @param statement 发言内容
*/
public void processStatement(int speakerId, String statement) {
Player speaker = players.get(speakerId);
speaker.statements.add(statement);
// 计算该发言与两个词的相似度
WordFeature stmtFeature = new WordFeature(statement);
double simCivilian = SemanticSimilarity.computeSimilarity(
stmtFeature, civilianWord);
double simUndercover = SemanticSimilarity.computeSimilarity(
stmtFeature, undercoverWord);
// 计算似然比
double likelihoodRatio = (simUndercover + 0.1) / (simCivilian + 0.1);
// 贝叶斯更新:P(卧底|发言) ∝ P(发言|卧底) * P(卧底)
double prior = speaker.undercoverProb;
double posterior = likelihoodRatio * prior;
// 归一化(简化处理:限制在合理范围)
speaker.undercoverProb = Math.max(0.01, Math.min(0.99,
posterior / (posterior + (1 - prior))));
// 传播约束:如果某玩家概率极低,将剩余概率质量分配给其他玩家
normalizeProbabilities();
}
/**
* 归一化概率,使其满足卧底人数约束
*/
private void normalizeProbabilities() {
double totalProb = 0;
int aliveCount = 0;
for (Player p : players) {
if (p.alive) {
totalProb += p.undercoverProb;
aliveCount++;
}
}
if (aliveCount == 0) return;
double expectedTotal = undercoverCount *
((double) aliveCount / players.size());
if (Math.abs(totalProb - expectedTotal) < 0.1) return;
// 线性缩放使总和接近期望值
double scale = expectedTotal / Math.max(totalProb, 0.001);
for (Player p : players) {
if (p.alive) {
p.undercoverProb = Math.max(0.01, Math.min(0.95,
p.undercoverProb * scale));
}
}
}
/**
* 获取当前最可疑的玩家
*/
public int getMostSuspiciousPlayer() {
int bestId = -1;
double maxProb = -1;
for (Player p : players) {
if (p.alive && p.undercoverProb > maxProb) {
maxProb = p.undercoverProb;
bestId = p.id;
}
}
return bestId;
}
public List<Player> getPlayers() { return players; }
}
核心算法三:信息熵最优提问策略
AI作为发言者时,需要生成既能帮助队友识别卧底、又不暴露自己身份的描述。采用信息增益最大化策略:选择能够最大程度区分平民词与卧底词的特征进行描述。
信息熵计算
特征 $f$ 的信息增益:
$$IG(f) = H(Y) – H(Y | f)$$
其中 $H(Y)$ 为当前身份分布的熵,$H(Y|f)$ 为观察到特征 $f$ 后的条件熵。
/**
* 信息熵驱动的发言生成器
*/
class StatementGenerator {
private WordFeature myWord;
private WordFeature otherWord; // 另一个候选词(AI不知道自己是平民还是卧底)
private List<String> usedFeatures; // 已用过的特征(避免重复)
public StatementGenerator(String myWord, String otherWord) {
this.myWord = new WordFeature(myWord);
this.otherWord = new WordFeature(otherWord);
this.usedFeatures = new ArrayList<>();
}
/**
* 生成最优描述特征
* @return 推荐的发言描述
*/
public String generateOptimalStatement() {
// 候选特征池
List<String> candidateFeatures = new ArrayList<>();
candidateFeatures.addAll(myWord.categories);
candidateFeatures.addAll(myWord.attributes);
String bestFeature = null;
double maxInfoGain = -1;
for (String feature : candidateFeatures) {
if (usedFeatures.contains(feature)) continue;
// 计算该特征的信息增益
double ig = calculateInfoGain(feature);
if (ig > maxInfoGain) {
maxInfoGain = ig;
bestFeature = feature;
}
}
if (bestFeature != null) {
usedFeatures.add(bestFeature);
return constructStatement(bestFeature);
}
// fallback:给出模糊描述
return "这是一种常见的事物";
}
/**
* 计算某特征的信息增益
* 高信息增益 = 该特征强烈区分两个词
*/
private double calculateInfoGain(String feature) {
boolean hasInMine = myWord.categories.contains(feature) ||
myWord.attributes.contains(feature);
boolean hasInOther = otherWord.categories.contains(feature) ||
otherWord.attributes.contains(feature);
// 理想特征:一个词有,另一个词没有
if (hasInMine && !hasInOther) return 2.0; // 强烈指向自己是平民
if (!hasInMine && hasInOther) return -1.0; // 危险!会暴露自己是卧底
if (hasInMine && hasInOther) return 0.1; // 两个词都有,信息量低
return 0.0; // 两个词都没有
}
/**
* 将特征构造为自然语言描述
*/
private String constructStatement(String feature) {
Map<String, String> templates = new HashMap<>();
templates.put("动物", "这是一种动物");
templates.put("食物", "可以吃的东西");
templates.put("职业", "这是一种工作");
templates.put("大型", "体型比较大");
templates.put("小型", "体型小巧");
templates.put("水生", "生活在水里");
templates.put("陆地", "生活在陆地上");
templates.put("飞行", "可以在天上飞");
templates.put("哺乳动物", "是哺乳动物");
templates.put("家禽", "属于家禽类");
return templates.getOrDefault(feature,
"这和" + feature + "有关");
}
/**
* 投票策略:基于贝叶斯推理结果投票给最可疑玩家
*/
public int vote(BayesianReasoner reasoner, int myId) {
int target = reasoner.getMostSuspiciousPlayer();
if (target == myId) {
// 如果自己最可疑,投票给第二可疑的(自保)
double secondMax = -1;
int secondTarget = -1;
for (Player p : reasoner.getPlayers()) {
if (p.alive && p.id != myId &&
p.undercoverProb > secondMax) {
secondMax = p.undercoverProb;
secondTarget = p.id;
}
}
return secondTarget;
}
return target;
}
}
完整游戏流程与AI决策
/**
* 谁是卧底游戏主控
*/
public class UndercoverGame {
private List<Player> players;
private BayesianReasoner reasoner;
private StatementGenerator aiGenerator;
private String civilianWord;
private String undercoverWord;
private int aiPlayerId;
public UndercoverGame(int playerCount, int undercoverCount,
String civWord, String ucWord, int aiId) {
this.civilianWord = civWord;
this.undercoverWord = ucWord;
this.aiPlayerId = aiId;
this.reasoner = new BayesianReasoner(civWord, ucWord,
playerCount, undercoverCount);
this.aiGenerator = new StatementGenerator(civWord, ucWord);
this.players = reasoner.getPlayers();
// 预加载语义知识(实际应用中可对接词库/知识图谱)
reasoner.enrichWordKnowledge(civWord, "动物", "大型", "陆地", "哺乳动物");
reasoner.enrichWordKnowledge(ucWord, "动物", "小型", "陆地", "家禽");
}
/**
* 执行一轮发言与投票
*/
public void playRound() {
System.out.println("=== 新一轮开始 ===");
// 1. 每位存活玩家发言
for (Player p : players) {
if (!p.alive) continue;
String statement;
if (p.id == aiPlayerId) {
statement = aiGenerator.generateOptimalStatement();
System.out.println("AI玩家" + p.id + "发言:\"" + statement + "\"");
} else {
// 模拟人类玩家发言(简化:随机生成)
statement = simulateHumanStatement(p.id);
System.out.println("玩家" + p.id + "发言:\"" + statement + "\"");
}
// 所有玩家(包括AI)根据此发言更新概率
for (int observerId = 0; observerId < players.size(); observerId++) {
if (players.get(observerId).alive) {
reasoner.processStatement(p.id, statement);
}
}
}
// 2. 显示当前概率分布
System.out.println("\n当前嫌疑概率:");
for (Player p : players) {
if (p.alive) {
System.out.printf(" 玩家%d: %.2f%%\n", p.id,
p.undercoverProb * 100);
}
}
// 3. 投票淘汰
Map<Integer, Integer> votes = new HashMap<>();
for (Player p : players) {
if (!p.alive) continue;
int voteTarget;
if (p.id == aiPlayerId) {
voteTarget = aiGenerator.vote(reasoner, aiPlayerId);
} else {
voteTarget = reasoner.getMostSuspiciousPlayer();
if (voteTarget == p.id) {
// 人类玩家不自投,随机选其他
for (Player other : players) {
if (other.alive && other.id != p.id) {
voteTarget = other.id;
break;
}
}
}
}
votes.merge(voteTarget, 1, Integer::sum);
System.out.println("玩家" + p.id + "投票给玩家" + voteTarget);
}
// 淘汰得票最高者
int maxVotes = -1;
int eliminated = -1;
for (Map.Entry<Integer, Integer> e : votes.entrySet()) {
if (e.getValue() > maxVotes) {
maxVotes = e.getValue();
eliminated = e.getKey();
}
}
if (eliminated >= 0) {
players.get(eliminated).alive = false;
System.out.println("\n玩家" + eliminated + "被淘汰!");
}
// 4. 检查游戏结束
checkGameOver();
}
private String simulateHumanStatement(int playerId) {
// 简化模拟:70%概率说与平民词相关的特征
String[] civFeatures = {"这是动物", "比较大", "有四条腿", "哺乳动物"};
String[] ucFeatures = {"会飞", "比较小", "家禽", "有两条腿"};
boolean isUndercover = (playerId % 3 == 0); // 简化:假设部分玩家是卧底
String[] pool = isUndercover ? ucFeatures : civFeatures;
return pool[(int)(Math.random() * pool.length)];
}
private void checkGameOver() {
int aliveCivilians = 0;
int aliveUndercover = 0;
for (Player p : players) {
if (!p.alive) continue;
// 简化:根据初始设定判断(实际应跟踪真实身份)
if (p.id % 3 != 0) aliveCivilians++;
else aliveUndercover++;
}
if (aliveUndercover == 0) {
System.out.println("平民胜利!");
} else if (aliveUndercover >= aliveCivilians) {
System.out.println("卧底胜利!");
}
}
public static void main(String[] args) {
// 示例:平民词"大象",卧底词"公鸡",6人局1卧底,AI为玩家0
UndercoverGame game = new UndercoverGame(6, 1,
"大象", "公鸡", 0);
for (int i = 0; i < 5; i++) {
game.playRound();
System.out.println();
}
}
}
算法复杂度分析
| 模块 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 相似度计算 | $O(k)$ | $O(k)$ | k为特征集合大小,通常为常数级 |
| 贝叶斯更新 | $O(n)$ | $O(n)$ | n为玩家数,每轮需更新所有玩家概率 |
| 信息增益计算 | $O(m \cdot k)$ | $O(1)$ | m为候选特征数 |
| 单轮游戏 | $O(n^2 \cdot k)$ | $O(n)$ | n轮发言,每次触发n次概率更新 |
扩展思路
- 深度词向量:对接预训练中文词向量(如Word2Vec、BERT),替代手工特征工程
- 图神经网络:将玩家发言构建为异构图,用GNN推理隐藏关系
- 多轮对话记忆:引入LSTM/Transformer编码长程发言上下文,捕捉矛盾陈述
- 对抗训练:训练两个AI互相对抗,一个隐藏身份,一个推理识别
总结
本文实现了一个”谁是卧底”AI玩家,核心算法包括:
– 多层语义相似度,融合字符、类别、属性三层特征评估词语关联
– 贝叶斯概率更新,根据每轮发言动态修正玩家身份的后验概率
– 信息增益最大化发言策略,选择最能区分两个词的特征进行描述
该框架是不完全信息博弈的典型实践,其概率推理机制可迁移至狼人杀、剧本杀等同类社交推理游戏的AI设计中。