代码随想录-动态规划-背包问题

115 阅读3分钟

背包问题

关于背包问题理论基础,可以移步背包类型问题学习

T46-携带研究材料

见卡码网第46题携带研究材料

题目描述

小明是一位科学家,他需要参加一场重要的国际科学大会,以展示自己的最新研究成果。他需要带一些研究材料,但是他的行李箱空间有限。这些研究材料包括实验设备、文献资料和实验样本等等,它们各自占据不同的空间,并且具有不同的价值。 小明的行李空间为 N,问小明应该如何抉择,才能携带最大价值的研究材料,每种研究材料只能选择一次,并且只有选与不选两种选择,不能进行切割。

示例

输入:
int[] value  = {2, 2, 3, 1, 5, 2}; // 第 i 个商品的价值为 value[i]
int[] weight = {2, 2, 1, 5, 4, 3} // 第 i 个商品的重量为 weight[i]
int w = 2; // 最大重量
输出:
3

我的思路

  • 0-1背包问题,需要定义二维数组int[][] dp
  • dp[i][j]的定义为,在仅有[0-i]之间的商品可选,并且重量限制在j的情况下,能够装到的最高的价值
  • 如何求得状态转移方程呢?
    • 对于第 i件商品,是装还是不装的问题
    • 当然,必须得是当前的背包容量能够容得下这个物品才可以
    • 如果装了,dp[i][j] = dp[i - 1][j - weight[i]]
    • 如果不装,dp[i][j] = dp[i - 1][j]

我的题解

public int maxValue(int w, int[] weights, int[] values) {

    int n = weights.length;

    // 初始化 dp 数组
    int[][] dp = new int[n + 1][w + 1];
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= w; j++) {
            // 如果当前的容量 j 小于当前商品的重量 weight[i],则不入袋
            if (j < weights[i - 1]) {
                dp[i][j] = dp[i - 1][j];
            } else {
                // 比较入袋和不入袋两种情况的价值哪个更大
                dp[i][j] = Math.max(
                      dp[i - 1][j - weights[i - 1]] + values[i - 1], // 入袋
                      dp[i - 1][j] // 不入袋
                );
            }
        }
    }
    return dp[n][w];
}

计算复杂度分析

  • 时间复杂度O(N2)O(N^2)
  • 空间复杂度O(N2)O(N^2)

优化思路

  • 可以将二维数组dp[][]压缩为一维数组dp[],因为当前dp[i][j]只和前一个物品的状态dp[i - 1][:]相关

T416-分割等和子集

见LeetCode第416题[分割等和子集]

题目描述

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

示例 1:

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

我的思路

  • 题目限定是两个子集,因此可以先求和,如果不是偶数,则直接返回false
  • 求出每个子集的和subSum,然后从元素里头挑选值去凑,看是加入子集还是不加入
  • 如果凑成了,则直接返回true

我的代码

public boolean canPartition(int[] nums) {

    int sum = Arrays.stream(nums).sum();
    if (sum % 2 == 1) return false;
    int subSum = sum / 2;
    if (nums[0] == subSum) return true;

    boolean[][] dp = new boolean[nums.length][subSum + 1];
    // 第一行初始化
    if (nums[0] < subSum) {
        dp[0][nums[0]] = true;
    }

    for (int i = 1; i < nums.length; i++) {
        for (int j = 0; j < dp[0].length; j++) {
            dp[i][j] = j == nums[i] // 只选择当前的数
                            || (j > nums[i] && dp[i - 1][j - nums[i]]) // 选择加入
                            || dp[i - 1][j]; // 不选择
        }
        if (dp[i][subSum]) return true;
    }
    return false;
}

计算复杂度分析

  • 时间复杂度O(N×subSum)O(N\times subSum)
  • 空间复杂度O(N×subSum)O(N\times subSum)

优化思路

  • 将二维DP数组压缩到一维,即boolea[] dp
  • 并且遍历的时候,应当从大到小遍历

优化代码

/**
 * 分割等和子集
 * @param nums
 * @return
 */
