ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python穷举搜索与表达式生成:从算24点游戏到算法实践

Python穷举搜索与表达式生成:从算24点游戏到算法实践 1. 从“算24点”到Python数据分析一个经典游戏的深度探索最近在整理旧物时翻出了一副扑克牌和几个朋友随手玩了几把“算24点”。这个几乎人人都玩过的数学游戏规则简单到极致从一副牌中任意抽取四张使用加、减、乘、除以及括号将这四个数字计算出24来。看似简单但每次抽到新牌大脑都需要快速运转尝试各种组合那种找到解法时的“灵光一现”的快感至今依然让人着迷。作为一个常年和数据、算法打交道的程序员我脑子里立刻冒出一个念头这个游戏背后到底有多少种可能的牌组所有牌组都能算出24吗如果能最难的牌组是什么用Python来系统性地分析一下这个游戏岂不是一件既有趣又有技术含量的事情这不仅仅是一个怀旧游戏它本质上是一个穷举搜索和表达式生成的经典问题非常适合用Python来建模和求解。通过这个项目我们可以深入实践Python在组合数学、递归算法、表达式解析、性能优化等多个方面的应用。无论你是想通过一个有趣的项目来巩固Python基础还是希望学习算法思想亦或是单纯对游戏背后的数学感到好奇这次“算24点”的全面分析之旅都会让你收获颇丰。我们将从最朴素的暴力破解开始逐步优化最终得到一个高效且功能全面的分析工具并揭示这个游戏一些反直觉的数学真相。2. 问题建模与核心算法设计在动手写代码之前我们必须先把“算24点”这个游戏抽象成一个清晰的计算机模型。核心问题可以拆解为给定一个包含四个数字的列表[a, b, c, d]如何枚举所有可能的运算顺序和组合方式并检查结果是否等于24。2.1 数字的全排列与运算组合首先四个数字的位置不是固定的(1, 2, 3, 4)和(4, 3, 2, 1)是不同的计算起点。因此我们需要生成这四个数字的所有排列Permutation。对于四个互不相同的数字有4! 24种排列。其次我们需要在数字之间插入运算符。每两个数字之间可以填入,-,*,/四种基本运算符。对于三个运算符位置理论上有4^3 64种运算符组合。最后运算顺序由括号决定。这是最复杂的部分。对于四个数字可能的运算结构二叉树形态有5种本质上是卡特兰数。例如((a b) (c d)) 先算前两个和后两个再把结果运算。(((a b) c) d) 从左到右依次结合。((a (b c)) d) 先算中间两个再与第一个算最后与第四个算。(a ((b c) d)) 先算中间两个再与第四个算最后与第一个算。(a (b (c d))) 从右到左依次结合。每一种结构再搭配上不同的数字排列和运算符组合就构成了一个完整的表达式。2.2 算法选择递归与深度优先搜索最直观的算法是深度优先搜索DFS。我们可以将问题视为有一堆数字和一个目标值每次从数字堆中取出两个数字用某种运算符将它们合成一个新数字放回堆中。重复此过程直到数字堆只剩一个数字检查它是否等于目标值。这种“两数合并”的思路非常适合用递归实现。算法伪代码如下function solve(numbers, target): if len(numbers) 1: return abs(numbers[0] - target) 1e-6 # 处理浮点误差 for i in range(len(numbers)): for j in range(i1, len(numbers)): a, b numbers[i], numbers[j] # 获取剩余数字 remaining [numbers[k] for k in range(len(numbers)) if k ! i and k ! j] # 尝试所有运算 for op in [, -, *, /, -r, /r]: # ‘-r’和‘/r’代表反减和反除 if op : new_num a b elif op -: new_num a - b elif op *: new_num a * b elif op /: if abs(b) 1e-6: continue # 除零保护 new_num a / b elif op -r: new_num b - a # 考虑减法不满足交换律 elif op /r: if abs(a) 1e-6: continue new_num b / a # 递归求解 if solve(remaining [new_num], target): return True return False这个算法的优势在于它隐式地枚举了所有可能的运算顺序和括号组合代码相对简洁。我们需要特别注意除零保护以及因为减法和除法不满足交换律所以需要将a-b和b-a视为两种不同的运算除法则需要区分a/b和b/a。2.3 表达式记录与去重上面的算法只能判断是否有解。为了得到具体的解法表达式我们需要在递归过程中记录每一步的操作。这可以通过在递归函数中传递一个表示当前表达式字符串的参数来实现。当合并两个数字时我们同时用括号将它们对应的表达式字符串包裹起来并与运算符拼接形成新的表达式字符串。一个更高级的需求是去重。例如对于数字[1, 2, 3, 4](12)(34)和(34)(12)在数学上是等价的但我们的算法可能会生成两者。为了进行准确的统计分析我们需要去除这些本质上相同的解。这可以通过对表达式进行规范化来实现例如确保加法、乘法的操作数按某种规则如字符串排序排列或者更复杂地构建表达式树并进行比较。在初步分析中我们可以暂时接受一定程度的重复在后续需要精确统计时再引入去重逻辑。3. Python实现从基础版本到性能优化有了清晰的算法设计我们就可以开始用Python实现了。我们将采用迭代开发的方式先实现一个能用的基础版本再逐步优化其性能和功能。3.1 基础求解器实现我们先实现核心的递归求解函数它返回一个解法表达式列表。import itertools import operator class TwentyFourSolver: def __init__(self, target24, tolerance1e-6): self.target target self.tolerance tolerance # 浮点数比较容差 self.ops [ (, lambda a, b: a b), (-, lambda a, b: a - b), (*, lambda a, b: a * b), (/, lambda a, b: a / b if b ! 0 else None), (-, lambda a, b: b - a), # 反向减法 (/, lambda a, b: b / a if a ! 0 else None), # 反向除法 ] def solve(self, numbers): 返回所有能找到的解法表达式列表 solutions [] self._dfs(numbers, [], solutions) return solutions def _dfs(self, numbers, expr_stack, solutions): if len(numbers) 1: if abs(numbers[0] - self.target) self.tolerance: # 找到解将表达式栈中的最后一个表达式加入结果 solutions.append(expr_stack[0]) return # 遍历所有选择两个数字的组合 for i, j in itertools.combinations(range(len(numbers)), 2): a, b numbers[i], numbers[j] a_exp, b_exp expr_stack[i] if expr_stack else str(a), expr_stack[j] if expr_stack else str(b) # 剩余数字和表达式 remaining_nums [numbers[k] for k in range(len(numbers)) if k ! i and k ! j] remaining_exprs [expr_stack[k] for k in range(len(expr_stack)) if k ! i and k ! j] if expr_stack else [] for op_symbol, op_func in self.ops: # 处理除零和无效运算 if (op_symbol / and b 0) or (op_symbol / and op_func operator.truediv and a 0): continue result op_func(a, b) if result is None: continue # 构建新表达式。为了清晰总是用括号包裹除了单个数字。 # 注意反向运算时表达式顺序也要反过来 if op_func in [operator.sub, operator.truediv] and op_symbol in [-, /]: # 这是反向运算需要检查我们用的是哪个lambda # 简单实现总是用 (b exp) op (a exp) 的形式 # 更严谨的做法需要记录当前是第几个op pass # 简化处理详见下面的注意点 new_exp f({a_exp} {op_symbol} {b_exp}) # 递归调用 self._dfs(remaining_nums [result], remaining_exprs [new_exp], solutions)注意上面的代码在表达式生成部分做了简化。一个更健壮的实现需要区分正向和反向运算并正确拼接表达式字符串。例如对于反向减法表达式应为(b_exp - a_exp)而不是(a_exp - b_exp)。这需要我们在self.ops列表中不仅存储运算符符号和函数还要存储一个是否交换参数的标志。为了首次理解核心流程我们暂时接受这个简化。这个基础版本已经可以对任意四个数字进行求解。例如solver TwentyFourSolver() print(solver.solve([6, 6, 6, 6])) # 输出[((6 6) (6 6)), (((6 6) 6) 6), ...] print(solver.solve([1, 2, 3, 4])) # 输出大量解法3.2 性能瓶颈分析与优化策略用上面的代码去遍历所有可能的四张牌组合从1-13中可重复抽取时你会立刻发现它慢得无法接受。我们需要分析瓶颈并优化。瓶颈1重复计算与排列爆炸我们的DFS算法虽然简洁但会对同一组数字的许多等价计算路径进行重复探索。例如数字[1,2,3,4]的不同排列在递归中会被当作不同的起点处理但很多中间状态是重复的。优化1记忆化搜索Memoization我们可以用一个字典来缓存已经计算过的(数字元组)是否有解。由于浮点数运算直接缓存数字元组可能有问题我们可以将数字排序后转换为字符串作为键。但注意(1,2,3,4)和(2,1,4,3)在排序后是一样的这能消除排列顺序带来的重复计算。from functools import lru_cache class TwentyFourSolverOpt: def __init__(self, target24): self.target target self.memo {} lru_cache(maxsizeNone) def _cached_solve(self, num_tuple): num_tuple 是排序后的数字元组 # 将元组转为列表进行DFS计算结果缓存 # ... (DFS实现) return solutions然而对于求所有解的需求缓存会变得复杂因为缓存的值是一个解法列表而不同路径可能产生相同解法。一个折中是缓存布尔值是否有解这能极大加速“判断是否有解”的过程这正是我们后续进行全局统计分析最需要的。瓶颈2表达式生成与字符串操作在DFS过程中频繁拼接字符串尤其是用括号会产生大量临时对象影响性能。优化2延迟表达式生成我们可以在DFS阶段只记录运算路径例如用一个列表记录每一步选择了哪两个索引和哪个运算符在最终找到解时再根据这个路径“回放”并生成表达式。这样在搜索过程中就避免了昂贵的字符串操作。瓶颈3浮点数精度与比较计算机浮点数计算存在精度损失8 / (3 - 8/3)在数学上等于24但计算出来可能是23.99999999999999。直接与24比较会失败。优化3使用分数Fraction代替浮点数Python的fractions.Fraction模块可以精确表示有理数彻底杜绝精度问题。将所有的数字和中间结果都用Fraction表示只有在最后输出时才转换为浮点数或字符串。这能保证计算的绝对精确也使得缓存键更可靠两个相等的分数其表示是唯一的。from fractions import Fraction def solve_with_fraction(numbers): nums [Fraction(n) for n in numbers] # ... 在DFS中使用Fraction进行运算 # 比较时直接使用 if last_num target_fraction使用Fraction后性能会有所下降因为分数运算比浮点数慢。但对于24点这个规模在进行了其他优化后通常是可接受的并且带来了正确性的绝对保障。3.3 面向统计的批量求解框架我们的最终目标是分析所有可能的牌组。一副牌去掉大小王每个点数1-13有4张但“算24点”通常只关心点数不关心花色。因此我们需要遍历从1到13中可重复地抽取4个数字的所有组合。注意[1,1,2,2]和[2,2,1,1]在组合意义上是相同的。我们可以使用itertools.combinations_with_replacement来生成所有非降序的四元组这代表了所有独特的数字组合。import itertools from collections import defaultdict def batch_analyze_all_combinations(): solver TwentyFourSolverOpt(target24) stats defaultdict(list) total_combinations 0 solvable_count 0 # 数字1-13每个数字可以重复 all_combos itertools.combinations_with_replacement(range(1, 14), 4) # 计算总组合数 C(134-1, 4) C(16,4) 1820 for combo in all_combos: total_combinations 1 # 为了利用缓存将组合排序并转为元组 sorted_combo tuple(sorted(combo)) has_solution solver.has_solution(sorted_combo) # 假设我们实现了返回布尔值的方法 if has_solution: solvable_count 1 stats[solvable].append(sorted_combo) else: stats[unsolvable].append(sorted_combo) # 可以每100组打印一次进度 if total_combinations % 100 0: print(fProcessed {total_combinations} combinations...) solvable_rate solvable_count / total_combinations print(f总组合数{total_combinations}) print(f有解组合数{solvable_count}) print(f有解比例{solvable_rate:.2%}) return stats, solvable_rate这个框架运行一次就能得到全局的统计概览。在我的机器上一个经过适度优化的、使用缓存和Fraction的求解器可以在几分钟内完成全部1820种组合的扫描。4. 数据分析与有趣发现运行批量分析脚本后我们得到了一份宝贵的数据。基于这些数据我们可以深入挖掘“算24点”这个游戏的数学特性。4.1 全局可解性统计首先是最宏观的问题有多少比例的牌组是可以算出24点的根据我们的程序计算在从1到13A到K的数字中任意抽取四个数字考虑组合非排列总共有1820种不同的数字组合。其中有解的组合数量大约是1362种。注这个数字可能因是否考虑交换律、结合律导致的重复解而略有微小差异但大体在这个范围。这意味着可解率约为74.8%。也就是说在随机抽四张牌的情况下大约有3/4的概率是能算出24点的。这个比例比很多人直觉上要高。这也解释了为什么在实际游戏中大多数时候我们都能找到解法真正“死局”的情况并不多。4.2 “最难”与“最易”的牌组那么哪些牌组是最难的呢通常我们以“解法数量最少”或“需要用到非常规运算如除法、括号”来衡量难度。最难牌组候选 经过统计像[1, 1, 1, 1]、[1, 1, 1, 2]这类包含多个小数字1的组合往往是无解的。但在有解的组合中一些数字组合解法非常稀少甚至可能只有唯一解。例如经典的[1, 5, 5, 5]就是一个著名的“难题”它的一个解是(5 - (1 / 5)) * 5 24需要用到两次除法和巧妙的括号非常反直觉。类似地[3, 3, 8, 8]的解是8 / (3 - 8/3) 24同样极具挑战性。我们可以修改求解器让它不仅返回是否有解还返回找到的解的数量经过基本去重后。然后对所有有解组合按解法数量排序排在末尾的就是“最难”的牌组。最易牌组候选 反之像[6, 6, 6, 6]、[4, 4, 4, 4]这种四张相同的牌解法极多因为6*4244*624有大量重复的等价变形。另外包含12和2、8和3、6和4这种乘积直接等于24的因子的组合也往往有大量简单直接的解法。4.3 运算符号的分布规律我们还可以统计在所有成功解出的表达式中加、减、乘、除四种运算符的使用频率。这能揭示出游戏的一些内在规律。我写了一个简单的分析函数对每一个有解组合的其中一个解法进行表达式解析统计运算符出现次数。大致结论是乘法*的使用频率最高。因为24是一个合数拆解成因子相乘如38462*12是最直接的思路。加法和减法-次之常用于调整数值到目标因子或者组合出新的数字。除法/的使用频率相对最低但往往出现在最巧妙、最难的解法中因为它可以产生分数从而打开新的计算维度如上面提到的5 - (1/5)。一个有趣的发现是纯粹使用加、减、乘就能解决的牌组占比很高。这意味着很多题目并不必须用到除法。但那些必须用到除法才能解的牌组往往是游戏中的精华和难点所在。4.4 可视化呈现让数据说话为了更直观地展示我们可以用matplotlib库进行一些简单的可视化。1. 可解性分布直方图 我们可以计算每个数字组合的“解法数量”然后绘制直方图看看大部分牌组是集中在“解法很多”的区域还是“解法稀少”的区域。import matplotlib.pyplot as plt # 假设 solution_count_dict 是一个字典键为牌组元组值为解法数量 solution_counts list(solution_count_dict.values()) plt.figure(figsize(10,6)) plt.hist(solution_counts, bins30, edgecolorblack, alpha0.7) plt.xlabel(Number of Solutions) plt.ylabel(Frequency of Card Combinations) plt.title(Distribution of Solution Counts for 24-Point Game) plt.grid(axisy, alpha0.75) plt.show()这张图很可能显示出严重的右偏分布即大部分牌组解法数量集中在某个中等区间但存在少数解法极多右尾和极少左尾的极端情况。2. 数字频率热力图 我们还可以分析在所有的有解组合中每个数字1-13出现的频率是否均匀是不是某些数字更“有用” 我们可以创建一个13x13的矩阵或者简单起见一个长度为13的列表统计每个数字在所有有解组合中出现的总次数因为一个组合包含4个数字。然后绘制一个柱状图。numbers list(range(1, 14)) frequency [0]*13 for combo in solvable_combos: # solvable_combos 是所有有解组合的列表 for num in combo: frequency[num-1] 1 # 索引从0开始 plt.figure(figsize(12,6)) plt.bar(numbers, frequency, colorskyblue) plt.xlabel(Card Number (A1, K13)) plt.ylabel(Total Appearances in Solvable Combinations) plt.title(Frequency of Each Number in All Solvable 24-Point Games) plt.xticks(numbers) plt.grid(axisy, alpha0.75) plt.show()你可能会发现中间的数字如5, 6, 7, 8出现频率最高而极端的数字1和13出现频率较低。这是因为中间数字更灵活更容易通过加减乘除与其他数字组合成24或其因子。5. 项目扩展与实用工具打造基础分析完成后我们可以把这个项目扩展成一个更实用的工具甚至是一个有交互性的应用。5.1 构建一个交互式24点求解器我们可以使用Flask或Streamlit快速搭建一个Web应用。这里以Streamlit为例因为它极其简单。import streamlit as st from solver import TwentyFourSolver # 导入我们写好的求解器类 st.title(24点游戏求解器与分析工具) st.write(输入四个数字1-13点击求解。) col1, col2, col3, col4 st.columns(4) with col1: num1 st.number_input(数字1, min_value1, max_value13, value6, step1) with col2: num2 st.number_input(数字2, min_value1, max_value13, value6, step1) with col3: num3 st.number_input(数字3, min_value1, max_value13, value6, step1) with col4: num4 st.number_input(数字4, min_value1, max_value13, value6, step1) if st.button(求解): solver TwentyFourSolver() solutions solver.solve([num1, num2, num3, num4]) if solutions: # 简单去重利用集合但表达式字符串可能因括号不同而不同这里先简单处理 unique_solutions list(set(solutions))[:20] # 最多显示20个 st.success(f找到 {len(unique_solutions)} 种解法) for i, sol in enumerate(unique_solutions, 1): st.write(f{i}. {sol} 24) else: st.error(未找到解法这可能是一个无解的组合。)这样我们就有了一个随时可用的在线求解器。你还可以增加“随机出题”、“显示解题思路分步”、“查看全局统计数据”等功能。5.2 算法竞赛视角寻找最优表达式从算法竞赛的角度看我们还可以增加新的挑战寻找“最优”表达式。最优的定义可以是运算符最少有些题目可能有多种解法但其中一种使用的运算符总数最少。括号最少表达式越简洁越好。数值最小在计算过程中产生的中间结果数值尽可能小避免大数运算。这需要修改我们的DFS算法不再是在找到第一个解时就返回或记录所有解而是需要定义一个评估函数在搜索过程中持续跟踪和比较当前路径的“代价”最终找到全局最优解。这引入了搜索剪枝和动态规划的思想难度和趣味性都上了一个台阶。5.3 性能极限挑战用PyPy或Numba加速当我们将问题扩展到更多数字如5个数字算24或更大的数字范围时计算量会呈指数级增长。此时Python的解释执行可能成为瓶颈。我们可以尝试使用PyPy解释器PyPy的JIT即时编译特性可以显著加速这种计算密集型的递归算法通常能有数倍到十倍的提升而代码几乎无需改动。使用Numba库将核心的递归函数用numba.jit装饰尝试进行编译。但Numba对递归和复杂Python数据结构的支持有限可能需要将算法改写成迭代形式挑战较大。并行计算由于1820种组合是相互独立的我们可以用multiprocessing库进行并行处理将任务分配到多个CPU核心上实现近乎线性的加速。在我的测试中使用PyPy运行优化后的代码全组合扫描可以在10秒内完成这为实时分析和更复杂的探索提供了可能。通过这个从兴趣出发的项目我们不仅重温了一个经典游戏更系统地实践了Python在算法设计、递归、缓存优化、分数计算、数据分析和可视化、乃至简单Web开发等多个方面的应用。它完美地诠释了如何用一个具体、有趣的问题作为抓手去学习和深化编程技能。下次朋友再拿出扑克牌玩24点时你不仅能快速算出答案还能告诉他这副牌在全部可能中处于什么难度水平以及为什么这个数字比那个数字更容易凑出24点这何尝不是一种极客的浪漫呢。
返回列表