有这样一个问题:给定不同面额的硬币 coins 和一个总金额 target,求出组成target金额的硬币序列。
延伸出下列问题:
- 1、零钱数组是否可以组成target表示的金额
- 2、在1问题的基础上,凑出target表示金额所需要的最少零钱数量
- 3、在2问题的基础上,进行排列组合
- 3.1、组合问题和排列问题
- 3.2、动态规划和回溯解法
- 4、零钱数组每个数仅能用1次的基础上,是否还能凑出target表示金额
- 5、在前4条的基础上,进行排列去重
- 6、上述5个问题,零钱序列都可以随意组合
- 6.1、如果限制随意组合,仅能选取连续的钱币,那么是否还能凑出target表示金额
- 6.2、如果限制随意组合,仅能选取间隔的钱币,那么是否还能凑出target表示金额
- 7、上述7个问题是加和问题,如果是乘积呢?
- 7.1、随意组合,凑出乘积等于target的组合
- 7.2、连续组合,凑出乘积等于target的组合
- 8、现在题目反过来,将target拆分为至少两个正整数的和,并使这些整数的乘积最大化
- 9、总结一下上面的问题
- 9.1、最大子段和(连续) LSS:LargestSumOfSubSequence
- 9.2、最大间隔和 LSG:LargestSumOfGap
- 9.3、最大子段乘积(连续) LMS:LargestMultiOfSubSequence
- 9.4、最大间隔乘积 LMG:LargestMultiOfGap
- 10、如果给定的coins数组修改为二叉树:选取非相邻的二叉树节点进行组装,组装的最大值是多少?
- 11、如果零钱数组加上数量限制数组,即每个零钱有一个限定使用的最大值,那么是否还能凑出target
值的你关注并提升你薪资待遇的面试算法:开源数据结构和算法实践
解答
问题1解答
零钱数组是否可以组成target表示的金额
- 设计思路:
- 1、如果数组可以无限重复的选择
- 2、如果数组不可以无限重复的选择【演变成背包问题】
- 代码实现:
- 数组可以无限重复的选择[动态规划]:CombinationNum_Dynamic
- 数组可以无限重复的选择[回溯]:CombinationNum_BackTrack
- 数组可以不无限重复的选择[动态规划]:CombinationNum_NonRepeat_Dynamic
- 数组可以不无限重复的选择[回溯]:CombinationNum_NonRepeat_BackTrack
- 主要代码:
- 注意事项:
问题2解答
凑出target表示金额所需要的最少零钱数量
- 设计思路:
- 代码实现:
- 主要代码:
- 注意事项:
问题3解答
3.1、组合问题和排列问题
回到题目本身:给定不同面额的硬币 array 和一个总金额 target。求出组成target金额的硬币序列。其中一种情况是:{1,2,2},但如果考虑排列问题,{1,2,2}、{2,1,2}、{2,2,1} 属于三个不同的答案。
- 设计思路:
- 在递归中有一个for循环,如果 i 从0开始,是排列问题,如果从上次的深度depth开始,是组合问题。
- 因为从0开始,排列中会出现先取到1再取到2和先取到2再取到1的情况,属于排列。
- 如果从上次的深度depth开始,则前面已经选择了的,没有回头路可走,因此是组合。
- 代码实现:
- 主要代码:
public void roll(int depth, int[] array, int target) {
if (sum == target) {
list_all.add(new ArrayList<>(list_temp));
return;
}
if (sum > target) {
return;
}
// i 从0开始,是排列问题,从上次的深度depth开始,是组合问题
for (int i = depth; i < array.length; i++) {
list_temp.add(array[i]);
sum += array[i];
roll(i, array, target);
sum -= array[i];
list_temp.remove(list_temp.size() - 1);
}
}
- 注意事项:roll递归下去的节点参数是 i 还是depth,需要根据题意和实现来具体分析。
3.2、动态规划和回溯解法
3.1 提到的是组合和排列问题使用递归方式的解法,那么动态规划是否也可以做到呢?对于题目要求的给定不同面额的硬币 array 和一个总金额 target。求出组成target金额的硬币序列的个数。
- 设计思路:
- 在求序列个数的时候,我们考虑使用双层循环,一层循环target,一层循环coins硬币数。两层for循环顺序的差异是:coins在外面是求解排列数,coins在里面是是求解组合数。
- coins在外层循环,那么已经循环过的coin就不会再次出现,所以遍历结果是组合数。
- coins在内层循环,会重复计算coin序列,比如[1,2]和[2,1]会计算两次。
- 代码实现:
- 主要代码:
for (int i = 0; i <= target; i++) {
for (int coin : coins) {
//VIPTips:VIPTips:两层for循环顺序的差异是:coins在外面是求解排列数,coins在里面是是求解组合数
if (i >= coin) {
dp[i] += dp[i - coin];
}
}
}
- 注意事项:
问题4解答
零钱数组每个数仅能用1次的基础上,是否还能凑出target表示金额
- 见 1.2
问题5解答
排列去重问题
当组合问题存在,属于同一组合不同排列的问题就会比较烧脑。即为:存在相同数字,比如 [1,2,2’],在排列的过程中存在答案 [1,2,2’] 和 [1,2’,2] 是一样的。但是[1,2,2’]和[2,1,2’]不一样。
- 设计思路:一个非常容易想到的思路是:依旧按照排列的方式进行递归,每次在做最后统计的时候,使用一个map对结果进行去重,达到过滤相同结果的目的。
- 代码实现:ArrayCombination_WithMap,测试用例:ArrayCombination_WithMapTest
- 主要代码:
public void roll(int depth, int[] nums) {
if (depth == nums.length) {
list_temp = new ArrayList<>();
String checkString = ArrayUtilsImpl.IntArray2Sequence(nums);
// 通过map去重
if (!containsMap.containsKey(checkString)) {
containsMap.put(checkString, 1);
for (int i = 0; i < nums.length; i++) {
list_temp.add(nums[i]);
}
list_all.add(new ArrayList<>(list_temp));
}
}
for (int i = depth; i < nums.length; i++) {
int temp = nums[i];
nums[i] = nums[depth];
nums[depth] = temp;
roll(depth + 1, nums);
temp = nums[i];
nums[i] = nums[depth];
nums[depth] = temp;
}
}
- 注意事项:但是在很多情况下,无法对结果进行序列化,无法通过map来进行去重,需要考虑在遍历结果的过程中,对结果做标记,来达到去重的目的。
使用访问设置
- 改进思路:在遍历到的每一层,放置一个 existMap,用于统计在当前层,是否访问过 nums[i]
- 主要代码:
public void roll(int depth, int[] nums) {
if (depth == nums.length) {
list_temp = new ArrayList<>();
for (int i = 0; i < nums.length; i++) {
list_temp.add(nums[i]);
}
list_all.add(new ArrayList<>(list_temp));
return;
}
// 每一层放置一个 existMap,用于统计在当前层,是否访问过 nums[i]
Map existMap = new HashMap();
for (int i = depth; i < nums.length; i++) {
if (existMap.containsKey(nums[i])) {
continue;
}
existMap.put(nums[i], true);
int temp = nums[i];
nums[i] = nums[depth];
nums[depth] = temp;
roll(depth + 1, nums);
temp = nums[i];
nums[i] = nums[depth];
nums[depth] = temp;
}
}
- 代码实现:
或者考虑设置全局的访问设置
问题6解答
选取连续的钱币
给定一个钱币数组,限制随意组合,仅能选取连续的钱币,那么是否还能凑出target表示的金额
- 设计思路:前缀和
- 代码实现:PreSumArrayApply_ChangeMoney,测试用例:PreSumArrayApply_ChangeMoneyTest
- 主要代码:
Map<Integer, Boolean> map = new HashMap();
int[] prefixSumArray = new int[array.length];
map.put(array[0], true);
prefixSumArray[0] = array[0];
for (int i = 1; i < array.length; i++) {
prefixSumArray[i] = prefixSumArray[i - 1] + array[i];
map.put(prefixSumArray[i], true);
int wantNum = prefixSumArray[i] - target;
if (map.getOrDefault(wantNum, false)) {
return true;
}
}
- 注意事项:
选取间隔的钱币
给定不同面额的硬币 array 和一个总金额 target,如果限制随意组合,仅能选取间隔的钱币,那么是否还能凑出target表示金额
- 设计思路:间隔回溯
- 代码实现:ChangeMoney_SumGap_BackTrack
- 主要代码:
private boolean roll(int depth, int[] array, int target) {
// 超过长度 或者 超过预期
if (depth >= array.length || array[depth] + sum > target) {
return false;
}
sum += array[depth];
if (sum == target) {
return true;
}
for (int i = depth + 2; i < array.length; i++) {
boolean flag = roll(i, array, target);
if (flag) {
return flag;
}
}
sum -= array[depth];
return false;
}
- 注意事项:本题和 LSG_Backtrack 的区别在于一个求解最大值,一个求解target。
问题7解答
7.1、随意组合,凑出乘积等于target的组合
- 设计思路:回溯的思想
- 代码实现:ChangeMoney_BackTrack,测试用例:ChangeMoney_BackTrackTest
- 主要代码:
public boolean roll(int depth, int[] array, int target) {
if (depth == array.length) {
return false;
}
if (multiSum == target || array[depth] == target) {
return true;
}
for (int i = 0; i < array.length; i++) {
if (array[i] == 0) {
continue;
}
multiSum *= array[i];
flag = roll(depth + 1, array, target);
if (flag) {
return true;
}
multiSum /= array[i];
}
return false;
}
- 注意事项:
- 单个元素的处理:array[depth] == target
7.2、连续组合,凑出乘积等于target的组合
- 设计思路:双指针定义左右边界,进行递归
- 代码实现:ArrayPermutation_Sliding,测试用例:ArrayPermutationTest
- 主要代码:
while (right < array.length || left < right) {
// 右指针扩张
while (right < array.length && mul * array[right] <= target) {
mul *= array[right];
right++;
}
roll(array, left, right - 1);
// 左指针收缩
mul /= array[left];
left++;
}
- 注意事项:
问题8解答
将 target 拆分为至少两个正整数的和,并使这些整数的乘积最大化
- 设计思路:假设先分成两段,每一段都有自己的最优解,再考虑每一段的最优解,细分下来就是,对于已有的长度target,从1到target逐步增大,进行求解最优解,求解过程为:Math.max(maxMultiarray[i], (i - j) * Math.max(j, maxMultiarray[j]));
- 代码实现:NumReduceMaxMulti,测试用例:NumReduceMaxMultiTest
- 主要代码:
for (int i = 2; i <= num; i++) {
for (int j = 1; j < i; j++) {
maxMultiarray[i] = Math.max(maxMultiarray[i], (i - j) * Math.max(j, maxMultiarray[j]));
}
}
- 注意事项:
问题9解答:子段和/积包括哪些问题
- 最大间隔乘积
- 在一组数中,间隔的取数并且相乘,使得乘积最大
- 最大子段乘积(连续)
- 在一组数中,连续的取数并且相乘,使得乘积最大
- 最大间隔和
- 在一组数中,间隔的取数并且相加,使得和最大
- 最大子段和(连续)
- 在一组数中,连续的取数并且相加,使得和最大
1和2是乘积问题,区别在于是否连续,3和4是求和问题,区别在于是否连续。
1、最大间隔乘积
- 设计思路:
- 回溯法主要考虑往下递归时,需要注意间隔数
- 动态规划主要考虑状态转换方程:MAX(“当前值”,“当前值*相距2个的最优解”,“相距1个的最优解”)
- 代码实现:
- 动态规划:LMG,测试用例:LMGTest
- 回溯法:Choir_Backtrack,测试用例:Choir_BacktrackTest
- 主要代码:
for (int i = 2; i < length; i++) {
valueMax[i] = Math.max(Math.max(Math.max(
valueMax[i - 2] * values[i], valueMin[i - 2] * values[i]), //选择间隔积
valueMax[i - 1]),//选择上一个最优解
values[i]); //选择当前值
valueMin[i] = Math.min(Math.min(Math.min(
valueMax[i - 2] * values[i], valueMin[i - 2] * values[i]), //选择间隔积
valueMin[i - 1]),//选择上一个最优解
values[i]); //选择当前值
}
- 注意事项:
- 动态规划的循环需要从2开始
- 回溯需要注意间隔
2、最大子段乘积(连续)
- 设计思路:
- 回溯法就是逐步确定数组连续的乘积最大值范围
- 要么使用最外层for循环,从数组的0开始,逐步探测,要么双指针来夹逼范围。
- 动态规划主要思路是:保存一个前序乘积的最小值和最大值,分别和当前值相乘,求MAX(“当前值”、“前序乘积的最小值当前值”、“前序乘积的最大值当前值”)
- 代码实现:LMS,测试用例:LMSTest
主要代码:
- 回溯法
public void roll(int depth, int[] array) {
for (int i = depth; i < array.length; i++) {
if (array[i] == 0) {
if (0 > best) {
best = 0;
}
return;
}
sum *= array[i];
list_temp.add(array[i]);
if (sum > best) {
list_best = new ArrayList(list_temp);
best = sum;
}
}
}
- 动态规划
for (int i = 1; i < array.length; ++i) {
long max_old = max, min_old = min;
// Tips: Math.max(max_old * array[i], min_old * array[i]) 不等于 Math.max(max_old, min_old) * array[i]
max = Math.max(array[i], Math.max(max_old * array[i], min_old * array[i]));
min = Math.min(array[i], Math.min(max_old * array[i], min_old * array[i]));
answer = Math.max(max, answer);
}
- 注意事项:
3、最大间隔和
给定一个数组,在这个数组中,进行非连续的选择,即挑选任意非相邻的数字组成的数组,求这些数组中和值最大的值。
- 设计思路:
- 回溯法依赖于每次对 i+2 的递归实现。
- 动态规划的设计思路依赖于状态转换方程:取当前值和间隔一个的累加值做对比:
bestGoodsValue[i] = Math.max(
bestGoodsValue[i - 1], //不选择当前的物品
Math.max(bestGoodsValue[i - 2] + array[i], array[i])//选择当前的物品
);
主要代码:
- 动态规划
for (int i = 2; i < length; i++) {
bestGoodsValue[i] = Math.max(
bestGoodsValue[i - 1], //不选择当前的物品
Math.max(bestGoodsValue[i - 2] + values[i], values[i])//选择当前的物品
);
}
- 回溯
for (int i = depth; i < array.length; i++) {
sum += array[i];
list_temp.add(array[i]);
// tips: 此处的 i 或者 depth 需要注意,常规情况下都是使用i+1,只有在对数组做全排列才会考虑使用depth。
roll(i + 2, array);
list_temp.remove(list_temp.size() - 1);
sum -= array[i];
}
- 注意事项:
- 回溯的注意事项比较多,其中全负数数组的选择,依赖于for循环中对每个元素的判断。
- 递归退出条件和更新最优解的先后顺序问题【具体见代码】
4、最大子段和(连续)
给定一个数组,求这个数组的连续子数组中,最大的那一段的和。
- 设计思路:
- 最大字段和主要有两种思路解决,一个是动态规划,用当前值和累加值进行对比,取最大的那个,所以状态转换方程是:
- LargestSum[i] = Math.max(LargestSum[i - 1] + array[i], array[i]);
- 另一种思路是:分治法,取数组的中间值,那么连续的子数组,要么出现在中间值的左边,要么出现在右边,要么横跨中间值。
- 代码实现:LSS,测试用例:LSSTest
主要代码:
- 动态规划:
for (int i = 1; i < length; i++) {
LargestSum[i] = Math.max(LargestSum[i - 1] + array[i], array[i]);
if (LargestSum[i] > sum) {
sum = LargestSum[i];
}
}
- 分治法:
int leftValue = divide(Sequence, left, mid);
int rightValue = divide(Sequence, mid + 1, right);
int midValue = mid(Sequence, left, right);
return Math.max(Math.max(leftValue, rightValue), midValue);
- 注意事项:
- 分治法需要注意判断终止条件:left < right
- 动态规划需要注意:求和数组的初始条件。
问题10解答
- 设计思路:这道题结合了二叉树的遍历方式和隔层取值的动态规划思想,其实想明白一点:当前节点是需要分成两处来统计:
- 1、包含当前节点的值,那么不可以包含当前值的孩子值
- 2、不包含当前值,那么需要求当前值的孩子们的最优解
如何求当前值的孩子们的最优解?再次递归进去
分两处统计,需要两个存储结构,建议使用Map, - 1、包含当前节点的值:containMap,需要加入当前值、nonContainMap中,左右孩子的值
- 2、不包含当前值:nonContainMap,需要加入左孩子的最大值+右孩子的最大值
- 3、孩子的最大值= Math.max(containMap.get(孩子), nonContainMap.get(孩子))
- 代码实现:BT_JumpLevelSum,测试用例:BT_JumpLevelSumTest
- 主要代码:
public void count(BinaryTreeImpl node) {
if (node == null) {
return;
}
count(node.left);
count(node.right);
// containMap需要把 node.value 考虑进去
containMap.put(node, node.value + nonContainMap.getOrDefault(node.left, 0) + nonContainMap.getOrDefault(node.right, 0));
nonContainMap.put(node, Math.max(containMap.getOrDefault(node.left, 0), nonContainMap.getOrDefault(node.left, 0))
+ Math.max(containMap.getOrDefault(node.right, 0), nonContainMap.getOrDefault(node.right, 0)));
}
- 注意事项:
问题11解答
给定不同面额的硬币 coins 和一个总金额 target,如果零钱数组加上数量限制数组 limit,即每个零钱有一个限定使用的最大值,那么是否还能凑出target
- 设计思路:递归,深度是coins数组,表示当前选择的硬币,广度是limit数组,表示选择多少枚。
- 代码实现:ChangeMoney_WithLimit_BackTrack,测试用例:ChangeMoney_WithLimit_BackTrackTest
- 主要代码:
private boolean roll(int depth, int[] array, int[] limit, int target) {
if (sumTemp == target) {
return true;
}
if (sumTemp > target || depth == array.length) {
return false;
}
for (int i = 0; i <= limit[depth]; i++) {
sumTemp += array[depth] * i;
boolean flag = roll(depth + 1, array, limit, target);
if (flag) {
return flag;
}
sumTemp -= array[depth] * i;
}
return false;
}
- 注意事项:for 循环的 i 从0开始,表示不选择该数。
总结
上述问题的考虑角度主要为:
- 1、重复的数字:给定数组中是否包含重复的数字,比如:重复:[1,1,2]、不重复[1,2,3,4]
- 2、选取方式:连续、不一定连续、一定不连续【子序列和子串问题】
- 3、计算方式:求和还是求积
- 4、匹配方式:选取集合为最值 best 还是指定值 target
- 5、输出方式:输出结果集合、还是集合的数量、能否凑出集合、集合中的最优解
- 6、输出结果:集合是排列还是组合结果,排列是否去重,还是结果集合中的最优
- 比如:[1,1,2] 只计算一次,是组合。
- [1,1,2]、[1,2、1]、[2,1,1] 计算三次是排列
- [1,1,2] 不重复计算是排列去重
常见做法:
1、求连续和为最优解一般是 最大字段和,求连续和为指定值一般是 前缀和,不一定连续的情况考虑使用背包
2、针对(5)输出结果,获取集合类最方便的是回溯,求最值问题一般是DP
3、针对(2)选取方式和(6)输出结果,考虑加锁
4、补充:回溯算法,递归的for循环中,i从0开始,是排列问题,从上次的深度depth开始,是组合问题,排列去重考虑加锁