动态规划之基础篇

动态规划入门

  • 一.什么是动态规划
  • 动态规划的解题步骤
  • 动态规划题目分析及讲解
    • 最经典的最短路径问题
    • 经典的背包问题
      • 什么是背包问题

一.什么是动态规划

动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。

所以动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的,

动态规划的解题步骤

1.确定dp数组(dp table)以及下标的含义
2.确定递推公式
3.dp数组如何初始化
4.确定遍历顺序
5.举例推导dp数组

动态规划题目分析及讲解

最经典的最短路径问题

不同路径
在这里插入图片描述
这道题眨眼一看,woc 肯定用dfs,我兴致勃勃直接使用dfs来做,思路上几乎没有什么障碍,但是做完后我发现我错大发了,你们看:
在这里插入图片描述
我百思不得其解,就在我说用bfs来试一试时,突然,我想到了动态规划,这道题使用动态规划显然非常简单,那么,我们就按照我们说的五步,一步一步来分析。
第一步:.确定dp数组(dp table)以及下标的含义
在这道题目中,我们来想一想dp数组的作用是什么,显然我们应该用一个二维数组dp[i][j]来表示从i到j,有几条路径可以到达这一点,至此,我们就完成了第一步。
第二步:确定递推公式
在这道题中,我们可以想一想,就按照上面的图来说,因为只能向下向右走,所以要达到(1,1)点的位置只有两种路径,那么可不可以设想为是它左边和上边的位置对应路径的和尼?当然了,大家一个例子可能看不出来,大家多用手画几组,就会发现规律其实很简单。
那么很自然,dp[i][j] = dp[i - 1][j] + dp[i][j - 1],因为dp[i][j]只有这两个方向过来。
第三步:dp数组如何初始化
第三步在整个动态规划中都是一个比较重要的步骤,那么,我们该如何初始化数组尼?
大家不妨想一想,按照上图来说,第一行和第一列是不是只有一种路径,那么

for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;

第四步:确定遍历顺序
遍历顺序是什么尼?
不难想到,这里要看一下递推公式dp[i][j] = dp[i - 1][j] + dp[i][j - 1],dp[i][j]都是从其上方和左方推导而来,那么从左到右一层一层遍历就可以了。
这样就可以保证推导dp[i][j]的时候,dp[i - 1][j] 和 dp[i][j - 1]一定是有数值的。
第五步:举例推导dp数组
就按照上图来举例
在这里插入图片描述
在完成这五步之后,我们开始用代码实现。

class Solution {
    public int uniquePaths(int m, int n) {
        int [][]dp = new int[m][n];
        int i=0,j=0;
        for(i=0;i<m;++i){
            dp[i][0] = 1;
        }
        for(j=0;j<n;++j){
            dp[0][j] = 1;
        }
         for (i = 1; i < m; ++i) {
            for (j = 1; j < n; ++j) {
                dp[i][j] = dp[i-1][j]+dp[i][j-1];
            }
        }
        return dp[m-1][n-1];
    }
}

显然到这里我们就完成了这道题目,那么还有没有什么方法能再优化一下尼,其实这道题,我们使用一维数组也能做。
滚动数组是一种空间优化的技巧,在动态规划中经常使用。它的基本思想是,将一个二维数组(或更高维度的数组)压缩成一个一维数组,通过滚动更新这个数组来保存部分信息。在每次更新之后,我们只需要保留必要的信息,而将不必要的信息丢弃,从而节省空间。

对于本题,我们可以使用一个一维数组dp来表示到达每个格子的不同路径数。初始时,我们将dp数组所有元素初始化为1,因为从起点到任意一个格子的路径数都是1。然后,我们使用两层循环结构,第一层循环遍历列,第二层循环遍历行,更新dp数组。对于每个格子,它的路径数等于其上方格子和左侧格子的路径数之和。在更新dp数组时,我们只需要保留必要的信息,即上方格子和左侧格子的路径数,而将不必要的信息丢弃。

