LeetCode刷题之动态规划(三)

1,342 阅读20分钟
  • 动态规划的题目是真的多,而且每道题都有好几种解法。每次都先写暴力递归,再转为动态规划的原因是只有这样才能更好地理解转移方程是怎么来的,不会一脸蒙蔽,傻傻的背转移方程。
  • 一开始看解答的时候看到大佬们都是上来直接怼转移方程,很担心这样会太慢,导致拖节奏,但是还是强行逼自己按照这个步骤走,做了前面十多道题也做出一点感觉了,所以继续按照这个方法做。
  • 重点是要理解怎么转成动态规划的。因为动态规划本身就是对暴力递归的优化,“欲速则不达”。

0-1背包问题

给定n个重量为w1,w2,w3...,价值为v1,v2,v3...的物品和容量为N的背包,求在不超过背包容量的情况下,能装进的最有价值的物品集。

0-1背包问题指的是每个物品只能使用一次

定义一个二维数组 dp 存储最大价值,其中 dp[i][j] 表示前 i 件物品体积不超过 j 的情况下能达到的最大价值。设第 i 件物品体积为 w,价值为 v,根据第 i 件物品是否添加到背包中,可以分两种情况讨论:

  • 第 i 件物品没添加到背包,总体积不超过 j 的前 i 件物品的最大价值就是总体积不超过 j 的前 i-1 件物品的最大价值,dp[i][j] = dp[i-1][j]。
  • 第 i 件物品添加到背包中,dp[i][j] = dp[i-1][j-w] + v。

第 i 件物品可添加也可以不添加,取决于哪种情况下最大价值更大。因此,0-1 背包的状态转移方程为:


416. 分割等和子集(Medium)

给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

注意:
每个数组中的元素不会超过 100
数组的大小不会超过 200

示例 1:

输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].

示例2:

输入: [1, 2, 3, 5]
输出: false
解释: 数组不能分割成两个元素和相等的子集.

解法一:

  • 暴力递归求解(回溯),先判断和是否为偶数,如果为奇数,直接返回false。求出所有和的可能,如果有等于1/2 sum的,返回true,否则返回false。
  • 先对nums进行排序,方便递归过程中进行剪枝
class Solution {
    public boolean canPartition(int[] nums) {

        int sum = 0;
        for (int i = 0; i < nums.length; i++) {
            sum += nums[i];
        }

        //和为奇数,返回false
        if (sum & 1 == 1){
            return false;
        }

        int target = sum / 2;

        //对数组进行降序排序,方便进行剪枝
        Arrays.sort(nums);
        reverse(nums);

        return canPartition(nums, 0, target);
    }

    private boolean canPartition(int[] nums, int index, int target) {
        if (index >= nums.length || nums[index] > target){
            return false;
        }
        
        if (nums[index] == target){
            return true;
        }
        
        //每个元素都可以选择或者不选择两种可能
        return canPartition(nums, index + 1, target - nums[index]) || canPartition(nums, index + 1, target);
    }

    //翻转数组
    public void reverse(int[] data) {
        for (int left = 0, right = data.length - 1; left < right; left++, right--) {
            // swap the values at the left and right indices
            int temp = data[left];
            data[left] = data[right];
            data[right] = temp;
        }
    }
}

解法二:将暴力递归转为动态规划

  • 变量:数组下标index和当前和curSum;dp[i][j]:表示从数组的 [0, i] 这个子区间内挑选一些正整数,每个数只能用一次,使得这些数的和等于 j。
  • 转移方程由递归表达式可知:dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]] (nums[i] <= j),每个数字都可以选或者不选,所以有两种可能
