LeetCode组合总和I~IV和背包问题小结

一、组合总和问题

最近在看leetcode的组合问题,一共四道,总结一下共通之处与不同之处。
原题链接:
组合总和
组合总和II
组合总和III
组合总和IV

对比如下,为了便于对比,将原题目的叙述方式进行了修改。

问题 输入 取值限定 解集限定 解法
I 无重复元素的数组 candidates且全为正数;目标数 target candidates元素可以无限制重复被选取 无重复集合 回溯法,对每一个候选值可以选0~n次,满足已选之数总和小于等于target。输入无重复+回溯本身保证结果集无重复
II 可能有重复元素的数组 candidates且全为正数;目标数 target candidates元素只能选一次 无重复集合 建立candidates元素与其个数的hashmap,基于选择个数做回溯法
III candidates=[1,2,...,9],目标数 target,个数k candidates元素只能选一次,只能选k个 无重复集合 回溯法,按顺序遍历每个元素分别考虑选与不选。其他解法见原链接
IV 无重复元素的数组 candidates且全为正数;目标数 target candidates元素可以无限制重复被选取 无重复数组(顺序不同认为是不同解) 转换为背包问题的动态规划解法。先排序再用回溯法求所有无重复集合的解,最后构造结果的解法会超时。

二、背包问题

对于【组合总和IV】相关联的背包问题,做进一步的研究。
背包可以归为三类:0-1背包、完全背包、多重背包。

共性

  • 背包容量有限,求解能使背包中放下最大价值总和的金额。(本文不讨论求得最大价值总和具体放法的方式)
  • 一共n种不同的物品,对应的体积w[1...n]和价值v[1...n]
  • 求解过程是动态规划,且dp[i][j]代表【在考虑第i件物品时(无论取不取),使用空间为j时最大的价值】。那么dp[n][1...V]中最大值即为所求的最终解。(因为可能放不满)
  • 可以根据求解dp[i][j]的过程,进行存储容量压缩从而降低空间复杂度
  • 初始化dp[0][j]=0

区别

分类 输入 取值限定 解法
0-1背包 背包容量V,n种物品其体积w[1...n]和价值v[1...n] 每个物品最多取1次 见状态转移方程
完全背包 背包容量V,n种物品其体积w[1...n]和价值v[1...n] 每个物品可以取无限次 见状态转移方程
多重背包 背包容量V,n种物品其体积w[1...n]和价值v[1...n],个数分别为k[1...n] 第i个物品可以取0至k[i]次 见状态转移方程

状态转移方程

0-1背包

  • dp[i][j] = dp[i-1][j] ,当 j-w[i]<0。表示使用容量为j时,无法放下第i件,因此选择不放它
  • dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]),当 j-w[i]>=0。表示取放和不放第i件的两种情况下的最大值

优化:

  1. 观察状态转移方程时可以发现,每次都直接使用i-1行的结果来构造第i行的结果,那么只需要存储一行即可。且在遍历时,必须使用倒序遍历j->1防止本轮的变化覆盖到上一轮的结果上去,导致这一变化被再次取出来。
  2. 保存当前行的最大值,那么这个最大值在求解最后一行时即为所求的结果。

去掉i这一个维度可改写为:

  • 保持不变,当 j-w[i]<0时。
  • dp[j] = max(dp[j], dp[j-w[i]] + v[i]),当 j-w[i]>=0

完全背包

在0-1背包基础上,因为每件可以使用无限次(实际上有一个上界——不超过当前剩余容量)。公式为:

  • dp[i][j] = max(dp[i-1][j-kw[i]] + kv[i]),其中k=0,1, 2...j/w[i]取整。

但是结合0-1背包优化的过程:j倒序遍历是为了避免重复取第i个元素造成重复更新。那么反过来利用这个特性,正好能表达每个元素取无限个的特点。
那么优化公式为:

  • dp[j] = max(dp[j], dp[j-w[i]] + v[i])。这个公式很抽象,表达为代码为
for (int i = 0; i <= V; i++) dp[i] = 0;//初始化二维数组
//循环每个物品
for (int i = 1; i <= n; i++)
{
      for (int j = w[i]; j <= V; j++)
      {
            dp[j] = max(dp[j], dp[j -w[i]] + v[i]);
      }
}

可以看出去掉了原始公式中k的这一层循环,并且将j的下界进行了优化,减少了判断语句。

多重背包

可以将所有类型的物品看做不同种类的,转化为0-1背包。
也可以沿着原先完全背包的思路, dp[i][j] = max(dp[i-1][j-k*w[i]] + k*v[i]),其中k=0,1, 2...k[i]取整。
这两种时间复杂度都是O(n^3)的。
有一种优化的方法是按2的幂将k件第i种物品拆分,如20=1+2+4+8+5,再使用0-1背包,可以降低至O(n^2logn)
还有更多的优化方法,可以参考 浅谈多重背包的一些解法

背包问题延伸:先遍历n个物品还是先遍历背包容量V

上文所讨论的三种背包问题基本场景,都是基于求结果的组合数的,即不考虑结果中元素的顺序,对于V=4,[1, 3]和[3, 1]是同一个解。
如果要求排列数,又如何解呢?
从上文的讨论过程可以发现,如果先按照顺序取n个/种物品再遍历背包容量V,解中第i个总在第i+1个前面,没有考虑顺序。如果先遍历容量V,再遍历元素,自然就形成了排序的解。还以V=4举例,取i=1时,V-i=3;取i=3时,V-i=1,此时可以得出出[1, 3]和[3, 1]两个不同的解。
因此,第一版的状态转移方程为:

  • dp[i][j] = Σdp[i-w[k]][j], 其中k=0...j-1,且使得i-w[k]>=0 。dp[i][j]代表占用容量为i、使用前j个元素时的组合数。如果不存在k,那么dp[i][j]=dp[i][j-1]。
    直观地看,这个复杂度是O(n^3),但是因为循环的结构是这样的
