Python成语接龙项目实战:用SQLite实现数据库版成语游戏 最近我把一个经典的练手小项目——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 可以让你把精力放在逻辑本身而不是怎么装服务、怎么处理连接异常。对比项SQLiteMySQL安装复杂度无需安装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 里还需要做一步配置按CtrlShiftP调出命令面板选择“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, encodingutf-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(输入 结束 或按 CtrlC 退出) 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 控制台遇到CtrlC会直接抛KeyboardInterrupt异常而不是像 Linux 那样干净退出。所以最好在main()外面包一层try...except KeyboardInterrupt不然用户按几下CtrlC会看到一长串红色堆栈非常影响体验。封装之后退出时可以打印一句“再见”然后正常结束。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 cant encode character。原因是 Windows 默认控制台代码页是 GBK而程序里要输出的某些字符不在 GBK 范围内。解决方案有三招。第一招在脚本开头加两行import sys sys.stdout.reconfigure(encodingutf-8)第二招运行前设置环境变量PYTHONIOENCODINGutf-8。第三招直接换一个对 UTF-8 支持更好的终端比如 Windows Terminal 或者 VS Code 的内置终端。我实际用的是第三招但脚本里也保留了第一招双保险。这个问题看似不起眼但几乎每个用 Windows 写中文 Python 程序的人都会遇到。我建议你在写任何带中文输入输出的程序时第一时间就把stdout和stdin的编码统一好省得排查半天发现是控制台的问题。4.2 txt 数据格式坑全角空格和 \r\n导入成语的时候最容易出问题的是数据文件里藏了不可见字符。比如你把成语从网页上复制下来中间可能混着全角空格\u3000Windows 文件每行末尾还有\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 控制台默认 GBKsys.stdout.reconfigure(encodingutf-8)或换终端数据库里出现带空格的词导入前没清洗全角空格导入脚本里replace(\u3000, )明明有成语却查不到txt 里有\r\n或半角空格用strip()和正则统一过滤接龙查询变慢缺少索引或数据量过大给first_char、last_char建索引用户按 CtrlC 后堆栈报错未捕获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( host127.0.0.1, userroot, passwordyourpassword, databaseidiom_game, charsetutf8mb4, cursorclasspymysql.cursors.DictCursor )有几个细节需要注意。MySQL 的表结构里word字段记得加UNIQUE约束否则重复成语会越存越多过一段时间数据库就脏了。连接的时候最好把charset明确指定为utf8mb4不然中文很容易变成乱码。生产环境还要考虑连接超时问题MySQL 服务端默认有wait_timeout长时间空闲的连接会被服务端断开所以应用层要么每次请求重建连接要么使用连接池。总的来说从 SQLite 切到 MySQL换的只是连接层核心的 SQL 逻辑和游戏代码几乎不用动这也正是当初选择数据库方案带来的最大红利。最后说一点我自己的体会。这个成语接龙项目我前后写了好几版最开始的版本把所有逻辑塞在一个文件里成语字典写在内存里后来慢慢拆出了db.py、utils.py、game.py每次重构都会发现上一版的很多设计问题。做这种小项目最大的收获不是“我写了一个能跑的游戏”而是养成了把数据存储、业务逻辑、交互界面分开思考的习惯。如果你也在学 Python强烈建议把这种小项目拆开写而不是一个脚本怼到底。最后再分享一个小技巧设计数据库表时把常用的查询条件单独拎出来加索引比如这里的first_char、last_char这一条能让你在后面的开发里少掉很多头发。