class Solution {
    public int uniquePaths(int m, int n) {
        int[] dp = new int[n];
        Arrays.fill(dp, 1);
        for (int j = 1; j < m; j++) {
            for (int i = 1; i < n; i++) {
                dp[i] += dp[i - 1];
            }
        }
        return dp[n - 1];
    }
}

来看第二题

不同路径2

在这里插入图片描述
与上题几乎完全一样,就是加了障碍物而已,有了上次的教训,这次我没有在使用dfs,而是直接动态规划
动规五部曲:

1.确定dp数组(dp table)以及下标的含义
dp[i][j] :表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径。

2.确定递推公式
递推公式和62.不同路径一样,dp[i][j] = dp[i - 1][j] + dp[i][j - 1]。

但这里需要注意一点,因为有了障碍,(i, j)如果就是障碍的话应该就保持初始状态(初始状态为0)。
3.dp数组如何初始化
因为有了障碍,所以

for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1;
for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1;

4.递归公式推导
从递归公式dp[i][j] = dp[i - 1][j] + dp[i][j - 1] 中可以看出,一定是从左到右一层一层遍历,这样保证推导dp[i][j]的时候,dp[i - 1][j] 和 dp[i][j - 1]一定是有数值。
5.举例推导dp数组
这次我们就不举例子了,大家自己演算一下在本子上。

直接上代码

class Solution {
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        
        int m = obstacleGrid.length;
        int n = obstacleGrid[0].length;
        int [][]dp = new int[m][n];
         if (obstacleGrid[m - 1][n - 1] == 1 || obstacleGrid[0][0] == 1) {
            return 0;
        }

        for (int i = 0; i < m && obstacleGrid[i][0] == 0; ++i) {
            dp[i][0] = 1;
        }
        for (int j = 0; j < n && obstacleGrid[0][j] == 0; ++j) {
            dp[0][j] = 1;
        }
        for(int i=1;i<m;++i){
            for(int j=1;j<n;++j){
                if(obstacleGrid[i][j]==1){
                    dp[i][j] = 0;
                    continue;
                }
                 
                dp[i][j] = dp[i-1][j]+dp[i][j-1];
            }
        }
        return dp[m-1][n-1];
    }
}

显然这道题也可以使用一维数组来做(滚动数组),这样能节省空间,当然,我还是建议大家用二维数组做,比较好理解,滚动数组的话就比较难了。
直接上代码

class Solution {
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        int m = obstacleGrid.length;
        int n = obstacleGrid[0].length;
        int[] dp = new int[n];

        for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) {
            dp[j] = 1;
        }

        for (int i = 1; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (obstacleGrid[i][j] == 1) {
                    dp[j] = 0;
                } else if (j != 0) {
                    dp[j] += dp[j - 1];
                }
            }
        }
        return dp[n - 1];
    }
}

经典的背包问题

什么是背包问题

有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
这是标准的背包问题
在这里插入图片描述

那么我们怎样去实现背包问题尼。
在这里,我直接就用两种数组为大家讲解,但背包问题在实际做题中完全可以用滚动数组解决,并不麻烦。
还是动态五部曲
1.确定dp数组以及下标的含义
对于背包问题,有一种写法, 是使用二维数组,即dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少,其中j就相当于背包的容量。
2.确定递推公式
——————1.不放物品i:由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以背包内的价值依然和前面相同。)
——————2.放物品i:由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值
所以递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3.dp数组如何初始化
首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。
状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。

dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。

那么很明显当 j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。

当j >= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。

for (int j = 0 ; j < weight[0]; j++) {  /。
    dp[0][j] = 0;
}
for (int j = weight[0]; j <= bagweight; j++) {
    dp[0][j] = value[0];
}

4.确定遍历顺序
那么问题来了,先遍历 物品还是先遍历背包重量呢?