public boolean canPartitionI(int[] nums) {

    int sum = Arrays.stream(nums).sum();
    if (sum % 2 == 1) return false;
    int subSum = sum / 2;
    if (nums[0] == subSum) return true;

    boolean[] dp = new boolean[subSum + 1];
    // 第一行初始化
    if (nums[0] < subSum) {
        dp[nums[0]] = true;
    }

    for (int i = 1; i < nums.length; i++) {
        for (int j = subSum; j >= 1; j--) {
            if (!dp[j]) {
                dp[j] = (j - nums[i] > 0 && dp[j - nums[i]])
                        || j == nums[i];
            }
        }
        if (dp[subSum]) return true;
    }
    return false;
}
  • 空间复杂度:空间复杂度降低到O(subSum)O(subSum)

T1049-最后一块石头的重量II

见LeetCode第1049题[最后一块石头的重量II]

题目描述

有一堆石头,用整数数组 stones 表示。其中 stones[i] 表示第 i 块石头的重量。

每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:

  • 如果 x == y,那么两块石头都会被完全粉碎;
  • 如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x

最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0

示例 1:

输入: stones = [2,7,4,1,8,1]
输出: 1
解释:
组合 2 和 4,得到 2,所以数组转化为 [2,7,1,8,1],
组合 7 和 8,得到 1,所以数组转化为 [2,1,1,1],
组合 2 和 1,得到 1,所以数组转化为 [1,1,1],
组合 1 和 1,得到 0,所以数组转化为 [1],这就是最优值。

我的思路

  • 这一题可以简化为,如何将石头分为两堆,使得两堆之间的差距最小
  • 可以先求出平均值,期望就是两堆的平均值
  • 初始化dp[i][j]表示当前使用前i个石头的情况下,距离期望值最近的重量为dp[i][j]
  • 这一题和上一题的解题思路相同

我的题解


public int lastStoneWeightII(int[] stones) {
    if (stones.length == 1) return stones[0];

    int sum = (Arrays.stream(stones).sum());
    int target = sum / 2;
    boolean[][] dp = new boolean[stones.length][target + 1];

    if (stones[0] == target) return sum % 2;

    // 初始化第一行
    if (stones[0] < target) {
        dp[0][stones[0]] = true;
    }

    for (int i = 1; i < stones.length; i++) {
        for (int j = 1; j <= target; j++) {
            dp[i][j] = j == stones[i] // 只要当前的石头
                    || dp[i - 1][j] // 不要当前的石头
                    || (j > stones[i] && dp[i - 1][j - stones[i]]); // 当前的时候和上一个石头叠加
        }
        // 即使达到了target,需要判断是否是真正的均分
        if (dp[i][target]) return sum % 2;
    }

    // 遍历寻找最近的target
    for (int i = target; i >= 0; i--) {
        if (dp[stones.length - 1][i]) {
            return sum - 2 * i;
        }
    }
    return sum;

}

计算复杂度分析

  • 时间复杂度O(N×subSum)O(N\times subSum)
  • 空间复杂度O(N×subSum)O(N\times subSum)

优化思路

同样的,可以将二维DP数组压缩到一维,并且更新DP数组的时候,需要注意从大到小更新

public int lastStoneWeightIII(int[] stones) {
    if (stones.length == 1) return stones[0];

    int sum = (Arrays.stream(stones).sum());
    int target = sum / 2;
    boolean[] dp = new boolean[target + 1];

    if (stones[0] == target) return sum % 2;


    for (int stone : stones) {
        for (int j = target; j >= 1; j--) {
            if (!dp[j]) { // 自动继承之前石头的重量
                dp[j] = j == stone // 选择当前的石头
                        || (j > stone && dp[j -stone]); // 当前石头加上之前的石头
            }
        }
        if (dp[target]) return sum % 2;
    }

    for (int j = target; j >= 1; j--) {
        if (dp[j]) return sum - 2 * j;
    }
    return sum;

}

T494-目标和

见Leetcode第494题[目标和]

