LC39 组合总和问题为什么不能用动态规划解

力扣题目

给你一个无重复元素的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的所有不同组合,并以列表形式返回。你可以按任意顺序返回这些组合

candidates 中的同一个数字可以无限制重复被选取。如果至少一个数字的被选数量不同,则两种组合是不同的

对于给定的输入,保证和为 target 的不同组合数少于 150 个

示例 1:

输入:candidates =[2,3,6,7],target = 7
输出:[[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次
7 也是一个候选, 7 = 7 
仅有这两种组合

示例 2:

输入:candidates = [2,3,5],target = 8
输出:[[2,2,2,2],[2,3,3],[3,5]]

示例 3:

输入:candidates =[2],target = 1
输出:[]

分析

先说结论,解不了的原因是动态规划表中只关注最优解,不关注所有解,使得填表后得不到所有组合

乍一看,使物品的大小和价值相等,用 i 遍历 candidates,使 j 取 1 到 target,然后填 dp 表,这不就是完全背包问题吗?但显然完全背包问题是不关心最优解的所有组合的(背包问题要求给出最优解的值和一个组合即可)

而在这个组合总和问题中,题目要求了返回数字和为 target 的所有不同组合,这在完全背包问题的填表策略中是不会关心的(只关心最优值问题,最多通过维护额外的策略表记录达到最优值的某一个组合)。这就意味着,按完全背包问题的动态规划策略填表之后,无法回溯得到解的所有不同组合

为了更详尽地说明上述结论,我编写了一段失败的动态规划代码示例,运行它,打断点观察规划表和策略表,可以更清晰地察觉到策略中是如何只关心最优解,而导致决策路径无法涉及所有不同组合的

一个失败的动态规划题解

代码示例

注意!下面代码是一个失败示例,因为它不能解决所有符合上面题目的组合总和问题,它仅用来展示尝试使用动态规划或完全背包思路来解决此问题时的代码逻辑

public List<List<Integer>> combinationSum(int[] candidates, int target) {
    int numCandidates = candidates.length;
    int[][] dp = new int[numCandidates + 1][target + 1]; // dp数组用于存储达到每个目标和的最大组合值
    int[][] decision = new int[numCandidates + 1][target + 1]; // decision数组用于记录每个目标和达到的决策点

    // 初始化决策数组为-1
    for (int i = 0; i <= numCandidates; i++) {
        Arrays.fill(decision[i], -1);
    }

    // 填dp表
    for (int i = 1; i <= numCandidates; i++) {
        for (int j = 1; j <= target; j++) {
            // 不使用当前数字时的情况
            dp[i][j] = dp[i - 1][j];
            // 考虑使用当前数字的情况
            if (j >= candidates[i - 1]) {
                int newValue = dp[i][j - candidates[i - 1]] + candidates[i - 1];
                if (newValue >= dp[i - 1][j]) {
                    dp[i][j] = newValue;
                    decision[i][j] = i - 1; // 记录选择当前数字的决策
                }
            }
        }
    }

    return buildResult(candidates, target, dp, decision);
}

private List<List<Integer>> buildResult(int[] candidates, int target, int[][] dp, int[][] decision) {
    List<List<Integer>> res = new ArrayList<>();
    int aSumPosition = decision.length - 1;
    while (dp[aSumPosition][dp[aSumPosition].length - 1] == target) {
        if (decision[aSumPosition][dp[aSumPosition].length - 1] != -1) { // 防止重复组合
            List<Integer> currentCombination = new ArrayList<>();
            int i = aSumPosition, remainingTarget = decision[0].length - 1;
            while (i > 0 && remainingTarget > 0) { // 从最优解逆序遍历
                if (decision[i][remainingTarget] != -1) {
                    int itemIndex = decision[i][remainingTarget];
                    currentCombination.add(candidates[itemIndex]);
                    remainingTarget -= candidates[itemIndex]; // 更新剩余目标和
                } else {
                    i--; // 移至前一个候选项
                }
            }
            Collections.reverse(currentCombination);
            res.add(currentCombination);
        }
        aSumPosition--;
    }
    return res;
}

@Test
void test() {
    // 示例1
    List<List<Integer>> res1 = combinationSum(new int[]{2, 3, 6, 7}, 7);
    Assert.assertTrue(res1.contains(Arrays.asList(7))); // true
    Assert.assertTrue(res1.contains(Arrays.asList(2, 2, 3))); // true

    // 示例2
    List<List<Integer>> res2 = combinationSum(new int[]{2, 3, 5}, 8);
    Assert.assertTrue(res2.contains(Arrays.asList(2, 2, 2, 2))); // true
    Assert.assertTrue(res2.contains(Arrays.asList(2, 3, 3))); // true
    Assert.assertTrue(res2.contains(Arrays.asList(3, 5))); // true

    // 示例3
    List<List<Integer>> res3 = combinationSum(new int[]{2}, 1);
    Assert.assertTrue(res3.isEmpty()); // true

    // 这一示例中的assertFalse说明了动态规划解不能处理所有符合题目的组合总和问题
    List<List<Integer>> res4 = combinationSum(new int[]{2, 3, 7}, 18);
    Assert.assertTrue(res4.contains(Arrays.asList(2, 2, 2, 2, 2, 2, 2, 2, 2)));
    Assert.assertFalse(res4.contains(Arrays.asList(2, 2, 2, 2, 2, 2, 2, 3, 3)));
    Assert.assertFalse(res4.contains(Arrays.asList(2, 2, 2, 2, 2, 2, 7)));
    Assert.assertFalse(res4.contains(Arrays.asList(2, 2, 2, 2, 3, 7)));
    Assert.assertTrue(res4.contains(Arrays.asList(3, 3, 3, 3, 3, 3)));
    Assert.assertFalse(res4.contains(Arrays.asList(3, 3, 3, 3, 6)));
    Assert.assertFalse(res4.contains(Arrays.asList(3, 3, 3, 7)));
    Assert.assertFalse(res4.contains(Arrays.asList(7, 11)));
}

补充说明

对于开始的 3 个题目示例,这段代码是能够解决问题的,因为示例中满足不同组合数量 ≤ candidates 长度,而动态规划发现解的个数也是基于 candidates 长度的(在遍历 candidates 的过程中发现解)

一旦 target 的值进一步增大,使组合数量更多,动态规划法就会忽略多余的不同组合(因为它们并不比策略中已选取了的组合更优),这一示例的描述如下:

输入:candidates = [2,3,7],target = 18
输出:[[2,2,2,2,2,2,2,2,2],[2,2,2,2,2,2,3,3],[2,2,2,2,3,7],[2,2,2,3,3,3,3],[2,2,7,7],[2,3,3,3,7],[3,3,3,3,3,3],[3,3,3,7,2],[7,7,2,2]]

更合适的算法

为了克服动态规划在解决组合总和问题中遇到的种种限制,回溯法提供了一种更为有效的解决方案。通过逐步构建解决方案的过程中,探索所有可能的组合。一旦发现当前路径不可能达到目标或已经达到目标,算法就会回溯,尝试其他可能的路径。这种方法自然地适用于解决需要枚举所有可能解的组合问题……

  • 6
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值