leetcode动态规划总结之01背包和完全背包问题

1 背包问题分类

leetcode动态规划总结之01背包和完全背包问题_第1张图片
其中,除了01背包和完全背包外,leetcode题目中好像还没有涉及其他类型的背包,在这里我就不做总结。

2 01背包理论

leetcode动态规划总结之01背包和完全背包问题_第2张图片
有N件物品和一个最大承载重量为W 的背包。第i件物品的重量是weight[i],其价值是value[i] 。每件物品只能用一次,求解将哪几种物品装入背包里物品价值总和最大。
现在假设如下:
有一个容量为4kg的背包,现有如下物品

物品 重量(kg) 价格(元)
手办 1 1500
笔记本 4 3000
手机 3 2000

求背包能装入物品的最大价值。

  1. 确定dp数组的含义
    dp[i][j]表示从下标为[1,i]的物品中任意选择,能装入容量为j的背包的最大价值;
0 1 2 3 4
0
手办
笔记本
手机

二维数组的行表示不同的物品i,列表示对应的最大容量j。

  1. 求dp数组的递推关系式
    dp[i][j]:容量为j的书包装入下标为[1,i]的物品能装入的最大价值。
    dp[i - 1][j]:容量为j的书包装入下标为[1,i - 1]的物品能装入的最大价值。
  • 如果第i件物品的重量大于背包的容量,即weight[i] > j,则
    dp[i][j] = dp[i - 1][j]
  • 如果第i件物品的重量小于等于背包的容量,即weight <= j,则
    dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
    注:当有第i件物品时,是不是要判断如何装价值最大?生活中常常出现以下情形:
    1)物品i的重量比背包还重,那么就根本装不进去,符合表达式
    dp[i - 1][j]
    2)背包不用拿出其他物品,还能装下第i件物品,这包含在表达式
    dp[i - 1][j - weight[i]] + value[i] >> dp[i - 1][j]
    3)背包再继续装第i件物品会爆,拿出部分物品保证能装入第i件物品然后与不装入第i件物品的最大价值情况进行比较即可,即
    max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
    2)和3)两种情况合并即可
  1. dp数组的初始化
    dp[0][j] = dp[i][0] = 0
  2. dp数组的遍历
    根据递推表达式,dp[i][j]需要使用到dp[i - 1][j],所以应该从左往右遍历然后从上往下遍历(好像也可以从上往下,然后从左往右)
    具体实现代码如下:
    public static void main(String[] args) {
        int[] weight = {1, 4, 2};
        int[] value = {1500, 3000, 2000};
        int[][] dp = new int[4][5];
        //这里无需初始化为0,因为创建数组时默认元素的值为0,这里只是为了体现dp数组的初始化
//        for (int i = 0; i < dp.length; i++)
//            dp[i][0] = 0;
//        for (int j = 1; j < dp[0].length; j++)
//            dp[0][j] = 0;
        for (int i = 1; i < dp.length; i++) {
            for (int j = 1; j < dp[0].length; j++) {
                if (weight[i - 1] > j)
                    dp[i][j] = dp[i - 1][j];
                else
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i - 1]] + value[i - 1]);
            }
        }
        System.out.println("背包能装入的最大价值为: " + dp[3][4] + "元");
    }

3 背包之滚动数组

3.1 滚动数组

DP是一个自底向上的扩展过程,所有就会有一系列连续的值,然而每次求解的值只与前几个DP值有关,所以前面的大部分解往往可以舍去,以节省空间。
滚动数组是DP中的一种编程思想。简单的理解就是让数组滚动起来,每次都使用固定的几个存储空间,来达到压缩,节省存储空间的作用。起到优化空间,
因为DP题目是一个自底向上的扩展过程,我们常常需要用到的是连续的解,前面的解往往可以舍去。所以用滚动数组优化是很有效的。利用滚动数组的话在N很大的情况下可以达到压缩存储的作用。

3.2 背包的一维求解问题

