1. 项目概述与核心价值

最近在GitHub上看到一个挺有意思的项目,叫“cschwartz525/20-questions”。光看名字,你可能会联想到那个经典的猜谜游戏“20个问题”。没错,这个项目正是用代码实现了这个古老而有趣的游戏逻辑。但它的价值远不止于此。作为一个在软件开发和算法领域摸爬滚打了十多年的老手,我一眼就看出,这不仅仅是一个简单的游戏复刻,而是一个绝佳的、用于学习和理解 决策树(Decision Tree) 机器学习分类(Classification) 以及 交互式系统设计 的微型范本。

这个项目的核心,是构建一个能够通过一系列“是/否”问题,最终“猜出”用户心中所想的实体(比如动物、物品、名人等)的智能系统。它模拟了人类通过二分法逼近答案的思维过程。对于初学者来说,这是一个理解“树”数据结构如何应用于实际问题的完美入口;对于有经验的开发者,它则展示了如何设计一个可扩展、可学习、能持续进化的知识系统。我花了些时间深入研究其代码和设计思路,发现其中蕴含的工程智慧和教学价值,远超一个简单的课堂作业。接下来,我将带你一起拆解这个项目,看看它如何从零构建一个会“思考”的AI,并分享我在复现和扩展过程中的一些实战心得。

2. 项目整体设计与架构拆解

2.1 核心算法:决策树的具象化

“20个问题”游戏的本质,就是一个标准的 二叉决策树 。树中的每个 内部节点 代表一个问题(例如,“它是活的吗?”),而每个 叶子节点 则代表一个具体的答案(例如,“狗”、“电脑”)。游戏从根节点开始,根据用户对当前问题的回答(“是”或“否”),沿着相应的分支(左子树或右子树)移动到下一个节点,直到到达一个叶子节点,此时系统给出最终猜测。

cschwartz525/20-questions 项目的精妙之处在于,它并非硬编码一个固定的决策树。相反,它设计了一个 动态学习 的机制。当系统猜错时,它会向用户学习:请求用户输入正确答案,并补充一个新的、能够区分新旧答案的问题。这个过程,实质上就是在线地 扩展和优化决策树

例如,系统猜“猫”,用户实际想的是“章鱼”。系统会问:“对于‘章鱼’,我应该问一个什么问题,而你的回答是‘是’,对于‘猫’,回答是‘否’?” 用户可能输入:“它生活在海里吗?”。系统随后将当前错误的叶子节点(“猫”)替换为一个新的内部节点(问题:“它生活在海里吗?”),并将“章鱼”和“猫”分别作为该节点的“是”和“否”分支下的新叶子节点。这样,知识库就得到了增强。

2.2 数据结构与持久化设计

一个健壮的系统离不开合理的数据结构和数据持久化方案。该项目通常采用 JSON 格式来序列化和存储决策树。JSON的层次结构天然与树形数据匹配,且人类可读,便于调试。

一个典型的节点数据结构可能如下所示(以Python字典为例):

{
  “text”: “它生活在海里吗?”,
  “yes”: {
    “text”: “章鱼”
  },
  “no”: {
    “text”: “猫”
  }
}

如果是内部节点, “yes” “no” 键对应的是子节点(可能也是内部节点或叶子节点)。如果是叶子节点, “text” 就是答案,并且没有 “yes” / “no” 键,或者这些键的值为 null

持久化意味着每次游戏结束后,更新后的树会被保存到文件(如 tree.json )中。下次启动程序时,从文件加载,从而保留了所有历史学习成果。这种设计使得系统具备了 记忆和成长 的能力,这是其从玩具项目升格为教学典范的关键。

2.3 交互流程与系统边界

