news 2026/10/14 17:49:20

递归求解24点:从四张牌到表达式树的Python实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
递归求解24点:从四张牌到表达式树的Python实战

24点问题算得上很多人在算法路上的第一个“硬茬子”。四张数字牌,加、减、乘、除任意组合,目标是凑出24,规则简单到一句话能说完,可真想用Python写一个自动求解器,第一反应往往是“把所有括号情况全枚举出来”,然后就没有然后了——括号怎么插、优先级怎么处理、重复表达式怎么去重,随便一个问题都能卡你半天。我这次用递归思想把这道题重新啃了一遍,不夸张地说,递归的三要素在一次又一次调试里彻底刻进了脑子。这篇就把完整思路、代码实现和踩过的坑都拆给你看。适合学过Python基础、想找个真正有营养的递归练手题的人,也适合已经会写递归但总在回溯和搜索里绕不清的进阶者。24点这个“小玩具”,递归价值一点不小。

1. 为什么说递归才是24点的正解

1.1 24点的本质是“把式子不断压缩”

很多人以为24点难题在“怎么枚举括号”。其实如果让你手写四层循环穷举所有表达式,你很快会发现:括号的嵌套结构不是循环能简单描述的。(a+b)*(c+d)和a+(b*(c+d)),它们的形状完全不同,用排列组合去生成所有括号组合非常痛苦。

递归的思路则完全不同。它不显式枚举括号,而是把一个4个数的问题,转化成“先挑两个数做一次运算,得到一个新数,再用这个新数和剩下两个数继续处理”的3个数问题。每做一次运算,数字数量就少一个。4变3,3变2,2变1,最后只剩一个数时,判断它是不是24。整个过程就是把表达式不断“压缩”成一棵树,而括号结构是压缩过程中自然带出来的。

这个思路能成立的关键在于:任意一棵合法的四则运算表达式树,都可以看成“最后一次运算把整个表达式分成了左右两棵子树”。你不需要知道括号长什么样,只需要不断选择两个子表达式合并成一个,最终合并出唯一结果。递归把这个合并过程原原本本模拟了出来。

1.2 递归三要素逐一映射

任何递归都要先想清楚三件事:终止条件、子问题拆分、返回值设计。24点问题恰好每一条都非常典型。

终止条件很直观:当前数字列表只剩一个数。如果这个数等于24,说明这条路径找到了解;否则说明这条路走不通,返回空值。这里有个新手容易踩的坑,判断相等不能用==,因为浮点运算会有误差,必须用绝对值差小于某个极小值,比如abs(value - 24) < 1e-6,我在后面还会细说。

子问题拆分是核心:从当前n个数里任选两个数,对它们执行加、减、乘、除(注意减和除都有两个方向),算出结果后,把结果和剩下的n-2个数拼成一个新的n-1个数列表,继续递归。这个拆分保证了问题规模严格递减,不会出现死循环。

返回值设计是容易被忽略但非常影响体验的一点。如果你只返回布尔值,程序确实能告诉你“有没有解”,但用户更想要的是“到底怎么算”。所以我让递归返回一个字符串表达式:从两个子表达式拼出父表达式。这样既能判断有没有解,又能在找到解的瞬间直接给出完整算式,一举两得。

2. 数据与函数设计:先想清楚再动手

2.1 为什么必须同时携带数值与表达式

写第一版代码前,我纠结过一个问题:递归过程中要不要同步维护表达式?答案是要,而且要跟数值一一对应地维护。

原因很简单:数值负责判断是否等于24,表达式负责记录计算过程。比如[3, 3, 8, 8]这组牌,某一步计算出了8/3,如果只保存一个浮点数2.666...,后面输出结果时就丢失了“这个数是怎么来的”信息。所以递归函数里必须同时维护两个列表:cur存当前参与计算的数值,exprs存每个数值对应的表达式字符串。两个列表长度始终一致,下标一一对应。选第i个和第j个数值做运算时,对应的两个表达式也要同步取出来拼接。

这里有个小技巧:生成“剩余列表”时可以用列表推导式,通过下标过滤掉i和j,这样比pop再append更安全,因为pop会改变列表长度和下标,很容易在循环里出错。我实际调试时见过太多人在这里踩坑。

2.2 递归函数签名与参数选择

