[LeetCode]-动态规划-1-理解题及01背包类型题

本文介绍了动态规划的适用条件,包括最优化原理、无后向性和子问题重叠性。通过斐波那契数、爬楼梯等多个经典例题,详细讲解了如何运用动态规划解题,包括状态定义、状态转移方程和边界确定。还探讨了背包问题类型,如分割等和子集、最后一块石头的重量Ⅱ等,并将其转化为 01 背包问题求解。

动态规划简介

动态规划(Dynamic Programming,dp)的适用条件

  1. 最优化原理(最优子结构性质)
    由上一个状态的解得到当前状态的最优解,那么上一个状态的解一定也是最优解。一个最优化策略的子策略总是最优的。一个问题具有最优化原理(或称其具有最优子结构性质),那我们就可以自底向上从子问题的最优解逐步构造出整个问题的最优解
  2. 无后向性(无后效性)
    对于当前状态,它以前各阶段的状态都无法直接影响他的后继决策,只有当前这个状态才会影响下一步的决策。也就是说,每个状态都是过去历史的一个完整总结
  3. 子问题的重叠性(非必要条件)
    每次产生的子问题并不是新的子问题,有些子问题会被重复计算。对于当前状态,决定其最优解的因素可能有过去的多个状态,这些状态都必须被保留。动态规划在实现的过程中不得不存储产生过程中各种状态,是一种空间换时间的技术,所以其空间复杂度会比其他算法大
    根据这些特点,在使用动态规划处理问题时,我们需要关注的是每当到达一个新的状态时,它与前一个状态之间有什么关系,需要做什么决策,然后推出状态转移方程,然后确定问题边界

用动态规划解题,我们只需牢记状态定义,状态转移方程,边界三步走即可

509.斐波那契数

斐波那契数列是理解动态规划最基础的开始。越难的动态规划方程,其递推关系式就更复杂。所以对于这道题,与其说是用动态规划来做这道题,还不如说是用这道题来初步理解动态规划