原则:

  • 确定dp数组的含义
  • 确定递推关系式
  • 确定dp数组的遍历顺序(外物品,内容量;零一逆,完全顺)
  • dp数组的初始化
    使用滚动数组来求解。我们看一下递推公式

    d p [ i ] [ j ] = { d p [ i − 1 ] [ j ] w [ i − 1 ] > j m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − w [ i − 1 ] ] + v [ i − 1 ] ) w [ i − 1 ] ≤ j dp[i][j] = \left\{ \begin{array}{rcl} dp[i -1][j] & &{w[i - 1] > j}\\ max(dp[i - 1][j], dp[i - 1][j - w[i -1]] + v[i -1]) & & {w[i - 1] \leq j } \end{array} \right. dp[i][j]={dp[i1][j]max(dp[i1][j],dp[i1][jw[i1]]+v[i1])w[i1]>jw[i1]j

    从上面的公式中我们可以发现dp[i][j]只由dp[i-1][j]和dp[i - 1][j - w[i -1]]决定,这说明二维数组dp[i][j]第i行的数据由i-1行决定,所以可以使用滚动数组解决。思考步骤如下:
  1. 确定dp数组的含义
    dp[j]:容量为j的背包,所背的物品价值可以最大为dp[j]。
  2. 确定递推关系式
    此时dp[j]有两个选择,⼀个是取dp[j] (也就是二维数组中的dp[i -1][j]),⼀个是取dp[j - w[i]] + v[i] (也就是二维数组中的dp[i -1][j - w[i]]),取最大值即可。
  3. 确定dp数组的遍历顺序
    规则:外物品,内容量;零一逆,完全顺
    含义:外循环遍历物品,内循环遍历背包容量(因为二维数组中只有上一行确定完了,下一行才好继续进行);01背包逆序遍历,完全背包顺序遍历。
    原因:
    leetcode动态规划总结之01背包和完全背包问题_第3张图片
    我们以其中的吉他为例子
    leetcode动态规划总结之01背包和完全背包问题_第4张图片
    假设初始条件是物品为0时,即dp[j] = 0。
  • 如果顺序遍历的情况
    dp[1] = max(dp[1], dp[1 - 1] + v) = max(0, 0 + 1500) = 1500;背包加入了G
    dp[2] = max(dp[2], dp[2 - 1] + v) = max(0, 1500 + 1500) = 3000;背包又重复加入了G。所以与01背包的情况不符合,与完全背包的情况相符合。
  • 如果逆序遍历的情况
    dp[4] = max(dp[4], dp[4 - 1] + v) = max(0, 0 + 1500) = 1500;
    dp[3] = max(dp[3], dp[3 - 1] + v) = max(0, 0 + 1500) = 1500;
    dp[2] = max(dp[2], dp[2 - 1] + v) = max(0, 0 + 1500) = 1500;
    dp[1] = max(dp[1], dp[1 - 1] + v) = max(0, 0 + 1500) = 1500;
    所以与01背包的情况相符合,与完全背包的情况不符合。
  1. dp数组的初始化
    dp[0]:背包的容量为0时能装入物品的最大价值,为0。

具体代码实现如下:

    public static void main(String[] args) {
        int[] weight = {1, 4, 2};
        int[] value = {1500, 3000, 2000};
        int[] dp = new int[5];
        //这里无需初始化为0,因为创建数组时默认元素的值为0,这里只是为了体现dp数组的初始化
//        dp[0] = 0;
        for (int i = 0; i < weight.length; i++) {
            for (int j = 4; j >= weight[i]; j--) {
                dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
            }
        }

        System.out.println("背包能装入的最大价值为: " + dp[4] + "元");
    }

4 背包的应用

4.1 背包应用的分类

背包的应用通常分为以下三类

  1. 最值问题: dp[j] = max/min(dp[j], dp[j-nums[i]]+1)或dp[j] = max/min(dp[j], dp[j-num[i]]+v[i])
  2. 存在问题(boolean):dp[j]=dp[j]||dp[i-nums[i]]
  3. 组合问题:dp[j]+=dp[j-nums[i]]

4.2 背包应用的转化

如何将应用问题转化为背包问题这是关键

  1. 确定题目求解的目标以及类别-----对应背包的容量target(难)
  2. 确定遍历的物品(中)
  3. 确定是01背包还是完全背包(易)

5 应用实例

5.1 存在问题

5.1.1 leetcode–416. 分割等和子集

416. 分割等和子集
leetcode动态规划总结之01背包和完全背包问题_第5张图片
题目如何转化为背包问题:

  • 是否存在target = 数组总和 / 2 ⇒ \Rightarrow 存在问题
  • 背包的重量W是多少? ⇒ \Rightarrow target = 数组元素总和 / 2
  • 物品是什么? ⇒ \Rightarrow 数组中的每个元素
  • 物品的重量weight[i]是多少? ⇒ \Rightarrow 每一个元素的值
  • 物品的价值value[i]是多少? ⇒ \Rightarrow 不知道
  • 题目的问题是什么? ⇒ \Rightarrow 能否恰好装满背包
  • 求解不需要用到物品的价值,所以无需知道
  1. 确定dp数组的含义
    dp[j] : 是否存在和为j的元素组合;

  2. 确定递推关系式
    1)如果nums[i] > j,则说明物品nums[i]肯定不能放入背包中,要不然重量一定超过j,那么前i项物品是否存在重量为j的组合就只能看前i - 1项物品中是否存在重量为j的组合,则
    dp[i][j] = dp[i - 1][j]
    2)如果nums[i] <= j, 则说明物品nums[i]能放入背包中,那么前i项物品是否存在重量为j的组合有两种可能:

  • 前i - 1项物品中是否存在重量为j的组合:dp[i][j] = dp[i - 1][j]
  • 将第i项物品装入后,前i - 1项物品是否存在重量为j - nums[i] 的组合,即
    dp[i][j] = dp[i - 1][j - nums[i]]
    只要其中任意一种情况满足即可,所以
    dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]]
    简化为一维dp
    dp[j] = dp[j] || dp[j - nums[i]];
  1. 确定dp数组的遍历顺序
    数组中的每个元素只能使用一次,所以属于01背包问题。
    先元素遍历,再元素和遍历,01背包逆序遍历

  2. dp数组的初始化
    dp[0]:假设数组元素为[2,4,6,8]。判断dp[2]是否存在需要使用
    dp[2] = dp[2] || dp[2 - nums[0]] = dp[2] || dp[0]。而dp[2]最开始为false,所以要保证dp[0]为true。故dp[0] = true;

