每日一题 leetCode,今天是经典动态规划,leetCode494—目标和(借鉴左神的算法思想讲解)
题目:给你一个整数数组 nums 和一个整数 target 。
向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :
例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
示例 1:
输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:一共有 5 种方法让最终目标和为 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3
示例 2:
输入:nums = [1], target = 1
输出:1
提示:
1 <= nums.length <= 20
0 <= nums[i] <= 1000
0 <= sum(nums[i]) <= 1000
-1000 <= target <= 1000
本题属于 leetCode 中的中等难度,对于动态规划问题训练较少的同学来说,是一个不错的切入点,笔者花了3小时用四种方法求解,分别展示了暴力递归 -> 记忆搜索 -> 经典动态规划 -> 动态规划调优,4个过程进化过程。
暴力递归方法最直观易懂,缺点是时间复杂度高,对于很多已经完成搜索递归的子问题,仍然“傻乎乎”递归求解。
记忆搜索方法是对暴力递归的升级,将已经完成的搜索子问题记录下来,等到下次再判断的时候,查看是否可以命中记录,如果可以命中,则无需递归,直接拿值返回;如果无法命中,则继续递归,并将递归结果记录下来。
经典动态规划是在记忆搜索基础上做出的抽象,可抽象为经典0 1背包问题(笔者在此做的比较麻烦,因为经典背包问题是不包括负数的,而在本题中,可能有负数的情况出现,笔者长时间卡在处理负数数据问题中,后续将详细说明)。
动态规划调优是对整题的优化,使用数学推理,将复杂问题简单化(后文说明)。
本题有两个优化点:
① 如果将所有数变成正数,总和小于 target,则没有方法满足条件
② 如果将所有数变成正数,总和与 target 奇偶性不同,则没有方法满足条件
(一)暴力求解
如上图所示:假设给定的数组是 [1,2,1],目标值 target = 2,我们需要定义两个变量,分别为 rest和 index。其中,index 表示 index之前的内容已经全部处理完成,只能处理 index后面的内容,index 初始值为0;rest 表示距离 target 差距大小,初始值为 target。
对于每次递归的 index 表示正在处理 index 位置,index 位置处理方法有两种情况——arr[index]前面是加号或减号。每次处理完成之后交给后面去做递归(形象一点描述——我处理完 index = 0位置,交给递归处理,递归结果是 index = 1,2,3 位置处理完成的综合结果,也就是图中第一层有 +1 和 -1 两种情况,将每一种情况交给递归,第一层只管接收后面层数的结果值;我处理完 index = 1位置,交给递归处理,递归结果是 index = 2,3 位置处理完成的综合结果,也就是图中第一层有 +2 和 -2 两种情况,将每一种情况交给递归,第二层只管接收后面层数的结果值;我处理完 index = 2位置,交给递归处理,递归结果是 index = 3 位置处理完成的综合结果,也就是图中第一层有 +1 和 -1 两种情况,将每一种情况交给递归,第三层只管接收后面层数的结果值)。
我们的递归出口是 index == arr.length,即达到最后一层了,需要判断当前的 rest 是否为0,如果为0,则计算结果正确,该方案成立,返回1,代表方案数;如果不为0,则计算结果错误,返回0种方案。
每次调用两种情况的时候,需要将情况累加,如果后续递归,将两个子问题的方法数叠加。
图中可以看出,有两条方案成立,分别是路线 1 2 4 和 8 9 11。
代码如下:
public int findTargetSumWays(int[] nums, int target) { int sum = 0; for (int i = 0; i < nums.length; i++) { int temp = nums[i]; if (temp < 0) { temp *= -1; } sum += temp; } // 判断直接淘汰方法 int sumCon = sum % 2; int targetCon = target % 2; if (nums == null || nums.length == 0 || sum < target || sumCon != targetCon) { return 0; } return baoliProcess(0, nums, target); }
/** * 暴力递归 * @param index * @param nums * @param target * @return */ public static int baoliProcess(int index, int[] nums, int rest) { // 递归出口 if (index == nums.length) { if (rest== 0) { return RESULT_OK; } else { return RESULT_FALSE; } } // 继续递归 return baoliProcess(index + 1, nums, rest+ nums[index]) + baoliProcess(index + 1, nums, rest- nums[index]); }
(二)记忆搜索
在暴力中,我们可以发现,变化的量只有两个,index 和 rest,不论 index 之前如何变化,到 index = x(x < nums.length)时,如果拥有相同的 rest,则后续过程一样,只需要计算一次之后将结果存储起来即可,下次计算到同样的 index 和 rest,则命中存储内容,这就是记忆化搜索!
代码如下:
public int findTargetSumWays(int[] nums, int target) { int sum = 0; for (int i = 0; i < nums.length; i++) { int temp = nums[i]; if (temp < 0) { temp *= -1; } sum += temp; } // 判断直接淘汰方法 int sumCon = sum % 2; int targetCon = target % 2; if (nums == null || nums.length == 0 || sum < target || sumCon != targetCon) { return 0; } // 定义缓存 HashMap<Integer, HashMap<Integer, Integer>> dp = new HashMap<>(10); return cacheProcess(0, nums, target, dp); }
/** * 记忆化搜索 * @param index * @param nums * @param rest * @param dp * @return */ public static int cacheProcess(int index, int[] nums, int rest, HashMap<Integer, HashMap<Integer, Integer>> dp) { // 递归出口 if (index == nums.length) { if (rest == 0) { return RESULT_OK; } else { return RESULT_FALSE; } } // 继续递归 // 判断是否命中缓存 if (dp.containsKey(index) && dp.get(index).containsKey(rest)) { // 命中缓存 return dp.get(index).get(rest); } // 没有命中缓存 // 继续向下递归 int nextAns = cacheProcess(index + 1, nums, rest + nums[index], dp) + cacheProcess(index + 1, nums, rest - nums[index], dp); if (!dp.containsKey(index)) { dp.put(index, new HashMap<>(10)); } dp.get(index).put(rest, nextAns); return nextAns; }
(三)经典动态规划
其实,记忆化搜索就是动态规划的一种,只不过,大牛更喜欢写成经典动态规划的样子。我们用一张图来看看!
我们只需要填写完成表格即可,从index = num.length(最后一行) 开始填写,根据地推关系:
return baoliProcess(index + 1, nums, target + nums[index]) + baoliProcess(index + 1, nums, target - nums[index]);
需要参照上一个位置 index + 1 ,target + nums[index] 和 index + 1,target - nums[index]。
代码如下:
/** * 经典动态规划 * @param dp * @param nums * @param start * @param end * @param left * @param right * @param target */ public static void dpProcess(HashMap<Integer, HashMap<Integer, Integer>> dp, int[] nums, int start, int end, int left, int right, int target) { // dp 初始化 dp.put(nums.length, new HashMap<>()); dp.get(nums.length).put(0, RESULT_OK); for (int i = left; i <= right; i++) { if (i != 0) { dp.get(nums.length).put(i, RESULT_FALSE); } } for (int i = 0; i < nums.length; i++) { if (!dp.containsKey(i)) { dp.put(i, new HashMap<>()); } for (int j = left; j <= right; j++) { dp.get(i).put(j, 0); } } for (int index = nums.length - 1; index >= 0; index--) { for (int rest = left; rest <= right; rest++) { int plusNextAns = 0; int deNextAns = 0; if (index + 1 <= nums.length && (rest + nums[index]) <= right && (rest + nums[index]) >= left) { plusNextAns = dp.get((index + 1)).get((rest + nums[index])); } if (index + 1 <= nums.length && (rest - nums[index]) <= right && (rest - nums[index]) >= left) { deNextAns = dp.get((index + 1)).get((rest - nums[index])); } int nextAns = plusNextAns + deNextAns; if (!dp.containsKey(index)) { dp.put(index, new HashMap<>()); } dp.get(index).put(rest, nextAns); } } } public static int findTargetSumWays(int[] nums, int target) { int sum = 0; for (int i = 0; i < nums.length; i++) { int temp = nums[i]; if (temp < 0) { temp *= -1; } sum += temp; } // 判断直接淘汰方法 int sumCon = sum % 2; int targetCon = target % 2; if (nums == null || nums.length == 0 || sum < target || sumCon != targetCon) { return 0; } int start = 0; int end = 0; int left = 0; int right = 0; if (sum >= 0 && target >= 0) { left = -sum - target; right = sum + target; } else if (sum <= 0 && target >= 0) { left = sum - target; right = -sum + target; } else if (sum >= 0 && target <= 0) { left = -sum + target; right = sum - target; } else { left = sum + target; right = (start + target) * (-1); } start = sum * (-1) - target; end = sum - target; // 定义缓存 HashMap<Integer, HashMap<Integer, Integer>> dp = new HashMap<>(); dpProcess(dp, nums, start, end, left, right, target); return dp.get(0).get(target); }
(四)dp 优化
假设加号集合为 p,减号集合为 q,那么一定有关系式:p - q = target,两遍同时加上 p + q 得:2p = target + p + q
得到:2p = target + sum -> p = (target + sum) / 2
换言之,只要能找到 P 集合,满足 (target + sum) / 2,即可获得答案,而 target 和 sum 是固定的,那么解决方案就有很多种了。比如:可以排序,再用双指针法求解;或者用经典0 1 背包动态规划求解。
总结:本题是一道中等难度算法,对笔者巩固动态规划思想以及自己从暴力递归到记忆化搜索到经典动态规划到优化一套体系的 code 过程有很大提升!