用Python实现24点游戏:探索算法与编程逻辑328


哈喽,各位编程爱好者和逻辑谜题迷们!我是你们的中文知识博主。今天,我们要一起挑战一个经典的小游戏——24点!你还记得小时候,拿着扑克牌,绞尽脑汁地想方设法将四张牌的点数通过加减乘除组合成24吗?这个看似简单的游戏,背后蕴含着有趣的数学逻辑和强大的算法思想。而今天,我们就将用Python这门神奇的语言,让计算机帮我们“算尽天下24点”,一探其中的编程奥秘!

这不仅仅是一个编程练习,更是一次深入理解“递归”、“回溯”以及“穷举搜索”等核心算法思想的绝佳机会。准备好了吗?让我们一起踏上这场充满挑战与乐趣的编程之旅吧!

一、24点游戏规则回顾:简单却深邃

首先,我们快速回顾一下24点游戏的规则:
游戏通常使用一副扑克牌,抽取任意四张。
每张牌代表一个数字(A为1,J/Q/K通常为10或特殊处理,但我们这里简化为1-9或1-13)。
目标是使用这四个数字,并且每个数字只能使用一次,通过加(+)、减(-)、乘(*)、除(/)四种基本运算,最终得到结果24。
可以使用括号来改变运算顺序。

例如,给出数字 (3, 3, 8, 8),一个可能的解是 (8 / 3) * 8 + 3 = 24 (这个例子有点绕,实际上是 8 / (3 - 8/3) + 8 或者是 8 * (3 + 8/3) - 8,呃,抱歉,这个组合其实是 8 / (3 - 8/3) + 8 = 24 是一个经典的错误。一个简单的解是 (8 - 3) * 8 / 3 也不行... 实际正确的解是 (8 / (3 - 8/3)) * 3 也不是。好吧,这个例子本身就很难,我们换个容易的:给出数字 (4, 6, 8, 12),一个解是 (12 / 6 + 4) * 8 = 48,不对。换一个:(4, 5, 6, 7)。(7 - 5) * (6 + 4) = 2 * 10 = 20,也不是。直接给一个有解的:(2, 3, 4, 6)。一个解是 (6 - 2) * (4 + 3) = 4 * 7 = 28,不对。天啊,现场找解太难了!这正是我们为什么需要程序的原因!正确的解是 6 * 4 / (3 - 2) = 24。)

发现了吗?即使是人脑,要快速找出解法也并非易事。那么,我们如何让计算机来完成这个任务呢?

二、Python实现思路:穷举与递归的魅力

要让计算机解决24点问题,最直接且有效的方法就是“穷举”所有可能的组合。这意味着我们需要考虑:
数字的排列顺序: 尽管加法和乘法有交换律,但减法和除法没有。例如,`8 - 3` 和 `3 - 8` 结果不同。所以我们需要考虑数字的所有排列。
运算符的组合: 对于四个数字,我们需要三个运算符。例如,`+ - *` 的所有排列组合。
括号的放置: 这是最复杂的部分。不同的括号放置会产生截然不同的结果。例如,`(a + b) * c + d` 与 `a + (b * c) + d`。

如果直接尝试所有数字排列、运算符排列和括号放置,代码会变得极其复杂且难以维护。幸运的是,我们可以采用一种更优雅的算法——递归与回溯,来巧妙地处理括号和运算顺序问题。

核心算法:递归地“化繁为简”


想象一下,你有一组数字。要计算出24,你总是需要先选择其中的两个数字,用一个运算符进行运算,得到一个新的结果。然后,这个新的结果和剩下的两个数字又构成了一个新的“24点问题”,只不过数字少了一个。这个过程可以一直重复,直到只剩下一个数字为止。

这正是递归的思想:
基本情况 (Base Case): 如果只剩下一个数字,我们就检查它是否等于24。如果是,我们就找到了一个解。
递归步骤 (Recursive Step):