具体代码实现如下:

class Solution {
    public boolean canPartition(int[] nums) {
        int n = nums.length;
        int target = 0, maxNum = 0;
        for (int num : nums) {
            target += num;
            maxNum = Math.max(maxNum, num);
        }
        //这部分是剪枝操作
        if (target % 2 != 0) 
            return false;
        target /= 2;
        if (maxNum > target) 
            return false;
        boolean[] dp = new boolean[target + 1];
        //初始化dp[0]
        dp[0] = true;
        for (int i = 0; i < nums.length; i++) {
            for (int j = target; j >= nums[i]; j--) {
                dp[j] = dp[j] || dp[j - nums[i]];
            }
        }
        return dp[target];
    }       
}

5.2 最值问题

5.2.1 leetcode–1049. 最后一块石头的重量 II

1049. 最后一块石头的重量 II
leetcode动态规划总结之01背包和完全背包问题_第6张图片

最后一块石头的重量问题如何转化为背包问题

  • 假设两块石头碰撞后[不放回]石头堆,要保证最后的石头重量最小,要将石头堆划分为正号堆和负号堆
  1. 将每次操作中[重量较大]的石子放到[正号堆],代表在这次操作中该石子重量在[最终运算结果]中应用 + 运算符
  2. 将每次操作中[重量较少/相等]的石子放到[负号堆],代表在这次操作中该石子重量在[最终运算结果]中应用 运算符
    所以,最终得到的结果,可以为原来 stones数组中的数字添加 + 符号,所形成的[计算表达式]所表示。
  • 假设两块石头碰撞后[放回]哪个石头堆,只是[某个原有石子]所在[哪个堆]发生变化,并不会真正意义上的产生[新的石子重量]。
    理由是:假设有石头a和b,且a >= b。两石头碰撞产生一个a - b的新石头。
  1. 将新石头放入[正号堆],最终结果相当于 +(a - b) = + a - b,而右边的式子的含义就是将a放入[正号堆],将b放入[负号堆];
  2. 将新石头放入[负号堆],最终结果相当于 -(a - b) = - a + b ,而右边的式子含义就是将a放入[负号堆],将b放入[正号堆]。
    所以不断地[重放]就是不断地改变原始石头所在[哪个堆]的位置,对所有的原始石头来说,只是新的一种分类方式,即便是[放回]操作,最终的结果仍可以使用[为原来的数组添加+或-,形成计算表达式,得到最后一块石头的重量],即
    w e i g h t = ∑ i = 1 n k i × s t o n e s [ i ] k i = { + 1 , − 1 } weight = \sum_{i=1}^{n}k_i \times stones[i] \quad k_i = \{ +1, -1\} weight=i=1nki×stones[i]ki={+1,1}
    最终石头的重量就是weight,要使weight最小,即表达式的正项和负项非常接近,即保证[正号堆]和[负号堆]石头的重量相减,得到石头重量最小。
    转化为背包问题:
    从 stones数组中选择,凑成总重量不超\frac{7x+5}{1+y^2}过 s u m 2 \frac{sum}{2} 2sum的石头最大重量
  • 找到不大于 s u m 2 \frac{sum}{2} 2sum的最大重量 ⇒ \Rightarrow 最值问题
  • 背包的重量W是多少? ⇒ \Rightarrow t a r g e t = s u m 2 target = \frac{sum}{2} target=2sum
  • 物品是什么? ⇒ \Rightarrow 数组中的每个石头
  • 物品的重量weight[i]是多少? ⇒ \Rightarrow 每个元素的值
  • 物品的价值value[i]是多少? ⇒ \Rightarrow 每个元素的值