我把核心递归函数设计成dfs(cur, exprs),其中exprs的默认逻辑由外层入口函数负责初始化。这样调用方只需要传一个数字列表,比如solve_24([3, 3, 8, 8]),不需要关心内部表达式列表怎么建。

入口函数里还应该把输入统一转成浮点数或分数,因为除法会产生小数,如果直接用整数除法,8/(3-8/3)这种式子会直接算错。Python的/在浮点数下没问题,但要确保参与计算的是float类型。

递归函数内部的骨架是这样的:先判断终止条件;然后双层循环选下标i和j;对每组i, j,构造出所有可能的运算候选;依次递归尝试;一旦某个候选返回非空结果,立刻向上返回,不再继续。

为什么要用双层循环选两个数,而不是直接选第一个数?因为加法乘法虽然满足交换律,减法除法不满足,而且运算顺序不同会得到完全不同的结果。(3-8/3)和(3/3-8)结果差远了。所以必须让任意两个数都有机会配对,不能固定只处理相邻元素。

2.3 六种运算一个都不能少

两个数a和b之间,基础的二元运算看起来只有加减乘除四种,但减法和除法必须考虑两个方向,所以实际要尝试六种组合:a+b、a-b、b-a、a*b、a/b、b/a。

很多初版代码会漏掉b-a和b/a,结果某些牌组明明有解却跑不出来。比如经典例子[1, 5, 5, 5],解是(5-1/5)*5,这里隐含了5-1/5这个顺序,你如果用1/5-5再乘5,结果离24差了十万八千里。所以那个“反方向”非常关键。

处理除法还要做除零判断。直接if b == 0在某些浮点误差场景下并不可靠,更稳妥的是if abs(b) > 1e-9。分母绝对值小于这个阈值就认为接近零,跳过这次除法。

3. 完整实现与跑通验证

3.1 第一版可运行代码

下面这个版本是我最推荐的入门版,逻辑清晰、没有花哨优化,适合先跑通再逐步理解。

