零钱交换及其延伸问题的讨论

有这样一个问题:给定不同面额的硬币 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表示金额所需要的最少零钱数量

  • 设计思路:
  • 代码实现:
    • 动态规划:ChangeMoney_Least_Dynamic,测试用例:ChangeMoney_LeastTest
    • 回溯:ChangeMoney_Least_BackTrack,测试用例:ChangeMoney_LeastTest
  • 主要代码:
  • 注意事项:

问题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开始,则前面已经选择了的,没有回头路可走,因此是组合。
  • 代码实现:
    • 组合代码:ChangeMoneyCombination_BackTrack,测试用例:ChangeMoneyCombination_BackTrackTest
    • 排列代码:ChangeMoneyPermutation_BackTrack,测试用例:ChangeMoneyPermutation_BackTrackTest
  • 主要代码:
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]会计算两次。
  • 代码实现:
    • 组合代码:ChangeMoneyCombination_Dynamic,测试用例:ChangeMoneyCombination_DynamicTest
    • 排列代码:ChangeMoneyPermutation_Dynamic,测试用例:ChangeMoneyPermutation_DynamicTest
  • 主要代码:
        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;
        }
    }
  • 代码实现:
    • ArrayCombination,测试用例:ArrayCombinationTest

或者考虑设置全局的访问设置

  • ArrayCombinationWithMapArray,测试用例:ArrayCombinationTest

问题6解答

选取连续的钱币

给定一个钱币数组,限制随意组合,仅能选取连续的钱币,那么是否还能凑出target表示的金额

  • 设计思路:前缀和
  • 代码实现:PreSumArrayApply_ChangeMoney,测试用例:PreSumArrayApply_ChangeMoneyTest
  • 主要代码:
        Map 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])//选择当前的物品
            );
  • 代码实现:LSG,测试用例:LSGTest

主要代码:

  • 动态规划
        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开始,是组合问题,排列去重考虑加锁

你可能感兴趣的:(数据结构和算法及其应用,算法,动态规划)