一文总结动态规划

一、背包问题

1 问题定义

如何确定一个题目是否可以用背包问题解决
背包问题的共同特征:给定一个背包容量target,再给定一个物品数组nums,能否按一定方式选取nums中的元素得到target
注意:
1、target和nums可能是数,也可能是字符串
2、target可以是显式(题目已经给出),也可以是非显式(需要从题目信息中挖掘)
3、常见nums选取方式:每个元素只能选一次 / 每个元素可以选多次 / 选元素进行排列组合

2 问题分类

常见的背包类型主要有以下几种:
1、0/1背包问题:每个元素最多选取一次
2、完全背包问题:每个元素可以重复选择
3、组合背包问题:背包中的物品要考虑顺序
4、分组背包问题:不止一个背包,需要遍历每个背包

而每个背包问题要求的也是不同的,按照所求问题分类,又可以分为以下几种:
1、最值问题:要求最大值/最小值
2、存在问题:是否存在…………,满足…………
3、组合问题:求所有满足……的排列组合

3 解题模板

01背包最值问题

这个问题是最简单最基础的,懂了这个问题,稍加变通就可以学会剩余背包问题

  • 有一个背包,最多能放重为 bagWeight 的物品,bagWeight=4
  • 每个物品的重量表示为数组 weight = {1, 3, 4}
  • 每个物品的价值表示为数组 value = {15, 20, 30}
  • 问:在不超重的前提下,背包最大能拿多少价值的物品
  • 解题思路:
  1. 首先有一个很容易思考的边界,当背包容量为0时,什么东西都放不下,最大价值全为0
  2. 然后考虑最容易解决的子问题,假设只有一个物品 1,背包最大容量为 1~bagWeight,那么最佳方案只能选择物品 1。
  3. 假设增加了一个物品 2,背包最大容量为 1~bagWeight。
    因为物品 2 的重量为3,当背包最大容量为1、2时,只能选择一个物品 1,因为根本就放不下物品 2。
    当背包最大容量 > 2 时,就需要做选择了,这里就是动规的精髓,需要比较两种方案,因为对于物品 2 来说,只有两种可能性,要么拿它,要么不拿,然后从两种方案中选择价值最大的。例如,当背包最大容量为 4时:
    方案 1:拿物品 2,那么背包的剩余容量为 4-3=1,那我们只需要知道背包剩余容量为 1 时,没有物品i时,能拿到的最大价值,然后加上物品 2 的价值,就是该方案的总价值;
    方案2: 不拿物品 2,那么总价值其实就是背包的剩余容量为 4 时,只有物品 1 的情况下,背包的最大价值。
    一直遵循这个原则,使背包最大容量从 1~bagWeight,就计算出了只有物品 1 和物品 2 时背包最大能拿多少价值的物品
  4. 遍历整个物品数组,对于每个子数组,都遍历背包容量从 0 ~ bagWeight,最后得到的完整物品数组对于背包容量为 bagWeight 时的最大价值,就是答案
  • 代码实现
    首先我们需要一个 dp 二维数组,用行表示物品,用 i 进行循环,用列表示背包容量,(用 j 进行循环),dp[i][j] 表示背包容量为 j 时,从 i 个物品中如何选择能得到最大价值。比如第 2 行第 3 列表示:当背包容量为 2 时,从物品1和2中如何选择能得到最大价值。
    继续根据上面的解题思路进行分析:
  1. 首先有一个很容易思考的边界,当背包容量为0时,什么东西都放不下,最大价值全为0,即dp 数组的第一列全为0,dp[i][0]=0)
  2. 然后考虑最容易解决的子问题,假设只有一个物品 1,背包最大容量为 1~bagWeight,那么最佳方案只能是选择物品 1,即 dp[0][j]=value[0]
  3. 假设增加了一个物品 2,背包最大容量为 1~bagWeight。
    因为物品 2 的重量为3(weight[i]=3),当背包最大容量为1、2时(j=1,j=2),就算只装一个物品2也装不下,即 dp[i][j]=dp[i-1][j], if j<weight[i]
    当背包最大容量 > 2 时(j>2),就需要做选择了,这里就是动规的精髓,需要比较两种方案,因为对于物品 2 来说,只有两种可能性,要么拿它,要么不拿,然后从两种方案选择价值最大的, 即 max(方案1,方案2) 。例如,当背包最大容量为4时(j=4):
    方案 1:拿物品 2,那么背包的剩余容量为 4-3=1, 即 j-weight[i] ,那我们只需要知道背包剩余容量为 1 时,没有物品i时,能拿到的最大价值, 即dp[i-1][j-weight[i]] ,然后加上物品 2 的价值(value[i]),就是该方案的总价值, 即dp[i-1][j-weight[i]]+value[i]
    方案 2: 不拿物品 2,那么总价值其实就是背包的剩余容量为 4 时,只有物品 1 的情况下,背包的最大价值, 即dp[i][j]=dp[i-1][j]
    一直遵循这个原则,使背包最大容量从 1~bagWeight,就计算出了只有物品 1 和物品 2 时背包最大能拿多少价值的物品
  4. 遍历整个物品数组,对于每个子数组,都遍历背包容量从 0 ~ bagWeight,最后得到的最后一个物品对于背包容量为bagWeight时的最大价值,就是答案 , 即dp[物品总数量-1][背包最大容量]