项目的用户交互流程设计得非常清晰,体现了良好的用户体验思维:

  1. 启动与加载 :程序启动,从磁盘加载已有的决策树数据。如果是第一次运行,则初始化一个简单的树(可能只包含一个如“狗”的猜测)。
  2. 游戏循环 : a. 从根节点开始,如果是内部节点,则向用户展示问题,等待“是/否”回答。 b. 根据回答移动到子节点。 c. 重复步骤a-b,直到到达叶子节点。
  3. 猜测与反馈 :系统给出叶子节点的内容作为猜测。
    • 如果猜对,游戏胜利结束。
    • 如果猜错,进入 学习流程
  4. 学习流程 : a. 询问用户正确答案是什么。 b. 询问用户一个可以区分正确答案和错误猜测的新问题。 c. 询问用户对于这个新问题,正确答案的答案是“是”还是“否”。 d. 根据以上信息,重构当前节点附近的树结构。
  5. 保存与退出 :将更新后的决策树保存到磁盘,结束本次会话。

这个流程闭环,逻辑自洽,是学习 状态机 循环控制 的绝佳案例。

3. 核心模块实现与代码解析

3.1 树节点的类设计

一个面向对象的设计会让代码更清晰。我们首先定义一个 TreeNode 类。这里我用Python来示例,其他语言思想相通。

import json

class TreeNode:
    def __init__(self, text, yes_node=None, no_node=None):
        """
        初始化一个树节点。
        :param text: 节点内容。对于内部节点是问题,对于叶子节点是答案。
        :param yes_node: 当答案为“是”时指向的子节点。
        :param no_node: 当答案为“否”时指向的子节点。
        """
        self.text = text
        self.yes = yes_node
        self.no = no_node

    def is_leaf(self):
        """判断当前节点是否为叶子节点(答案节点)。"""
        return self.yes is None and self.no is None

    def to_dict(self):
        """将节点及其子树序列化为字典(用于JSON存储)。"""
        node_dict = {“text”: self.text}
        if not self.is_leaf():
            # 递归序列化子节点
            node_dict[“yes”] = self.yes.to_dict() if self.yes else None
            node_dict[“no”] = self.no.to_dict() if self.no else None
        return node_dict

    @classmethod
    def from_dict(cls, data):
        """从字典反序列化构建节点及其子树(用于JSON加载)。"""
        if data is None:
            return None
        # 递归构建子树
        yes_node = cls.from_dict(data.get(“yes”)) if “yes” in data else None
        no_node = cls.from_dict(data.get(“no”)) if “no” in data else None
        return cls(data[“text”], yes_node, no_node)

注意 :在 to_dict 方法中,对于叶子节点,我们不存储 “yes” “no” 键,或者将其值设为 null 。这取决于你希望的JSON简洁度。上述代码在叶子节点时不会添加这两个键,更节省空间。在 from_dict 中,我们通过检查键是否存在来安全地构建子树。

3.2 游戏引擎类的封装

我们将游戏的主要逻辑封装在一个 TwentyQuestionsGame 类中,职责分离,高内聚低耦合。

