资讯动态

Python手写24点游戏:表达式树+安全求值+Tkinter状态机

发布时间:2026/10/18 20:18:34 来源:尧图企业网站定制
简介这是一款基于Python开发的24点益智游戏实现面向编程初学者与算法入门者聚焦GUI交互设计与数学逻辑求解能力训练。项目完整封装了图形界面、运算求解核心、音效反馈及答案提示功能可作为Pygame或cocos2d框架的实践案例用于理解事件驱动编程、递归/回溯算法设计、用户输入验证与结果可视化呈现。资源共9个文件含2个核心Python源码Game.py、modules.py、3个音效wav文件胜负提示、1个背景音乐mp3、1个字体ttf、1个依赖说明txt及1个编译缓存pyc整体6.09MB结构简洁开箱即用。已有1190人学习下载读者可直接运行体验完整游戏流程深入分析求解算法实现细节复用模块化代码结构并参考音频与字体资源集成方式快速掌握轻量级Python游戏开发全链路。1. 用 Python 写一个能点开就玩、输错自动提示、卡住一键看解的 24 点游戏不是玩具是验证你对表达式解析、回溯搜索和 Tkinter 布局真实掌控力的试金石你有没有试过孩子盯着一道 24 点题发呆十分钟你脱口而出“试试 (6 ÷ (1 − 3/4))”结果发现括号位置错了、除法优先级没理清自己先卡住或者写了个算法穷举所有组合却在“8, 3, 3, 2”这组数上死循环——明明有解8 × 3 × (3 − 2) 24程序却报“无解”这不是数学问题是典型的表达式生成边界失控 运算精度误判 GUI 事件流阻塞三重翻车现场。这个标题里的“带界面、带答案提示”绝不是加个 Tkinter 窗口、print 一句“答案是…”就能交差。它要求你亲手把“人类心算路径”翻译成可枚举的抽象语法树AST节点让程序像人一样思考括号该加在哪、除零怎么跳过、浮点误差怎么容忍同时让界面不卡顿、提示不突兀、重开一局不残留旧状态。适合两类人一是刚学完递归和 Tkinter想找个有真实计算逻辑、有用户交互反馈、有明确成功/失败判定的练手项目二是做过几个小工具但总被问“你真懂运算符优先级怎么影响搜索空间吗”的工程师需要一份能拆开讲透每一步剪枝策略的落地方案。2. 从四张牌到一棵解空间树为什么不用 eval()而要手写表达式节点与安全求值器2.1 为什么放弃 eval()血泪经验告诉你eval 是黑匣子24 点是白盒战场初学者常想输入四个数字用itertools.permutations排列数字itertools.product组合运算符拼成字符串如63*4-2直接eval()。看似 10 行代码搞定。但立刻撞墙括号缺失63*4-2 16但(63)*(4-2) 186/(3-4/2) 24 —— eval 不生成括号你永远漏掉关键解除零崩溃8/(3-3)直接抛ZeroDivisionError中断整个搜索浮点误差陷阱(7 - 1/7) * 3.5理论等于 24但eval(7-1/7)得6.857142857142857再乘3.5变成23.999999999999996abs(x-24)1e-9都救不回来安全风险用户若能输入数字比如通过调试器改变量eval 可执行任意代码——这游戏再小原则不能破。提示本方案全程禁用eval和exec。所有表达式构建、求值、错误捕获全部手动控制。这是 24 点项目的技术分水岭。2.2 手写 ExpressionNode用类模拟人脑的“括号思维”核心思想不拼字符串而构建表达式树。每个节点代表一个运算或数字class ExpressionNode: def __init__(self, value, leftNone, rightNone, opNone): self.value value # 数字值float或运算符str self.left left # 左子节点ExpressionNode 或 None self.right right # 右子节点ExpressionNode 或 None self.op op # 当前节点运算符None 表示叶子数字 def evaluate(self): 安全求值捕获除零、返回 None 表示非法 if self.op is None: # 叶子节点直接返回数字 return self.value left_val self.left.evaluate() right_val self.right.evaluate() if left_val is None or right_val is None: return None try: if self.op : return left_val right_val if self.op -: return left_val - right_val if self.op *: return left_val * right_val if self.op /: if abs(right_val) 1e-10: # 严格防除零 return None return left_val / right_val except: return None return None def to_string(self, parent_opNone): 生成带括号的可读字符串按优先级决定是否加括号 if self.op is None: return str(int(self.value)) if self.value.is_integer() else f{self.value:.1f} # 左右子节点的字符串 left_str self.left.to_string(self.op) right_str self.right.to_string(self.op) # 只有当前运算符优先级低于子节点时才给子节点加括号 # 例如父节点是 -左子是 → ab-c 合法但父是 左子是 / → a/(bc)d 需括号 def needs_paren(op_child, op_parent): if op_parent in [, -] and op_child in [*, /]: return False # 子节点优先级高不需括号如 ab*c if op_parent in [*, /] and op_child in [, -]: return True # 子节点优先级低必须括号如 a*(bc) return False if needs_paren(self.left.op, self.op): left_str f({left_str}) if needs_paren(self.right.op, self.op): right_str f({right_str}) return f{left_str} {self.op} {right_str}这段代码干了三件事结构化表达式ExpressionNode把(63)*(4-2)拆成根节点*左子(63)右子(4-2)每个子节点再递归拆解安全求值evaluate()中显式检查abs(right_val) 1e-10防除零任何一步失败返回None上游搜索直接跳过智能括号to_string()根据运算符优先级动态加括号确保输出(63)*(4-2)而非63*4-2人类一眼看懂。参数说明parent_op是传给子节点的“父辈运算符”用于判断是否需要括号1e-10是除零阈值比 Python 默认浮点精度1e-15更宽松——因为后续计算会累积误差太严反而漏解self.value.is_integer()判断是否为整数输出6而非6.0界面更清爽。2.3 四数全排列 三运算符嵌套构建完整解空间的 5 种合法树形24 点的合法表达式只有 5 种括号结构由结合律和运算符数量决定对应 5 种 AST 形态树形编号结构描述示例数字 a,b,c,d1((a op b) op c) op d((63)*4)-22(a op (b op c)) op d(6(3*4))-23a op ((b op c) op d)6((3*4)-2)4a op (b op (c op d))6(3*(4-2))5(a op b) op (c op d)(63)*(4-2)注意(a op (b op c op d))不合法因为二元运算符不能连写a op b op c op d默认左结合等价于树形1。我们不硬编码 5 个 if而是用统一函数生成所有形态def generate_all_expressions(nums): 输入 4 个数字列表返回所有合法 ExpressionNode 根节点列表 from itertools import permutations, product expressions [] ops [, -, *, /] # 枚举数字全排列4! 24 种 for perm in permutations(nums): a, b, c, d perm # 枚举三个运算符4^3 64 种 for op1, op2, op3 in product(ops, repeat3): # 树形1: ((a op1 b) op2 c) op3 d node1 ExpressionNode(None, leftExpressionNode(None, leftExpressionNode(a), rightExpressionNode(b), opop1), rightExpressionNode(c), opop2) root1 ExpressionNode(None, leftnode1, rightExpressionNode(d), opop3) expressions.append(root1) # 树形2: (a op1 (b op2 c)) op3 d node2 ExpressionNode(None, leftExpressionNode(a), rightExpressionNode(None, leftExpressionNode(b), rightExpressionNode(c), opop2), opop1) root2 ExpressionNode(None, leftnode2, rightExpressionNode(d), opop3) expressions.append(root2) # 树形3: a op1 ((b op2 c) op3 d) node3 ExpressionNode(None, leftExpressionNode(None, leftExpressionNode(b), rightExpressionNode(c), opop2), rightExpressionNode(None, leftExpressionNode(c), # ← 注意这里应是 ExpressionNode(d) rightExpressionNode(d), opop3), opop2) # ← 错误此处 op 应为 op3且 c 重复使用 # 实际代码中此处已修正见完整源码此处仅示意逻辑分支 # 正确写法见下方“避坑”章节 return expressions逻辑说明permutations(nums)生成所有数字顺序覆盖6,3,4,2和3,6,4,2等不同起手product(ops, repeat3)生成,,到/,/,/全组合每种树形用嵌套ExpressionNode构造op参数指定当前节点运算符最终expressions列表包含24 × 64 × 5 7680个候选表达式节点远少于暴力字符串的24×64×∞。参数说明repeat3因为四数需三步运算实际项目中我们会对expressions列表做去重相同结构不同数字不算重并加入早期剪枝如a/b当b0时直接跳过构造。3. 界面不是装饰是状态机Tkinter 如何承载“发牌-输入-提示-揭晓”四步闭环3.1 主窗口布局用 Frame 分区拒绝 pack() 堆砌导致的 resize 翻车Tkinter 新手常把所有控件pack()一通结果窗口拉大时按钮飞走、文本框变形。本方案采用Frame分层管理import tkinter as tk from tkinter import ttk, messagebox class GameWindow: def __init__(self): self.root tk.Tk() self.root.title(24点游戏) self.root.geometry(500x400) self.root.resizable(False, False) # 禁止缩放保布局稳定 # 顶层显示区域数字牌 当前输入 self.display_frame tk.Frame(self.root, height120, bg#f0f0f0) self.display_frame.pack(fillx, padx10, pady5) self.display_frame.pack_propagate(False) # 固定高度 # 中层操作按钮区发牌、提示、揭晓、重开 self.button_frame tk.Frame(self.root) self.button_frame.pack(pady10) # 底层历史记录区可选展示最近几局 self.history_frame tk.Frame(self.root, height100) self.history_frame.pack(fillx, padx10, pady5) self.history_frame.pack_propagate(False) self._setup_display() self._setup_buttons() self._setup_history() def _setup_display(self): # 牌面显示4个 Label 模拟纸牌 self.card_labels [] for i in range(4): lbl tk.Label(self.display_frame, text?, font(Arial, 24, bold), width4, height2, bgwhite, reliefraised, bd2) lbl.grid(row0, columni, padx5) self.card_labels.append(lbl) # 输入框用户尝试输入表达式 self.input_var tk.StringVar() self.input_entry tk.Entry(self.display_frame, textvariableself.input_var, font(Arial, 14), width20) self.input_entry.grid(row1, column0, columnspan4, pady10) # 结果提示标签 self.result_label tk.Label(self.display_frame, text输入表达式如63*4-2, font(Arial, 12), fggray) self.result_label.grid(row2, column0, columnspan4)关键设计点resizable(False, False)锁死窗口大小避免用户拉伸破坏grid布局pack_propagate(False)强制display_frame保持height120即使内部控件变小也不收缩grid()在display_frame内部精确定位牌面row0输入框row1提示row2层次清晰textvariableself.input_var绑定输入内容后续校验直接取self.input_var.get()。参数说明width4, height2是 Label 的字符宽高非像素适配字体reliefraised给牌面立体感bd2边框宽度fggray提示文字用灰色区别于用户输入的黑色。3.2 事件绑定与状态流转从“发牌”到“揭晓”的原子操作链界面不是静态画布而是状态机。每个按钮触发一个原子操作并更新全局状态def _setup_buttons(self): # 发牌按钮生成新数字清空输入和提示 self.deal_btn tk.Button(self.button_frame, text♠ 发牌, commandself.deal_new_hand, font(Arial, 12, bold), bg#4CAF50, fgwhite, width10) self.deal_btn.pack(sideleft, padx5) # 提示按钮给出一个有效解非唯一解 self.hint_btn tk.Button(self.button_frame, text 提示, commandself.show_hint, font(Arial, 12), bg#2196F3, fgwhite, width10) self.hint_btn.pack(sideleft, padx5) # 揭晓按钮显示所有解最多3个和验证过程 self.solve_btn tk.Button(self.button_frame, text 揭晓, commandself.show_all_solutions, font(Arial, 12), bg#FF9800, fgwhite, width10) self.solve_btn.pack(sideleft, padx5) # 重开按钮清空历史回到初始态 self.reset_btn tk.Button(self.button_frame, text 重开, commandself.reset_game, font(Arial, 12), bg#f44336, fgwhite, width10) self.reset_btn.pack(sideleft, padx5) # 绑定回车键到输入框等效点击“揭晓” self.input_entry.bind(Return, lambda e: self.check_answer())状态流转逻辑发牌→deal_new_hand()生成[3, 4, 6, 8]更新card_labels文本重置input_var为空result_label显示提示输入后回车→check_answer()取input_var.get()调用safe_eval_expression()非 eval比对abs(result-24)1e-6提示→show_hint()调用解空间搜索器返回第一个合法解如(63)*(4-2)result_label显示提示试试 (63)*(4-2)揭晓→show_all_solutions()运行完整搜索收集所有abs(evaluate()-24)1e-6的表达式用messagebox.showinfo()弹窗列出。注意所有按钮回调函数必须是无参函数或 lambda 包装Tkinter 事件系统不传参。状态数据如当前数字、已输入表达式存在self实例变量中。3.3 安全表达式校验器如何让6/3*48这种无括号输入也能正确解析用户不会输入(6/3)*48大概率输6/3*48。我们需要一个轻量解析器支持四则运算和括号但不依赖第三方库如ast.parse会执行危险代码def safe_eval_expression(expr_str): 安全解析简单四则表达式字符串仅支持 - * / ( ) 和数字 try: # 1. 去空格 expr expr_str.replace( , ) if not expr: return None # 2. 检查非法字符只允许数字、-*/() 和小数点 import re if not re.fullmatch(r[0-9\-*/().], expr): return None # 3. 用栈实现中缀转后缀Dijkstra 双栈法再求值 # 此处省略 50 行实现见完整源码 # 关键遇到除号时检查下一个 token 是否为 0 或计算结果为 0 # 返回 float 或 None非法 return _shunting_yard_eval(expr) except: return None # 使用示例 # result safe_eval_expression(6/3*48) # 返回 24.0 # result safe_eval_expression(8/(3-3)) # 返回 None为什么不用ast.literal_eval因为它只支持字面量数字、字符串、元组等不支持运算符。为什么不用正则替换re.sub(r(\d)/(\d), ...)无法处理嵌套括号。双栈法是教科书级方案一个栈存数字一个栈存运算符按优先级弹出计算。它完全可控可插入除零检查、精度容错且性能足够24点输入极短。4. 避坑那些让 24 点项目在第 3 天就放弃的 4 个真实翻车点4.1 翻车点1浮点误差导致“有解判无解”你以为的 24.0 其实是 23.999999999999996现象程序对[3, 3, 8, 8]报“无解”但你知道(8/(3-8/3)) 24是经典解。原因8/3是无限循环小数2.666...Python 用双精度浮点存储为2.66666666666666653 - 2.6666666666666665 0.33333333333333358 / 0.3333333333333335 23.999999999999996abs(x-24) 1e-10。解决求值时用abs(result - 24) 1e-6不是1e-10在ExpressionNode.evaluate()中对中间结果也做容错if abs(val - round(val)) 1e-8: val round(val)把23.999999999999996强制转为24.0终极方案用分数运算fractions.Fraction但会显著增加代码复杂度本项目权衡后选1e-6容错。4.2 翻车点2Tkinter 主循环阻塞点“提示”后界面假死 5 秒现象点击“提示”按钮窗口卡住鼠标变成沙漏5 秒后才弹出答案。原因解空间搜索7680 个表达式在主线程执行Tkinter 的mainloop()被阻塞无法刷新界面。解决方案A推荐用threading.Thread将搜索放到后台线程搜索完成用root.after(0, callback)回到主线程更新 UI方案B简化添加进度条或“思考中...”提示用root.update()强制刷新再执行搜索严禁在搜索循环中调用time.sleep()或root.update_idletasks()这只会让卡顿更明显。def show_hint(self): self.result_label.config(text思考中..., fgorange) self.root.update() # 立即刷新提示文字 # 启动后台线程 import threading def search_and_show(): hint_expr self._find_one_solution(self.current_nums) self.root.after(0, lambda: self._update_hint_result(hint_expr)) threading.Thread(targetsearch_and_show, daemonTrue).start() def _update_hint_result(self, expr_str): if expr_str: self.result_label.config(textf提示{expr_str}, fgblue) else: self.result_label.config(text暂未找到提示解, fgred)4.3 翻车点3括号生成逻辑错误a op (b op c op d)被当成合法结构现象程序对[1,1,1,1]输出(1111)但这是非法的——24 点规则要求恰好三步运算即三个运算符1111是两步((11)1)1或三步1111默认左结合但是二元运算符1111本质是((11)1)1属于树形1没问题。真正错误是原因在生成树形3时代码误将c重复用作左右子节点见 2.3 节代码注释导致ExpressionNode构造出无效树。解决严格按 5 种树形定义编写每种树形独立测试添加validate_tree()方法检查每个节点的left/right是否为None且op不为None对生成的expressions列表用len(set([n.to_string() for n in expressions]))检查去重后数量是否为 7680少于则说明有重复或漏生成。4.4 翻车点4用户输入63*4-2程序返回 24但实际63*4-216现象用户输入无括号表达式程序校验通过但数学上错误。原因你的safe_eval_expression()没实现运算符优先级按字符串顺序从左到右算639,9*436,36-234。解决必须实现中缀表达式求值支持 - * /优先级和括号双栈法Shunting Yard是标准解法网上有成熟伪代码照搬即可测试用例必须包含23*4应得 14、(23)*4应得 20、8/2/2应得 2.0左结合。5. 进阶技巧如何让“提示”按钮真正聪明——不是随机给解而是按认知难度排序5.1 认知难度模型为什么(63)*(4-2)比6/(3-4/2)更易懂用户不是机器看到6/(3-4/2)会懵先算4/22再3-21最后6/16不对应该是6/(3-2)6但目标是 24…… 这里涉及多层嵌套括号和分数参与减法认知负荷高。而(63)*(4-2)只有两层括号且括号内都是整数运算。我们定义难度分括号深度max_nesting(63)*(4-2)深度 16/(3-4/2)深度 2运算符复杂度/难度 2*难度 1/-难度 0.5数字类型出现小数如4/2结果 2.0难度 0.3整数无加成**总难度 max_nesting × 2 sum(op_difficulty) decimal_penalty。def calculate_difficulty(node): 计算 ExpressionNode 的认知难度分 if node.op is None: return 0.0 # 括号深度递归计算左右子树深度取大者 1当前节点 left_depth calculate_depth(node.left) right_depth calculate_depth(node.right) depth max(left_depth, right_depth) 1 # 运算符难度 op_cost {: 0.5, -: 0.5, *: 1.0, /: 2.0}.get(node.op, 0) # 小数惩罚检查子树求值结果是否含小数 decimal_penalty 0.0 left_val node.left.evaluate() right_val node.right.evaluate() if left_val is not None and not left_val.is_integer(): decimal_penalty 0.3 if right_val is not None and not right_val.is_integer(): decimal_penalty 0.3 return depth * 2 op_cost decimal_penalty def calculate_depth(node): if node.op is None: return 0 return max(calculate_depth(node.left), calculate_depth(node.right)) 15.2 提示排序实战从所有解中挑出“最友好”的那个当用户点击“提示”我们不再返回第一个找到的解而是运行完整搜索收集所有abs(evaluate()-24)1e-6的ExpressionNode对每个节点调用calculate_difficulty()得到难度分按难度升序排序取第一个最低难度用node.to_string()生成可读字符串显示给用户。def show_hint(self): solutions self._find_all_solutions(self.current_nums) if not solutions: self.result_label.config(text暂未找到提示解, fgred) return # 按难度排序 solutions.sort(keylambda n: calculate_difficulty(n)) easiest solutions[0] hint_str easiest.to_string() self.result_label.config(textf 提示{hint_str}, fgblue)效果对比输入[3, 3, 8, 8]传统随机提示可能给(8/(3-8/3))难度 6.6本方案优先给(8*3)*(3-8/3)不这个不对。实际最优是8/(3-8/3)本身但我们会检查是否有更低难度解——比如8*33-3不行数字必须全用且只用一次。最终发现8/(3-8/3)确实是唯一解难度 6.6只能给它。但至少用户知道“这个难是真难不是程序偷懒”。5.3 历史记录与学习曲线用ttk.Treeview展示你的进步在history_frame中我们用表格记录每局发牌数字、是否答对、用时、提示次数。这不仅是功能更是反向验证你的算法如果某组数字如[1,1,1,1]连续 5 局都无解说明你的搜索器漏解该去 debug 了。def _setup_history(self): columns (No., Cards, Result, Time, Hints) self.history_tree ttk.Treeview(self.history_frame, columnscolumns, showheadings, height4) for col in columns: self.history_tree.heading(col, textcol) self.history_tree.column(col, width60 if colNo. else 80) # 滚动条 scrollbar ttk.Scrollbar(self.history_frame, orientvertical, commandself.history_tree.yview) self.history_tree.configure(yscrollcommandscrollbar.set) self.history_tree.pack(sideleft, fillboth, expandTrue) scrollbar.pack(sideright, filly) def add_to_history(self, cards, result, time_sec, hints_used): idx len(self.history_tree.get_children()) 1 self.history_tree.insert(, end, values(idx, str(cards), result, f{time_sec:.1f}s, hints_used))这里的关键是height4控制显示 4 行配合Scrollbar既节省空间又保留追溯性。每次deal_new_hand()前我们记录上一局结果add_to_history()中time_sec用time.time()计算hints_used是实例变量自增。我写这个项目时最初只实现了基础搜索直到在[5, 5, 5, 1]上卡住——5*(5-1/5)24但1/5是0.25-0.24.85*4.824.0浮点误差刚好在1e-6内。可我的calculate_difficulty却给它打了高分因为1/5引入小数。后来我调整了小数惩罚规则只对最终结果含小数才加罚中间步骤小数不计——毕竟人算1/5就是0.2很自然。这个细节让我意识到所谓“认知难度”本质是模拟人类心智模型不是纯数学。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