public int fib(int n) {
    if(n == 0 || n == 1) return n;
    int dp[] = new int[n + 1];
    dp[0] = 0;
    dp[1] = 1;
    for(int i = 2;i <= n;i++){
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
}

70.爬楼梯

到i层楼梯(大于等于3阶)的前一步,可以是从第i-1阶梯跳一级到达,也可以是从第i-2级阶梯跳两级到,状态dp[i]表示跳到第i级阶梯有几种跳法,那么状态转移方程就是dp[i]=dp[i-1]+dp[i-2],边界为dp[1]=1,dp[2]=2

public int climbStairs(int n) {
    if(n == 1 || n == 2) return n;
    int dp[] = new int[n + 1];
    dp[1] = 1;
    dp[2] = 2;
    for(int i = 3;i <= n;i++){
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
}

746.使用最少花费爬楼梯

要求到达顶层的最少花费,可以知道到达第i层(i>=3)的最少花费,是从前面i-1级阶梯跳过来所需费用(也就是跳到i-1级的最小费用+从i-1级跳走需要的cost)以及从前面i-2级阶梯跳过来所需费用(也就是跳到i-2级的最小费用+从i-1级跳走需要的cost)中的较小者。所以状态dp[i]表示跳到i级阶梯所需最小费用,状态转移方程为dp[i] = Math.min(dp[i - 1] + cost[i - 1],dp[i - 2] + cost[i - 2]),边界为dp[0]=0,dp[1]=0,因为既可以从0开始,也可以从1开始

public int minCostClimbingStairs(int[] cost) {
    int length = cost.length;
    if(length == 1) return cost[0];
    int dp[]  = new int[length + 1];
    dp[0] = 0;
    dp[1] = 0;
    for(int i = 2;i <= length;i++){
        dp[i] = Math.min(dp[i - 1] + cost[i - 1],dp[i - 2] + cost[i - 2]);
    }
    return dp[length];
}

62.不同路径

把地图看成m*n的矩阵,要到达坐标(i.j)的格子(坐标从0开始),可以从(i - 1,j)往下移动一格到达,也可以从(i,j-1)往右一格到达。因此,状态dp[i][j]表示到达坐标{i,j)的格子有多少不同路径,状态转移方程为dp[i][j] = dp[i - 1][j] + dp[i][j - 1]。由于只能向下和向右移动,所以第0行和第0列的dp值都为1,这就是本题的边界。因为第0行所有格子只能从起点一直向右移动到达,第0列所有格子只能从起点一直向下移动到达,路径只有一条。

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

63.不同路径2

public int uniquePathsWithObstacles(int[][] obstacleGrid) {
    int row = obstacleGrid.length;
    int column = obstacleGrid[0].length;
    int dp[][] = new int[row][column];
    //可能一开始就是个障碍物......
    dp[0][0] = obstacleGrid[0][0] == 1 ? 0 : 1;
    //对于第一行和第一列,从左往右从上往下,只要某个格子有一个障碍物,那么后面的格子一定是到不了的
    for(int i = 1;i < column;i++) dp[0][i] = obstacleGrid[0][i] == 1 ? 0 : dp[0][i - 1];
    for(int i = 1;i < row;i++) dp[i][0] = obstacleGrid[i][0] == 1 ? 0 : dp[i - 1][0];
    //如果当前格子的上面格子为障碍物,说明不可能从上面到达当前格子,上面格子dp值为0,右边格子同理
    for(int i = 1;i < row;i++){
        for(int j = 1;j < column;j++){
            dp[i][j] = obstacleGrid[i][j] == 1 ? 0 : dp[i - 1][j] + dp[i][j - 1];
        }
    }
    return dp[row - 1][column - 1];
}

343.整数拆分-递推/数论

有一条数论,将一个正整数拆分成几个正整数之和,使这些和数的积最大,且这些正整数可以相同,那么就把正整数不断拆出3,最后剩下4的话就不用再拆,然后把所有3跟剩下的数相乘就是最大积

有数论基础的话很好理解,我们可以写几个找找规律:5–3*2,6–3*3,7–3*4,8–3*3*2,9–3*3*3,10–3*3*3*4…可以发现优先拆出3得到的积是最大的,因此可以以3作为周期,这样也不用去考虑拆到最后剩余4的情况,只要直接令dp[4]=4即可。dp[i]表示正整数i能得到的最大积
这样的话这道题其实更像是找规律,找递推关系,并不涉及决策

public int integerBreak(int n) {
	//2跟3跟4也必须拆,所以专门处理
    if(n == 2) return 1;
    if(n == 3) return 2;
    if(n == 4) return 4;
    int dp[] = new int[n + 1];
    //n大于等于5的情况下dp[2],dp[3],dp[4]并不符合他们对应的解
    dp[2] = 2;
    dp[3] = 3;
    dp[4] = 4;
    for(int i = 5;i <= n;i++){
        dp[i] = dp[i - 3] * 3;
    }
    return dp[n];
}

96.不同的二叉搜索树

首先,一棵二叉搜索树,比根的值大的结点都在根的右边,比根的值小的结点都在根的左边。对于一个输入n,可以以1,2,3,4…n为根做出一棵二叉搜索树,设定dp[i]表示输入i时能得到多少不同二叉搜索树。当以1为根时,其它i - 1个结点都只会在1的右边出现,那么这i - 1个节点组成一个二叉搜索树的种数就是这i个节点以1为根的时候组成一个二叉搜索树的种数,即dp[i - 1];当以2为根时,1只会在2的左边,剩下i - 2个节点只会在2的右边出现,对于左边1这个结点他会有dp[1](即1)种组成二叉搜索树的方式,对于右边i - 2个节点,组成二叉搜索树有dp[i - 2]种方式,根据组合的特性,共有dp[1] * dp[i - 2]种组成二叉搜索树的方式;同理,以3为根时,1跟2只会在3的右边出现,他们有dp[2]种组成二叉搜索树的方式,剩下i - 3个结点只会出现在3的右边,他们有dp[i - 3]种组成二叉搜索树的方式,所以以3为根时共有dp[2] * dp[i - 3]种组成二叉搜索树的方式。以此类推,对每一个输入i只要将分别以1,2,3…i为结点时的组成二叉搜索树的方式都加起来就是这i个节点组成二叉搜索树的总的种树,dp[i] = dp[i - 1] + dp[1] * dp[i - 2] + dp[2] * dp[i - 3] + ... + dp[i -1],最后考虑一下如何在代码中实现这段运算就可以了

public int numTrees(int n) {
    if(n == 1) return 1;
    int dp[] = new int[n + 1];
    dp[1] = 1;
    dp[2] = 2;
    for(int i = 3;i <= n;i++){
        dp[i] += 2 * dp[i - 1];
        int j,p;
        for(j = 1,p = i - 2;j < p;j++,p--){
            dp[i] += 2 * dp[j] * dp[p];
        }
        if(j == p) dp[i] += dp[j] * dp[j];
    }
    return dp[n];
}

剑指 Offer 47. 礼物的最大价值

直接在grid数组上进行状态转移,grid[i][j] 表示选取到 grid[i][j] 时能达到的最大价值,由于选取到 grid[i][j] 时,上一步选的要么是 grid[i - 1][j] 要么是 grid[i][j - 1] ,所以状态转移方程就是 grid[i][j] = grid[i][j] + Math.max(grid[i - 1][j],grid[i][j - 1])。边界情况就是第一行以及第一列。那么最终最优解就是grid[row - 1][col - 1]

public int maxValue(int[][] grid) {
    int row = grid.length;
    int col = grid[0].length;
    for (int i = 1; i < row; i++) {
        grid[i][0] += grid[i - 1][0];
    }
    for (int i = 1; i < col; i++) {
        grid[0][i] += grid[0][i - 1];
    }
    for(int i = 1;i < row;i++){
        for(int j = 1;j < col;j++){
            grid[i][j] += Math.max(grid[i - 1][j],grid[i][j - 1]);
        }
    }
    return grid[row - 1][col - 1];
}

背包问题类型

01背包与完全背包问题

416.分割等和子集

二维数组dp

先遍历一遍数组,计算元素总和,问题就转化为在数组中选取元素,能否使选取的元素的和为数组所有元素总和的一半。然后就可以转化为01背包问题:

构建一个 n 行 sum + 1 列的布尔型二维数组 dp (这里的 sum 是数组所有元素总和的一半),dp[i][j] 表示在 [0,i] 的数组元素中,能否选取元素使得这些元素和为 j:

  1. 如果 nums[i] > j,说明选取 nums[i] 肯定不能使和为 j,那么 nums[i] 不能取,则 dp[i][j] = dp[i - 1][j];
  2. 如果 nums[i] == j,那么选取 nums[i] 就可以使和为 j,dp[i][j] 直接等于true;如果 nums[i] < j,那么说明 nums[i] 可以选,但不是说一定要选,可能不选 nums[i] 只从 [0,i - 1] 的数组元素中选也可以使和为 j,所以此时 dp[i][j] = dp[i - 1][j - nums[i]] || dp[i - 1][j]
public boolean canPartition(int[] nums) {
    int len = nums.length;
    //数组长度为1肯定不能分割
    if(len == 1)return false;
    int sum = 0;
    for(int i = 0;i < len;i++){
        sum += nums[i];
    }
    //数组所有元素总和为奇数,那就肯定不能分为和相等的两部分
    if(sum % 2 == 1) return false;
    sum /= 2;
    boolean[][] dp =  new boolean[len][sum + 1];
    /*第0列初始化,不过是赋值为false,boolean数组一开始创建时默认值就是false,所以可以不用这一步
    for(int i = 0;i < len;i++){
        dp[i][0] = false;
    }*/
    //第0行的初始化中,就是只选取nums[0]一个数时,只有dp[0][nums[0]]为true
    if(nums[0] <= sum)dp[0][nums[0]] = true;
    //状态转移
    for(int i = 1;i < len;i++){
        for(int j = 1;j <= sum;j++){
            if(nums[i] > j) dp[i][j] = dp[i - 1][j];
            else if(nums[i] == j) dp[i][j] = true;
            //这个数小于j,但又不是一定要选,可以不选
            else dp[i][j] = dp[i - 1][j - nums[i]] || dp[i - 1][j];
        }
    }
    return dp[len - 1][sum];
}

一维数组dp

类似于01背包的降维操作

public boolean canPartition(int[] nums) {
    int len = nums.length;
    if(len == 1)return false;
    int sum = 0;
    for(int i = 0;i < len;i++){
        sum += nums[i];
    }
    if(sum % 2 == 1) return false;
    sum /= 2;
    boolean[] dp =  new boolean[sum + 1];
    if(nums[0] <= sum)dp[nums[0]] = true;
    for(int i = 1;i < len;i++){
    	//j应该从大到小,即数组应该从右到左进行赋值
        for(int j = sum;j >= 1;j--){
            /*if(nums[i] > j) dp[j] = dp[j];
            else */
            if(nums[i] == j) dp[j] = true;
            else if(nums[i] < j)dp[j] = dp[j - nums[i]] || dp[j];
        }
    }
    return dp[sum];
}

1049.最后一块石头的重量Ⅱ

二维数组dp

假设所有 n 个石头质量总和为 sum,每次都是在所有石头中选取两块,然后两者都减掉较小那块的质量,假设每次较小的石头质量为 w1,w2,w3…wn,最后剩下的石头的质量为 w,那么可以发现,所有石头的重量和 sum 其实就是 2(w1 + w2 + w3 + … + wn) + w,要使 w 最小,就是要看 w1 + w2 +w3 +… wn 能与 sum / 2 相差多小,相差越小,2(w1 + w2 + w3 + … + wn) 与 sum 相差越小,剩下的 w 就能越小

所以就能转化为01背包问题:构建一个 n 行 sum / 2 + 1 列的 boolean 数组dp,dp[i][j] 表示从 [0,i] 的石头中能否选取石头使他们质量和为 j ( 如果能选取石头使他们质量和为 j,那么一定能选取石头使他们质量和为sum - j,所以没有必要设置数组为sum列 )。最后从 dp[n - 1][sum / 2] 遍历到 dp[n - 1][0],第一个为true的元素,其列坐标就是对应的最大的 (w1 + w2 + w3 + … + wn)

public int lastStoneWeightII(int[] stones) {
    int len = stones.length;
    //处理只有数组只有一个元素的特例
    //没加这段之前提交时过了89/90的用例,就是没过那个数组中只有一个元素的用例
    if(len == 1)return stones[0];
    int sum = 0;
    for(int i = 0;i < len;i++){
        sum += stones[i];
    }
    int sum2 = sum / 2;
    boolean[][] dp = new boolean[len][sum2 + 1];
    if(stones[0] <= sum2) dp[0][stones[0]] = true;
    for(int i = 0;i < len;i++){
        dp[i][0] = false;
    }
    for(int i = 1;i < len;i++){
        for(int j = 1;j <= sum2;j++){
            if(stones[i] > j) dp[i][j] = dp[i - 1][j];
            else if(stones[i] == j) dp[i][j] = true;
            else dp[i][j] = dp[i - 1][j] || dp[i - 1][j - stones[i]];
        }
    }
    for(int i = sum2;i >= 0;i--){
        if(dp[len - 1][i] == true) return (sum - i) - i;
    }
    return 0;
}

一维数组dp

public int lastStoneWeightII(int[] stones) {
    int len = stones.length;
    if(len == 1)return stones[0];
    int sum = 0;
    for(int i = 0;i < len;i++){
        sum += stones[i];
    }
    int sum2 = sum / 2;
    boolean[] dp = new boolean[sum2 + 1];
    if(stones[0] <= sum2) dp[stones[0]] = true;
    for(int i = 1;i < len;i++){
    	//列从大到小遍历
        for(int j = sum2;j >= stones[i];j--){
            if(j == stones[i]) dp[j] = true;
            else dp[j] = dp[j] || dp[j - stones[i]];
        }
    }
    for(int i = sum2;i >= 0;i--){
        if(dp[i] == true) return (sum - i) - i;
    }
   
    return 0;
}

494.目标和

每个数前加一个 ‘+’ 或一个 ‘-’,那么到最后,数组所有数就会分为前面加 ‘+’ 跟前面加 ‘-’ 两种,那么最终的运算结果就相当于前面加 ‘+’ 的数的和减掉前面加’-'的数的和

那么转化为01背包问题就是,在数组中选取几个数,使得他们的和减掉剩下的未被选取的数的和等于 target。数组元素个数为 n,元素总和为 sum,构建一个 n 行 sum + 1 列的整型数组 dp,其中 dp[i][j] 表示从 [0,i] 的数组元素中选取若干元素,使得他们的和为 j 的选取方式有多少种。

题目要求的是总的选取方式种数,所以与上面两道题使用布尔型数组不同,仅仅知道能否从 [0,i] 的元素中选取元素使他们和为 j 是不够的,能的话还要知道有多少种方式,比如在 [1,1,1,1,1] 选出和为3的方式就有 10 种

注意到如果数组中有 0元素 的话,不管是 +0 还是 -0 都对其它数组元素没有影响,所以动态规划的方程只用来解决不含0元素的数组,对于数组中的0应该先行处理

public int findTargetSumWays(int[] nums, int target) {
    int n = nums.length;
    int sum = 0;
    int zeroCount = 0; //计算原数组中0的个数
    for(int i = 0;i < n;i++){
        if(nums[i] == 0) zeroCount += 1;
        sum += nums[i];
    }
    //原数组都是0
    if(n == zeroCount) return target == 0 ?  (int)Math.pow(2,zeroCount) : 0;
    //复制非0元素,进行动态规划
    int[] nums1 = new int[n - zeroCount];
    int index = -1;
    for(int i = 0;i < n;i++){
        if(nums[i] != 0) nums1[++index] = nums[i];
    }
    n = n - zeroCount;
    //只有一个非0元素的特例
    if(n == 1) return (nums1[0] == target || nums1[0] == -target)? (int)Math.pow(2,zeroCount) : 0;
    int[][] dp = new int[n][sum + 1];
    //边界
    if(nums1[0] <= sum)  dp[0][nums1[0]] = 1;
    //状态转移
    for(int i = 1;i < n;i++){
        for(int j = 0;j <= sum;j++){
            if(nums1[i] > j) dp[i][j] = dp[i - 1][j];
            else if(nums1[i] == j) dp[i][j] = dp[i - 1][j] + 1;
            else dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums1[i]];
        }
    }
    int count = 0;
    int row = n - 1;
    int end = sum / 2;
    for(int i = 0;i <= end;i++){
        if(dp[row][i] > 0){
            if((sum - i) - i == target) count += dp[row][i];
        }
    }
    return count * (int)Math.pow(2,zeroCount);
}

474.一和零

题目要求子集中最多有 m 个 0 和 n 个 1,对照到01背包问题中就相当于,每个二进制串为一件物品,每件物品有 两个“重量”,0 的个数跟 1 的个数,要求出从所有二进制串中选取若干个串,使他们 0 的个数最多为 m,1 的个数最多为 n 的选取方式中,选取的元素个数最多时元素的个数

既然有两个价值,那么不做降维处理的话应该需要一个三维的 dp 数组,所以这里直接做降维用二维数组。dp[i][j] 表示使选取的子集中0的个数最多为 i,1 的个数最多为j的选取方式中最多的元素个数
边界 dp[i][0],dp[0][j] 都为 0

class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        int len = strs.length;
        //对只有一个二进制串的情况进行处理
        if(len == 1){
            int zero = 0,one = 0;
            for(int i = 0;i < strs[0].length();i++){
                if(strs[0].charAt(i) == '0') zero += 1;
                else one += 1;
            }
            if(zero <= m && one <= n) return 1;
            else return 0;
        }
        item[] items = new item[len];
        //初始化存储每个串0跟1的个数的数组,相当于01背包中的物品数组
        for(int i = 0;i < len;i++){
            items[i] = new item();
            String s = strs[i];
            for(int j = 0;j < s.length();j++){
                if(s.charAt(j) == '0') items[i].zero += 1;
                else items[i].oneNum += 1;
            }
        }
        //状态转移。dp数组的大小根据“重量”得到
        int[][] dp = new int[m + 1][n + 1];
        //最外层表示每件物品
        for(int i = 0;i < len;i++){
        	//这里的二维相当于其它普通01背包中的一维。横跟列都应该从大到小遍历
            for(int j = m;j >= items[i].zero;j--){
                for(int k = n;k >= items[i].oneNum;k--){
                    dp[j][k] = Math.max(dp[j][k],dp[j - items[i].zero][k - items[i].oneNum] + 1);
                }
            }
        }
        return dp[m][n];
    }
    static class item{
        int zero;
        int oneNum;
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值