class Solution {
    public boolean canPartition(int[] nums) {

        int sum = 0;
        for (int i = 0; i < nums.length; i++) {
            sum += nums[i];
        }

        //和为奇数,返回false
        if (sum % 1 == 1){
            return false;
        }

        int target = sum / 2;
        int len = nums.length;

        //创建二维数组,行:物品索引,列:和
        boolean[][] dp = new boolean[len][target + 1];
        //先填充第一行
        for (int i = 1; i < target + 1; i++) {
            if (nums[0] == i){
                dp[0][i] = true;
            }
        }

        for (int i = 1; i < len; i++) {
            for (int j = 0; j < target + 1; j++) {
                //j<nums[i],只能不选nums[i]
                dp[i][j] = dp[i - 1][j];
                //可以选nums[i]
                if (j > nums[i]){
                    dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]];
                }
            }
        }
        return dp[len - 1][target];
    }
}

解法三:从二维降到一维,减少空间复杂度在填 dp 数组的时候,从第2行开始,每一行都参考了前一行的值,因此状态数组从可以从二维降到一维,从而减少空间复杂度。

注意:从第 2 行开始,每一行都参考了前一行的当前位置的值,并且还参考了前一行的小于当前位置的值。

后一行的值总是参考了它上面一行 “头顶上” 那个位置和“左上角”某个位置的值。因此,我们在填 dp 数组的时候,可以“从后向前”填写。

public class Solution {

    public boolean canPartition(int[] nums) {
        int size = nums.length;
        
        int s = 0;
        for (int num : nums) {
            s += num;
        }
        if ((s & 1) == 1) {
            return false;
        }

        int target = s / 2;

        // 从第 2 行以后,当前行的结果参考了上一行的结果,因此使用一维数组定义状态就可以了
        boolean[] dp = new boolean[target + 1];
        // 先写第 1 行,看看第 1 个数是不是能够刚好填满容量为 target
        for (int j = 1; j < target + 1; j++) {
            if (nums[0] == j) {
                dp[j] = true;
                // 如果等于,后面就不用做判断了,因为 j 会越来越大,肯定不等于 nums[0]
                break;
            }
        }
        // 注意:因为后面的参考了前面的,我们从后向前填写
        for (int i = 1; i < size; i++) {
            // 后面的容量越来越小,因此没有必要再判断了,退出当前循环
            for (int j = target; j >= 0 && j >= nums[i]; j--) {
                dp[j] = dp[j] || dp[j - nums[i]];
            }
        }
        return dp[target];
    }
}

494. 目标和(Medium)

给定一个非负整数数组,a1, a2, ..., an, 和一个目标数S。现在你有两个符号 + 和 -。对于数组中的任意一个整数,你都可以从 + 或 -中选择一个符号添加在前面。

返回可以使最终数组和为目标数 S 的所有添加符号的方法数。

示例 1:

输入: nums: [1, 1, 1, 1, 1], S: 3
输出: 5

解释:

-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3
一共有5种方法让最终目标和为3。

注意:

数组非空,且长度不会超过20。
初始的数组的和不会超过1000。
保证返回的最终结果能被32位整数存下。

解法一:使用暴力递归,每个数字前面都可以加 + 或者 - 号,那么可以用回溯的方法来解决。

class Solution {
    int count = 0;
    
    public int findTargetSumWays(int[] nums, int S) {
        findTargetSumWays(nums, S, 0, 0);
        return count;
    }

    private void findTargetSumWays(int[] nums, int S, int sum, int index) {
        //base case
        if (index == nums.length){
            if (sum == S){
                count++;
            }
        }else {
            // +
            findTargetSumWays(nums, S, sum + nums[index], index + 1);
            // -
            findTargetSumWays(nums, S, sum - nums[index], index + 1);
        }
    }
}

解法二:转为0-1背包问题,这个解法比较巧妙,笔者也是参考LeetCode上题解区才知道怎么做的。

  • 假设nums中正数子集为P,负数子集为N,他俩的绝对值和分别为sum(P)和sum(N),那么有
    • sum(P) - sum(N) = S
    • => sum(nums) + sum(P) - sum(N) = target + sum(nums)
    • => 2 * sum(P) = target + sum(nums)
    • => sum(P) = (target + sum(nums)) / 2
    • 因此题目转化为01背包,也就是能组合成容量为sum(P)的方式有多少种
  • dp[i]表示容量为i的子集的方法数,转移方程为dp[i]=dp[i-num]+dp[i]