代码实现(java)

    public static void main(String[] args) {
    
    
        Scanner scanner = new Scanner(System.in);
        int bagWeight = scanner.nextInt(); // 背包最大容量
        int n = scanner.nextInt(); // 物品数量
        // 物品重量数组
        int[] weight = new int[n];
        for (int i = 0; i < n; i++) {
    
    
            weight[i] = scanner.nextInt();
        }
        // 物品价值数组
        int[] value = new int[n];
        for (int i = 0; i < n; i++) {
    
    
            value[i] = scanner.nextInt();
        }
        // 调用方法求解不超出最大容量的前提下,背包最多能背多大价值的物品
        System.out.println(bags(bagWeight, weight, value));

    }

    public static int bags(int bagWeight, int[] weight, int[] value) {
    
    
        int n = weight.length; // 物品数量
        int[][] dp = new int[n][bagWeight+1]; // dp数组,行表示物品,列表示从0到最大容量
        // 第一列表示背包容量为0时的情况,第一列应该全为0。
        // 由于建dp数组时,java会默认为数组赋0,所以保持第一列为0,更新第二列及以后的即可
        // 从上到下从左到右计算dp,右下角即答案
        for (int i = 0; i < n; i++) {
    
    
            for (int j = 1; j <= bagWeight; j++) {
    
    
                // 第一行表示只能选第一个物品
                if (i == 0) {
    
    
                    dp[i][j] = value[i];
                }
                // 剩余行表示有多个物品可选,需要考虑两种情况
                else {
    
    
                    // 情况1:背包容量就算只装一个物品i也装不下
                    if (j < weight[i]) {
    
    
                        dp[i][j] = dp[i-1][j];
                    }
                    // 情况2:背包容量可以装下物品i,需要考虑两种方案,然后取最大
                    else {
    
    
                        // 方案1:不装物品i
                        // 方案2:装物品i,最大价值为 物品i的价值 加上 去掉物品i的重量后背包剩余容量的最大价值
                        dp[i][j] = Math.max(dp[i-1][j], value[i] + dp[i-1][j-weight[i]]);
                    }
                }
            }
        }
        return dp[n-1][bagWeight]; // 答案是数组的右下角
    }

得到的dp数组和答案:

dp = 
[0, 15, 15, 15, 15]
[0, 15, 15, 20, 35]
[0, 15, 15, 20, 35]
answer = 35
  • 进阶:观察计算过程,dp是一行一行算下来的,为了节省空间,我们可以只保存一行数据。
    public static int bags(int bagWeight, int[] weight, int[] value) {
    
    
        int n = weight.length; // 物品数量
        int[] dp = new int[bagWeight+1]; // dp数组,表示从0到最大容量可以装的最大价值
        // 第一个元素表示背包容量为0时的情况。
        // 由于建dp数组时,java会默认为数组赋0,所以保持第一个元素为0,更新第二个元素及以后的即可
        // 从左到右计算dp,最后一个元素即答案
        for (int i = 0; i < n; i++) {
    
    
            // 注意!!!在计算转移方程的过程中,我们需要用到上一次循环得到的dp数组,所以内层循环必须倒序,否则转移方程的dp[j-weight[i]]会被覆盖掉,二维数组不存在这个问题
            for (int j = bagWeight; j > 0; j--) {
    
    
                // 当背包容量可以装下物品i时
                if (j >= weight[i]) {
    
    
                    // 如果只有一个物品可选
                    if (i == 0) {
    
    
                        dp[j] = value[i];
                    }
                    // 如果有多个物品可选
                    else {
    
    
                        // 方案1:不装物品i
                        // 方案2:装物品i,最大价值为 物品i的价值 加上 去掉物品i的重量后背包剩余容量的最大价值
                        dp[j] = Math.max(dp[j], value[i] + dp[j-weight[i]]);
                    }
                }
            }
            System.out.println(Arrays.toString(dp));
        }

        return dp[bagWeight]; // 答案是数组的最后一个元素
    }

        return dp[bagWeight]; // 答案是数组的最后一个元素
    }