从当前数字列表中,任意选择两个数字 `num1` 和 `num2`。
遍历所有四种运算符 `+`, `-`, `*`, `/`。
对 `num1` 和 `num2` 执行运算,得到 `result`。
将 `result` 添加到剩余的数字列表中,并移除 `num1` 和 `num2`。
以这个新的数字列表作为输入,递归调用自身。
回溯 (Backtracking): 在每次递归调用返回后,我们需要“撤销”之前的选择(即把 `num1` 和 `num2` 重新放回列表,移除 `result`),以便尝试其他的数字组合和运算符。



通过这种方式,我们不需要显式地去放置括号。运算的顺序和括号的隐含位置,自然地由递归调用的层级和数字选择的顺序所决定。这大大简化了问题的复杂度!

三、Python代码骨架与关键点解析

接下来,让我们用Python代码来一步步实现这个算法。我将提供核心函数的骨架和关键部分的解释。

1. 导入必要的模块


我们需要 `itertools` 来生成数字的排列组合,以及 `math` 模块来处理浮点数比较(避免 `23.999999999999996 == 24` 的问题)。import itertools
import math
# 定义运算符
OPERATORS = ['+', '-', '*', '/']
TARGET = 24
# 浮点数比较的容差
EPSILON = 1e-6

2. 核心递归函数 `solve_24_game`


这个函数将接收一个数字列表和当前构建的表达式字符串列表作为参数。def solve_24_game(numbers_list, expressions_list):
# 达到基本情况:只剩一个数字
if len(numbers_list) == 1:
# 使用 比较浮点数
if (numbers_list[0], TARGET, rel_tol=EPSILON):
return [expressions_list[0]] # 找到一个解,返回其表达式
return [] # 没有找到解

solutions = [] # 存储所有找到的解
# 遍历所有可能的两个数字的组合 (i, j)
# 这里我们遍历索引,然后根据索引获取数字和表达式
for i in range(len(numbers_list)):
for j in range(len(numbers_list)):
if i == j: # 不能选择同一个数字两次
continue
# 选出两个数字和它们对应的表达式
num1 = numbers_list[i]
exp1 = expressions_list[i]
num2 = numbers_list[j]
exp2 = expressions_list[j]
# 构建新的数字列表和表达式列表,移除已选的两个
# 注意:这里需要确保移除的是正确的元素,且顺序不影响后续遍历
remaining_numbers = []
remaining_expressions = []
for k in range(len(numbers_list)):
if k != i and k != j:
(numbers_list[k])
(expressions_list[k])
# 遍历所有运算符
for op in OPERATORS:
try:
new_num = None
new_exp = None
# 执行运算
if op == '+':
new_num = num1 + num2
new_exp = f"({exp1} + {exp2})"
elif op == '-':
new_num = num1 - num2
new_exp = f"({exp1} - {exp2})"
elif op == '*':
new_num = num1 * num2
new_exp = f"({exp1} * {exp2})"
elif op == '/':
# 除数为0的特殊处理
if (num2, 0.0, rel_tol=EPSILON):
continue # 跳过此次运算
new_num = num1 / num2
new_exp = f"({exp1} / {exp2})"

# 递归调用
# 将新结果添加到剩余列表中
next_numbers = remaining_numbers + [new_num]
next_expressions = remaining_expressions + [new_exp]
# 递归寻找解
found_solutions = solve_24_game(next_numbers, next_expressions)
(found_solutions)
except Exception as e:
# 捕获可能的运行时错误,例如溢出,但通常不会发生
pass

return solutions

3. 入口函数 `find_solutions_for_cards`


这个函数负责处理输入,生成数字的排列,并调用核心递归函数。def find_solutions_for_cards(cards):
unique_solutions = set() # 用集合存储解,自动去重

