24点这个游戏,我从小就会玩,扑克牌里抽四张,加减乘除凑出24。后来学了编程,第一反应是暴力枚举,四个数、三种运算符、括号位置,排列组合算一遍。但代码写着写着就发现,递归才是这种“规模逐步缩小”问题的最自然解法。这篇文章继续聊聊基于Python的24点求解器,重点放在递归程序设计上——如何建模、如何准确记录表达式、如何避免重复计算,以及我在这类题目上踩过的几个坑。
如果你是刚接触递归不久,建议先把“阶乘”“斐波那契”这类基础题写顺,再来挑战24点。如果你已经会写递归,但拿到24点还是不知道怎么组织代码,这篇文章正好可以给你一个完整的思路:先不考虑效率,把正确性跑通;再考虑去重和剪枝;最后再谈输出美观。整个过程就是一个典型的递归程序设计案例,值得拆开揉碎讲。
1. 递归模型:把24点拆成一个自相似问题
1.1 核心思路:从4个数到1个数
24点表面上是一个“表达式求值”问题,但换个角度看,它其实是一个不断“合并”数字的过程。给定四个数,比如 [1, 3, 4, 6],我可以任选其中两个数,比如 3 和 6,做一个运算得到一个新数(3+6=9)。此时原来的四个数就变成了三个数:[1, 4, 9]。然后我继续选两个数合并,直到只剩一个数。如果这个最终数字等于24,说明存在一个合法表达式。
这个过程完美符合递归的定义:一个大问题不断缩小为同类型的小问题。递归函数做的事情就是“从当前数字列表里挑两个数做运算,然后递归处理剩余的数字”。当列表长度变成1时,就到了递归出口,判断这个数是不是24。这就是整个算法的骨架。
很多同学一开始容易走偏,去想“怎么枚举所有括号位置”,其实括号只是“合并顺序”的视觉效果。递归里,每次选哪两个数先合并,就自动决定了括号结构。比如先合并1和4,再合并结果和3,最后和6合并,对应的就是((1+4)+3)*6这种括号嵌套。所以,不需要显式枚举括号,递归天然覆盖了所有括号情况。
1.2 递归出口:浮点数比较与精度陷阱
递归出口看起来简单:只剩一个数时,判断是否等于24。这里马上就遇到第一个坑——浮点数比较。比如 4/(1-2/3) 这个表达式,如果按浮点算,2/3=0.6666666666666666,1-2/3=0.33333333333333337,4/0.33333333333333337≈11.999999999999998,并不是精确的12。再往后算,最终结果很可能是24.000000000000004这样的数。
如果直接写result == 24,绝大概率返回False,导致明明有解的牌被判成无解。解决方式是用一个容差范围:
EPSILON = 1e-6 def is_close_to_24(val): return abs(val - 24) < EPSILON为什么取1e-6?因为整个运算过程最多经过三次合并,浮点误差在10的负12次方量级左右,1e-6留的余量足够,而且不会把接近24但不等于24的结果误判成合法。如果你只做四张牌的24点,这个阈值很稳;如果扩展成五张牌凑某个目标值,建议把阈值放宽到1e-4左右,因为运算次数越多,误差累积越大。
递归出口的另一个细节:如果只需要判断“有没有解”,返回布尔值就够了;但如果要打印出表达式,递归函数必须同时传递“数值”和“表达式的字符串形式”。这两者的变化是同步的,所以在设计递归函数的参数时,最好把数值列表和表达式列表放在一起维护,避免出现数值计算正确、表达式对不上号的情况。
2. 基础递归实现:从纯数值判断到带表达式输出
2.1 先写出能“判断有解”的版本
我习惯先写一个只返回True/False的版本,把逻辑跑通。核心代码大概是这样:
def can_solve(nums): if len(nums) == 1: return abs(nums[0] - 24) < 1e-6 n = len(nums) for i in range(n): for j in range(i + 1, n): # 剩下的数字 rest = [nums[k] for k in range(n) if k != i and k != j] a, b = nums[i], nums[j] # 枚举所有二元运算结果 results = [a + b, a - b, b - a, a * b] if abs(b) > 1e-9: results.append(a / b) if abs(a) > 1e-9: results.append(b / a) for res in results: if can_solve(rest + [res]): return True return False这里有几个细节值得说。
第一,为什么枚举i和j的时候只让i < j?因为对于两个数字来说,选a和b与选b和a本质上是同一个组合,就算我们有a-b和b-a两种运算,也只是交换了顺序,组合层面不需要重复枚举。这样可以减少一半的迭代量。
第二,为什么加法、乘法只保留一次,减法、除法各保留两次?因为加法和乘法满足交换律,a+b和b+a结果一样;而减法、除法不满足,所以要分别考虑两种方向。这一点在“只求是否有解”时,能保证不漏解。
第三,除零判断。这里用abs(b) > 1e-9,而不是直接写b != 0,因为浮点数可能出现极小的数,直接比较0可能躲不过去;用绝对值和阈值比较更安全。不过对一个还没开始复杂运算的初始数来说,基本不会出现这种情况,但养成这个习惯没坏处。
跑一下can_solve([1,3,4,6]),大概率返回True。因为6/(1-3/4)=24是经典解。但如果只返回True,你还看不到表达式,下一步就是让它吐出算式。
2.2 让递归返回表达式:数值和字符串同步走
要输出表达式,需要把每一步合并过程记录下来。最直观的做法是在递归函数里维护两个列表:nums存数值,exprs存对应的表达式字符串。例如初始时,nums=[1,3,4,6],exprs=["1","3","4","6"]。合并1和3时,如果做加法,新数值是4,新表达式就是"(1+3)",同时把两个旧元素从列表里移除,加入新元素。
代码如下:
def solve_24(nums, exprs=None): if exprs is None: exprs = [str(n) for n in nums] if len(nums) == 1: if abs(nums[0] - 24) < 1e-6: return exprs[0] return None n = len(nums) for i in range(n): for j in range(i + 1, n): rest_nums = [nums[k] for k in range(n) if k != i and k != j] rest_exprs = [exprs[k] for k in range(n) if k != i and k != j] a, b = nums[i], nums[j] ea, eb = exprs[i], exprs[j] # 每个候选操作:运算符、是否交换、计算结果 candidates = [ ("+", False, a + b), ("-", False, a - b), ("-", True, b - a), ("*", False, a * b), ("/", False, a / b) if abs(b) > 1e-9 else None, ("/", True, b / a) if abs(a) > 1e-9 else None, ] for op, swapped, res in candidates: if res is None: continue if op in ("+", "*"): if swapped: continue # 交换律,只保留一种 new_expr = f"({ea}{op}{eb})" else: if swapped: new_expr = f"({eb}{op}{ea})" else: new_expr = f"({ea}{op}{eb})" result = solve_24(rest_nums + [res], rest_exprs + [new_expr]) if result is not None: return result return None这个版本已经能打印出表达式了。比如调用print(solve_24([1,3,4,6])),可能得到(((6/(1-(3/4)))))之类的带括号字符串。注意括号比较多,这是正常的,因为我们每做一步合并就套一层括号,绝对合法,但不够简洁。如果你对输出格式有洁癖,后面可以加一个“去冗余括号”的步骤,或者干脆在生成表达式时减少不必要的括号。这个不是核心,先不纠结。
这里有一个容易踩的坑:在构造表达式时,减法、除法的方向千万别搞反。假设当前a=6,b=3,我们要做a/b,就应该输出(6/3)。但如果用统一的模板f"({ea}{op}{eb})",当 op 是/且 swapped 为 True 时,实际上是需要(3/6)的,所以必须按swapped分支处理。我最初写的时候偷懒,直接用一个模板,结果很多解打印出来表达式和计算过程对不上,查了半天才发现方向搞错了。
2.3 递归返回的两种设计:层层传递 vs 全局变量
上面代码用的是“递归函数直接返回最终表达式字符串”,如果无解则返回None。这种设计的好处是代码简单,每一步递归都知道当前表达式是什么;坏处是同一个路径如果最终无解,前面所有努力都白费了,而且无法提前知道解的完整形式。
另一种常见做法是用一个全局变量或可变容器保存找到的第一个解,例如:
solution = None def search(nums, exprs): global solution if solution is not None: return ... if len(nums) == 1 and abs(nums[0]-24) < 1e-6: solution = exprs[0] return ...两种做法效果一样,但全局变量方案在“只需要找一个解”时不方便,因为一旦找到解,你希望立即终止所有递归,而层层传递返回值可以让每一层都判断“下层已经找到,那我也返回”。全局变量方案则需要在每层递归开头检查if solution is not None: return,多一道判断,但思路更直白。我日常写递归会更偏爱“返回值传递”,因为它符合函数式思考,不容易产生“层之间共享可变状态”的隐患。
3. 去重与剪枝:让递归快起来
3.1 为什么朴素递归会重复计算
上面的代码虽然能跑,但如果你去统计递归次数,会发现同一个数字列表可能被反复处理。举个简单例子,[1,2,3,4]先合并1和2,得到3,再合并3和3,最后合并4;但另一种路径是先合并2和4,得到6,再合并1和3,得到4,最后合并4和6。中间会出现数字列表完全一样的情况,但由于合并顺序不同,递归会重新计算一遍。数量少的时候无所谓,四张牌的搜索空间也就几千次,但如果让你扩展到五张牌、六张牌,指数爆炸立刻就体现出来了。
去重的核心思路是:如果某个数字组合已经被判断过“有解/无解”,就不要重复判断。用frozenset或者是排序后的元组作为缓存key。
3.2 用记忆化缓存消除重复状态
因为递归过程中,数字列表的元素顺序并不影响最终结果(它只是多重集合),所以我们可以把数字列表排序后转成元组,作为缓存键。代码如下:
from functools import lru_cache @lru_cache(maxsize=None) def can_solve_cached(tuple_nums): nums = list(tuple_nums) if len(nums) == 1: return abs(nums[0] - 24) < 1e-6 n = len(nums) for i in range(n): for j in range(i + 1, n): rest = [nums[k] for k in range(n) if k != i and k != j] a, b = nums[i], nums[j] results = [a + b, a - b, b - a, a * b] if abs(b) > 1e-9: results.append(a / b) if abs(a) > 1e-9: results.append(b / a) for res in results: # 注意:这里要把新列表也排序再缓存 next_nums = rest + [res] if can_solve_cached(tuple(sorted(next_nums))): return True return False这样,只要某个数字组合已经被判断过,下次再遇到直接从缓存取结果,速度提升非常明显。实测四张牌从几千次递归降到几百次,五张牌的话差别更夸张,堪称指数级压缩。不过这个方案只能判断“是否有解”,如果要输出表达式,就无法直接复用缓存,除非你缓存的是表达式本身。但通常做法是:先用缓存版本判断是否有解,如果有,再用不带缓存的搜索去求具体表达式。反正有解的状态递归路径不会太长,找到第一个解很快就返回。
3.3 交换律剪枝:避免无意义的重复路径
除了记忆化,还可以在递归过程中直接减少分支。最典型的剪枝是:对于加法和乘法,由于交换律,我们不希望同时尝试(a+b)和(b+a),所以可以在枚举操作时只保留一个方向。上面2.2的代码已经用了这个技巧,这里说一个更彻底的优化:对于重复数字,合并操作时也跳过相同的组合。
比如列表[2,2,3,4],第一个2和第二个2在数值上完全等价。如果我们在外层枚举i和j时,对两个相同数字的组合只做一次,能进一步减少重复。实现方式可以是用一个集合记录本轮已经处理过的数字值组合:
seen_pairs = set() for i in range(n): for j in range(i + 1, n): key = (min(nums[i], nums[j]), max(nums[i], nums[j])) if key in seen_pairs: continue seen_pairs.add(key) ...注意,这里用min/max是为了保证两个值不管顺序如何都映射到同一个key。但这样处理有个副作用:如果两个数字值相同但原始下标不同,所有组合都会被合并为一种,这不会漏解,因为数字相同的两个数互换位置没有意义。这个剪枝对带有大量重复牌面的情况尤其有效,比如[4,4,4,4]只有一根组合,而不是六种。
3.4 关于剪枝的冷静思考
有些优化能大幅提速,有些则可能因为“剪过头”漏解。比如有人会想:如果当前中间值已经大于24,是不是就不用继续算了?这个想法是错的。举个例子,[1,20,4,9],中间可能算出25,然后25-1=24,所以大于24的中间值完全可能参与最终结果。同理,小于0的中间值也可能有意义,比如0- (-24)=24。
所以在这个问题里,不要用“中间值是否在某个区间”来剪枝,除非你能严格证明某些值绝对不可能成为最终24的一部分。24点这个游戏本身的特点就是数字和运算的组合非常灵活,任何数值都有潜力。唯一无害的剪枝是除数的非零判断,以及基于交换律的去重。还有一种是“如果当前只剩下一个数,且它和24的误差已经超过某个上界,必然无解”,但这在递归出口已经处理了,不算额外剪枝。
4. 从递归到完整工程:调试、扩展与思考
4.1 调试递归的“三板斧”
递归代码最大的问题是“运行时不会告诉你错在哪一层”。我自己的调试经验有三条。
第一,打印递归入口。在函数开头加一行print("enter:", nums, exprs),然后运行一个小输入,观察每一步的列表变化。如果打印量太大,可以用一个全局计数器限制打印次数,只打印前几层。
第二,写一个不依赖递归的暴力验证函数。其实24点的最终表达式可以转化为后序表达式(Reverse Polish Notation),你可以用栈来直接计算任意后序表达式的结果。写一个独立的求值函数,对你递归打印出来的表达式字符串,用eval(注意安全性,本地调试无所谓)或者自建解析器算一下,看是不是等于24。这样能快速定位是数值逻辑错了还是表达式构造错了。
第三,控制输入规模。先用[1,2,3,4]这种简单牌面手动验算,再用[3,3,7,7]这种典型“难题”来测试。如果难题也能解出来,说明大概率没漏公式。
4.2 从四张牌扩展到N张牌
一旦掌握了递归建模,扩展到五张牌甚至六张牌只是改个参数的事。比如五张牌凑某个目标值,只需要把终止条件从“等于24”改成“等于target”,同时把EPSILON稍微放大。但搜索空间会急剧膨胀:四张牌的组合数还比较友好,五张牌的时候,每一步合并的选择数量是C(5,2)种,大约10种,每一步又有6种运算,递归深度为4,所以分支数量是10×6 × 6×6…,大概几万次调用,加了记忆化后可以接受。六张牌如果不做任何优化会很吃力,这时就需要更好的剪枝策略,比如把数字列表做更聪明的规约,或者用动态规划的思路,把每次合并后的状态压缩成位掩码,再配合记忆化。
4.3 递归程序设计的小结:为什么说24点是“艺术”
可能有人会觉得,24点不过是一个小玩具,写个递归就算完事。但在我眼里,它是练习递归程序设计的绝佳素材。因为24点几乎包含了递归设计的全部要素:状态定义(数字列表)、状态转移(选两个数做运算)、递归出口(只剩一个数)、返回值设计(要么布尔要么表达式)、去重与剪枝(缓存和交换律)。把这些想明白,以后遇到“正则表达式匹配”“汉诺塔”“二叉树的序列化”这类问题,你会觉得很亲切。
更重要的是,24点还教会我一件事:写递归不要一开始就想着优化。先把最简单的能跑通的版本写出来,哪怕它慢,哪怕它输出一堆括号,它至少是一个正确的基准。然后你再慢慢加缓存、加剪枝,性能变好了,而且你有基准测试可以对照,不会改着改着把正确性改没了。我见过很多同学一上来就想着用动态规划、位运算优化,结果半天写不出一个能跑的版本,最后心态崩了。脚踏实地,先把朴素递归撸出来,再谈“艺术”不迟。
4.4 一个值得尝试的小扩展:输出所有解而不是一个解
如果你想让项目更有挑战性,可以尝试让程序输出所有可能的解法,而不是找到第一个就停。方法也很简单:把递归函数改造成生成器,或者用一个列表收集所有有效表达式。不过你会发现输出结果极其冗余——同一个数学表达式,因为操作数交换、括号位置不同,会打印出很多看着不同实则等价的版本。这时候你就需要引入“表达式规范化”的概念,比如把加法、乘法操作中的子表达式按字典序排序,把减法、除法视为不可交换,然后对最终表达式做去重。这个过程做起来并不容易,但非常锻炼人,而且做出来之后,你会对“程序如何理解数学表达式”有更深的认识。
我个人做这个扩展时的一个体会:不要试图完全消除所有冗余,因为有些表达式虽然数学上等价,但视觉上差别很大(比如(1+2)*(3+4)和(3+4)*(1+2))。如果追求完全去重,你可能需要引入代数化简,那是一条深不见底的路。适可而止地去掉最明显的重复,保留“看起来不同”的解法,反而更贴近人的认知习惯。
5. 写在最后:给递归初学者的建议
这篇文章很长,核心内容其实就一句话:24点的递归程序就是把“选两个数合并”这一操作反复应用,直到只剩一个数。围绕这句话,我们逐步展开了状态定义、表达式记录、去重优化和常见坑点。
如果你现在正在学习递归,我强烈建议你不要仅仅停留在阅读,而是自己动手把这个程序写一遍。从最简单的“判断是否有解”开始,再到“输出一个表达式”,再到“输出所有不重复解”,每完成一个阶段,你对递归的理解都会提升一层。如果过程中遇到“明明有解但程序说无解”,先检查浮点比较;遇到“表达式方向不对”,先检查减法和除法的方向分支;遇到“程序很慢”,再加缓存。这三个排查方向,基本覆盖了我遇到过的所有问题。
最后再分享一个小技巧:当你看一个递归函数感到头晕时,试着在纸上画一棵“状态树”——每一层画一个节点,节点写当前数字列表,分支写合并出来的新数。画着画着,很多抽象的东西就落地了。我就是靠这个办法彻底搞懂递归的。24点不是终点,它只是递归路上的一块很好的垫脚石;把这个磨透了,后面更复杂的算法,你都会有底气去碰一碰。