class TwentyQuestionsGame:
    def __init__(self, tree_file=“tree.json”):
        self.tree_file = tree_file
        self.root = self._load_tree()

    def _load_tree(self):
        """从文件加载决策树。如果文件不存在,则创建初始树。"""
        try:
            with open(self.tree_file, ‘r’, encoding=‘utf-8’) as f:
                data = json.load(f)
                return TreeNode.from_dict(data)
        except FileNotFoundError:
            # 初始化一个简单的树:根节点就是一个猜测
            print(“未找到知识库文件,创建初始树(例如,猜测‘狗’)。”)
            return TreeNode(“狗”) # 初始叶子节点

    def _save_tree(self):
        """将当前决策树保存到文件。"""
        with open(self.tree_file, ‘w’, encoding=‘utf-8’) as f:
            json.dump(self.root.to_dict(), f, indent=2, ensure_ascii=False)

    def _get_user_input(self, prompt, valid_options=None):
        """获取用户输入,并进行简单的验证。"""
        while True:
            user_input = input(prompt).strip().lower()
            if valid_options is None:
                return user_input
            if user_input in valid_options:
                return user_input
            print(f“请输入有效的选项: {valid_options}”)

    def play_round(self):
        """进行一轮游戏。"""
        current_node = self.root
        path = [] # 可选:记录路径,用于调试或高级功能

        # 问答遍历过程
        while not current_node.is_leaf():
            print(f“问题:{current_node.text}”)
            answer = self._get_user_input(“(是/否)? “, [“是”, “否”, “y”, “n”, “yes”, “no”])
            path.append((current_node, answer)) # 记录路径
            if answer in [“是”, “y”, “yes”]:
                current_node = current_node.yes
            else:
                current_node = current_node.no

        # 到达叶子节点,进行猜测
        print(f“我猜是……{current_node.text}!”)
        is_correct = self._get_user_input(“我猜对了吗?(是/否) “, [“是”, “否”, “y”, “n”, “yes”, “no”])

        if is_correct in [“是”, “y”, “yes”]:
            print(“太棒了!我又学到了(其实没有)。再来一局?”)
        else:
            self._learn(current_node, path)

    def _learn(self, wrong_leaf_node, path):
        """学习新知识:处理猜错的情况。"""
        print(“哎呀,猜错了!你心里想的是什么呢?”)
        correct_answer = input(“正确答案是:”).strip()

        print(f“请帮我提一个问题,用来区分‘{correct_answer}’和‘{wrong_leaf_node.text}’。“)
        print(“对于你心中所想的东西,这个问题的答案应该是‘是’。”)
        new_question = input(“新问题是:”).strip()

        # 确认新问题对于旧答案的答案
        print(f“那么对于‘{wrong_leaf_node.text}’,这个‘{new_question}’的答案应该是‘否’,对吗?”)
        # 这里通常默认用户同意,或者可以再确认一次。我们简化处理。

        # 重构树
        # 1. 创建新的问题节点
        new_question_node = TreeNode(new_question)
        # 2. 根据用户描述,设置“是”分支为正确答案,“否”分支为旧答案
        new_question_node.yes = TreeNode(correct_answer)
        new_question_node.no = wrong_leaf_node # 注意:这里直接复用旧节点对象

        # 3. 将新问题节点“挂载”到原树中
        if not path:
            # 如果路径为空,说明树一开始就是错的(只有一个节点),直接替换根节点
            self.root = new_question_node
        else:
            # 找到wrong_leaf_node的父节点,以及它是父节点的哪个子节点(yes/no)
            parent_node, answer_to_parent = path[-1]
            if answer_to_parent in [“是”, “y”, “yes”]:
                parent_node.yes = new_question_node
            else:
                parent_node.no = new_question_node

        print(“谢谢!我学到了新知识!”)
        self._save_tree()

    def run(self):
        """运行游戏主循环。"""
        print(“欢迎来到20个问题游戏!想一个东西,我来猜。”)
        while True:
            self.play_round()
            continue_play = self._get_user_input(“再玩一局吗?(是/否) “, [“是”, “否”, “y”, “n”, “yes”, “no”])
            if continue_play in [“否”, “n”, “no”]:
                print(“游戏结束,知识已保存。再见!”)
                break

这个实现包含了核心的游戏逻辑、学习机制和持久化。 _learn 方法中的树重构是算法的精髓,需要仔细理解指针(引用)的替换过程。

4. 关键难点剖析与实战优化

4.1 树的平衡性与问题质量

一个朴素的学习算法可能导致决策树变得非常 不平衡 。例如,用户一直输入“是”,系统可能会创建出一条极长的“是”分支链,而“否”分支几乎为空。这样的树效率低下,平均问题数量会接近20个,失去了游戏趣味。

优化思路1:主动平衡引导 在用户输入新问题时,可以给出提示:“请尽量提出一个能让‘是’和‘否’概率相近的问题,例如‘它是哺乳动物吗?’比‘它是不是昨天新闻里那只特别的猫?’更好。” 这需要一点自然语言处理(NLP)的启发,但即使简单提示也能改善。