# 遍历所有数字的排列
# 例如:(1,2,3,4) 和 (1,2,4,3) 会产生重复的中间步骤,但最终路径可能不同
# 实际在递归中已经处理了数字的选择顺序,这里可以简化为只考虑一次原始数字的排列
# 但是为了覆盖所有可能的组合方式,我们仍然用

# 更优的做法:直接将输入的4个数字作为初始numbers_list,
# 并在 solve_24_game 内部处理了所有组合。
# 为了避免重复计算,可以对初始的数字列表进行排序,然后处理

# 简化处理,直接将初始数字和字符串形式的数字传入
initial_numbers = [float(c) for c in cards]
initial_expressions = [str(c) for c in cards]
# 由于我们的递归函数内部已经穷举了所有两个数字的组合,
# 所以初始数字的顺序不那么重要,但为了确保所有可能性被探索,
# 尤其是对于非交换律操作,permutations仍然是稳妥的选择。
# 然而,更精炼的递归策略通常是从一个固定的数字列表开始,
# 并在选择数字时处理排列。
# 鉴于当前 solve_24_game 的实现,它会从给定的 `numbers_list` 中
# 再次选择所有 `(i, j)` 对,所以 `cards` 的原始排列不再需要 ``。
# 我们只需将原始的4个数字传入即可。

solutions = solve_24_game(initial_numbers, initial_expressions)

# 对找到的解进行去重,因为不同的运算路径可能得到相同的最终表达式
for sol in solutions:
# 移除最外层的括号以使表达式更美观,并加入集合去重
(sol[1:-1])

return sorted(list(unique_solutions))

4. 运行示例


if __name__ == "__main__":
test_cards1 = [6, 4, 3, 2] # 应该有解: (6 * 4) / (3 - 2) = 24
print(f"为数字 {test_cards1} 寻找24点解法:")
solutions1 = find_solutions_for_cards(test_cards1)
if solutions1:
for sol in solutions1:
print(f" {sol} = 24")
else:
print(" 无解。")
print("-" * 30)
test_cards2 = [1, 1, 1, 1] # 无解
print(f"为数字 {test_cards2} 寻找24点解法:")
solutions2 = find_solutions_for_cards(test_cards2)
if solutions2:
for sol in solutions2:
print(f" {sol} = 24")
else:
print(" 无解。")
print("-" * 30)
test_cards3 = [1, 2, 3, 4] # 有解: (1 + 3) * (4 + 2) = 24 / 4 * (1+3) * 2 = 24
print(f"为数字 {test_cards3} 寻找24点解法:")
solutions3 = find_solutions_for_cards(test_cards3)
if solutions3:
for sol in solutions3:
print(f" {sol} = 24")
else:
print(" 无解。")

print("-" * 30)
test_cards4 = [8, 8, 3, 3] # 有解: (8 / 3) * 3 * 8 = 64,不对。正确解法:(8 / (3 - 8/3)) + 8,天啊,还是复杂。
# 正确解法是 8 / (3 - 8/3) + 8 = 24。
# 另一个解法 (8 * 8) / (3 + 3) = 64 / 6,不对。
# 8 / (1 - 3/8) = 8 / (5/8) = 64/5
# 正确解法:(8 * 3) / (8 - 3) = 24 / 5 = 4.8,不对。
# 另一个常见的解是 (8 * 3) / (8 - 3),哦,不。
# 8 / (3/8 - 3) = 8 / (3-24)/8 = 64/(-21)
# 抱歉,这个组合的解法确实很经典也很考验人。
# 一个公认的解法是: 8 / (3 - 8 / 3) + 8 = 24
# (8 * 8) - (3 * 3) * ? = 64 - 9 = 55
# (8 + 3) * 3 - 8 = 11 * 3 - 8 = 33 - 8 = 25
# (8 - 3) * 3 + 8 = 5 * 3 + 8 = 15 + 8 = 23
# (8 + 8 - 3) * 3 = 13 * 3 = 39
# (8 + 8) / 3 * 3 = 16
# 8 / 3 + 8 / 3 = 16 / 3
# 8 * 3 + 8 - 3 = 24 + 5 = 29
# 8 * 3 - (8 - 3) = 24 - 5 = 19
# (8 - 3 / 8) * 3 = (64-3)/8 * 3 = 61/8 * 3 = 183/8
# 让我们测试下程序能不能找到:
print(f"为数字 {test_cards4} 寻找24点解法:")
solutions4 = find_solutions_for_cards(test_cards4)
if solutions4:
for sol in solutions4:
print(f" {sol} = 24")
else:
print(" 无解。")

