Problem: 679. 24 点游戏
整体思路
这段代码旨在解决经典的 “24点游戏” (24 Game) 问题。问题要求判断给定的四个整数(代表四张牌)是否能通过加、减、乘、除四种运算以及括号的组合,得到结果24。
该算法采用的是一种 回溯 (Backtracking) 或 深度优先搜索 (DFS) 的方法。其核心思想是,通过递归地、穷举地尝试所有可能的运算组合,来探索是否存在一个解。算法的策略是不断地减少牌的数量,直到只剩一张牌,然后判断这张牌是否为24。
算法的逻辑步骤如下:
-
初始化:
- 主函数
judgePoint24
接收一个整数数组cards
。为了处理除法运算可能产生的浮点数,它首先将这四个整数转换为一个List<Double>
。 - 然后,它启动核心的
dfs
递归函数。
- 主函数
-
递归函数
dfs
的核心逻辑:- 基础情况 (Base Case):递归的终点是当列表
cards
中只剩下一张牌时 (n == 1
)。此时,检查这张牌的值是否在误差范围EPS
内等于 24。如果是,说明找到了一条通往24的运算路径,返回true
。 - 递归步骤 (Recursive Step):如果列表中有多于一张牌,算法需要选择任意两张牌进行运算,然后用运算结果替换掉这两张牌,进入下一层递归。
a. 选择两张牌:通过两层嵌套的for
循环,不重复地(j = i + 1
)从当前列表中选出任意两张牌x
和y
。
b. 穷举所有运算:对选出的x
和y
,计算所有可能的运算结果:x+y
,x-y
,y-x
,x*y
,x/y
,y/x
。注意,减法和除法不满足交换律,所以x-y
和y-x
都要计算。除法操作前要检查除数是否为0(通过Math.abs(num) > EPS
判断)。
c. 构造新的牌组并递归:对于每一个运算出的结果candidate
,算法会:- 创建一个当前牌组的副本
newCards
。 - 从副本中移除
x
和y
,并加入candidate
,形成一个数量减一的新牌组。 - 对这个新牌组调用
dfs
函数。 - 代码中的实现技巧:为了高效地构造新牌组,代码先移除索引较大的牌
y
(newCards.remove(j)
),然后用candidate
替换掉索引较小的牌x
(newCards.set(i, candidate)
)。这样操作后,newCards
的大小也减一了。
d. 结果传播:如果在任何一次递归调用中,dfs
返回了true
,意味着已经找到了一条解法。此时,无需再尝试其他组合,应立即将true
向上层返回,实现“短路”。
- 创建一个当前牌组的副本
- 基础情况 (Base Case):递归的终点是当列表
-
最终结果:
- 如果所有可能的组合都尝试完毕,仍未找到解(即没有任何
dfs
调用返回true
),则说明这四张牌无法凑出24,函数返回false
。
- 如果所有可能的组合都尝试完毕,仍未找到解(即没有任何
完整代码
import java.util.ArrayList;
import java.util.List;
class Solution {
// EPS (Epsilon) 是一个极小的正数,用于处理浮点数比较时的精度误差。
// 如果两个浮点数的差的绝对值小于 EPS,我们认为它们相等。
private final static double EPS = 1e-9;
/**
* 判断给定的四张牌是否能通过运算得到 24。
* @param cards 包含四个整数的数组
* @return 如果可以得到 24 则返回 true, 否则返回 false
*/
public boolean judgePoint24(int[] cards) {
// 将整数牌转换为 double 类型的列表,以支持除法运算。
List<Double> temp = new ArrayList<>();
for (int card : cards) {
temp.add((double) card);
}
// 启动回溯/DFS过程。
return dfs(temp);
}
/**
* 核心的回溯/DFS函数。
* @param cards 当前剩余的牌(数字)的列表
* @return 如果这组牌能凑出 24 则返回 true
*/
private boolean dfs(List<Double> cards) {
int n = cards.size();
// 基础情况:如果只剩下一张牌
if (n == 1) {
// 检查这张牌的值是否约等于 24
return Math.abs(cards.get(0) - 24) < EPS;
}
// 递归步骤:从 n 张牌中选出两张进行运算
// 外层循环,选择第一张牌 x
for (int i = 0; i < n; i++) {
double x = cards.get(i);
// 内层循环,选择第二张牌 y (j > i 保证不重复选择)
for (int j = i + 1; j < n; j++) {
double y = cards.get(j);
// 生成 x 和 y 运算后的所有可能结果
List<Double> candidates = new ArrayList<>();
candidates.add(x + y);
candidates.add(x - y);
candidates.add(y - x); // 减法不满足交换律
candidates.add(x * y);
if (Math.abs(x) > EPS) { // 避免除以0
candidates.add(y / x);
}
if (Math.abs(y) > EPS) { // 避免除以0
candidates.add(x / y); // 除法不满足交换律
}
// 对于每一种运算结果,构造新的牌组并进行下一层递归
// 关键:创建一个副本,保证回溯时状态不受影响
List<Double> newCards = new ArrayList<>(cards);
// 先移除索引较大的 j,防止 i 的索引发生变化
newCards.remove(j);
for (double candidate : candidates) {
// 用运算结果替换掉 i 位置的牌
newCards.set(i, candidate);
// 进行递归调用
if (dfs(newCards)) {
// 如果找到了解,立即返回 true,实现“短路”
return true;
}
}
}
}
// 如果所有组合都尝试完毕,仍未找到解,则返回 false
return false;
}
}
时空复杂度
时间复杂度:O(1)
- 计算依据:这个问题的输入规模是固定的,永远都是4张牌。
- 分析:
- 第一次递归 (
n=4
):从4张牌中选2张,有 C(4,2) = 6 种组合。每种组合最多产生6种运算结果。所以最多有 36 次进入下一次递归。 - 第二次递归 (
n=3
):从3张牌中选2张,有 C(3,2) = 3 种组合。每种组合最多产生6种运算结果。所以最多有 18 次进入下一次递归。 - 第三次递归 (
n=2
):从2张牌中选2张,有 C(2,2) = 1 种组合。最多产生6种运算结果。 - 总的计算路径数量是一个与输入值无关的常数。虽然这个常数可能比较大,但在 Big O 表示法中,它被认为是 O(1)。
- 第一次递归 (
综合分析:
由于输入大小固定为4,整个算法的执行步骤数有一个固定的上限。因此,时间复杂度为 O(1)。
空间复杂度:O(1)
- 递归栈深度:递归的深度是固定的。
dfs(4)
->dfs(3)
->dfs(2)
->dfs(1)
。最大深度为 3。 - 数据存储:在递归的每一层,都会创建一个
List
的副本。- 第一层
dfs(4)
: 列表大小为 4。 - 第二层
dfs(3)
: 列表大小为 3。 - 第三层
dfs(2)
: 列表大小为 2。 - 由于递归深度和列表大小都有一个固定的上限,所以占用的总空间也是一个与输入值无关的常数。
- 第一层
综合分析:
算法所需的额外空间(递归栈 + 列表副本)有一个固定的上限。因此,空间复杂度为 O(1)。
参考灵神