AI 猜物 20 问

经典 20 问:AI 通过二分式提问逐步缩小候选,直至猜中你心里想的物品。

心里想一个常见物品,我来猜。

候选物品来自内置知识库;采用“区分度优先”策略,每轮选择最能把剩余候选一分为二的问题。

使用体验

  1. 心里想好一个物品(如“冰箱”“雨伞”“吉他”),点击“开始”。
  2. AI 依次提问,你点击“是 / 否 / 不确定”如实回答;日志区会记录问答历史。
  3. 候选收敛到唯一物品时 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` 替换为你的打分函数即可(例如接大模型生成候选问题)

运行方式

  1. 新建一个目录,把上面全部文件按文件名分别保存(用「一键复制整份工程」可一次拿全)。
  2. 前端部分:直接双击 index.html 即可运行;若浏览器限制本地文件读取,执行 python -m http.server 8000 后访问 http://localhost:8000/。
  3. 逻辑自测:各文件 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 即可

复制结果为 # ===== 文件名 ===== 分隔的多文件文本,按文件名分别保存即可;也可逐块用右上角“复制代码”单独取用。

来源参考

实现涉及的官方文档(可直接访问):