其实都可以!! 但是先遍历物品更好理解。
5.举例推导
在这里插入图片描述
让我们看一看代码的实现:

class Main6{
    public int beibao(int[] weight,int[] values,int bagSize){
        int len1 = weight.length;//获取物品的数量
        int len2 = bagSize+1;
        int [][]dp = new int[len1][len2];
        //初始化dp数组,创建后默认是0
        for(int j=weight[0];j<=bagSize;++j){
            dp[0][j] = values[0];
        }
        for(int i=1;i<len1;++i){
            for (int j=0;j<len2;++j){
                if(j<weight[i]){
                    dp[i][j] = dp[i-1][j];
                }else{
                    dp[i][j] = Math.max(dp[i-1][j],dp[i-1][j-weight[i]]+values[i]);

                }
            }
        }
        return dp[len1-1][len2-1];
    }

那么讲完了二维数组,我觉得我想要优化空间,那我们来讲一讲一维数组的做法
用同样的题目,同样的步骤
继续五步法
1.确定dp数组的定义
在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。
2.一维dp数组的递推公式
dp[j]为 容量为j的背包所背的最大价值,那么如何推导dp[j]呢?

dp[j]可以通过dp[j - weight[i]]推导出来,dp[j - weight[i]]表示容量为j - weight[i]的背包所背的最大价值。

dp[j - weight[i]] + value[i] 表示 容量为 j - 物品i重量 的背包 加上 物品i的价值。(也就是容量为j的背包,放入物品i了之后的价值即:dp[j])

此时dp[j]有两个选择,一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j],即不放物品i,一个是取dp[j - weight[i]] + value[i],即放物品i,指定是取最大的,毕竟是求最大价值。
所以递推公式为;

dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);

3.一维dp数组如何初始化
关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。

dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。

那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢?

看一下递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。

这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。

那么我假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。
4.确定遍历顺序
二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。

为什么呢?

倒序遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!

举一个例子:物品0的重量weight[0] = 1,价值value[0] = 15

如果正序遍历

dp[1] = dp[1 - weight[0]] + value[0] = 15

dp[2] = dp[2 - weight[0]] + value[0] = 30

此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。

为什么倒序遍历,就可以保证物品只放入一次呢?

倒序就是先算dp[2]

dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)

dp[1] = dp[1 - weight[0]] + value[0] = 15

所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。
5.举例子
大家自行举例子
直接上代码:

public int beibao2(int[] weight,int[] values,int bagSize){
        int len1 = weight.length;
        int []dp = new int[bagSize+1];
        for(int i=0;i<len1;++i){
            for (int j=bagSize;j>=weight[i];--j){
                dp[j] = Math.max(dp[j],dp[j-weight[i]]+values[i]);

            }

        }
        return dp[bagSize];
    }

不知道大家掌握了没有,以下是两个关于背包问题的例题,大家可以做做看。

最后一块石头的重量2

class Solution {
    public int lastStoneWeightII(int[] stones) {
         int sum = Arrays.stream(stones).sum();
         int k = sum>>1;
         int[] dp = new int[k+1];
         for(int i=0;i<stones.length;++i){
             for(int j=k;j>=stones[i];--j){
          dp[j] = Math.max(dp[j],dp[j-stones[i]]+stones[i]);

             }
         }
         return sum-2*dp[k];
    }
}


分割等和子集

class Solution {
    public boolean canPartition(int[] nums) {
        int sum = Arrays.stream(nums).sum();
        int[] dp = new int[sum/2+1];
        int target = sum/2;
        if(sum % 2 != 0) return false;
        int len = nums.length;
        for(int i = 0; i < len; i++) {
            for(int j = target; j >= nums[i]; j--) {
            dp[j] = Math.max(dp[j], dp[j-nums[i]] + nums[i]);
            }
        }
        return dp[target] == target;
    }
}

大家可以自己尝试用五步法来解决,重点就在如何把它们转换为背包问题哦,大家自行思考。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值