from typing import List, Optional def solve_24(nums: List[int]) -> Optional[str]: def dfs(cur: List[float], exprs: List[str]) -> Optional[str]: if len(cur) == 1: if abs(cur[0] - 24) < 1e-6: return exprs[0] return None n = len(cur) for i in range(n): for j in range(i + 1, n): rest_nums = [cur[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] candidates = [ (cur[i] + cur[j], f"({exprs[i]} + {exprs[j]})"), (cur[i] - cur[j], f"({exprs[i]} - {exprs[j]})"), (cur[j] - cur[i], f"({exprs[j]} - {exprs[i]})"), (cur[i] * cur[j], f"({exprs[i]} * {exprs[j]})"), ] if abs(cur[j]) > 1e-9: candidates.append((cur[i] / cur[j], f"({exprs[i]} / {exprs[j]})")) if abs(cur[i]) > 1e-9: candidates.append((cur[j] / cur[i], f"({exprs[j]} / {exprs[i]})")) for new_val, new_expr in candidates: res = dfs(rest_nums + [new_val], rest_exprs + [new_expr]) if res is not None: return res return None return dfs([float(n) for n in nums], [str(n) for n in nums])

这段代码里,candidates列表里每个元素是两个值的元组:一个是运算后的数值,一个是拼好的表达式字符串。rest_nums + [new_val]和rest_exprs + [new_expr]是在剩余列表末尾追加新结果,顺序不影响正确性,因为后续递归仍然会对整个列表做任意两两配对。

3.2 用经典样例验证结果

跑一下最经典的[3, 3, 8, 8],输出是(8 / (3 - (8 / 3))),验证一下:8/3约等于2.6667,3-2.6667约等于0.3333,8/0.3333等于24。这个解要用到除法再取分母,初看上去非常反直觉,但递归正是在某次选3和8做除法时,把这个关键分支探索到了。

再跑[1, 5, 5, 5],输出类似(5 - (1 / 5)) * 5。注意这里减法方向是5-1/5,如果代码里没做b-a的尝试,这组牌就解不出来。我初版就漏了这个方向,当时对着正确结果怀疑了半天人生。

再试[1, 1, 1, 1],程序会返回None。四个1只能得到-4到4之间的值,不可能凑到24。这类无解用例必须测试,确保递归在穷尽所有分支后能正确返回空值,而不是死循环或抛异常。

3.3 升级版:用Fraction彻底消灭浮点误差

浮点方案的abs(cur[0] - 24) < 1e-6在绝大多数情况下够用,但如果你想追求严谨,可以把参与计算的数值全部换成Fraction分数类型。分数四则运算的结果总是精确的,不会出现0.1+0.2变成0.30000000000000004这种问题,终止条件也可以直接用cur[0] == 24判断。

from fractions import Fraction from typing import List, Optional def solve_24_exact(nums: List[int]) -> Optional[str]: def dfs(cur: List[Fraction], exprs: List[str]) -> Optional[str]: if len(cur) == 1: if cur[0] == 24: return exprs[0] return None n = len(cur) for i in range(n): for j in range(i + 1, n): rest_nums = [cur[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] candidates = [ (cur[i] + cur[j], f"({exprs[i]} + {exprs[j]})"), (cur[i] - cur[j], f"({exprs[i]} - {exprs[j]})"), (cur[j] - cur[i], f"({exprs[j]} - {exprs[i]})"), (cur[i] * cur[j], f"({exprs[i]} * {exprs[j]})"), ] if cur[j] != 0: candidates.append((cur[i] / cur[j], f"({exprs[i]} / {exprs[j]})")) if cur[i] != 0: candidates.append((cur[j] / cur[i], f"({exprs[j]} / {exprs[i]})")) for new_val, new_expr in candidates: res = dfs(rest_nums + [new_val], rest_exprs + [new_expr]) if res is not None: return res return None return dfs([Fraction(n) for n in nums], [str(n) for n in nums])

使用Fraction的代价是性能会比纯浮点稍慢,但在4张牌的规模下几乎无感。我个人的建议是:练习和教学用Fraction版本,能帮你建立精确计算的直觉;如果要扩展到更大牌数,再考虑浮点加误差容忍。

4. 剪枝与优化:从“能解”到“解得快”

4.1 不剪枝的搜索空间到底有多大

4张牌时,递归搜索树其实很小。粗略估算,每层递归要选两个数,4个里选2个有6种选法,每个选法最多试6种运算,下一层3个里选2个有3种选法,再下一层2个里选2个只有1种选法。整体节点数大概在几百到一千的量级,现代计算机跑一遍就是毫秒级。

但把牌数从4增加到6,搜索空间立刻变得可怕。6个里选2个是15种,5个里选2个是10种,4个里选2个是6种,3个里选2个是3种,每层最多6种运算,乘起来大约是15×6×10×6×6×6×3×6×1×6,量级在千万级。如果目标是找全部解而不做任何剪枝,6张牌已经能感觉到明显卡顿,8张牌更是直接跑到天荒地老。

这里有一个很关键的认识:找第一个解和找全部解是两种完全不同的任务。找第一个解可以用剪枝加速,只要命中一个就立即返回;找全部解则必须完整遍历,剪枝也会更受限。

4.2 三种实用剪枝策略

第一种是数值去重。如果当前数字列表里有两个相同的数,那么先选哪个作为操作数本质上没有区别。以[8, 8, 8, 8]为例,第一个8和第二个8配对,与第一个8和第三个8配对,搜出来的子树是镜像重复的。实现方式可以是:遍历时记录本轮已经用过的cur[j]值,遇到重复值直接跳过。

第二种是状态去重。递归搜索中,同一个数值集合可能通过不同的运算路径到达,比如从[1, 2, 3, 4]出发,先算(1+2)得到[3, 3, 4],和先算(2+1)再得到[3, 3, 4],两种情况后续搜索完全一样。为了不重复探索,可以用一个visited集合记录已经处理过的状态。由于状态只关心数值集合与顺序无关,可以把当前数值列表排序后转成元组作为key。进入dfs时先检查key是否存在,存在就直接返回None,不存在则加入集合。

visited = set() def dfs(cur, exprs): key = tuple(sorted(cur)) if key in visited: return None visited.add(key) # 后续逻辑不变

注意:这个状态去重会牺牲“求全部解”的完整性。如果你要输出所有不同的表达式,不能用这个剪枝,因为不同路径到达同一状态后,拼接出的表达式字符串可能不同。但对“求一个可行解”的场景,这是性价比极高的优化。

第三种是尽早失败。在一些扩展场景里,比如你要求“用任意张牌凑目标值”,可以在每一层递归先做快速判断:当前所有数能组成的最大值和最小值范围是多少,如果目标24不在这个范围内,说明这条子树无论如何都无解,直接剪掉。这个策略对整数牌和浮点数都成立,而且实现也不复杂。不过对4张牌的24点来说收益不大,更适合作为通用思想保留。

4.3 实测效果对比

我在一台普通笔记本上做了个小对比,随机生成若干组测试数据,每组跑10个用例取平均时间,量级感受如下:

牌数无剪枝耗时(粗略)加状态去重后(粗略)说明
4<1ms<1ms差距不明显
510ms左右3~5ms剪枝开始有意义
61秒左右200ms以内差距非常明显
7数秒到数十秒1~3秒没有剪枝基本不可用

不同机器的数据差异很大,但方向是一致的:牌数越多,剪枝收益越大。我实际开发时遇到过6张牌无剪枝跑到怀疑人生的情况,加了状态去重后明显流畅。如果你的目标是只解4张牌,那剪枝属于锦上添花;但如果你想把这个求解器扩展成“任意n张牌凑24”,剪枝就是必需品。

5. 常见问题与排错速查

5.1 “为什么跑了半天全是None”的排查清单

这个问题我见过太多人问,包括我自己初版也踩过。通常按以下顺序排查。

第一,检查除法方向。确认a/b和b/a都尝试了。很多隐蔽的无解都源于漏掉一个方向。第二,检查终止条件。确认只剩一个数时判断的是abs(value - 24) < 1e-6,而不是value == 24,也不是判断len(cur) == 0。第三,检查递归返回值。确认找到解后一层层向上返回表达式,而不是只在最深层打印结果。如果只在最深层print,上层根本收不到结果,最后只能返回None。

还有一个非常隐蔽的问题:输入数字里带有重复值时,如果代码用“下标删除”的方式生成剩余列表,可能出现删错元素导致状态错乱。比如[3, 3, 8, 8],选了第一个3和第一个8,删除时如果按值查找并删除,可能删掉第二个3。务必用下标过滤,不要用remove按值删除。

5.2 五个高频坑与应对

除零崩溃:直接除整数0会抛异常;浮点环境下除一个接近0的数会得到极大值。应对方法是在做除法前检查分母abs(x) > 1e-9,用Fraction时直接判断x != 0。

浮点相等判断失败:8/(3-8/3)在浮点运算下可能得到24.000000000000007,如果用==判断会漏掉真正的解。应对方法是误差阈值法,或者改用Fraction。

表达式括号不全:输出结果像8 / 3 + 1 * 3,读者根本分不清优先级。应对方法是每次拼接子表达式时强制加括号,宁可多一层括号也不要漏,反正最终结果可读。

递归深度过深:如果扩展到8张牌以上,Python默认递归深度可能不够。应对方法是在程序开头写sys.setrecursionlimit(10000),但要注意这并不能解决指数级的时间复杂度,只是防止栈溢出。

返回多个解但只收到一个:很多实现找到第一个解就return,这是正常的。如果你想要全部解,不要让函数中途返回,而应该把所有命中解收集到一个列表里。收集全部解时要去重,因为同一算式可能被不同的括号路径重复生成。

5.3 一个有效的调试套路

我给这段代码调试时最常用的手段是加一个全局计数器,统计递归调用次数。每进入一次dfs就+1,最后打印总数。这个数字能直接反映剪枝是否生效:同一组牌,优化前调用1000次,优化后变成300次,说明剪枝确实砍掉了大量重复分支。

另一招是打印递归树。在dfs入口加一个depth参数,输出时缩进打印当前数值列表。这样能看到程序沿着哪条分支一路向下。比如调试[3, 3, 8, 8]时,你会看到它先尝试把两个8相加、相乘等,全部失败后才回头尝试用8除以某个数,最终走到8/(3-8/3)这条路径。这种“先错后对”的探索过程,光看最终结果完全感受不到,但打印递归树之后一切变得非常直观。

call_count = 0 def dfs(cur, exprs, depth=0): global call_count call_count += 1 print(" " * depth + str(cur)) # 原有逻辑

调试完记得把打印去掉,否则大量递归输出会把正常结果淹没,而且性能也会受影响。

6. 递归思想还能带你去哪

6.1 从24点抽象出回溯模型

如果你把24点的递归过程抽象一层,会发现它和很多经典搜索问题共享同一个骨架:当前有一个状态集合,每一步通过某个操作把状态缩小或变形,直到达到终止状态。如果某条路径走不通,就回退到上一层,换一个操作继续。这就是回溯算法的原始形态,也是深度优先搜索的一种具体呈现。

区别只在于“操作”的定义。24点里操作是“取出两个数,做一次四则运算,放回一个数”;迷宫问题里操作是“向上下左右走一步”;排列问题里操作是“从未使用的元素里挑一个放到当前位置”。状态定义不同,递归结构一模一样。你一旦在24点里建立了这种“选择-递归-回退”的肌肉记忆,再去看八皇后、全排列、组合总和、括号生成,会发现它们都是同一棵树上的不同果实。

我后来去解决一道表达式补运算符的问题:给你一串数字,往中间插运算符,判断能否算出目标值。思路跟24点极其相似,只是把“任选两个数合并”改成“相邻两个数合并”。当时我几乎没怎么看题解就写出来了,靠的就是24点练出来的递归直觉。这就是反复啃透一个经典问题带来的迁移能力。

6.2 几个同源扩展题

顺着24点往下走,有很多值得动手的变体。

把4张牌改成5张、6张牌,目标还是24,考验的就是节4里的剪枝功底。把目标值从24改成任意整数,甚至可以改成负数或小数,代码几乎不用变。要求输出全部可行解,而不是第一个解。这个扩展会逼你解决表达式去重问题:(1+2)+3和1+(2+3)算不算同一个解?规则需要先定义清楚。允许使用平方、开方、阶乘等高级运算符,搜索空间会进一步膨胀,但对递归框架来说只是增加几个候选操作而已。

这些变体每一个都够你写一下午,但核心仍然是那棵递归树。我个人非常建议你从“求全部解”开始练,因为它在递归之外又多了一层“如何定义等价”的抽象思考,很训练人。

回到最初的问题:为什么要用递归解决24点?因为递归不是“枚举括号”的变种,而是对表达式树结构的直接模拟。你不需要手动生成括号,括号会在递归合并的过程中自动长出来;你也不需要提前规划运算顺序,深度优先搜索会替你尝试所有顺序。把这个过程写通、跑通、调通之后,你会对递归多一层真实体感。我在实际调试中最深的体会是:递归真正的难点从来不是“调用自身”这个动作,而是“如何把大问题缩小成子问题”以及“如何让每次递归后的信息完整传递”。24点这个小游戏,恰好把这两件事展现得淋漓尽致。如果你正卡在递归上,找一天下午,泡杯茶,把这个代码从头敲一遍,收获绝对比刷十道简单题来得扎实。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 17:49:20

9块9云计算入门课笔记:云服务器选型与高可用架构实战

9块9能买到什么&#xff1f;一杯奶茶&#xff0c;或者三天的云计算入门课程。说实话&#xff0c;我买之前是抱着“看看你怎么忽悠”的心态去的&#xff0c;结果三天课程跟完&#xff0c;发现整个内容把云计算最核心的骨架给搭完了——云服务器怎么选、存储和数据库怎么用、计费…

作者头像 李华
网站建设 2026/10/14 17:49:14

SAP MTO委外加工:移动类型与批次策略的库存转移实战

简介&#xff1a;SAP按单生产委外加工是围绕客户定制需求组织的常见生产模式。围绕这一主题的总结文档专为SAP实施顾问、生产计划及物料管理人员编写&#xff0c;聚焦MTO外包流程中的核心控制点。内容系统性对比了两个典型场景&#xff1a;两层BOM结构下外包自身销售成品&#…

作者头像 李华
网站建设 2026/10/14 17:49:11

CST和HFSS哪个好学?从时域与频域算法看懂电磁仿真软件选型

CST和HFSS哪个好学&#xff1f;我几乎每隔几天就会被问到一次。问这个问题的&#xff0c;要么是刚进实验室的研究生&#xff0c;要么是公司里要转行做电磁设计的硬件工程师&#xff0c;还有一部分是纯粹自学的在校生。每次我都得从“这两个软件不是同一个东西”开始讲起&#x…

作者头像 李华
网站建设 2026/10/14 17:49:04

编译原理实验报告:构建可验证的编译器工程实践骨架

简介&#xff1a;本资源是东北大学秦皇岛分校编译原理课程的词法分析实验报告&#xff0c;面向计算机专业本科生及编译技术初学者&#xff0c;聚焦PL/0语言词法分析器的设计与C实现&#xff0c;解决从理论到代码落地的关键实践问题。压缩包为单个122KB的Word文档&#xff08;.d…

作者头像 李华