class Solution {
    public int findTargetSumWays(int[] nums, int S) {
        int sum = 0;
        for (int num : nums) {
            sum += num;
        }
        //如果总和<S或者不为偶数,返回0
        if (sum < S || (S + sum) % 1 == 1){
            return 0;
        }
        
        int w = (sum + S) / 2;
        int[] dp = new int[w + 1];
        //容量为0只有一种方式
        dp[0] = 1;
        for (int num : nums) {
            for (int j = w; j >= num; j--) {
                dp[j] = dp[j] + dp[j - num];
            }
        }
        return dp[w];
    }
}

474. 一和零(Meidum)

在计算机界中,我们总是追求用有限的资源获取最大的收益。

现在,假设你分别支配着 m 个 0 和 n 个 1。另外,还有一个仅包含 0 和 1 字符串的数组。

你的任务是使用给定的 m 个 0 和 n 个 1 ,找到能拼出存在于数组中的字符串的最大数量。每个 0 和 1 至多被使用一次。

注意:

给定 0 和 1 的数量都不会超过 100。
给定字符串数组的长度不会超过 600。

示例 1:

输入: Array = {"10", "0001", "111001", "1", "0"}, m = 5, n = 3
输出: 4
解释: 总共 4 个字符串可以通过 5 个 0 和 3 个 1 拼出,即 "10","0001","1","0" 。

示例 2:

输入: Array = {"10", "0", "1"}, m = 1, n = 1
输出: 2
解释: 你可以拼出 "10",但之后就没有剩余数字了。更好的选择是拼出 "0" 和 "1" 。

解法一:这道题是道0-1背包问题,m个0和n个1可以看做背包,数组可以看做是物品,将物品放入背包,则每个物品都有放或者不放进去的选择,选出最多能放进去的数量

class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        if (strs.length == 0 || m == 0 && n == 0){
            return 0;
        }
        return findMaxForm(strs, 0, m, n);
    }

    private int findMaxForm(String[] strs, int index, int m, int n) {
        //base case
        if (index >= strs.length){
            return 0;
        }

        //统计字符串的0和1的数量
        int count0 = 0;
        int count1 = 1;
        String str = strs[index];
        for (int i = 0; i < str.length(); i++) {
            if (str.charAt(i) == '1'){
                count1++;
            }else {
                count0++;
            }
        }

        //剩下的1和0够,可以选择要或不要这个字符串
        if (m >= count0 && n >= count1){
            return Math.max(findMaxForm(strs, index + 1, m, n), findMaxForm(strs, index + 1, m - count0, n - count1));
        }else {
            //不够的话只能跳过
            return findMaxForm(strs, index + 1, m, n);
        }
    }
}

解法二:第一种解法肯定会超时,我们可以将这种方法优化一下,将递归过程的结果用数组存储下来,就可以减少重复计算

public class Main {

    int[][][] memo;

    public int findMaxForm(String[] strs, int m, int n) {
        if (strs.length == 0 || m == 0 && n == 0){
            return 0;
        }
        this.memo = new int[strs.length][m + 1][n + 1];
        for (int i = 0; i < memo.length; i++) {
            for (int j = 0; j < memo[i].length; j++) {
                Arrays.fill(memo[i][j], -1);
            }
        }
        return findMaxForm(strs, 0, m, n);
    }

    private int findMaxForm(String[] strs, int index, int m, int n) {
        //base case
        if (index >= strs.length){
            return 0;
        }

        if (memo[index][m][n] != -1){
            return memo[index][m][n];
        }

        //统计字符串的0和1的数量
        int count0 = 0;
        int count1 = 1;
        String str = strs[index];
        for (int i = 0; i < str.length(); i++) {
            if (str.charAt(i) == '1'){
                count1++;
            }else {
                count0++;
            }
        }


        //剩下的1和0够,可以选择要或不要这个字符串
        if (m >= count0 && n >= count1){
            memo[index][m][n] = Math.max(findMaxForm(strs, index + 1, m, n), findMaxForm(strs, index + 1, m - count0, n - count1));
        }else {
            //不够的话只能跳过
            memo[index][m][n] = findMaxForm(strs, index + 1, m, n);
        }
        return memo[index][m][n];
    }
}

