最近我把一个经典的练手小项目——Python 成语接龙,重新写了一遍。这次和以前不一样的是,我把成语库存进了数据库里,而不是写死在 Python 列表中。这个改动看着简单,其实把 Python 编程里的好几个重点都串了起来:控制台交互、中文编码处理、数据库连接与查询、索引优化,甚至还有一点数据清洗的活儿。如果你学 Python 学了一段时间、正想找个项目把知识点串起来,这个项目特别合适;就算你刚入门,跟着一步步做下来,也会对“程序怎么读数据、怎么存数据”有很直观的感受。
我打算从整体设计讲起,一直写到完整代码和踩坑记录。文章里所有代码都是可以直接复制跑的,数据库用的是 Python 自带的 SQLite,不需要额外装任何服务端。
1. 项目思路与整体设计:为什么把成语接龙做成了“数据库版”
1.1 接龙规则拆解:程序到底要处理什么
成语接龙的规则用一句话说:下一个成语的第一个字,必须和上一个成语的最后一个字相同。听起来很简单,但落到代码里,需要拆成几个明确的问题:怎么判断用户输入的成语存在?怎么判断是否符合接龙规则?怎么保证成语不重复?电脑找不到合适成语时该怎么收场?
这些问题看着简单,其实每一个解法背后都对应一个设计决策。比如“成语是否存在”这个问题,你可以把一万条成语读进内存慢慢比对,也可以交给数据库做索引查询。前者在小数据量时没什么问题,可一旦成语库做大,或者游戏要记录大量对局数据,内存方案就会越来越别扭。
我当时第一版就是用 Python 列表做的,把成语全部读进内存,然后接龙时从头遍历匹配。数据量几千条时还挺流畅,但很快就遇到了几个麻烦:每次启动都要重新读文件解析一次;查成语是否合法只能循环遍历,数据量上到几万条时明显变慢;想记录玩家历史成绩和每局过程,用文件追加拼接非常痛苦。这些痛点直接促使我决定重构,把数据层彻底交给数据库。
1.2 为什么选数据库而不是纯文本或者内存列表
换成 SQLite 之后,上面提到的三个问题基本上都解决了。SQLite 是 Python 自带的轻量级嵌入式数据库,不需要额外安装数据库服务,所有数据就存在一个.db文件里,复制备份都非常方便。对于成语接龙这种单机项目,它是最省事的数据库选择。
数据库带来的另一个好处是查询表达能力的提升。比如“从所有能接得上龙的成语里随机挑一个”,用列表方案你得先过滤一遍再random.choice,用数据库一条 SQL 就能搞定:WHERE first_char = ? ORDER BY RANDOM() LIMIT 1。再比如“统计某个末字开头的成语数量”,数据库有现成的聚合函数,几行代码就能出结果。
还有一点很多人容易忽略:把数据存进数据库之后,程序的边界变得清晰了。游戏逻辑专注处理输入输出和规则判断,数据层专注存储和查询。后面如果想把控制台版改成网页版、手机版,游戏逻辑基本可以复用,只需要换交互层就行。这个“逻辑与存储分离”的习惯,越早养成越好。
1.3 技术选型:SQLite 还是 MySQL
可能有朋友会问,直接上 MySQL 是不是更接近生产环境?我的回答是:学习阶段先用 SQLite,把思路打通了再换 MySQL。两者在 Python 里的操作方式高度相似,都是execute(sql, params)这种套路。用 SQLite 可以让你把精力放在逻辑本身,而不是怎么装服务、怎么处理连接异常。
| 对比项 | SQLite | MySQL |
|---|---|---|
| 安装复杂度 | 无需安装,Python 标准库自带 | 需要安装服务端和客户端 |
| 适用场景 | 单机、小并发、学习项目 | 多用户、并发写入、生产环境 |
| 连接方式 | sqlite3.connect("文件.db") | pymysql.connect(host=...) |
| 并发能力 | 写入锁粒度较大,适合低并发 | 支持并发连接,需要连接池 |
| 迁移成本 | 文件即数据库,备份方便 | 需要导出导入,权限管理更重 |
这里我不是说 MySQL 不好,而是建议学习节奏要有阶梯。SQLite 能让项目最快跑起来,SQL 语法和 Python 操作方式又和 MySQL 高度一致。等你把 SQLite 版本玩明白了,再切换到 MySQL 也就是改一个连接模块的事情。后面第 5 小节我会具体讲切换时要注意什么。
2. 环境搭建与成语数据入库:先把“题库”准备好
2.1 开发环境怎么搭
我用的是 Python 3.10 版本,操作系统是 Windows 10,开发工具用的 VS Code。项目本身不需要装任何第三方包,因为sqlite3是 Python 标准库的一部分。如果你电脑上还没装 Python,直接去官网下载安装包,安装时记得勾选“Add Python to PATH”,否则命令行里敲python会提示找不到命令。
建议在项目目录下创建一个虚拟环境,隔离依赖。这个项目目前没有第三方包,虚拟环境的优势还不明显,但养成本地隔离的习惯,后面做 Flask、做爬虫项目时会省很多事。创建方式很简单:
mkdir idiom_game cd idiom_game python -m venv venv激活虚拟环境在 Windows 上是venv\Scripts\activate,在 macOS 或 Linux 上是source venv/bin/activate。激活成功后命令行前面会出现(venv)标记,说明环境已经生效。
VS Code 里还需要做一步配置:按Ctrl+Shift+P调出命令面板,选择“Python: Select Interpreter”,找到刚才创建的虚拟环境。这一步不设置的话,你很可能在终端里用的是全局 Python,而 VS Code 的调试器用的是另一个版本,两个环境不一致会导致各种莫名其妙的问题。
2.2 成语数据从哪来
成语数据是整个项目的地基。我当时用的是一份开源的中文成语词库,大概收了一万六千多条。如果你手头没有现成数据,有三个路线可以参考:
- 用 GitHub 上开源的成语数据集,这是最省事的方式。
- 从词典网站爬虫抓取,但要注意目标网站的 robots 协议和版权问题,抓取频率也别设得太高。
- 先手工整理一百个常用成语,把代码逻辑跑通再换大数据集。
为了演示,我在项目里维护了一个idioms.txt文件,每一行一个成语,比如:
一马当先 先发制人 人山人海 海阔天空 空穴来风 风调雨顺 顺水推舟 舟车劳顿这里有两个细节必须提醒。第一,文件保存时必须选 UTF-8 编码。Windows 下记事本默认可能是 GBK,后面用 Python 读取时会出现UnicodeDecodeError或者乱码,非常坑。第二,从网页复制过来的文本经常会混入全角空格\u3000,看起来是空格,实际上不是普通的空格字符,直接strip()根本去不掉。我现在都会在idioms.txt文件顶部加一行注释说明格式,避免自己过两个星期忘了。
2.3 建表与导入:写一个初始化脚本
我们需要一张成语表。最简单的设计可能是CREATE TABLE idiom (id INTEGER PRIMARY KEY, word TEXT),但我在实际做的时候,额外加了两列:first_char和last_char,分别存成语的第一个字和最后一个字。
为什么要这么设计?因为在接龙过程中,最频繁的查询就是WHERE first_char = ?,如果把首字单独存成一列并加上索引,查询效率会高很多。你也可以在每次查询时用substr(word, 1, 1)去截取,但那样等于让数据库每次接龙都做一次字符串截取,性能上不划算。数据表设计阶段稍微多花一点心思,后面写查询逻辑会顺手很多。
我写了init_db.py这个脚本,负责建库、读文件、导入数据:
import sqlite3 import re DB_NAME = "idiom.db" TXT_FILE = "idioms.txt" def clean_word(line: str) -> str: # 去掉首尾空白、全角空格和普通空格 word = line.strip().replace("\u3000", "").replace(" ", "") return word def main(): conn = sqlite3.connect(DB_NAME) cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS idiom ( id INTEGER PRIMARY KEY AUTOINCREMENT, word TEXT NOT NULL UNIQUE, first_char TEXT NOT NULL, last_char TEXT NOT NULL ) """) cursor.execute("CREATE INDEX IF NOT EXISTS idx_first_char ON idiom(first_char)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_last_char ON idiom(last_char)") count = 0 with open(TXT_FILE, "r", encoding="utf-8") as f: for line in f: word = clean_word(line) # 简单过滤:长度小于2或者含有非中文字符的跳过 if len(word) < 2 or not re.fullmatch(r"[\u4e00-\u9fff]{2,}", word): continue try: cursor.execute( "INSERT INTO idiom (word, first_char, last_char) VALUES (?, ?, ?)", (word, word[0], word[-1]), ) count += 1 except sqlite3.IntegrityError: # 唯一约束冲突说明成语重复,直接跳过 continue conn.commit() conn.close() print(f"导入完成,共导入 {count} 条成语") if __name__ == "__main__": main()这个脚本里有几个关键点值得展开。正则[\u4e00-\u9fff]{2,}的作用是只保留两个汉字及以上的中文成语,防止文件里混入英文、数字或者标点。IntegrityError是撞上唯一约束时抛出的异常,说明这个成语已经插入过了,用continue跳过就行。另外INSERT语句用的是占位符?,而不是直接拼字符串,这是防止 SQL 注入的基本习惯,做任何数据库项目都应该从一开始就养成这个写法。
跑完python init_db.py之后,项目目录下会生成一个idiom.db文件。你可以用命令行或者 DB Browser 这类工具看一眼数据是否正常。我通常会跑一条简单的查询验证:SELECT COUNT(*) FROM idiom;,如果数字和 txt 文件里有效成语的数量对得上,说明导入成功。
3. 游戏核心逻辑实现:连接、校验、接龙一条龙
3.1 数据库连接模块封装
数据导好之后,最重要的就是怎么在游戏里使用数据库。我单独建了一个db.py文件来封装数据库连接,而不是在游戏主逻辑里到处写sqlite3.connect。这样做的目的是让数据访问逻辑集中在一处,后面换 MySQL 或者加缓存,只需要改这一个文件。
import sqlite3 DB_NAME = "idiom.db" def get_connection(): conn = sqlite3.connect(DB_NAME) conn.row_factory = sqlite3.Row return conn def idiom_exists(word: str) -> bool: with get_connection() as conn: row = conn.execute( "SELECT 1 FROM idiom WHERE word = ?", (word,) ).fetchone() return row is not None def get_next_idiom(first_char: str, used_words: list) -> str | None: with get_connection() as conn: placeholders = ",".join("?" * len(used_words)) sql = f""" SELECT word FROM idiom WHERE first_char = ? AND word NOT IN ({placeholders}) ORDER BY RANDOM() LIMIT 1 """ row = conn.execute(sql, [first_char] + used_words).fetchone() return row["word"] if row else None这里有两个细节值得展开。第一,conn.row_factory = sqlite3.Row设置之后,查询结果的每一行就可以用row["word"]这种字典式访问,代码可读性比row[0]好太多。第二,with get_connection() as conn这种写法不仅会自动提交事务,退出with块时也会自动关闭连接。很多人写 sqlite3 代码喜欢手动conn.close(),一旦中间抛异常就容易漏掉,用with更稳妥。
可能有朋友注意到了一个隐患:NOT IN后面的占位符是动态拼接的,如果used_words列表特别长,SQL 语句会变得很长,性能会下降。不过成语接龙一局最多也就几十轮,这个列表不会太长,所以完全够用。这也是“先跑通再优化”的体现,不要为了一个永远不会出现的极端场景去把代码搞复杂。
3.2 接龙合法性校验
用户输入的成语要过四道关:第一,必须是中文并且长度不小于 2;第二,必须在数据库里存在;第三,不能是之前已经用过的;第四,和上一轮接的成语要首尾对得上。
其中最关键的是第四道关。我写了一个is_valid_chain(prev_word, curr_word)函数:
def is_valid_chain(prev_word: str, curr_word: str) -> bool: return prev_word[-1] == curr_word[0]就这一行。不过真正接龙的时候,用户输入的词可能带标点或者括号,比如“一马当先!”或者“(一马当先)”,所以我在读取输入后先做了一步清洗,把中文标点全部去掉再判断。清洗函数放在utils.py里,逻辑也不复杂,就是遍历字符串,只保留中文字符和英文字母,其他全部丢弃。
校验顺序也很有讲究。我建议的顺序是:先查是否存在,再查是否用过,最后再判断接龙规则。原因很直白:如果这是一个不存在的成语,数据库直接告诉你False,省去了后续不必要的字符串处理;如果已经用过了,就直接提示重复,不需要再做规则判断。把代价低的判断放前面,整体交互会更流畅,也能减少不必要的数据库查询。
3.3 玩家输入和游戏主循环
接下来是game.py,这是整个项目的入口文件。我先贴一下核心循环:
import sys from db import idiom_exists, get_next_idiom from utils import clean_text def main(): print("=== 成语接龙 ===") print("输入 结束 或按 Ctrl+C 退出") used = [] # 电脑先出一个成语开局 current = "一马当先" used.append(current) print(f"电脑:{current}") while True: user_input = input("轮到你了:").strip() if user_input in {"结束", "退出", "quit"}: print("游戏结束") break word = clean_text(user_input) if len(word) < 2: print("请至少输入两个字的成语") continue if word in used: print("这个成语已经用过了,换一个") continue if not idiom_exists(word): print("成语库里没有这个词,再想想") continue if word[0] != current[-1]: print(f"接不上,上一个成语的末尾字是:{current[-1]}") continue used.append(word) print(f"你:{word}") # 电脑接龙 current = word comp_word = get_next_idiom(current[-1], used) if comp_word is None: print("电脑接不上了,你赢了!") break used.append(comp_word) current = comp_word print(f"电脑:{comp_word}") if __name__ == "__main__": main()这里我把电脑的第一个成语写死为“一马当先”,主要是为了演示方便。实际项目里可以改成随机开局,从库里随机挑一条成语当龙头。用户在输入的时候,我用clean_text把全角空格、标点都清理掉,避免因为一个逗号导致匹配失败。
另外提醒一下,input()在 Windows 控制台遇到Ctrl+C会直接抛KeyboardInterrupt异常,而不是像 Linux 那样干净退出。所以最好在main()外面包一层try...except KeyboardInterrupt,不然用户按几下Ctrl+C会看到一长串红色堆栈,非常影响体验。封装之后,退出时可以打印一句“再见”,然后正常结束。
3.4 电脑的接龙策略:从随机到反杀
上面get_next_idiom用的是ORDER BY RANDOM() LIMIT 1,意思是只要存在能接得上的成语,就随机挑一条。这个策略的好处是每局不重样,缺点是电脑不太聪明,有时候会接出特别冷门的成语,把玩家难住。
如果你想增加一点挑战性,可以稍微改一下 SQL。优先挑选那些“能被接得上”的成语,也就是选择last_char在成语表中出现次数较多的词,这样玩家更容易继续;反过来,如果想当一个接龙终结者,就优先选last_char出现次数最少的词,让玩家无路可走。这个用一条带LEFT JOIN和GROUP BY的 SQL 就能实现:
SELECT i.word FROM idiom i LEFT JOIN idiom j ON i.last_char = j.first_char WHERE i.first_char = ? AND i.word NOT IN (...) GROUP BY i.word ORDER BY COUNT(j.id) ASC LIMIT 1COUNT(j.id)统计的是“以当前成语末字开头的其他成语数量”,数量越少,说明玩家越难接下去。如果你想让游戏难度更低、更友好,就把ORDER BY改成DESC,优先选那些给了玩家更多后续选择的成语。把这个策略单独做成一个函数,代到get_next_idiom里,游戏的可玩性能瞬间上一个台阶。
这里有一个性能点需要提一下:带LEFT JOIN和GROUP BY的查询会比普通WHERE查询慢不少。在几千条的小数据量项目里无所谓,但如果以后导入了十万条成语,还是建议给last_char和first_char都建上索引,并且用EXPLAIN QUERY PLAN看一眼执行计划,确认 SQLite 真的在用索引。
4. 实操中踩过的坑:编码、数据格式和性能问题
4.1 Windows 下控制台中文乱码或者直接报错
这个项目遇到的第一个大坑就是中文编码。我在 Windows 自带命令行里跑game.py,第一次启动就直接抛UnicodeEncodeError: 'gbk' codec can't encode character。原因是 Windows 默认控制台代码页是 GBK,而程序里要输出的某些字符不在 GBK 范围内。
解决方案有三招。第一招,在脚本开头加两行:
import sys sys.stdout.reconfigure(encoding="utf-8")第二招,运行前设置环境变量PYTHONIOENCODING=utf-8。第三招,直接换一个对 UTF-8 支持更好的终端,比如 Windows Terminal 或者 VS Code 的内置终端。我实际用的是第三招,但脚本里也保留了第一招,双保险。
这个问题看似不起眼,但几乎每个用 Windows 写中文 Python 程序的人都会遇到。我建议你在写任何带中文输入输出的程序时,第一时间就把stdout和stdin的编码统一好,省得排查半天发现是控制台的问题。
4.2 txt 数据格式坑:全角空格和 \r\n
导入成语的时候,最容易出问题的是数据文件里藏了不可见字符。比如你把成语从网页上复制下来,中间可能混着全角空格\u3000;Windows 文件每行末尾还有\r\n。我一开始没做清洗,结果数据库里出现了很多带空格的“伪成语”,游戏里无论怎么匹配都查不到。
解决办法就是在init_db.py里老老实实做清洗:line.strip()去掉首尾换行和空格,再用replace("\u3000", "")去掉全角空格,再统一用正则过滤。宁可导入的时候慢一点,也别把脏数据带进数据库。数据清洗是数据相关项目最枯燥但最重要的环节,早早建立起“外部数据不可信”的意识,后面能少走很多弯路。
4.3 用户输入空行和重复成语的提示体验
还有一个交互上的坑:用户可能在输入时直接按回车,或者输入一个刚才用过的成语。如果不做处理,程序会继续往后走,最后查数据库返回False,体验非常糟糕。我在主循环里加了几道if判断,每道判断都给出明确的提示。
这里提示文案要尽量具体。比如用户接不上的时候,我会提示“接不上,上一个成语的末尾字是:XX”,而不是笼统地写“错误”。用户一眼就能看出问题在哪,是没接上龙,还是成语重复了,还是成语库力根本没有这个词。交互体验这种东西,往往就在这些细节里,控制台程序也不例外。
4.4 查询性能:没有索引的后果
成语库如果只有几千条,全表扫描也无所谓。但有些开源词典收录了十几万条成语,每次接龙都做一次WHERE first_char = ?,如果没有索引,数据库会把整张表从头扫一遍。我的初始化脚本里提前建了idx_first_char和idx_last_char两个索引,接龙查询基本都能在毫秒级返回。
这里我建议你在自己的电脑上做个简单实验:把建索引的那两行代码注释掉,再随机跑几十轮接龙,对比一下耗时。这比任何理论讲解都更能让你理解索引的意义。当然,索引也不是越多越好,每个索引都会占用磁盘空间,并且拖慢写入速度。像成语接龙这种读多写少、成语数据基本不变的应用,多建几个查询索引完全值得。
我把这一小节的排查思路整理成了一张速查表,方便你以后直接对照:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 控制台中文乱码或报错 | Windows 控制台默认 GBK | sys.stdout.reconfigure(encoding="utf-8")或换终端 |
| 数据库里出现带空格的词 | 导入前没清洗全角空格 | 导入脚本里replace("\u3000", "") |
| 明明有成语却查不到 | txt 里有\r\n或半角空格 | 用strip()和正则统一过滤 |
| 接龙查询变慢 | 缺少索引或数据量过大 | 给first_char、last_char建索引 |
| 用户按 Ctrl+C 后堆栈报错 | 未捕获KeyboardInterrupt | 在入口处用try...except包住主循环 |
5. 从“能玩”到“好用”:几个值得做的扩展
5.1 记录历史战绩到数据库
成语数据被存进数据库之后,一个很自然的扩展就是记录玩家每局战绩。项目做到这一步,数据库的优势就完全体现出来了。我加了一张对局记录表:
CREATE TABLE game_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT, rounds INTEGER, result TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )胜负结果可以用win、lose、quit三种值表示。每局结束后往表里插入一条记录,然后再用SELECT统计各玩家的胜率和平均轮数,就是一套很简单的排行榜逻辑。你也可以把每局双方接过的成语详细记录到另一张表里,复盘的时候能看得清清楚楚。这就是“从能玩到好用”的关键一步,功能不复杂,但数据沉淀下来了,后面的玩法扩展才有基础。
5.2 用 Flask 把它变成网页版
如果你不想只停留在控制台,可以把游戏逻辑封装成函数,再用 Flask 提供 HTTP 接口。前端页面负责收集用户的输入,后端每次接龙都是一次请求。原来的game.py里的while循环就得改成基于会话状态的逻辑,比如把当前龙尾字和已用成语列表存到 session 或者 Redis 里。
这个改造过程对理解“有状态服务”特别有帮助。控制台版的游戏状态都保存在本地变量里,改造成 Web 版之后,每次请求都是独立的,你必须明确地把状态存到某个地方。很多初学者第一次写 Web 应用时都会卡在这里,如果你已经有了这个成语接龙的逻辑基础,上手会快很多。
5.3 换成 MySQL 需要注意什么
如果以后想把项目部署到服务器上,或者想让多个玩家同时玩,SQLite 就不够用了,因为它在并发写入上的表现比较弱。换成 MySQL 有两种常见方式:一种是用pymysql,另一种是用SQLAlchemy。前者更接近原生的execute写法,后者更偏对象关系映射,各有各的使用场景。
连接方式从sqlite3.connect("idiom.db")换成类似这样:
import pymysql conn = pymysql.connect( host="127.0.0.1", user="root", password="yourpassword", database="idiom_game", charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor )有几个细节需要注意。MySQL 的表结构里,word字段记得加UNIQUE约束,否则重复成语会越存越多,过一段时间数据库就脏了。连接的时候最好把charset明确指定为utf8mb4,不然中文很容易变成乱码。生产环境还要考虑连接超时问题,MySQL 服务端默认有wait_timeout,长时间空闲的连接会被服务端断开,所以应用层要么每次请求重建连接,要么使用连接池。总的来说,从 SQLite 切到 MySQL,换的只是连接层,核心的 SQL 逻辑和游戏代码几乎不用动,这也正是当初选择数据库方案带来的最大红利。
最后说一点我自己的体会。这个成语接龙项目,我前后写了好几版,最开始的版本把所有逻辑塞在一个文件里,成语字典写在内存里,后来慢慢拆出了db.py、utils.py、game.py,每次重构都会发现上一版的很多设计问题。做这种小项目,最大的收获不是“我写了一个能跑的游戏”,而是养成了把数据存储、业务逻辑、交互界面分开思考的习惯。如果你也在学 Python,强烈建议把这种小项目拆开写,而不是一个脚本怼到底。最后再分享一个小技巧:设计数据库表时,把常用的查询条件单独拎出来加索引,比如这里的first_char、last_char,这一条能让你在后面的开发里少掉很多头发。