解题步骤

  1. 确定dp数组的含义
    dp[j]:容量为j的书包能装入的最大重量
  2. 确定递推关系式
    dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i])
  3. 确定dp数组的遍历顺序(外物品,内容量;零一逆,完全顺)
    先遍历石头,再遍历容量,由于每个石头只能用一次,所以是01背包,背包容量是逆序遍历的。
  4. dp数组的初始化
    当物品重量为0时,dp数组能装入的最大重量为0即dp[j] = 0。

具体代码实现如下

class Solution {
    public int lastStoneWeightII(int[] stones) {
        int sum = 0;
        for (int i = 0; i < stones.length; i++) {
            sum += stones[i];
        }
        int target = sum / 2;
        int[] dp = new int[target + 1];
        //初始化
        for(int j = 0; j <= target; j++)
            dp[j] = 0;
        //遍历
        for (int i = 0; i < stones.length; i++) {
            for (int j = target; j >= stones[i]; j--) {
                dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
            }
        }
        return sum - 2 * dp[target];
    }
}

注意:

  1. 当sum为奇数时, t a r g e t = s u m 2 target =\frac{sum}{2} target=2sum相当于一半向下取整达不到一半,但石头的重量都为整数,也不可能恰好为sum的一半,所以[**负号堆]最多只能一半重量向下取整符合target的目标;
  2. 此题也能归纳为存在问题,就是看石头组合的重量从0到 s u m 2 \frac{sum}{2} 2sum是否存在,得到的dp数组从后往前遍历,选择存在的最大的重量即为[**负号堆]的重量,具体代码实现如下:
class Solution {
    public int lastStoneWeightII(int[] stones) {
        int sum = 0;
        for (int i = 0; i < stones.length; i++) {
            sum += stones[i];
        }
        int target = sum / 2;
        boolean[] dp = new boolean[target + 1];
        //初始化
        dp[0] = true;
        //遍历
        for (int i = 0; i < stones.length; i++) {
            for (int j = target; j >= stones[i]; j--) {
                dp[j] = dp[j] || dp[j - stones[i]];
            }
        }
        int flag = 0;
        for (int j = target; j >= 0; j--) {
            if (dp[j]) {
                flag = j;
                break;
            }
        }
        return sum - 2 * flag;
    }
}