解法三:转为动态规划,变量:index、m、n以及dp[index][m][n]表示m个0和n个1能组成的最大字符串数量。

class Solution {

    public int findMaxForm(String[] strs, int m, int n) {
        if(strs.length == 0 || (m==0 && n==0)){
            return 0;
        }
        // dp[i][j][k] 表示j个0,k个1组成s[0...i]的最大个数,默认0
        int[][][] dp = new int[strs.length][m+1][n+1];
        
        for(int i=0;i<strs.length;i++){
            int numsOf0 = 0;
            int numsOf1 = 0;
            String str = strs[i];
            for(int j = 0;j<str.length();j++){
                if(str.charAt(j) == '0'){
                    numsOf0++;
                }else{
                    numsOf1++;
                }
            }
            for(int j=m;j>=0;j--){
                for(int k=n;k>=0;k--){
                    if(j>=numsOf0 && k >= numsOf1){
                        对于一个字符穿来说,只能组成一个
                        if(i==0){
                            dp[i][j][k] = 1;
                        }else{
                            dp[i][j][k] = Math.max(dp[i-1][j][k],1+dp[i-1][j-numsOf0][k-numsOf1]);
                        }
                    }else{
                        dp[i][j][k] = i>0 ? dp[i-1][j][k] : 0;
                    }   
                }
            }
        }
        return dp[strs.length-1][m][n];
    }
}

解法四:观察转移方程可知,dp[i][][]只与dp[i-1][][]有关,所以可以将其压缩为二维数组。

class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        if(strs.length == 0 || (m==0 && n==0)){
            return 0;
        }
        
        int[][] dp = new int[m+1][n+1];
       
        for(int i=0;i<strs.length;i++){
            int numsOf0 = 0;
            int numsOf1 = 0;
            String str = strs[i];
            //统计1和0的个数
            for(int j = 0;j<str.length();j++){
                if(str.charAt(j) == '0'){
                    numsOf0++;
                }else{
                    numsOf1++;
                }
            }
            //填充dp
            for(int j=m;j>=numsOf0;j--){
                for(int k=n;k>=numsOf1;k--){
                    dp[j][k] = Math.max(dp[j][k],1+dp[j-numsOf0][k-numsOf1]);
                }
            }
        }
        return dp[m][n];
    }
}

322. 零钱兑换(Medium)

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

示例 1:

输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1

示例 2:

输入: coins = [2], amount = 3
输出: -1

说明:

你可以认为每种硬币的数量是无限的。

解法一:暴力递归,这道题也是一道完全背包问题,完全背包指的是物品为无限个,而0-1背包是指物品只有一个。

  • 这道题用到了「最优子结构」性质:原问题的解由子问题的最优解构成。以示例一为例,f(11)的最优解,是由f(10),f(9)和f(6)的最优解转移而来的,那么就可以写出递归表达式f(n)=1+min(f(n-ci) | 1=<i<=k)
class Solution {
    public static int coinChangeT(int[] coins, int amount) {
        //base case
        if (amount == 0){
            return 0;
        }

        int ans = Integer.MAX_VALUE;
        for (int i = 0; i < coins.length; i++) {
            //金额不可达
            if (coins[i] > amount){
                continue;
            }
            int subProb = coinChangeT(coins, amount - coins[i]);
            //子问题无解
            if (subProb == -1){
                continue;
            }
            ans = Math.min(ans, subProb + 1);
        }
        return ans == Integer.MAX_VALUE ? -1 : ans;
    }
}

解法二:优化后的暴力递归,使用数组记忆中间已经计算过的部分,并且对数组进行排序,遇到