得到每一次循环的dp数组和答案:

dp = [0, 15, 15, 15, 15]
dp = [0, 15, 15, 20, 35]
dp = [0, 15, 15, 20, 35]
answer = 35

可以发现思想本质和计算过程是一样的,只是节省了空间而已

剩余背包问题

分析套路和01最值背包问题基本一样,存在以下区别:

  • 循环
  1. 0/1背包:外循环物品数组,内循环背包容量,如果用滚动一维数组,内循环从最大容量倒序;
  2. 完全背包:外循环物品数组,内循环背包容量,内循环正序
  3. 组合背包:外循环背包容量,内循环物品数组,外循环正序
  4. 分组背包:这个比较特殊,需要三重循环:外循环背包个数bags,内部两层循环根据题目的要求转化为1,2,3三种背包类型的模板
  • 状态转移方程
  1. 最值问题: dp[i] = max/min(dp[i], dp[i-nums]+1)或dp[i] = max/min(dp[i], dp[i-num]+nums);
  2. 存在问题(bool):dp[i] = dp[i]||dp[i-num];
  3. 组合问题:dp[i] += dp[i-num];

4 例题分析

LeetCode1049.最后一块石头的重量II

  • 将问题抽象成背包问题(难的就是这里)
  1. 题目描述:从一堆石头中,每次拿两块重量分别为x,y的石头,若x=y,则两块石头均粉碎;若x<y,两块石头变为一块重量为y-x的石头,求最后剩下石头的最小重量。很容易想,最小值是个非负数,最小为0
  2. 问题转换:把一堆石头分成两堆,求两堆石头重量差最小值(具体解释一下 :每次拿到两个石头,一边扔一个,最后可以得到两堆石头,这两堆石头重量分别为x,y,若x=y,则两堆石头均粉碎;若x<y,两堆石头变为一块重量为y-x的石头,求两堆石头重量差的最小值)
  3. 继续转换:这堆石头的总重量 sum 是不变的,最完美的情况是两堆石头的重量一样,一抵消就是0。要想让两堆石头的重量尽可能一样,就要让第一堆石头的重量尽可能接近一半的总重量,即 sum/2。这个 sum/2 就是背包的最大容量,而挑选的物品就是所有的石头,每个石头的重量就是物品的价值。
  4. 继续转换成01背包最值问题:有一个背包,可以承受的最大重量为 sum/2,给你一堆不同重量的石头stones,求在不超出背包最大重量的前提下,最多可以装多重的石头?
  5. 计算答案:假设4.得到的答案是 maxWeight,那么回归原来的问题,第一堆石头的重量就是 maxWeight,第二堆石头的重量就是 sum-maxWeight。第二堆的重量肯定大于等于第一堆,所以两堆石头的重量差值就是 (sum-maxWeight)-maxWeight。
    简化一下,答案就是sum-2*maxWeight,而 maxWeight 我们可以抽象成01最值背包问题进行求解。
  • 代码实现(java)
    public static void main(String[] args) {
    
    
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        int[] stones = new int[n];
        for (int i = 0; i < stones.length; i++) {
    
    
            stones[i] = scanner.nextInt();
        }
        System.out.println(lastStoneWeightII(stones));
    }

    // 动规
    public static int lastStoneWeightII(int[] stones) {
    
    
        // 非显式的背包最大容量,需要计算
        int sum = 0;
        for (int i = 0; i < stones.length; i++) {
    
    
            sum += stones[i];
        }
        int maxWeight = sum/2; // 背包可以承受的最大重量
        
        // 把石头分成两堆,计算第一堆石头不超出sum/2的最大重量
        // dp表示i个石头时,最大容量为j时,背包最多可以装的重量
        int[] dp = new int[maxWeight+1];
        for (int i = 0; i < stones.length; i++) {
    
    
            // 注意!!用一维数组时,内循环必须倒序,否则状态转移方程用到的dp[j-stones[i]]已经被覆盖掉了
            for (int j = maxWeight; j > 0; j--) {
    
    
                // 边界,第一行,只有一个石头
                if (i == 0 && j >= stones[i]) {
    
    
                    dp[j] = stones[i];
                }
                // 有两个及以上石头
                else {
    
    
                    if (j >= stones[i]) {
    
    
               			// 两种方案(拿石头i或者不拿石头i)取最大重量
                        dp[j] = Math.max(dp[j], stones[i] + dp[j-stones[i]]);
                    }
                }
            }
        }
        // 计算两堆石头的差值,即答案
        return sum-2*dp[maxWeight];
    }