题目描述

给你一个非负整数数组 nums 和一个整数 target 。

向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :

  • 例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。

返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

示例 1:

输入: nums = [1,1,1,1,1], target = 3
输出: 5
解释: 一共有 5 种方法让最终目标和为 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
+1 + 1 + 1 + 1 - 1 = 3

我的思路

x+y=sum,xy=target,x=sum+target2,x + y = sum, \\ x - y = target, \\ x = \frac{sum + target}{2},
  • 这题和上面的石头问题本质上是一个问题
  • 不过本题是要求出解的数量,而不是任意一个解
  • 所以一个关键的问题就是,我们的dp[][]数组的定义是什么?
    • 行,表示物品的种类
    • 列,表示当前的和
    • 值,表示,使用当前 i + 1个物品的情况下,能够有几种方式达到当前和curSum
  • 状态转移方程是什么呢?
    • 选择当前的数字:dp[i][j] = dp[i - 1][j - nums[i]]
    • 不选择当前的数字:dp[i][j] = dp[i - 1][j]
    • 所以能够凑成当前和j的方式有两种,将他们加起来即可【有点像爬楼梯】
public int findTargetSumWays(int[] nums, int target) {
    if (nums.length == 1 && Math.abs(target) != nums[0]) return 0;
    int sum = Arrays.stream(nums).sum();
    if (sum < (-1) * target) return 0;
    if ((sum + target) % 2 != 0) {
        return 0;
    }
    int subSum = (sum + target) / 2;
    int[][] dp = new int[nums.length][subSum + 1];
    // 初始化一些条件
    for (int i = 0; i < nums.length; i++) {
        dp[i][0] = 1;
    }
    if (nums[0] <= subSum) dp[0][nums[0]] += 1;

    for (int i = 1; i < nums.length; i++) {
        for (int j = 0; j <= subSum; j++) {
            dp[i][j] = dp[i - 1][j]; // 不选择当前数
            if (j >= nums[i]) {
                dp[i][j] += dp[i - 1][j - nums[i]];
            }
        }
    }

    return dp[nums.length - 1][subSum];
}

T474 1-0和

见LeetCode第474题[1-0和]

题目描述

给你一个二进制字符串数组 strs 和两个整数 m 和 n 。

请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。

如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。

示例 1:

输入: strs = ["10", "0001", "111001", "1", "0"], m = 5, n = 3
输出: 4
解释: 最多有 5031 的最大子集是 {"10","0001","1","0"} ,因此答案是 4 。
其他满足题意但较小的子集包括 {"0001","1"} 和 {"10","1","0"} 。{"111001"} 不满足题意,因为它含 41 ,大于 n 的值 3

我的思路

  • 这一题也是 0-1背包问题,对于strs中的每一个元素,要不就是放进去,要不就是不放
  • 之前的背包问题,约束条件为背包的容量,但是现在的约束条件为二维的,可能需要一个三维的dp数组?
  • dp[i][j][k]表示在只有前i + 1个元素的情况下,j0k1最多能够装的个数
  • 什么时候应该装?什么时候不应该装?
  • 不应该装:
    • str.length() > m + n || numbers of 0 > m || numbers of 1 > n:装不下
    • dp[i - 1][j - x][k - y] + 1 < dp[i - 1][j][k]:装的少
  • 其他情况:可以装

我的代码

public int findMaxForm(String[] strs, int m, int n) {

    // 在只有前 i + 1个元素,并且最多有 j 个 0 和 k 个 1 的情况下,最多能装多少个
    int[][][] dp = new int[strs.length + 1][m + 1][n + 1];

    // 遍历 strs 中的元素
    for (int i = 1; i < dp.length; i++) {
        int[] nums = calculateOneZeros(strs[i - 1]);
        int x = nums[0]; // 0 的数量
        int y = nums[1]; // 1 的数量
        for (int j = 0; j <= m; j++) {
            for (int k = 0; k <= n; k++) {
                if (x <= j && y <= k) {
                    dp[i][j][k] = Math.max(dp[i - 1][j][k], // 不选择这个元素
                            dp[i - 1][j - x][k - y] + 1); // 选择这个元素
                } else {
                    // 选择这个元素
                    dp[i][j][k] = dp[i - 1][j][k];
                }
            }
        }
    }
    return dp[strs.length][m][n];
}