public int coinChange(int[] coins, int amount) {
        //先给硬币排序
        Arrays.sort(coins);
        int len = coins.length;
        //记忆数组
        int[] memo = new int[amount + 1];
        Arrays.fill(memo, -2);
        return coinChangeSort(coins, amount, memo, len - 1);
    }
    
//排序的解法
public int coinChangeSort(int[] coins, int amount, int[] memo, int index) {
    //base case
    if (amount == 0){
        return 0;
    }

    //如果已经计算过,直接返回
    if (memo[amount] != -2){
        return memo[amount];
    }

    int res = Integer.MAX_VALUE;
    for (int i = 0; i < coins.length; i++) {
        //金额不可达
        if (coins[i] > amount){
            continue;
        }
        int subProb = coinChangeSort(coins, amount - coins[i], memo, i);
        //子问题无解
        if (subProb == -1){
            continue;
        }
        res = Math.min(res, subProb + 1);
    }
    //记录本轮答案
    memo[amount] = (res == Integer.MAX_VALUE) ? -1 : res;
    return memo[amount];
}

解法三:转为动态规划,做出来了带记忆数组的递归,动态规划的雏形也就基本出来了,不同点是动态规划是自底向上,另外一个是自顶向下

  • 如果遍历到的硬币值小于i值时,用 dp[i - coins[j]] + 1 来更新 dp[i]吗,如果大于,就跳过。
public int coinChange(int[] coins, int amount) {
    //dp[i]表示目标值为i的最少硬币组成数
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, amount + 1);
    dp[0] = 0;
    for (int i = 1; i < amount + 1; i++) {
        for (int coin : coins) {
            //如果遍历到的硬币值小于i值时,用 dp[i - coins[j]] + 1 来更新 dp[i]
            if (coin <= i){
                dp[i] = Math.min(dp[i], dp[i - coin] + 1);
            }
        }
    }
    return dp[amount] > amount ? -1 : dp[amount];
}

518. 零钱兑换 II(Medium)

给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。

示例 1:

输入: amount = 5, coins = [1, 2, 5]
输出: 4
解释: 有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

示例 2:

输入: amount = 3, coins = [2]
输出: 0
解释: 只用面额2的硬币不能凑成总金额3。

示例 3:

输入: amount = 10, coins = [10]
输出: 1

注意:

你可以假设:
0 <= amount (总金额) <= 5000
1 <= coin (硬币面额) <= 5000
硬币种类不超过 500 种
结果符合 32 位符号整数

解法一:

  • 这道题也是典型的“完全背包”问题,是上道题的延伸,不同点是上道题求达成目标金额的最少硬币数,这道题是求能达到目标金额的方法数,这类问题不难想到可以使用“回溯”解决。
  • 不过使用回溯会产生一些重复解,比如“1,2,1”和“2,1,1”这种,只要保证结果都是非降序的就不会出现重复,此时就需要对数组进行排序,并且遍历的时候保证当前选的硬币面值>=上次选择的,这里借用LeetCode题解区一位大佬的

class Solution {

    private int res = 0;

    public int change(int amount, int[] coins) {
        //对数组进行排序
        Arrays.sort(coins);
        int len = coins.length;
        backTrackin(amount, coins, 0, len);
        return res;
    }

    private void backTrackin(int amount, int[] coins, int start, int len) {
        //base case
        if (amount == 0){
            res++;
            return;
        }

        //遍历硬币
        for (int i = start; i < len; i++) {
            //如果当前目标值小于当前硬币面值,直接跳出循环,因为硬币是递增的
            if (amount < coins[i]){
                break;
            }
            backTrackin(amount - coins[i], coins, i, len);
        }
    }
}

解法二:

转为动态规划,dp[i][j]表示前i种硬币能够凑出金额为j的种数,前i种硬币凑出面值为j的种数由前i-1种凑出j-coins[i-1]组成。

  • 转移方程为 dp[i][j] = dp[i - 1][j - 0 * coins[i - 1]] +
         dp[i - 1][j - 1 * coins[i - 1]] +
         dp[i - 1][j - 2 * coins[i - 1]] +
          ... +
         dp[i - 1][j - k * coins[i - 1]]
  • 因为一种面额的硬币可以有多个,所以为k,根据这个状态转移方程可以画出表格