二、区间动态规划

1 解题模板

区间DP,其实求的就是一个区间内的最优值.
一般这种题目,在设置状态的时候,都可以设f[i][j]为区间i-j的最优值
而 f[i][j] 的最优值,这有两个小区间合并而来的,为了划分这两个更小的区间,我们则需用用一个循环变量 k 来枚举,而一般的状态转移方程便是:
f[i][j] = max/min (f[i][j], f[i][k]+f[k][j]+something)
我们则需要根据这个题目的实际含义进行变通即可.
而区间dp的大致模板是:

for (int len=2;len<=n;len++)
    for (int i=1;i+len-1<=n;i++)
    {
    
    
        int j=i+len-1;
        for (int k=i;k<=j;k++)
            f[i][j]=max/min(f[i][j],f[i][k]+f[k][j]+something)
    }

len枚举区间的长度,i和j分别是区间的起点和终点,k的作用是用来划分区间.

2 例题分析

牛客.石子合并

  • 题目描述

请添加图片描述

  • 将问题转换为区间dp
  1. 假设有 5 堆沙子,沙子重量用数组 nums=[1, 3, 4, 2, 5] 表示,现在要求这 5 堆沙子的最小合并代价(答案是34)。下面给出两种合并的方案进行对比
    在这里插入图片描述
    倒推得到最后总代价的计算过程,发现规律
    5 堆沙子的最小合并代价 = 5 堆沙子的重量总和 + 上一次合并的两个子堆的最小合并代价
  2. 举例解释该规律 :对于第一种方案,我们可以想象2和5中间有一个分界线,把5堆沙子分成了两部分。那么5堆沙子(nums=[1, 3, 4, 2, 5])的最小合并代价 = (1+3+4+2+5)+ 4堆沙子(nums=[1, 3, 4, 2])的最小合并代价 + 1堆沙子(nums=[5])的最小合并代价
    但实际上,我们可以从2和5中间分成两半(即第一种方案),也可以从4和2中间分成两半(即第二种方案),也可以从3和4中间分成两半等等。。。所以我们需要枚举所有能把5堆沙子分成两半的情况,然后用我们总结的规律计算各种情况的合并代价,然后取最小。(区间的概念就体现在这里,用指针将一整个数组分成两个区间)
  3. 想象递归:对于该规律来说,“上一次合并的两个子堆的最小合并代价”可以通过递归来计算,相当于递归的入口。而递归的出口就是边界,这里有两个边界:
    边界1:只有1堆沙子,合并代价为0;
    边界2:只有2堆沙子,合并代价为2堆沙子的重量之和;
  4. 举例解释递归:对于2.中的例子,4堆沙子的最小合并代价可以递归进去,用同样的方法计算出来。假如指针把4堆沙子分成了1,3和4,2两堆,那么4堆沙子(nums=[1, 3, 4, 2])的最小合并代价 = (1+3+4+2)+ 2堆沙子(nums=[1, 3])的最小合并代价 + 2堆沙子(nums=[4, 2])的最小合并代价,而2堆沙子是递归出口,可以直接用nums计算出来
  5. 将递归改成动规:递归的计算是从外向内的,层层进入递归深处,遇到出口才会层层计算至最外层,会有很多重复计算。动规其实就是牺牲空间换时间的思想,从内向外计算,边算边填表,避免了大量重复计算。
    所以,动规的计算是从边界开始的,即先填1堆沙子和2堆沙子的最小合并代价,根据1堆和2堆计算3堆沙子的最小合并代价,然后4堆,然后5堆…
  6. 动规的完整过程
    事先计算好sum:sum表示子堆沙子的重量之和,例如sum[2][4]表示子堆沙子 nums=[4,2,5] 的重量之和