/**
 * 计算当前字符串 0 和 1 的个数
 * @param str
 * @return
 */
private int[] calculateOneZeros(String str) {
    int[] res = new int[2];
    for (char c : str.toCharArray()) {
        if (c == '0') {
            res[0]++;
        } else {
            res[1]++;
        }
    }
    return res;
}

计算复杂度分析

  • 时间复杂度:O(N3)O(N^3)
  • 空间复杂度:O(N3)O(N^3),用于存储dp数组

优化思路

将三维dp数组压缩到二维,但是更新的时候,需要从大到小进行更新,防止更新过的数据影响待更新数据。

T518-零钱兑换II

见LeetCode第518题[零钱兑换II]

题目描述

给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。

假设每一种面额的硬币有无限个。 

题目数据保证结果符合 32 位带符号整数。

示例 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

我的思路

  • 这是一道典型的完全背包问题,物品可以无限次放进背包
  • dp[i]表示凑齐当前的金额,有多少种方法
  • 要想凑齐 i
    • 遍历硬币中的每一个元素
    • dp[i] = dp[i - coin] + dp[i]
  • 返回结果dp[amount]即可

上面的思路有什么错误?

如果想凑出3元,给[1,2]两个硬币,根据上面的思路,就会有

3 = 1 + 1 + 1
3 = 1 + 2
3 = 2 + 1
总共三种方式,但是可以看出,第二种和第三种完全一样

上面这种情况出现的原因就是:对于 2 这颗硬币,当时没选择用,但是其他地方又用到了,处于 用和不用 的叠加状态。

为了避免这种情况,在敲定 2 这枚硬币的时候:

  • 用:dp[3][i] = dp[3 - 2][i],表示用了还能再用
  • 不用:dp[3][i] = dp[3][i - 1],永远不用

这两种情况之和即为当前所有的可能性。

/**
 * 优化后方法
 * @param amount
 * @param coins
 * @return
 */
public int changeII(int amount, int[] coins) {
    // 一维 DP 数组
    int[] dp = new int[amount + 1];
    dp[0] = 1;

    // 外层循环为硬币,内层循环为金额
    for (int coin : coins) {
        for (int i = coin; i <= amount; i++) {
            dp[i] += dp[i - coin];
        }
    }
    return dp[amount];
}

计算复杂度分析

  • 时间复杂度:O(N2)O(N^2)
  • 空间复杂度:O(N)O(N)

T377-组合总和IV

见LeetCode第377题[组合总和IV]

题目描述

给你一个由 不同 整数组成的数组 nums ,和一个目标整数 target 。请你从 nums 中找出并返回总和为 target 的元素组合的个数。

题目数据保证答案符合 32 位整数范围。

这一题应该被称为 排列总和

示例 1:

输入: nums = [1,2,3], target = 4
输出: 7
解释:
所有可能的组合为:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
请注意,顺序不同的序列被视作不同的组合。

我的思路

  • 这一题,顺序不同的序列被认为是不同的组合,是不是和上一题的错误思路很像?
  • 上一题的思路为什么会有重复的答案呢?
    • 就是因为将硬币放在内层循环,每个金额又和前面的金额相关,本次选择和前面的选择为[1,2]和[2,1]造成的同组合不同排列的问题
  • 因此,本题可直接借用上题的思路,将硬币放在内层循环
/**
 * 排列总和
 * @param nums
 * @param target
 * @return
 */
public int combinationSum4(int[] nums, int target) {
    int[] dp = new int[target + 1];
    dp[0] = 1;
    for (int i = 1; i <= target; i++) {
        for (int num : nums) {
            if (i >= num) {
                dp[i] += dp[i - num];
            }
        }
    }
    return dp[target];
}

