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

有这样一个问题:给定不同面额的硬币 coins 和一个总金额 target,求出组成target金额的硬币序列。

延伸出下列问题:

值的你关注并提升你薪资待遇的面试算法开源数据结构和算法实践

解答


问题1解答

零钱数组是否可以组成target表示的金额


问题2解答

凑出target表示金额所需要的最少零钱数量


问题3解答

3.1、组合问题和排列问题

回到题目本身:给定不同面额的硬币 array 和一个总金额 target。求出组成target金额的硬币序列。其中一种情况是:{1,2,2},但如果考虑排列问题,{1,2,2}、{2,1,2}、{2,2,1} 属于三个不同的答案。

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表示的金额

        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表示金额

    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的组合

    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的组合

        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个的最优解”)
  • 代码实现:
  • 主要代码:
        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

    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开始,是组合问题,排列去重考虑加锁
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
零钱问题是一个经典的动态规划问题。假设你有 $n$ 种不同面值的硬币,每种硬币的数量无限。现在你需要用这些硬币来凑出总价值为 $m$ 的零钱。求出总共有多少种凑法。 可以使用动态规划来解决这个问题。定义状态 $dp[i]$ 表示凑出价值为 $i$ 的零钱的总方案数。状态转移方程为: $$ dp[i] = \sum_{j=0}^{n-1} {dp[i-coins[j]]} $$ 其中 coins 是硬币面值的数组,n 是硬币种类的数量。这个方程的意思是,如果我们要凑出 $i$ 的零钱,可以选择任意一枚硬币 $coins[j]$,然后再凑出剩下的 $i-coins[j]$ 的零钱。因此总方案数就是选择每一种硬币的方案数之和。 具体实现时,需要初始化 $dp[0]=1$,因为凑出价值为 $0$ 的零钱只有一种方案,就是不选任何硬币。同时需要注意内外层循环的顺序,因为外层循环枚举的是硬币种类,内层循环枚举的是要凑出的零钱金额,所以必须先枚举硬币种类,再枚举金额。 下面是 C++ 代码实现: ```c++ #include <iostream> #include <vector> using namespace std; int main() { int n, m; cin >> n >> m; vector<int> coins(n); for (int i = 0; i < n; i++) { cin >> coins[i]; } vector<int> dp(m + 1, 0); dp[0] = 1; for (int i = 0; i < n; i++) { for (int j = coins[i]; j <= m; j++) { dp[j] += dp[j - coins[i]]; } } cout << dp[m] << endl; return 0; } ``` 其中,输入的第一行是两个整数 $n$ 和 $m$,分别表示硬币种类的数量和要凑出的零钱金额。接下来 $n$ 行是每种硬币的面值。输出的是凑出总价值为 $m$ 的零钱的方案数。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值