0 1 2 3 4
0 0 4 8 10 15
1 - 0 7 9 14
2 - - 0 6 11
3 - - - 0 7
4 - - - - 0

初始化dp: 动规二维数组dp,表示子堆沙子的最小合并代价,例如 dp[2][4] 表示子堆沙子 nums=[4,2,5] 的最小合并代价。
对角线为1堆沙子的情况,副对角线为2堆沙子的情况,剩余全为超大的数(因为求的是最小代价,如果求最大代价,剩余应该全为超小的数,方便比较)

0 1 2 3 4
0 0 4 max max max
1 - 0 8 max max
2 - - 0 7 max
3 - - - 0 6
4 - - - - 0

用状态转移方程填表
从下到上从左到右填表,dp数组的右上角dp[0][4],表示 nums=[1,3,4,2,5] 的最小合并代价,就是我们要的答案
用 i 和 j 表示子数组的两个边,用 k 表示能将子数组分成两个区间的指针,枚举 k 的所有情况,计算合并代价,取最小,状态转移方程如下:
dp[i][j] = min (dp[i][j], sum[i][j] + dp[i][k] + dp[k+1][j])

0 1 2 3 4
0 0 4 12 20 34
1 - 0 7 15 28
2 - - 0 6 17
3 - - - 0 7
4 - - - - 0
  • 代码实现(java)
    public static void main(String[] args) {
    
    
        Scanner scanner = new Scanner(System.in);
        int N = scanner.nextInt();
        int[] nums = new int[N];
        for (int i = 0; i < nums.length; i++) {
    
    
            nums[i] = scanner.nextInt();
        }
        System.out.println(stonesCombine(N, nums));

    }

    static int stonesCombine(int N,int[] nums) {
    
    
        if (N == 0) {
    
    
            return -1; // 边界,0堆沙子
        }
        int[][] dp = new int[N][N]; // 从i到j的子数组的最小代价
        int[][] sum = new int[N][N]; // 从i到j的子数组的总代价
        // 初始化dp全为最大值,斜对角线全为0,副对角线全为两堆沙子之和
        for (int i = 0; i < N; i++) {
    
    
            for (int j = 0; j < N; j++) {
    
    
                if (i == j) {
    
    
                    dp[i][j] = 0; // 边界1,只有1堆沙子
                }
                else if (i+1 == j) {
    
    
                    dp[i][j] = nums[i] + nums[j]; // 边界2,只有2堆沙子
                }
                else {
    
    
                    dp[i][j] = Integer.MAX_VALUE; // 求最小值,初始化为最大值
                }
            }
        }
        // 计算sum
        for (int i = 0; i < N; i++) {
    
    
            for (int j = i+1; j < N; j++) {
    
    
                if (j == i+1) {
    
    
                    sum[i][j] = nums[i] + nums[j]; // 特殊情况,2堆沙子,1堆沙子总代价为0
                }
                else {
    
    
                    sum[i][j] = sum[i][j-1] + nums[j];
                }
            }
        }
        // 计算dp剩余部分,从下到上,从左到右
        for (int i = N-3; i >= 0; i--) {
    
    
            for (int j = i+2; j < N; j++) {
    
    
                // 枚举所有指针分割成两个区间的情况,取最小
                for (int k = i; k < j; k++) {
    
    
                    dp[i][j] = Math.min(dp[i][j], sum[i][j]+dp[i][k]+dp[k+1][j]);
                }
            }
        }
        // dp右上角即答案
        return dp[0][N-1];
    }
  • 进阶
    空间复杂度还可以进一步优化,表示子数组重量之和的 sum 数组用一维就可以

总结与分析

  1. 动态规划其实是一种牺牲空间换时间的思想,相当于将递归的结果记录下来,避免了重复计算
  2. 背包问题的难点在于能看出一个问题是否具有背包问题的特征,并把它抽象成背包问题进行解决
  3. 填动规数组第一步先填边界,根据边界和状态转移方程算剩下的,这里的边界其实相当于递归的出口,状态转移方程相当于递归的入口
  4. 递归的计算是从外向内的,动规的计算是从内向外的

参考网址

猜你喜欢

转载自blog.csdn.net/weixin_46838605/article/details/130422867