计算复杂度分析

  • 时间复杂度:O(N2)O(N^2)
  • 空间复杂度:O(N)O(N)

T322-零钱兑换

见LeetCode第322题[零钱兑换]

题目描述

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。

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

示例 1:

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

我的思路

  • 定义int[] dp表示要凑齐amount元最少需要多少枚硬币
  • 因为硬币原则上是无限可以用的,对于当前的金额i
    • 可以用当前硬币:dp[i - coin] + 1
    • 不用当前硬币:dp[i]
  • 金额遍历方式为从小到大
  • 数组初始化为~~-1~~Integer.MAX_VALUE
public int coinChange(int[] coins, int amount) {
    if (amount == 0) return 0;

    // 初始化 dp 数组,表示凑齐金额 i 最少需要多少枚硬币
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, Integer.MAX_VALUE);
    dp[0] = 0;

    // 外层遍历金额,内层遍历硬币
    for (int i = 1; i <= amount; i++) {
        for (int coin : coins) {
            // 当前金额大于硬币面值,并且余额可以凑成
            if (i >= coin && dp[i - coin] != Integer.MAX_VALUE) {
                dp[i] = Math.min(dp[i], dp[i - coin] + 1);
            }
        }
    }
    return dp[amount] == Integer.MAX_VALUE ? -1 : dp[amount];
}

计算复杂度分析

  • 时间复杂度:O(N2)O(N^2)
  • 空间复杂度:O(N)O(N)

T279-完全平方数

见LeetCode第279题[完全平方数]

题目描述

给你一个整数 n ,返回和为 n 的完全平方数的 最少数量 。

完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。

示例 1:

输入:n = 12 
输出:3 
解释:12 = 4 + 4 + 4

我的思路

  • 这题本质上还是完全背包问题
  • 对于数 n,其可用的硬币[完全平方数为]:1~sqrt(n)
  • 定义int dp[],达到当前值所需要的最小的硬币
public int numSquares(int n) {
    if (n == 1) return 1;
    int m = (int) Math.sqrt(n);

    // 初始化 dp 表示和为 i 所需最小平方数的数量为 dp[i]
    int[] dp = new int[n + 1];
    Arrays.fill(dp, Integer.MAX_VALUE);
    dp[0] = 0;
    dp[1] = 1;

    for (int i = 2; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            // 当前的数 i 大于 当前的硬币面值(j * j)
            if (i >= j * j && dp[i - j * j] != Integer.MAX_VALUE) {
                dp[i] = Math.min(dp[i], dp[i - j * j] + 1);
            }
        }
    }

    return dp[n];
}

计算复杂度分析

  • 时间复杂度:O(N2)O(N^2)
  • 空间复杂度:O(N)O(N)

T139-单词拆分

见LeetCode第139题[单词拆分]

题目描述

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s 则返回 true。 注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用

示例 1

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

我的思路

  • 定义int[][] dp表示在使用前i个单词时,能否拼接成字符串subString(0,j)
  • 对于第i个单词,想要拼成前j个字符的字符串
    • 使用该单词:dp[i][j - subStr.length]
    • 不用该单词: dp[i - 1][j]
public boolean wordBreak(String s, List<String> wordDict) {
    boolean[][] dp = new boolean[s.length() + 1][wordDict.size() + 1];

    // 初始化第一行为 true
    Arrays.fill(dp[0], true);

    for (int i = 1; i <= s.length(); i++) {
        for (int j = 1; j <= wordDict.size(); j++) {
            // 什么时候用?
            String sub = s.substring(0, i);
            int index = sub.lastIndexOf(wordDict.get(j - 1));
            if (index != -1 && index + wordDict.get(j - 1).length() == i) {
                dp[i][j] = dp[index][wordDict.size()]  // 选择
                                || dp[i][j - 1]; // 不选择
            } else {
                dp[i][j] = dp[i][j - 1]; // 不选择
            }
        }
    }
    return dp[s.length()][wordDict.size()];
}