优化思路2:事后重构(再平衡) 定期(例如每学习100个新节点后)对保存的树数据进行离线分析。可以计算每个内部节点下左右子树的规模差异,如果差异过大,可以尝试寻找一个更“居中”的问题来替换当前问题。这涉及到树的编辑和问题语义的重新组织,实现较复杂,但可作为高级扩展。

4.2 歧义处理与输入验证

原始设计对用户输入非常信任。现实中,用户可能输入无效答案、前后矛盾,或者提出模糊的问题。

  • 矛盾检测 :当用户教授新知识时,系统可以沿着新的问题路径模拟一遍。例如,用户说“章鱼”对“它生活在海里吗?”回答“是”。但系统知识库里可能已有“金鱼”也对这个问题回答“是”。此时,系统可以追问:“等等,如果我问‘它生活在海里吗?’,‘章鱼’和‘金鱼’都会回答‘是’,那我该如何区分它们呢?” 这迫使系统学习更具辨别力的问题,也使用户思考更精确。
  • 输入清洗 :对于用户输入的新问题和答案,进行基本的清洗(去除首尾空格,确保问题以问号结尾等)。
  • 重复学习 :如果用户输入了一个系统中已存在的答案,如何处理?是直接跳到该叶子节点,还是允许存在多个相同的答案节点?通常,我们允许重复,因为从不同路径到达同一实体是合理的(例如,“狗”既可以是“它是宠物吗?->是”的答案,也可以是“它会汪汪叫吗?->是”的答案)。但在猜测时,可能会遇到多个候选。一个简单的策略是,当遍历到叶子节点且猜错时,如果用户提供的正确答案文本与某个兄弟叶子节点相同,则可以合并路径或提示用户。

4.3 持久化数据的版本管理与回滚

随着不断学习, tree.json 文件会变得庞大且复杂。如果某次用户输入了错误或恶意的知识(例如,把“猫”教会成对“它生活在海里吗?”回答“是”),可能会污染整个知识库。

实战技巧:实现快照备份 在每次保存前,将旧文件重命名为 tree.json.backup 或带有时间戳的 tree_20231027.json 。甚至可以维护一个简单的日志文件,记录每次学习的内容(谁,什么时候,添加了什么问题和答案)。当系统行为明显异常时,可以手动或用工具回滚到上一个稳定版本。

import shutil
import os
import time

def safe_save(tree_data, filename):
    backup = f“{filename}.backup.{int(time.time())}”
    if os.path.exists(filename):
        shutil.copy2(filename, backup)
        # 可选:只保留最近5个备份
        # ...
    with open(filename, ‘w’) as f:
        json.dump(tree_data, f, indent=2)

5. 项目扩展与高级玩法

基础版本已经很有趣,但我们可以让它变得更强大。

5.1 支持模糊匹配与置信度

当前系统是确定性的,非“是”即“否”。我们可以引入概率。

  • 每个问题可以关联一个置信度(例如,基于历史回答的统计)。
  • 用户回答可以是“可能是”、“可能不是”、“不知道”。
  • 系统可以综合所有回答,计算每个可能答案的概率分布,然后给出Top N猜测。这实际上将决策树升级为了一个简单的 贝叶斯分类器

5.2 图形化界面(GUI)

命令行虽然经典,但GUI能吸引更广泛的用户。可以使用 Tkinter (Python)、 Electron (JavaScript)或任何你熟悉的GUI框架来构建。界面可以显示树的当前遍历路径,用动画展示“思考”过程,甚至可视化整棵知识树(对于大型树需要布局算法如Reingold-Tilford)。

5.3 网络化与多人游戏

将系统升级为客户端-服务器(C/S)架构。

  • 服务器 :维护一个中心化的、不断增长的知识树。
  • 客户端 :用户连接服务器进行游戏。 这样,所有用户都在共同训练一个全球性的“大脑”。你可以看到你的朋友教了系统什么奇怪的东西。需要处理并发学习时的数据一致性问题(如加锁、合并冲突),这是一个很好的分布式系统入门课题。

5.4 领域专业化与初始化