for(int i=0;i<=target;i++) {
  for(int j=0;j<nums.length;j++) {
    // 对k做一次循环,计算dp
  ...
  }
}

假如把dp[i]看做每一步的累加结果,即dp[i]的含义是n种物品在容量i时的摆放方式数目,这时的转移公式为:

  • dp[i] += dp[i-nums[j]],其中i-nums[j]>=0。

当然,此时的dp[i],与dp[i][j]已经不是一个含义了,dp[i]是j取最大时的dp[i][j],它的变化过程中体现了dp[i][j]。可以看出【组合问题】和原始的完全背包问题已经显现出差异。

习题求解

组合问题

377. 组合总和 Ⅳ

class Solution {
    public int combinationSum4(int[] nums, int target) {
        if(nums==null || nums.length ==0) {
            return 0;
        }
        int dp[] = new int[target+1];
        for(int j=0;j<nums.length;j++) {
            dp[0] = 1;
        }
        for(int i=1;i<=target;i++) {
            for(int j=0;j<nums.length;j++) {
                if(i-nums[j] >= 0) {
                    dp[i] += dp[i-nums[j]];
                }
        }
        return dp[target];
    }
}

494. 目标和

可以看做元素是取正还是取反的背包问题。注意这一题进行坐标平移(+1000)和使用递推式替代状态转移方程,复杂度会更低。后者即
将 dp[i][j] = dp[i - 1][j - nums[i]] + dp[i - 1][j + nums[i]] 改写为

  • dp[i][j + nums[i]] += dp[i - 1][j]
  • dp[i][j - nums[i]] += dp[i - 1][j]
    可以理解为通过上一层的基准值构造下一层的值。
    由于直接原地保存dp结果会造成干扰,优化解需要一个临时数组。

518.零钱兑换 II

典型的完全背包问题,典型的优化方式。

class Solution {
    public int change(int amount, int[] coins) {
        if(amount == 0) {
            return 1;
        }
        if(amount<0) {
            return 0;
        }
        if(coins == null || coins.length == 0) {
            return 0;
        }

        int dp[] = new int[amount+1];
        dp[0] = 1;
        for(int i=0;i<coins.length;i++) {
            for (int j=coins[i]; j<=amount;j++) {
                dp[j] += dp[j-coins[i]];
            }
        }
        return dp[amount];
    }
}

true-false问题

416. 分割等和子集

0-1背包。变化点是求固定的dp[V]是否存在(true or false)。

class Solution {
    public boolean canPartition(int[] nums) {
        if(nums==null || nums.length == 0) {
            return false;
        }
        int sum = 0;
        for(int i=0;i<nums.length;i++) {
            sum+=nums[i];
        }
        if((sum & 1) == 1) {
            return false;
        }
        int half = sum>>1;

        // 0-1背包
        // 第i个数字, 和为j
        boolean dp[] = new boolean[half+1];
        dp[0] = true;
        for(int i=0;i<nums.length;i++) {
            for(int j=half;j>=0;j--) {
                if(j>=nums[i]) {
                    dp[j] = (dp[j] || dp[j-nums[i]]);
                }
                if(j==half && dp[j]) {
                    return true;
                }
            }
        }
        return false;
    }
}

139. 单词拆分

直接套用参考文档希望用一种规律搞定背包问题
中true-false * 完全背包 问题的公式:

class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        if(s==null || s.isEmpty()) {
            return true;
        }
        if(wordDict == null ||wordDict.size() ==0) {
            return false;
        }

        boolean dp[] = new boolean[s.length()+1];
        dp[0] = true;
        for(int i=0;i<=s.length();i++) {
            for(int j=0;j<wordDict.size();j++) {
                String wj = wordDict.get(j);
                if(wj.length() <= i ) {
                    dp[i] = dp[i] || (dp[i-wj.length()] && wj.equals(s.subSequence(i-wj.length(),i)));
                }
            }
        }
        return dp[s.length()];
    }
}

最大最小问题

  • dp[i] = min(dp[i], dp[i-num]+1)或者dp[i] = max(dp[i], dp[i-num]+1)

474. 一和零

二维的背包问题,限制了两个维度,因此是O(mnl)的时间复杂度。因为第一遍没想清楚,解法不粘贴了,请参考官方解。
为什么状态转移方程里有一个+1?因为取了一个新的元素,元素个数+1。

322. 零钱兑换

官方解的初始化方式理解起来不太直观,因此我直接用Integer.MAX_VALUE来标识。

class Solution {
    public int coinChange(int[] coins, int amount) {
        if(amount==0) {
            return 0;
        }
        if(amount<0 || coins==null || coins.length == 0) {
            return -1;
        }
        int dp[] = new int[amount+1];
        dp[0] = 0;
        for(int i=1;i<=amount;i++) {
            dp[i] = Integer.MAX_VALUE;
        }
        for(int i=0;i<coins.length;i++) {
            for (int j=coins[i];j<=amount;j++) {
                if(dp[j-coins[i]] < Integer.MAX_VALUE) {
                    dp[j] = Math.min(dp[j], dp[j-coins[i]] + 1);
                }
            }
        }
        return dp[amount] == Integer.MAX_VALUE? -1 : dp[amount];
    }
}

参考文档

希望用一种规律搞定背包问题
【算法总结】动态规划-背包问题

posted @ 2021-02-20 15:23  五岳  阅读(973)  评论(0编辑  收藏  举报
回到顶部