5.2.1 leetcode–322. 零钱兑换

322. 零钱兑换
leetcode动态规划总结之01背包和完全背包问题_第7张图片
转化为背包问题:
从 coins数组中选择,凑成总数为amount的硬币的最小数量

  • 找到凑成总数为amount的硬币的最小数量 ⇒ \Rightarrow 最值问题
  • 背包的重量W是多少? ⇒ \Rightarrow amount
  • 物品是什么? ⇒ \Rightarrow 数组中的每种硬币
  • 物品的重量weight[i]是多少? ⇒ \Rightarrow 每种硬币的值
  • 物品的价值value[i]是多少? ⇒ \Rightarrow 硬币的数量

解题步骤

  1. 确定dp数组的含义
    dp[j] : 构成值为j的硬币的最小数量。
  2. 确定递推关系式
    dp[j] = min(dp[j], dp[j - coins[i]] + 1)
    注意:这里要考虑dp[j]和dp[j - coins[i]]是否存在的问题。
  • 如果将dp[j]不存在设置为dp[j] = -1;那么上面的最小值计算只适合dp[j]和dp[j - coins[i]]都存在的情况;如果两个都不存在,dp[j] = -1;如果由一个存在,另一个不存在,dp[j] = max(dp[j], dp[j - coins[i]] + 1)。这种比较麻烦,分类讨论太多。
  • 由于面值都为整数,即coins[i] >= 1。如果将dp[j]不存在设置为dp[j] = amount + 1;其中一个存在,另一个不存在时,存在的值一定小于amount + 1,所以符合上式;当两个都不存在时,dp[j] = min(amount +1, amount + 1 + 1) = amount + 1相当于不存在。也符合上式。
  1. 确定dp数组的遍历顺序(外物品,内容量;零一逆,完全顺)
    先遍历金币的种类,再遍历容量;完全背包问题,顺序遍历内循环。
  2. dp数组的初始化
    当金币面额值为0时,dp[0] = 0,dp[j] = amount + 1。

具体代码实现如下

class Solution {
    public int coinChange(int[] coins, int amount) {
        int[] dp = new int[amount + 1];
        //初始化
        for (int j = 1; j <= amount; j++)
            dp[j] = amount + 1;
        for (int i = 0; i < coins.length; i++) {
            for (int j = coins[i]; j <= amount; j++) {
                dp[j] = Math.min(dp[j], dp[j - coins[i]] + 1);
            }
        }
        return dp[amount] == (amount + 1)? -1 : dp[amount];
    }
}

5.3 组合问题

5.3.1 leetcode–518. 零钱兑换 II

518. 零钱兑换 II
leetcode动态规划总结之01背包和完全背包问题_第8张图片
转化为背包问题:
从 coins数组中选择,凑成总金额为amount的方式种类数

  • 找到组合为amount的种类数量 ⇒ \Rightarrow 组合问题
  • 背包的重量W是多少? ⇒ \Rightarrow amount
  • 物品是什么? ⇒ \Rightarrow 数组中每种面额的金币
  • 物品的重量weight[i]是多少? ⇒ \Rightarrow 每种金币的面额

解题步骤

  1. 确定dp数组的含义
    dp[j]:总金额为j的金币组合方式共有dp[j]种。
  2. 确定递推关系式
    dp[j] = dp[j] + dp[j - coins[i]]
  3. 确定dp数组的遍历顺序(外物品,内容量;零一逆,完全顺)
    先遍历金币,再遍历总金额,由于每种金币用无数次,所以是完全背包,背包容量是顺序遍历的。
  4. dp数组的初始化
    当金币面额为0时,dp[0] = 1,dp[j] = 0。

具体代码实现如下

class Solution {
    public int change(int amount, int[] coins) {
        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];
    }
}

你可能感兴趣的:(动态规划,leetcode,leetcode,算法,动态规划)