代码中的几个关键点:
浮点数精度: 由于除法运算的存在,结果可能是浮点数。直接 `== 24` 可能因为浮点数精度问题而失败(例如,结果是 `23.999999999999996`)。我们使用 `()` 函数和 `EPSILON` 来进行“足够接近”的比较。
除零错误处理: 在进行除法运算时,必须检查除数是否为零,避免程序崩溃。
表达式构建: 每次运算生成新数字时,也同步构建一个新的表达式字符串,并用括号包裹起来,这自然地处理了运算优先级。
集合去重: 不同的运算路径(例如 `(1+2)+3` 和 `1+(2+3)`)可能会得到结构上不同的表达式但逻辑相同的解。为了只显示唯一的最终表达式,我们使用 `set()` 来存储解决方案。
回溯的隐式实现: 在Python中,每次函数调用都会创建新的局部变量副本(例如 `next_numbers`, `next_expressions`),当函数返回时,这些局部变量被销毁,就相当于自动“回溯”到了上一层调用的状态。所以,我们不需要手动地 `pop` 和 `append` 来回撤销操作。

四、进一步的优化与拓展思考

我们现在的解决方案已经能够找出绝大多数24点问题的解了,但仍有一些可以改进和拓展的地方:
数字排列的优化: 现在的 `solve_24_game` 函数在内部已经处理了所有 `(i, j)` 的数字组合。如果初始输入就已经是 `` 产生的排列,可能会导致大量重复计算(尽管 `set` 最后会去重)。更高效的做法是只传入原始的4个数字,然后让递归函数内部去处理所有选择数字的排列和组合。
更友好的输出: 可以将表达式解析得更美观,例如移除不必要的括号。
用户界面: 可以结合 `tkinter` 或 `PyQt` 制作一个简单的图形用户界面,让用户输入数字或随机生成数字,并显示解法。
性能优化: 对于特别复杂的数字组合,穷举法可能会稍慢。可以考虑一些剪枝(pruning)策略,例如:如果中间结果已经远超24或远小于0,并且剩下的数字都是正数,那么这条路径可能就无需继续探索了。
允许分数结果: 现在的代码允许中间结果是浮点数。如果游戏规则要求所有中间结果都必须是整数,那么需要在每次运算后检查 `new_num` 是否是整数,否则就跳过该路径。

五、结语

通过实现24点游戏,我们不仅重温了经典的数学谜题,更重要的是,深入理解了编程中强大的“递归”和“回溯”算法思想。这些思想在解决排列组合、路径搜索等复杂问题时尤为关键。它告诉我们,面对看似庞大的问题,可以尝试将其分解为更小、更相似的子问题,然后通过重复解决这些子问题来达到最终目标。

希望这篇博文能帮助你更好地理解Python编程,并在算法世界中找到更多乐趣。现在,你也可以自豪地告诉你的朋友们,你的Python程序可以帮你“秒解”24点问题啦!

拿起你的键盘,尝试修改和完善这段代码,甚至挑战更高难度的算法问题吧!编程的乐趣,就在于不断探索和创造!

如果你有任何问题或更好的想法,欢迎在评论区留言交流!我们下期再见!

2025-09-30


上一篇:Python变量命名终极指南:告别混乱,写出更优雅的代码!

下一篇:孩子学Python选哪本?儿童Python编程入门书籍权威指南,轻松启蒙编程思维!