AI 猜物 20 问
经典 20 问:AI 通过二分式提问逐步缩小候选,直至猜中你心里想的物品。
心里想一个常见物品,我来猜。
候选物品来自内置知识库;采用“区分度优先”策略,每轮选择最能把剩余候选一分为二的问题。
使用体验
- 心里想好一个物品(如“冰箱”“雨伞”“吉他”),点击“开始”。
- AI 依次提问,你点击“是 / 否 / 不确定”如实回答;日志区会记录问答历史。
- 候选收敛到唯一物品时 AI 给出答案;答错或超 20 问则认输重来。
代码要点
js
// 候选物品 + 属性位(1 = 具有该属性),问题即属性
const ITEMS = [
{ name: "冰箱", props: ["电器", "能制冷", "有门", "厨房"] },
{ name: "雨伞", props: ["能挡雨", "可折叠", "出门携带"] }
];
// 区分度优先:选择使“是 / 否 / 不确定”分组最均衡的属性提问
function bestQuestion(candidates, asked) {
let best = null, bestScore = -1;
for (const prop of ALL_PROPS) {
if (asked.has(prop)) continue;
const yes = candidates.filter(it => it.props.includes(prop)).length;
const no = candidates.length - yes;
const score = -Math.abs(yes - no); // 越均衡分数越高
if (score > bestScore) { bestScore = score; best = prop; }
}
return best;
}
// 回答后过滤候选;只剩 1 个即猜中
function answer(prop, value) {
candidates = candidates.filter(it =>
value === "yes" ? it.props.includes(prop)
: value === "no" ? !it.props.includes(prop)
: true);
if (candidates.length === 1) guess(candidates[0].name);
}完整可运行工程
可直接运行纯前端 · 零依赖双击 index.html 即用
把演示区的「20 问猜物」抽成一个独立小工程:4 个文件、零依赖,逻辑与演示区一致(信息量优先提问),并额外提供 simulate() 供命令行自测。
ai-guess/
├─ index.html 页面骨架
├─ app.js 核心算法 + 交互层
├─ styles.css 样式(跟随系统深浅色)
└─ README.md 运行方式与扩展点
index.html
页面骨架:问题区 / 按钮区 / 提问日志
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>20 问猜物 · 独立工程版</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="wrap">
<h1>20 问猜物</h1>
<p class="sub">心里想一个常见物品,AI 用区分度最高的提问逐步缩小候选范围。</p>
<section class="panel">
<p id="q" class="q">准备好了吗?</p>
<div id="btns" class="btns"></div>
<p id="msg" class="msg"></p>
</section>
<h2>提问日志</h2>
<section class="log-wrap"><div id="log" class="log"></div></section>
</main>
<script src="app.js"></script>
</body>
</html>
app.js
核心算法 + 浏览器交互层(含 simulate 自测入口)
javascript
/* app.js —— 20 问猜物:核心算法 + 浏览器交互(node 中可直接 require 做逻辑自测) */
var ITEMS = [
{ name: "冰箱", props: ["电器", "能制冷", "有门", "放在厨房", "插电使用", "体积较大"] },
{ name: "空调", props: ["电器", "能制冷", "挂在高处", "插电使用", "遥控操作", "体积较大"] },
{ name: "手机", props: ["电器", "能上网", "屏幕", "放进口袋", "触屏操作", "随身携带"] },
{ name: "电脑", props: ["电器", "能上网", "屏幕", "有键盘", "办公使用", "体积较大"] },
{ name: "台灯", props: ["电器", "能发光", "插电使用", "放在桌上", "手动开关", "体积较小"] },
{ name: "雨伞", props: ["能挡雨", "可折叠", "出门携带", "布类材质", "手持使用"] },
{ name: "帽子", props: ["能遮阳", "穿戴身上", "布类材质", "出门携带"] },
{ name: "眼镜", props: ["戴在脸上", "玻璃镜片", "出门携带", "体积较小"] },
{ name: "牙刷", props: ["洗漱用品", "放进口袋", "体积较小", "塑料材质", "手动使用"] },
{ name: "水杯", props: ["装液体", "放在桌上", "出门携带", "杯状容器", "体积较小"] },
{ name: "书包", props: ["装物品", "背在身上", "出门携带", "布类材质", "体积较大"] },
{ name: "吉他", props: ["乐器", "能发声", "手持使用", "木制材质", "体积较大"] },
{ name: "篮球", props: ["运动器材", "圆形球体", "能弹跳", "户外运动", "体积较大"] },
{ name: "钥匙", props: ["金属材质", "放进口袋", "体积较小", "出门携带", "开锁使用"] }
];
var ALL_PROPS = ["电器", "能上网", "能制冷", "能发光", "能挡雨", "能发声", "能弹跳", "屏幕",
"有门", "插电使用", "触屏操作", "戴在脸上", "背在身上", "手持使用", "放在桌上", "放在厨房",
"放进口袋", "出门携带", "体积较小", "体积较大", "金属材质", "塑料材质", "布类材质", "木制材质",
"玻璃镜片", "圆形球体", "户外运动", "随身携带", "手动开关", "穿戴身上", "洗漱用品", "装液体",
"杯状容器", "有键盘", "办公使用", "遥控操作", "挂在高处", "能遮阳", "可折叠", "开锁使用"];
/* 信息量优先:选 yes/no 最接近平分的属性作为下一个问题 */
function bestQuestion(candidates, asked) {
var best = null, bestScore = -Infinity;
for (var i = 0; i < ALL_PROPS.length; i++) {
var prop = ALL_PROPS[i];
if (asked.indexOf(prop) >= 0) continue;
var yes = 0;
for (var j = 0; j < candidates.length; j++) {
if (candidates[j].props.indexOf(prop) >= 0) yes++;
}
var score = -Math.abs(yes - (candidates.length - yes));
if (score > bestScore) { bestScore = score; best = prop; }
}
return best;
}
function filterBy(candidates, prop, value) {
return candidates.filter(function (it) {
var has = it.props.indexOf(prop) >= 0;
if (value === "yes") return has;
if (value === "no") return !has;
return true;
});
}
/* simulate:用一个"诚实回答者"跑完整流程,返回 {rounds, hit, left} —— 最小用例自测入口 */
function simulate(targetName) {
var target = null;
for (var i = 0; i < ITEMS.length; i++) if (ITEMS[i].name === targetName) target = ITEMS[i];
if (!target) throw new Error("未知物品:" + targetName);
var candidates = ITEMS.slice(), asked = [], rounds = 0;
while (rounds < 20) {
var prop = bestQuestion(candidates, asked);
if (!prop) break;
rounds++;
asked.push(prop);
candidates = filterBy(candidates, prop, target.props.indexOf(prop) >= 0 ? "yes" : "no");
if (candidates.length <= 1) break;
}
return { rounds: rounds, hit: candidates.length === 1 && candidates[0].name === targetName,
left: candidates.map(function (c) { return c.name; }).join("、") };
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { ITEMS: ITEMS, ALL_PROPS: ALL_PROPS, bestQuestion: bestQuestion, filterBy: filterBy, simulate: simulate };
}
/* ---- 浏览器交互层(node 环境下自动跳过) ---- */
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", function () {
var qEl = document.getElementById("q"), btnEl = document.getElementById("btns"),
logEl = document.getElementById("log"), msgEl = document.getElementById("msg");
var candidates = [], asked = [], rounds = 0;
function say(text) {
var d = document.createElement("div");
d.textContent = text;
logEl.appendChild(d);
logEl.scrollTop = logEl.scrollHeight;
}
function buttons(list) {
btnEl.innerHTML = "";
list.forEach(function (item) {
var b = document.createElement("button");
b.type = "button";
b.className = item[2] || "btn";
b.textContent = item[0];
b.addEventListener("click", item[1]);
btnEl.appendChild(b);
});
}
function finish(won, reason) {
var left = candidates.map(function (c) { return c.name; }).join("、");
if (won) {
qEl.textContent = "我猜到了:是「" + candidates[0].name + "」!";
msgEl.textContent = "用了 " + rounds + " 问,命中 " + candidates[0].name + "。";
} else if (reason === "empty") {
qEl.textContent = "没有匹配的候选了,我认输。";
msgEl.textContent = "这个物品不在候选库里,可在 ITEMS 中补充。";
} else if (reason === "noprop") {
qEl.textContent = "没有更有区分度的问题了,我认输。";
msgEl.textContent = "剩余候选:" + left;
} else {
qEl.textContent = "超过 20 问,我认输。";
msgEl.textContent = "剩余候选:" + left;
}
buttons([["再来一局", start, "btn"]]);
}
function ask() {
rounds++;
var prop = bestQuestion(candidates, asked);
if (!prop) { finish(false, "noprop"); return; }
if (rounds > 20) { finish(false, "rounds"); return; }
asked.push(prop);
qEl.textContent = "第 " + rounds + " 问:" + prop + "?";
buttons([
["是", function () { answer(prop, "yes"); }],
["否", function () { answer(prop, "no"); }],
["不确定", function () { answer(prop, "maybe"); }, "btn ghost"]
]);
}
function answer(prop, value) {
say("Q" + rounds + " " + prop + " → " + (value === "yes" ? "是" : value === "no" ? "否" : "不确定"));
candidates = filterBy(candidates, prop, value);
if (candidates.length === 1) { finish(true); return; }
if (candidates.length === 0) { finish(false, "empty"); return; }
ask();
}
function start() {
candidates = ITEMS.slice();
asked = [];
rounds = 0;
logEl.innerHTML = "";
msgEl.textContent = "心里想一个常见物品,我来猜(候选 " + ITEMS.length + " 个)。";
ask();
}
buttons([["开始游戏", start]]);
});
}
styles.css
样式:跟随系统深浅色,全部用 CSS 变量
css
:root {
--bg: #0e1014; --panel: #171a20; --ink: #e8ecf1; --muted: #9aa4b2; --line: #262b33; --brand: #6fb3a8;
}
@media (prefers-color-scheme: light) {
:root { --bg: #f6f7f8; --panel: #ffffff; --ink: #1a1d21; --muted: #5b636e; --line: #e3e6ea; }
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px 16px 48px; background: var(--bg); color: var(--ink);
font: 15px/1.6 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
}
.wrap { max-width: 760px; margin: 0 auto; }
h1 { font-size: 1.5rem; margin: 0 0 6px; }
h2 { font-size: 1.05rem; margin: 22px 0 10px; }
.sub { color: var(--muted); margin: 0 0 18px; }
.panel, .log-wrap { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
.q { font-size: 1.06rem; margin: 0 0 12px; }
.btns, .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.btn {
padding: 8px 16px; border-radius: 8px; border: 1px solid var(--brand);
background: var(--brand); color: #0b0f12; cursor: pointer; font-size: .92rem;
}
.btn.ghost { background: transparent; color: var(--brand); }
.msg { color: var(--muted); margin: 12px 0 0; font-size: .92rem; }
.log { max-height: 200px; overflow: auto; font-size: .88rem; color: var(--muted); }
.log div { padding: 3px 0; border-bottom: 1px dashed var(--line); }
input, select {
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
background: var(--bg); color: var(--ink); font: inherit;
}
canvas { width: 100%; display: block; border-radius: 10px; }
README.md
运行方式、自测命令与扩展点
markdown
# 20 问猜物(独立可运行工程)
把作品站 fun 区的「AI 猜物 20 问」演示抽成的最小可运行工程:零依赖、纯前端。
## 文件
- `index.html`:页面骨架(问题区 / 按钮区 / 提问日志)
- `app.js`:核心算法(属性表 + 信息量优先提问 + 过滤)与浏览器交互层
- `styles.css`:样式(跟随系统深浅色)
## 运行
1. 双击 `index.html`,或 `python -m http.server 8000` 后访问 http://localhost:8000/
2. 逻辑自测:`node -e "console.log(require('./app.js').simulate('手机'))"` → 期望 `{ rounds: 4, hit: true, left: '手机' }` 之类结果
## 扩展点
- 想扩充物品:在 `app.js` 的 `ITEMS` 中按 `{ name, props }` 增加,并把新属性加入 `ALL_PROPS`
- 想换成"任意问题":把 `bestQuestion` 替换为你的打分函数即可(例如接大模型生成候选问题)
运行方式
- 新建一个目录,把上面全部文件按文件名分别保存(用「一键复制整份工程」可一次拿全)。
- 前端部分:直接双击
index.html即可运行;若浏览器限制本地文件读取,执行python -m http.server 8000后访问http://localhost:8000/。 - 逻辑自测:各文件
README.md中给出了对应的 Node / Python 自测命令(均为真实可跑的最小用例)。
标注:可直接运行、无需构建;要改造只需动 app.js 顶部的常量与 styles.css 里的变量。
依赖清单:无第三方依赖 —— 纯前端(浏览器原生 JS/DOM),不需要 Node、不需要构建工具
可直接运行:可直接运行 双击 index.html 即可开玩;命令行自测 node -e "console.log(require('./app.js').simulate('手机'))"
需先改的配置项:无配置项需要修改。想扩充题库时改 app.js 的 ITEMS 与 ALL_PROPS 即可
来源参考
实现涉及的官方文档(可直接访问):