原题: https://leetcode.com/problems/24-game/
试了下这题,第一印象是用动态规划来做,思路大概是说尝试构造一颗计算二叉树,树的叶子节点是数字,非叶子节点为加减乘除计算符号之一。那么只要存在这样的树使得其计算值等于24,就返回true, 如果不存在则返回false.
尝试用动态规划的思路,写了一下状态转移函数dp(N),结果越写越复杂,发现要面对一些排列组合的问题,另外为了处理这个整数和实数的除法问题甚至还自己写了一个实数数据结构。
根据直觉突然发现这种思路不对。
后来再仔细研究了一下,才发现这题用动态规划来做并不合适,用回溯法才是合适的。
原因如下:
先总结一下这题的特点,如果我们尝试把解空间找出来会发现,这道题的解空间规模是固定的,因为只有4张卡片,然后4种运算符,逻辑上构造出的可能树的总数是固定的,也就是说计算它的时间复杂度是O(1), 剩下的逻辑就是在解空间里找到符合条件的答案,这其实就是个搜索的过程,是不是很像八皇后问题,棋子在棋盘上的摆法的排列组合总数(即解空间大小)是固定的,只是要你找到解空间中符合条件的元素。
回溯法无疑了。那么根据这种思路Java代码如下:
class Solution {
public boolean judgePoint24(int[] nums) {
ArrayList A = new ArrayList<Double>();
for (int v: nums) A.add((double) v);
return solve(A);
}
private boolean solve(ArrayList<Double> nums) {
if (nums.size() == 0) return false;
if (nums.size() == 1) return Math.abs(nums.get(0) - 24) < 1e-6;
for (int i = 0; i < nums.size(); i++) {
for (int j = 0; j < nums.size(); j++) {
if (i != j) {
ArrayList<Double> nums2 = new ArrayList<Double>();
for (int k = 0; k < nums.size(); k++) if (k != i && k != j) {
nums2.add(nums.get(k));
}
for (int k = 0; k < 4; k++) {
if (k < 2 && j > i) continue;
if (k == 0) nums2.add(nums.get(i) + nums.get(j));
if (k == 1) nums2.add(nums.get(i) * nums.get(j));
if (k == 2) nums2.add(nums.get(i) - nums.get(j));
if (k == 3) {
if (nums.get(j) != 0) {
nums2.add(nums.get(i) / nums.get(j));
} else {
continue;
}
}
if (solve(nums2)) return true;
nums2.remove(nums2.size() - 1);
}
}
}
}
return false;
}
}
来解释一下这段代码中的几重for循环部分,这部分大概是整段代码中最核心的段落,首先nums2是一个数组,这个数组被初始化为4个元素中的两个元素(用排除法选出来的,根据对称性,没啥区别,总之就是4选2), 这里面有两个循环都用到了k这个临时变量,别被误导了,第一个k是表示4选2的遍历操作,后面那个k代表四则运算加减乘除。 但总之就是,通过一对元素的计算归并(加减乘除之一),使得元素数下降了,4降为3,3降为2,最终算出唯一结果。第二个k循环中有个小trick就是:
if (k < 2 && j > i) continue;
这什么意思呢?须知加法和乘法是有交换律的,而减法和除法是没有交换律的,所以,k<2代表的加法和乘法,没必要算两次,只算i>j的情况即可。这个算是一种剪枝吧。
这个回溯法的终止条件为
if (nums.size() == 0) return false;
if (nums.size() == 1) return Math.abs(nums.get(0) - 24) < 1e-6;
1e-6又是一个小trick,用来应付实数计算的,大家要注意,这里也是个考察功底的地方。
至于最后为什么要remove,这纯属回溯法的套路框架了,下面总结一下:
当然这个框架中的backtrack参数列表可以酌情考虑,但思路是,在循环中:做选择->问题降级->撤销选择
大家结合回溯法的题目反复看这个框架,每一次就会加深一层理解,直到信手拈来。
这是一个非常重要的框架结构,如果你能深刻理解这种框架,那么碰到回溯法的题目都不会有问题。
下面再针对本题做一个扩展,上面只给出了是否存在某颗计算树,那么如果要细究到底存在多少个呢?
稍微改改就可以了:
package com.example.demo.leetcode; import java.util.ArrayList; import java.util.List; public class TwentyfourGame { int cnt = 0; List<String> solutions = new ArrayList<>(); public void judgePoint24(int[] nums) { ArrayList A = new ArrayList<Double>(); for (int v: nums){ A.add((double) v); } solve(A); System.out.println("this many solutions: "+ cnt); } private boolean solve(ArrayList<Double> nums) { if (nums.size() == 0) { return false; } if (nums.size() == 1) { if(Math.abs(nums.get(0) - 24) < 1e-6) { cnt++ ; return true; }else{ return false; } } boolean matched = false; for (int i = 0; i < nums.size(); i++) { for (int j = 0; j < nums.size(); j++) { if (i != j) { ArrayList<Double> nums2 = new ArrayList<>(); for (int k = 0; k < nums.size(); k++) { if (k != i && k != j) { nums2.add(nums.get(k)); } } for (int k = 0; k < 4; k++) { if (k < 2 && j > i) continue; if (k == 0) nums2.add(nums.get(i) + nums.get(j)); if (k == 1) nums2.add(nums.get(i) * nums.get(j)); if (k == 2) nums2.add(nums.get(i) - nums.get(j)); if (k == 3) { if (nums.get(j) != 0) { nums2.add(nums.get(i) / nums.get(j)); } else { continue; } } if(solve(nums2)){ matched = true; System.out.println(nums2); System.out.println("k="+k); } nums2.remove(nums2.size() - 1); } } } } return matched; } public static void main(String[] args) { int[] nums = {1, 2, 3, 5}; TwentyfourGame demo = new TwentyfourGame(); // this should return true demo.judgePoint24(nums); } }