初始知识库只有一个“狗”很单薄。我们可以为不同领域初始化不同的树:

  • 动物树 :从生物学分类初始化(是脊椎动物吗?是哺乳动物吗?)。
  • 名人树 :从领域、年代、国籍初始化。
  • 电影树 :从类型、年代、导演初始化。 用户启动时可以选择题库领域。这大大提升了初始游戏的体验和成功率。

6. 常见问题与调试技巧实录

在实现和运行这类项目时,你肯定会遇到一些坑。以下是我踩过或预见到的:

Q1: 游戏陷入无限循环,或者猜测时崩溃。 A1: 这几乎总是 树结构损坏 导致的。可能的原因:

  • 序列化/反序列化错误 :检查 to_dict from_dict 方法,确保它们能正确处理叶子节点和空节点。一个常见的错误是叶子节点错误地包含了 “yes” “no” 键,其值为 null ,但在 from_dict 时没有正确识别为叶子节点,导致后续访问 .yes 属性时出错。
  • 学习逻辑错误 :在 _learn 方法中,重构树时指针替换出错。仔细检查 path 的记录是否正确,以及替换父节点子引用的逻辑。 调试技巧 :在 play_round 中打印当前节点的 id() 或内存地址,并在学习前后打印树的结构(可以写一个简单的树形打印函数),观察变化。

Q2: 保存的JSON文件变得混乱,有重复节点或循环引用。 A2: 这是 学习逻辑不严谨 输入验证不足 的后果。

  • 确保在学习新知识时, new_question_node.yes .no 被正确赋值,且没有意外地创建循环(例如,新节点的子节点指向了其祖先)。在简单的二叉决策树学习中,只要正确地将新节点插入到原错误叶子节点的位置,就不会产生循环。
  • 实现一个树的 验证函数 ,定期检查:1) 从根节点出发,是否能到达所有叶子节点;2) 没有节点指向自身或其祖先。这可以作为单元测试的一部分。

Q3: 用户输入了中文或其他非ASCII字符,保存到JSON后显示乱码。 A3: 这是 编码问题 。在Python中,使用 json.dump 时务必指定 ensure_ascii=False ,并明确文件编码为 utf-8

with open(‘tree.json’, ‘w’, encoding=‘utf-8’) as f:
    json.dump(data, f, indent=2, ensure_ascii=False)

读取时也同样指定 encoding=‘utf-8’

Q4: 知识树越来越大,每次猜测要问很多问题,速度变慢。 A4: 对于纯内存操作,即使有上万个节点,遍历20层也微秒级,不会慢。如果感觉慢,可能是I/O或打印输出导致的。如果树真的巨大(几十万节点),可以考虑:

  • 懒加载 :不从文件一次性加载整棵树,而是按需加载子树。这需要修改存储结构,将树分块存储。
  • 缓存路径 :对于常见的答案,缓存从根节点到它的路径。
  • 剪枝 :合并那些区分度很低的问题节点(例如,某个问题的“是”分支下只有1个答案,而“否”分支下有1000个,可以考虑用另一个问题来替换)。

Q5: 如何让AI提出的问题更“智能”、更平衡? A5: 除了前面提到的引导用户,还可以在系统学习时主动计算信息增益。当需要区分一个新答案 A 和一个旧答案 B 时,系统可以访问一个预置的 属性知识库 (例如,一个关于各种事物属性的数据库)。系统可以自动筛选出那些对 A 为真、对 B 为假的属性,并从中选择最“通用”的一个作为新问题。这需要外部知识源的介入,将项目推向真正的AI领域。

这个“20个问题”项目就像一颗种子,从简单的数据结构练习开始,可以生长到触及机器学习、人机交互、软件工程、分布式系统等多个领域。它完美地诠释了“小项目,大道理”。我强烈建议每一位开发者,无论新手还是老鸟,都亲手实现一遍。你会对递归、树、数据持久化、程序状态管理有更深刻的理解。更重要的是,你会感受到创造一个有“生命”的、会学习的程序的乐趣。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