public int change(int amount, int[] coins) {
        int len = coins.length;
        int[][] dp = new int[len + 1][amount + 1];
        //初始化dp,dp[0][0]表示前0个硬币组成金额为0的数量,这个肯定是1
        dp[0][0] = 1;

        //遍历硬币数组,所有硬币都需要尝试
        for (int i = 1; i <= len; i++) {
            for (int j = 0; j <= amount; j++) {
                //每种硬币都可以投多次
                for (int k = 0; j - k * coins[i - 1] >= 0; k++) {
                    //累加前i-1个硬币不同金额的可能
                    dp[i][j] = dp[i][j] + dp[i - 1][j - k * coins[i - 1]];
                }
            }
        }
        return dp[len][amount];
    }

解法三:其实每一行单元的值的填写我们只要看它的左边就好了,如果没有左边,它至少是上一行单元格的值。

public class Solution {

    public int change(int amount, int[] coins) {
        int len = coins.length;
        int[][] dp = new int[len + 1][amount + 1];
        dp[0][0] = 1;
        
        //遍历硬币
        for (int i = 1; i <= len; i++) {
            //遍历每一行的的数值
            for (int j = 0; j <= amount; j++) {
                dp[i][j] = dp[i - 1][j];
                if (j - coins[i - 1] >= 0) {
                    dp[i][j] += dp[i][j - coins[i - 1]];
                }
            }
        }
        return dp[len][amount];
    }
}

解法四:将二维数据压缩到一维

public class Solution {

    public int change(int amount, int[] coins) {
        int[] dp = new int[amount + 1];
        dp[0] = 1;

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

139. 单词拆分(Medium)

给定一个非空字符串 s 和一个包含非空单词列表的字典 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。

说明:

拆分时可以重复使用字典中的单词。
你可以假设字典中没有重复的单词。

示例 1:

输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以被拆分成 "leet code"。

示例 2:

输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以被拆分成 "apple pen apple"。

注意你可以重复使用字典中的单词。

示例 3:

输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出: false

解法一:

  • 这道题也是典型的“完全背包问题”,单词可以使用多次,s可以看做是背包,字典看做是物品。
  • 使用暴力递归解决,一个字符串怎么判读字典中的单词是否包含在里面,办法就是分段,问题是怎么分段,分几段,这个可以交给递归解决,遍历字符串s,将其分为前半段和后半段,后半段继续调用这个递归函数,继续分段,如果最后分出来的单词都在字典中,则返回true,为例提高查询效率,将字典的值转存到HashSet中
class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        return wordBreak(s, new HashSet<String>(wordDict), 0);
    }

    private boolean wordBreak(String s, HashSet<String> wordDict, int start) {
        //base case
        if (start == s.length()){
            return true;
        }

        //将字符串s进行分段
        for (int end = start + 1; end <= s.length(); end++) {
            //如果wordDict包含前半段和后半段,返回true
            if (wordDict.contains(s.substring(start, end)) && wordBreak(s, wordDict, end)){
                return true;
            }
        }
        //如果不在,返回false
        return false;
    }
}

解法二:带记忆数组的暴力递归

public boolean wordBreak(String s, List<String> wordDict) {
        return wordBreak(s, new HashSet<String>(wordDict), 0, new Boolean[s.length()]);
    }

    private boolean wordBreak(String s, HashSet<String> wordDict, int start, Boolean[] memo) {
        //base case
        if (start == s.length()){
            return true;
        }

        //如果后半段已经出现过,直接返回记忆数组内容
        if (memo[start] != null){
            return memo[start];
        }

        //将字符串s进行分段
        for (int end = start + 1; end <= s.length(); end++) {
            //如果wordDict包含前半段和后半段,返回true
            if (wordDict.contains(s.substring(start, end)) && wordBreak(s, wordDict, end, memo)){
                memo[start] = true;
                return memo[start];
            }
        }
        //如果不在,返回false
        memo[start] = false;
        return memo[start];
    }

解法三:动态规划,有了带记忆数组的暴力递归,转动态规划就比较简单,因为前者是自顶向下,后者是自底向上。准备一个数组dp[],dp[i]表示前范围为[0,i)的字符串可以被拆分,状态转移方程是

if (dp[j] && wordSet.count(s.substr(j, i - j))){
    dp[i] = true;
}
public class Solution {

    public boolean wordBreak(String s, List<String> wordDict) {
        int len = s.length();
        boolean[] dp = new boolean[len + 1];
        //将list转存到set
        HashSet<String> set = new HashSet<String>(wordDict);

        //初始化dp,前0个肯定可以被拆分
        dp[0] = true;
        //i:子串的结尾,j:拆分的位置
        for (int i = 0; i <= len; i++) {
            for (int j = 0; j < i; j++) {
                //如果前半段和后半段都为true,那么整个字符串都为true
                if (dp[j] && set.contains(s.substring(j, i))){
                    dp[i] = true;
                    break;
                }
            }
        }
        return dp[len];
    }
}

377. 组合总和 Ⅳ(Medium)

给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。

示例:

nums = [1, 2, 3]
target = 4
所有可能的组合为:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)

请注意,顺序不同的序列被视作不同的组合。因此输出为 7。

进阶:

如果给定的数组中含有负数会怎么样?
问题会产生什么变化?
我们需要在题目中添加什么限制来允许负数的出现?

解法一:这道题跟518.零钱兑换 II类似,不同的是零钱兑换顺序不同的序列视为相同的组合,本题视为不同组合。本题也是典型的“完全背包问题

class Solution {
    int res = 0;

    public int combinationSum4(int[] nums, int target) {

        combinationSum4Helper(nums, target);
        return res;
    }

    private void combinationSum4Helper(int[] nums, int target) {
        //当前和等于目标和
        if (target == 0){
            res++;
            return;
        }

        if (target < 0){
            return;
        }

        //遍历nums
        for (int i = 0; i < nums.length; i++) {
            combinationSum4Helper(nums, target - nums[i]);
        }
    }
}

解法二:依旧是对暴力递归进行初步的优化:带记忆数组的暴力递归,有记忆数组的递归函数返回值得为int。

public class Solution {
    
    public int combinationSum4(int[] nums, int target) {

        int[] memo = new int[target + 1];
        return combinationSum4Helper(nums, target, memo);
    }

    private int combinationSum4Helper(int[] nums, int target, int[] memo) {
        //当前和等于目标和
        if (target == 0){
            return 1;
        }

        if (target < 0){
            return 0;
        }

        if (memo[target] != 0){
            return memo[target];
        }

        int res = 0;
        //遍历nums
        for (int i = 0; i < nums.length; i++) {
            res += combinationSum4Helper(nums, target - nums[i], memo);
        }
        return memo[target] = res;
    }
}

解法三:转为动态规划,变量:当前总和curSum,状态转移方程:dp[i] = dp[i] + dp[i - nums[j]],(当前的dp[i]由前面的构成)

public class Solution {

    public int combinationSum4(int[] nums, int target) {
        int len = nums.length;
        if (len == 0){
            return 0;
        }
        int[] dp = new int[target + 1];
        
        //初始化dp
        dp[0] = 1;
        //遍历target
        for (int i = 1; i <= target; i++) {
            //遍历数组
            for (int j = 0; j < len; j++) {
                if (i >= nums[j]){
                    dp[i] += dp[i - nums[j]];
                }
            }
        }
        return dp[target];
    }
}

至此,背包问题也就结束了,基本上每道题目都有从暴力递归->带记忆数组的暴力递归->动态规划的解法,不能一下子写出动态规划的,可以按照这个步骤走会好做很多,解答速度也算还可以。