动态规划刷题总结

本文探讨了动态规划在多个经典问题中的应用,如斐波那契数列、爬楼梯、最小路径和等,同时聚焦于回文子串的最长回文子序列算法。通过实例解析,理解动态规划的递推公式和状态定义,以及如何将编辑距离和删除操作融入动态规划解决实际问题。
摘要由CSDN通过智能技术生成


首先要理解什么是动态规划?和贪心的区别是什么?
所谓的动态规划中的每一个状态是由上一个状态推导出来的,贪心是没有状态推导,而是从局部直接选出最优。在动态规划中涉及到最优子结构、重叠问题(相比较递归而言,使用一个dp数据记录了前面已经计算过的值)
动规的五部曲:
1.确定dp数组(dp table)以及下标的含义
2.确定递推公式
3.dp数组如何初始化
4.确定遍历顺序
5.举例推导dp数组
在这里插入图片描述

509. 斐波那契数

本题很经典的就是使用递归解法,递归出口和处理函数题目也给出来了。

class Solution {
    public int fib(int n) {
        if(n == 0){
            return 0;
        }else if(n == 1){
            return 1;
        }
        return fib(n-1)+fib(n-2);

    }
}

但是递归过程中会重复计算前面已经计算过的值,效率不高(涉及重复计算的,思考dp)

class Solution {
    public int fib(int n) {
        if( n <= 1){
            // 剪枝
            return n;
        }
       // 定义一维dp数组,代表的下标j的费波纳兹值,0-n总共是n+1个数
       int []dp = new int[n + 1];
       // dp 初始化
       dp[0] = 0; 
       dp[1] = 1;
       // 递归公式 dp[j] = dp[j - 1] + dp[j - 2],所以从左到右遍历
       for(int j = 2; j <= n; j++){
           dp[j] = dp[j - 1] + dp[j - 2];
       } 
       // 返回最后一个值
       return dp[n];

    }
}

上面的每次遍历都是使用前两个数值,因此可以再进行状态压缩空间

class Solution {
    public int fib(int n) {
        if( n <= 1){
            // 剪枝
            return n;
        }
       // 定义一维dp数组,代表的下标j的费波纳兹值,0-n总共是n+1个数
       int []dp = new int[2];
       // dp 初始化
       dp[0] = 0; 
       dp[1] = 1;
       // 递归公司 dp[j] = dp[j - 1] + dp[j - 2],所以从左到右遍历
       for(int j = 2; j <= n; j++){
           // 使用sum进行过度
           int sum = dp[0] + dp[1];
           dp[0] = dp[1];
           dp[1] = sum;
       } 
       // 返回最后一个值
       return dp[1];

    }
}

70. 爬楼梯

这里要注意:不要去初始化dp[0],因为dp[0]没有意义

class Solution {
    public int climbStairs(int n) {
        /**
        分析:
        定义dp数组,下标i为楼梯阶数,dp[i]为到达该楼梯的方法数
         */
         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] 是怎么得到的?
             // dp[i] 不就是dp[i-1] 然后再跳一步或者dp[i-2]再跳2步,所以dp[i]方程就出来了
             dp[i] =  dp[i-1] + dp[i-2];
         }
        // 返回最后一个值
         return dp[n];

    }
}

746. 使用最小花费爬楼梯

在A这道题的时候,犯迷糊了,究其原因还是没有思考清除递归公式的推理过程。我们已经定义好了dp数组(最后的求解天台最小花费),那么dp递推公式就是自然出来了

class Solution {
    public int minCostClimbingStairs(int[] cost) {
        /**
        分析:
        定义一个dp数组,dp[i]表示到达该阶梯最小花费值
         */
         int len = cost.length;
         // 要爬到天台,所以dp数组个数必须是len+1,实际上是 0-n
         int[] dp = new int[len + 1];
         // 初始化前两个是0
         dp[0] = 0;
         dp[1] = 0;
         for(int i = 2; i <= len; i++){
             // 第i个楼梯 只能由第i-1个楼梯跳一格或者第i-2个楼梯跳2格
             // 第i-1个阶梯状态和花费值 以及 i-2阶梯状态和花费值 两者的最小值
             dp[i] = Math.min(dp[i-1] + cost[i-1],dp[i-2] + cost[i-2]);
         }
         // 天台的花费值
        return dp[len];
    }
}

62. 不同路径

把动规五部曲套上去,然后思考具体的业务场景

class Solution {
    public int uniquePaths(int m, int n) {
        /**
        分析:定义一个二维dp数组,ij表示坐标,dp[i][j]表示机器人到达该点的方法数
        dp[i][j] = dp[i-1][j] + dp[i][j-1],一个点的方法数等于左边和上边的方法数
        dp数组的初始化:边界条件
        遍历顺序:从左到右,从上到下
         */
         int[][] dp = new int[m][n];
         // 初始化dp
         for(int i = 0; i < m; i++){
             dp[i][0] = 1;
         }
         for(int j = 0; j < n; j++){
             dp[0][j] = 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. 不同路径 II

业务场景的判断(如何初始化数据)以及递推公式使用条件

class Solution {
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        /**
        分析:
        在上一题的基础上加上了判断条件:该坐标是否有障碍物
         */
         int m = obstacleGrid.length, n = obstacleGrid[0].length;
         int[][] dp = new int[m][n];
         // 初始化dp,障碍物后面的网格直接跳过
         for(int i = 0; i < m; i++){
             if(obstacleGrid[i][0] == 0){
                 // 是空位置的话,初始化为1
                 dp[i][0] = 1;
             }else{
                 // 否则就跳出循环
                 break;
             }
         }
         for(int j = 0; j < n; j++){
             if(obstacleGrid[0][j] == 0){
                 // 是空位置的话,初始化为1
                 dp[0][j] = 1;
             }else{
                 // 否则就跳出循环
                 break;
             }
         }
         // 从左到右 从上到下遍历
         for(int i = 1; i < m; i++){
             for(int j = 1; j < n; j++){
                 if(obstacleGrid[i][j] == 1){
                     // 遇到障碍物
                     dp[i][j] = 0;
                 }else{
                     // 否则按照递推公式来
                    dp[i][j] = dp[i-1][j] + dp[i][j-1];
                 }
             }
         }
         return dp[m-1][n-1];

    }
}

64. 最小路径和

在这里插入图片描述
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 1→3→1→1→1 的总和最小。

class Solution {
    public int minPathSum(int[][] grid) {
        // 只求最终值,不求具体值,就是使用dp
        int m = grid.length;
        int n = grid[0].length;
        int[][]dp = new int[m][n];
        dp[0][0] = grid[0][0];
        // 初始化 类似机器人那一题!
        for(int i = 1; i < n; i++){
            dp[0][i] = dp[0][i-1]+grid[0][i];
        }
        for(int j = 1; j < m; j++){
            dp[j][0] = dp[j-1][0] + grid[j][0];
        }
        for(int i = 1; i < m; i++){
            for(int j = 1; j < n; j++){
                dp[i][j] = Math.min(dp[i-1][j],dp[i][j-1]) + grid[i][j];
                // System.out.print(dp[i][j]);
            }
        }
        return dp[m-1][n-1];

    }
}

343. 整数拆分

这道题刚开始想,怎么都没有思路,一直在想要几等分呢?测试案例中10是三等分,得到最大,后面举例子,发现也是三等分是最大的。事实真是如此的嘛?
是的,这个也可以理解成剪绳子
将数字 n 尽可能以因子 3 等分时,乘积最大。

拆分规则:
最优: 3 。把数字 n 可能拆为多个因子 3 ,余数可能为 0,1,2 三种情况。
次优: 2 。若余数为 2 ;则保留,不再拆为 1+1 。
最差: 1 。若余数为 1 ;则应把一份 3 + 1 替换为 2 + 2,因为 2×2>3×1。
在这里插入图片描述

class Solution {
    public int integerBreak(int n) {
        /**
        分析:
        如何获取这些整数的最大乘积?可以使用模拟法,然后取max
        整数个数区间为[2,n](n>=2)
         */
        // 尽量三等分
        if(n <= 3) return n-1;
        int a = n / 3, b = n % 3;
        // 判断余数b
        // 余数是1,那么3*1拆开成2*2
        if(b == 1) return (int)Math.pow(3,a-1) * 4;
        else if(b == 2) return (int)Math.pow(3,a) * 2;
        return (int)Math.pow(3,a);
    }
}

上面那种方法不就是记忆结论(做数学题????)试试用dp做做

class Solution {
    public int integerBreak(int n) {
        /**
        分析:
        如何获取这些整数的最大乘积?
        整数个数区间为[2,n](n>=2)

        定义一个dp数组,下标i代表要拆分的数字i,dp[i]表示可以得到的最大乘积dp[i]
        递推公式:
        dp[i]最大乘积怎么得到的呢?其实可以从1遍历j,有两种渠道得到:一种是 j * (i -j)直接相乘,另一种是 j * dp[i-j](相当于拆分 i -j,想想dp的定义!)
        其实也可以这么理解: j * (i-j)是单纯将数字拆分成两个数相乘,而j * dp[i-j]是拆分成两个以及两个以上个数相乘
        dp[i] = Math.max(dp[i],Math.max(j * (i-j),j*dp[i-j]))
        初始化:
        dp[2] = 1
         */
        int[] dp = new int[n+1];
        // 初始化
        dp[2] = 1;
        for(int i  = 3; i <= n ; i++){
            // 这里为什么是 j < i -1,也就是 j最大只能去 i- 2,因为 i -j = i- (i-2) = 2,dp[2]有意义,dp[0]和dp[1]没有意义
            for(int j = 1; j < i -1; j++){      
                dp[i] = Math.max(dp[i],Math.max(j*(i-j),j*dp[i-j]));
            }
        }
        return dp[n];


    }
}

总结:最难想到的就是dp[i]的定义,dp[i]可以由两种渠道获取(这就达到拆分的目的了)

96. 不同的二叉搜索树

本题虽然是中等题,但是已经可以算的上是难题了。提取不了题意啊,看了carl的题解后,才有思路。
首先画出n=1,n=2,n=3的二叉搜索树。会发现n=3的时候,分别是1,2,3作为头节点。那么头结点是1的时候,其左右子树结构和n=2相同(左子树0个,右子树2个),头结点为2的时候,其左右子树结构和n=1相同(左子树1个,右子树1个),头节点为3的时候,其左右子树结构个n=2相同(左子树0个,右子树2个)
那么,记录dp数组,dp[i]表示第i个节点的形成的二叉搜索树,
dp[3] = dp[0]*dp[2]+dp[1]*dp[1]+dp[2]*dp[0],其中dp[0]=1,dp[1]=1
最后可以形成地推公式:dp[i] = 求和dp[j-1]*dp[i-j],(j>=1)
在这里插入图片描述

class Solution {
    public int numTrees(int n) {
        int[] dp = new int[n+1];
        dp[0] = 1;dp[1] = 1;
        for(int i = 2; i <= n; i++){
            for(int j = 1; j <= i; j++){
                //对于第i个节点,需要考虑1作为根节点直到i作为根节点的情况,所以需要累加
                //一共i个节点,对于根节点j时,左子树的节点个数为j-1,右子树的节点个数为i-j
                dp[i] += dp[j-1] * dp[i-j];
            }
        }
        return dp[n];

    }
}

0-1背包理论学习

代码随想录
参考了代码随想录的文章,最终的本质是要理解这个推导过程。而不是简单的记住公式(早晚会忘记的)
记得画图验证

package com.douma._26_day._5;

public class test {
    public static void main(String[] args) {
        int[] weight = {1, 3, 4};
        int[] value = {15, 20, 30};
        int bagSize = 4;
        testWeightBagProblem(weight, value, bagSize);
    }

    public static void testWeightBagProblem(int[] weight, int[] value, int bagSize){
        int wLen = weight.length, value0 = 0;
        //定义dp数组:dp[i][j]表示背包容量为j时,前i个物品能获得的最大价值
        // new int[wLen + 1][bagSize + 1]增加一行一列作为虚拟头,这个的好处就是可以不用进行处理初始化的一些细节
        // 默认初始化 都是0
        int[][] dp = new int[wLen + 1][bagSize + 1];
        //初始化:背包容量为0时,能获得的价值都为0
        // 使用虚拟头,接下来的初始化可以省略了
        // for (int i = 0; i <= wLen; i++){
        //     dp[i][0] = value0;
        // }
        //遍历顺序:先遍历物品,再遍历背包容量
        for (int i = 1; i <= wLen; i++){
            for (int j = 1; j <= bagSize; j++){
                // 背包容量 小于当前重量(注意ij都是从1开始遍历的,所以是从i-1)
                if (j < weight[i - 1]){
                    // 取上一次的背包最大价值
                    dp[i][j] = dp[i - 1][j];
                }else{
                    // 取max值
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i - 1]] + value[i - 1]);
                }
            }
        }
        //打印dp数组
        for (int i = 0; i <= wLen; i++){
            for (int j = 0; j <= bagSize; j++){
                System.out.print(dp[i][j] + "\t");
            }
            System.out.print("\n");
        }
    }
}

在这里插入图片描述
上面给的例子是二维dp(代码是给出类似链表虚拟头形式,物品和背包遍历次序无所谓,并且遍历初始值为1),那么实际上背包问题状态都是可压缩的,使用递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);(未使用虚拟头)
其实可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);

与其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。
滚动数组:上一层可以重复利用,直接拷贝当前层。
在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。
此时dp[j]有两个选择,一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j],即不放物品i,一个是取dp[j - weight[i]] + value[i],即放物品i,指定是取最大的,毕竟是求最大价值,

dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
    // 遍历背包容量是倒序遍历,为了保证物品i只被放入一次
    // 必须先遍历物品,再遍历背包,并且背包是倒序遍历
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    }
}

一维dp的测试代码

package com.douma._26_day._5;

public class test2 {
    public static void main(String[] args) {
        int[] weight = {1, 3, 4};
        int[] value = {15, 20, 30};
        int bagWight = 4;
        testWeightBagProblem(weight, value, bagWight);
    }

    public static void testWeightBagProblem(int[] weight, int[] value, int bagWeight){
        int wLen = weight.length;
        //定义dp数组:dp[j]表示背包容量为j时,能获得的最大价值
        int[] dp = new int[bagWeight + 1];
        //遍历顺序:先遍历物品,再遍历背包容量
        for (int i = 0; i < wLen; i++){
            // 背包容量是倒序遍历
            for (int j = bagWeight; j >= weight[i]; j--){
                dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
            }
        }
        //打印dp数组
        for (int j = 0; j <= bagWeight; j++){
            System.out.print(dp[j] + " ");
        }
    }
}

为什么要逆序遍历呢???如果正序遍历的话,那么就会覆盖前面的值。后面用前面的值,实际上不是上一层滚动数组保留下来i-1的值了。

逆序的关键就在于这个状态转移方程

f[i][v]只与f[i-1][v]和f[i-1][v-C[i]]有关,即只和i-1时刻状态有关,所以我们只需要用一维数组f[]来保存i-1时的状态f[]。
假设i-1时刻的f[]为{a0,a1,a2,…,av},那么i时刻的f[]中第v个应该为max(av,av-C[i]+W[i])即max(f[v],f[v-C[i]]+W[i]),

这就需要我们遍历V时逆序遍历,这样才能保证求i时刻f[v]时f[v-C[i]]是i-1时刻的值。如果正序遍历则当求f[v]时
其前面的f[0],f[1],…,f[v-1]都已经改变过,里面存的都不是i-1时刻的值,这样求f[v]时利用f[v-C[i]]必定是错的值。最后f[V]即为最大价值。

数组遍历有时候必须逆序大多是这个原因。

一维dp 的01背包,要比二维简洁的多! 初始化 和 遍历顺序相对简单了,尽量使用一维dp!
二维dp和一维dp的区别还是挺明显的。

灵魂拷问:
二维dp的递推公式?dp[i][j]代表含义是什么?如何初始化?物品和背包的遍历顺序是怎样的?两者可以调换嘛?可以倒序遍历嘛?为什么?
一维dp的递推公式?dp[j]代表含义是什么?如何初始化?物品和背包的遍历顺序是怎样的?两者可以调换嘛?可以正序遍历嘛?为什么?

416. 分割等和子集

解法一:回朔法,使用哈希表空间换取时间

class Solution {
    Map<String,Boolean> map = new HashMap<>();
    public boolean canPartition(int[] nums) {
        /**
        分析:
        题目实际上要求的是能不能找到target = sum / 2 的子集。
        最初的想法就是使用dfs遍历去找,当target == 0 就意味着找到了
        但是这样的做法是超时了,使用一个哈希表记录(用空间换时间)
         */
         int sum = 0;
         for(int num:nums){
             sum += num;
         }
         // sum是奇数 直接return false
         if(sum % 2==1) return false;
         int target = sum / 2;
         return dfs(nums,target,0);

    }
    public boolean dfs(int[]nums,int target,int index){
        String key = target+"&"+index;
        if(map.containsKey(key)) return map.get(key);
        if(index >= nums.length || target < 0){
            // 下标溢出或者target没有找到
            return false;
        }
        if(target == 0){
            return true;
        }
        // 遍历有两种情况
        // 取nums[index] 下标加1 或者 不取nums[index]下标加1
        boolean res =  dfs(nums,target-nums[index],index+1) || dfs(nums,target,index+1);
        // 回溯  放入map中
        map.put(key,res);
        return res;
    }
}

解法二:一维dp
相对来说比较难理解,因为想不到这个业务场景。。。。

class Solution {
    Map<String,Boolean> map = new HashMap<>();
    public boolean canPartition(int[] nums) {
        /**
        分析:
        题目实际上要求的是能不能找到target = sum / 2 的子集。
        最初的想法就是使用dfs遍历去找,当target == 0 就意味着找到了
        但是这样的做法是超时了,使用一个哈希表记录(用空间换时间)
        ======================================================
        换一种思路套用0-1背包。
        背包的体积是sum/2,背包要放入的商品(集合里面的元素)重量为元素的数值,价值也为元素的数值
        背包正好装满,说明找到了总和为sum/2的子集
        背包中的每一个元素不可重复放入
         */
        int sum = 0;
        for(int num:nums){
            sum += num;
        }
        if(sum % 2 == 1){
            // 奇数直接return
            return false;
        } 
        int target = sum /2;
        // dp[j] 代表的是 容量j的总价值
        int[] dp = new int[target + 1];
        // 遍历集合元素(物品)
        for(int i = 0; i < nums.length; i++){
            // 倒序遍历背包,背包容量 必须大于nums[i](当前重量),才有更新意义
            // 每一个元素一定是不可重复放入,所以从大到小遍历
            for(int j = target; j >= nums[i]; j--){
                // nums[i] 是重量 同时也是价值
                dp[j] = Math.max(dp[j],dp[j - nums[i]] + nums[i]);
            }
        }
        // 集合中的元素正好可以凑成总和target
        return dp[target] == target;
    }
}

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

class Solution {
    public int lastStoneWeightII(int[] stones) {
        /**
        分析:
        简直丧心病狂啊,这个都能转换为0-1背包,完全想不出,但是看完题解感觉还是有点小技巧。
        首先,捋清楚题意,我们要选出两块重量相近的石头出来。那么整个stone数组就拆分成2组了,分别对应着石头重量相近。最后返回重量较重的一个数组总重量-另一个数组总重量。
        构造0-1背包问题,石头只有取和不取的情况,且只能取一次
        dp[i]表示 容量为i的背包总价值
        重量和价值都是stone[i]
        dp数组的长度,也就是背包的容量,实际上就是石头总重量的一半 + 1(因为石头总重量分成两组了,java一半是向下取整)
         */
         int sum = 0;
         for(int stone:stones){
             sum += stone;
         }
         int target = sum / 2;
         // 背包容量是 石头总重量的一半
         int[] dp = new int[target + 1];
         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]);
             }
         }
         // 较大的一组sum - dp[target] 减去较小的一组dp[target]
         return sum - dp[target] - dp[target];

    }
}

494. 目标和

核心是理解题目意思,拆解成正子集合负子集,还有就是这里就不是取和不取的问题了,而是±问题

class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        /**
        分析:
        原问题等同于: 找到nums一个正子集P和一个负子集N,使得总和等于target。即sum(P) - sum(N) == target,
即sum(P) + sum(N) + sum(P) - sum(N) == target + sum(P) + sum(N)
即2 * sum(P) == target + sum(nums), 其中target + sum(nums)必须>=0且为偶数,否则等式不可能成立。
则问题转换为:存在多少个子集P,使sum(P) == (target + sum(nums))/2。

        目的:求出正子集P中的方法数,背包数为(target + sum(nums))/2
        定义一维dp,dp[i] 表示背包容量为i的价值总数
        重量和价值都是 nums[i]
        背包容量为:(target + sum(nums))/2
        初始化:dp[0] = 1,容量为0的背包,有1种方法,就是装0件物品
        最后寻找的是dp[n]的方法数
         */
         int sum = 0;
         for(int num:nums){
             sum += num;
         }
         // 推导得知target + sum(nums)必须>=0且为偶数
         if((target + sum) % 2 == 1) return 0;
         if(Math.abs(target) > sum) return 0;

         // 背包容量
         int size = (target + sum) / 2;
         // 因为target有可能是负数
         if(size < 0 ){
             size = -size;
         }
         int []dp = new int[size+1];
         // 初始化dp,容量为0的背包,有1种方法,就是装0件物品
         dp[0] =  1;
         for(int i = 0; i < nums.length; i++){
             // 背包一定要大于等于当前重量
             for(int j = size; j >= nums[i]; j--){
// 状态转移:dp[j] = dp[j] + dp[j - nums[i]],
// 当前填满容量为j的包的方法数 = 之前填满容量为j的包的方法数 + 之前填满容量为j - nums[i]的包的方法数
// 也就是当前数nums[i]的加入,可以把之前和为j -nums[i]的方法数加入进来。
                 dp[j] += dp[j - nums[i]];
             }
         }
         return dp[size];

    }
}

474. 一和零

二维0-1背包问题,这里使用滚动数组优化了(所以遍历背包的时候,记得倒序遍历),不然实际上得是三维数组了,一个数物品维度,两个背包容量维度

class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        /**
        分析:
        首先得意识到这是一个0-1背包问题,str字符串数组是物品,m和n是背包容量(只不过这里的背包容量是两个维度)
        dp[i][j] 表示的是最多有i个0和j个1的strs的最大子集的大小为dp[i][j]
         */
         int[][]dp = new int[m+1][n+1];
         // 先遍历物品strs
         for(String str:strs){
             int zeroNum = 0;
             int oneNum = 0;
             // 对str中的01计数
             for(char c:str.toCharArray()){
                 if(c == '0'){
                     zeroNum++;
                 }else{
                     oneNum++;
                 }
             }
             // 遍历背包,两个维度
             // 倒序遍历
             for(int i = m; i >= zeroNum; i--){
                 for(int j = n; j >= oneNum; j--){
// 递推公式dp[i][j] 可以由前一个strs里的字符串推导出来,strs里的字符串有zeroNum个0,oneNum个1
// dp[i][j] 就可以是 dp[i - zeroNum][j - oneNum] + 1。
// 然后我们在遍历的过程中,取dp[i][j]的最大值。
                     dp[i][j] = Math.max(dp[i][j],dp[i-zeroNum][j-oneNum] + 1);
                 }
             }
         }
         return dp[m][n];

    }
}

完全背包理论学习

根据对比学习法,完全背包和0-1背包的唯一不同的地方就是每种物品有无限件。根据这个条件就会衍生出很多细节问题。
在0-1背包遍历的核心diamante中,我们强调了要先遍历物品,再遍历背包(并且背包的遍历必须是倒序遍历,防止重复取值)

for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

但是完全背包的物品是可以添加多次的,所以要从小到大取遍历

// 先遍历物品,再遍历背包
for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = weight[i]; j < bagWeight ; j++) { // 遍历背包容量,j<背包容量即可,且是从小到大
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    }
}

注意:在完全背包中,遍历物品和遍历背包的次序是无所谓的(原因就是物品可以多次取,无所谓的)

package com.douma._26_day._5;

public class test3 {
    public static void main(String[] args) {
        test3.testCompletePack();
        //0   
        // 15
        // 30
        // 45
        // 60
        testCompletePackAnotherWay();
    }
    //先遍历物品,再遍历背包
    private static void testCompletePack(){
        int[] weight = {1, 3, 4};
        int[] value = {15, 20, 30};
        int bagWeight = 4;
        int[] dp = new int[bagWeight + 1];
        for (int i = 0; i < weight.length; i++){
            for (int j = 1; j <= bagWeight; j++){
                // 背包容量要大于等于当前物品的重量
                if (j - weight[i] >= 0){
                    dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
                }
            }
        }
        for (int maxValue : dp){
            System.out.println(maxValue + "   ");
        }
    }

    //先遍历背包,再遍历物品
    private static void testCompletePackAnotherWay(){
        int[] weight = {1, 3, 4};
        int[] value = {15, 20, 30};
        int bagWeight = 4;
        int[] dp = new int[bagWeight + 1];
        for (int i = 1; i <= bagWeight; i++){
            for (int j = 0; j < weight.length; j++){
                if (i - weight[j] >= 0){
                    dp[i] = Math.max(dp[i], dp[i - weight[j]] + value[j]);
                }
            }
        }
        for (int maxValue : dp){
            System.out.println(maxValue + "   ");
        }
    }
}

518. 零钱兑换 II

注意:组合数的递推公式;组合数和排列数的遍历顺序问题;完全背包问题在实际问题中的变形,也就是dp数组初始化问题
注意:求解组合数是 先物品再背包,求解排列数是 先背包再物品

class Solution {
    public int change(int amount, int[] coins) {
        /**
        分析:
        把coins看做是物品,amount看做是背包。那么题目意思就是一个完全背包问题(物品可以无限取)
        纯完全背包问题是能否凑成总金额,而本题求得是凑成总金额的个数!!!
        所以在递推公式上得做出变形。
        题目描述的是凑成总金额的组合数,而不是排列数!!!
        比如 5 = 2+2+1 和 5=2+1+2 是等价的
        dp[j]:凑成总金额j的货币组合数为dp[j]
        确定递推公式:dp[j] += dp[j-coins[j]];
        dp[j](考虑conis[i]的组合总和就是所有dp[j-coins[i]]相加) 
        初始化,金额为0时只有一种情况,那就是什么都不装

        如果求组合数就是外层for循环遍历物品,内层for遍历背包。
        如果求排列数就是外层for遍历背包,内层for循环遍历物品。
         */
         int n = coins.length;
         int[]dp = new int[amount+1];
         // 初始化,金额为0时只有一种情况,那就是什么都不装
         dp[0] = 1;
         for(int i = 0; i < n; i++){
             // 遍历背包从小到大
             for(int j = coins[i]; j <= amount; j++){
                //  对于元素之和等于 i - coin 的每一种组合,在最后添加 coin 之后即可得到一个元素之和等于 i 的组合,因此在计算 dp[i] 时,应该计算所有的 dp[i − coin] 之和。
                // 这个是组合数常用公式
                 dp[j] += dp[j-coins[i]];
             }
         }
         return dp[amount];

    }
}

377. 组合总和 Ⅳ

本题就考察了完全背包问题的变形,以及排列数如何遍历的问题

class Solution {
    public int combinationSum4(int[] nums, int target) {
        /**
        分析:
        本题和零钱兑换是一样的思路。是一个变形的完全背包问题,求的是组合个数,这里的组合其实即使排列数
        1,1,2和1,2,1是不同的组合
        dp[i]:背包为i的元素组合个数
        递推公式:dp[j] += dp[j-nums[i]]
        初始化:dp[0] = 1,表示背包为0,1种方法,什么都不装
        遍历顺序:先遍历背包,再遍历物品
         */
         int n = nums.length;
         int[]dp = new int[target+1];
         dp[0] = 1;
         // 先遍历背包
         for(int j = 0; j <= target; j++){
             // 再遍历物品,
             for(int i = 0; i < n ; i++){
                 // 确保背包容量大于等于物品重量
                 if( j >= nums[i]){
                     // dp[j] 的方法数应该是 不取nums[i]的方法数总和
                     dp[j] += dp[j -nums[i]];
                 }
                 
             }
         }
         return dp[target];

    }
}

0-1背包和完全背包套路总结

参考leetcode写的总结:
https://leetcode-cn.com/problems/combination-sum-iv/solution/xi-wang-yong-yi-chong-gui-lu-gao-ding-bei-bao-wen-/

322. 零钱兑换

class Solution {
    public int coinChange(int[] coins, int amount) {
        /**
        分析:
        从题目上看,这是一个完全背包问题的变形,返回的是可以凑成总金额所需的最少银币个数
        dp[j]:背包为j的凑成总金额最少银币
         */
         int n = coins.length;
         int []dp = new int[amount+1];
        //  dp[j]必须初始化为一个最大的数,否则就会在min(dp[j - coins[i]] + 1, dp[j])比较的过程中被初始值覆盖。
// 所以下标非0的元素都是应该是最大值。
         Arrays.fill(dp,Integer.MAX_VALUE);
         // 当金额为0时候,所需钱币一定为0
         dp[0] = 0;
         for(int i = 0; i < n; i++){
             // 完全背包正序遍历
             for(int j = coins[i]; j <= amount; j++){
                //  凑足总额为j - coins[i]的最少个数为dp[j - coins[i]],那么只需要加上一个钱币coins[i]即dp[j - coins[i]] + 1就是dp[j](考虑coins[i])
                // 只有dp[j-coins[i]]不是初始最大值时,该位才有选择的必要
                if(dp[j - coins[i]] != Integer.MAX_VALUE){
                    dp[j] = Math.min(dp[j],dp[j - coins[i]] + 1);
                }
             }
         }
         return dp[amount] == Integer.MAX_VALUE ? -1 : dp[amount];


    }
}

279. 完全平方数

基本上和前面一样的套路,只不过这里的物品集合需要从n中抽取

class Solution {
    public int numSquares(int n) {
        /**
        分析:
        这是一个完全背包问题的变种,组合数问题
        dp[j],表示背包为j的返回j的完全平方数的最小数量
        dp[j] = Math.min(dp[j],dp[j-square]+1)
        
         */
         int[] dp = new int[n+1];
         Arrays.fill(dp,Integer.MAX_VALUE);
         dp[0] = 0;
         // 先遍历物品,物品满足 条件:n以内的完全平方数(从1开始)
         for(int i = 1; i * i<= n; i++ ){
             // 在遍历背包,背包也是从1开始
             for(int j = 1; j <= n; j++){
                 // 背包容量大于等于物品
                 if( j >= i * i){
              // 求解最小值的公式,dp[j]等于,j-i*i容量的最小值dp[j-i * i],再加上i*i这个数(+1)
                   dp[j] = Math.min(dp[j],dp[j-i * i]+1) ;
                 }
             }
         }
         return dp[n];

    }
}

139. 单词拆分

class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        /**
        单词就是物品,字符串s就是背包,单词能否组成字符串s,就是问物品能不能把背包装满。
        拆分时可以重复使用字典中的单词,说明这是一个完全背包。
        dp[i]:字符串长度为i的话,dp[i]为true,表示可以拆分为一个或多个在字典中出现的单词。
        如果dp[j] 是true,且[j,i]这个区间的子串在这个字典里,那么dp[i]一定为true(j<i)
        所以递推公式是 if([j,i]这个区间的子串出现在字典里 && dp[j]是true) 那么dp[i]=true
        dp数组如何初始化:
        dp[i] 的状态依靠dp[j]是否为true,那么当j=0时dp[0]一定为true,否则递归下去后面都是false
        遍历顺序,选择遍历物品或者遍历背包先后都可以
         */
         boolean[] valid = new boolean[s.length() + 1];
         // 初始化为 true
         valid[0] = true;
         // 遍历背包
         for(int i = 1; i <= s.length(); i++){
             // 遍历物品
             for(int j = 0; j < i; j++){
                 // 判断是否子串
                 if(wordDict.contains(s.substring(j,i)) && valid[j]){
                     valid[i] = true;
                 }
             }
         }
         return valid[s.length()];
    }
}

198. 打家劫舍

时刻牢记dp的定义

class Solution {
    public int rob(int[] nums) {
        /**
        分析:
        dp[i]:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i]
        递推公式:决定dp[i]的因素就是第i房间偷还是不偷,如果偷第i间房间,那么dp[i] = dp[i-2]+nums[i],即第i-1房一定是不考虑的,找出下标i-2(包括i-2)以内的房屋,最多可以偷窃的金额为dp[i-2]加上第i房间偷到的钱
        如果不偷第i间房间,那么dp[i] = dp[i-1]即考虑i-1房
        dp[i] = max(dp[i-2]+nums[i],dp[i-1])
        初始化:从递推公式上看,基础是dp[0]和dp[1],那么从dp[i]的定义看,dp[0] = nums[0],dp[1] = max(nums[0],nums[1]) 
         */
         if(nums.length == 0){
             return 0;
         }
         if(nums.length == 1){
             return nums[0];
         }
         if(nums.length == 2){
             return nums[0] > nums[1] ? nums[0]:nums[1];
         }
         int[] dp = new int[nums.length];
         dp[0] = nums[0];
         dp[1] = Math.max(nums[0],nums[1]);
         for(int i = 2; i < nums.length; i++){
             // 递推公式
             dp[i] = Math.max(dp[i-2] + nums[i],dp[i-1]);
         }
         return dp[nums.length - 1];

    }
}

213. 打家劫舍 II

class Solution {
    public int rob(int[] nums) {
        /**
        分析:
        再打家劫舍的过程中增加一个条件,首尾相连。
        针对这个首尾相连的环,其实就是把环拆开成为两个队列,一个从0到n-2,另一个是从1到n-1,然后返回两个结果最大的
         */
         if(nums.length == 0){
             return 0;
         }
         if(nums.length == 1){
             return nums[0];
         }
         if(nums.length == 2){
             return nums[0] > nums[1] ? nums[0]:nums[1];
         }
         // 一个从0到n-2,另一个是从1到n-1
         return Math.max(robCircle(nums,0,nums.length - 2),robCircle(nums,1,nums.length - 1));
    }
    // 这里是非环的逻辑,也就是打家劫舍的核心代码
    public int robCircle(int[]nums,int start,int end){
        int[] dp = new int[nums.length];
        // 初始化发生了变化  从start开始
        dp[start] = nums[start];
        dp[start+1] = Math.max(nums[start],nums[start+1]);
        // start后两位开始,逻辑和打家劫舍的核心代码一致
        for(int i = start + 2; i <= end; i++ ){
            dp[i] = Math.max(dp[i-2] + nums[i],dp[i-1]);
        }
        // 返回最后一个状态
        return dp[end];

    }
}

337. 打家劫舍 III

这个题解写的很好
https://leetcode-cn.com/problems/house-robber-iii/solution/san-chong-fang-fa-jie-jue-shu-xing-dong-tai-gui-hu/
版本一:后续遍历递归解法

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode() {}
 *     TreeNode(int val) { this.val = val; }
 *     TreeNode(int val, TreeNode left, TreeNode right) {
 *         this.val = val;
 *         this.left = left;
 *         this.right = right;
 *     }
 * }
 */
class Solution {
    public int rob(TreeNode root) {
        // 后续遍历dfs
        if( root == null ) return 0;
        int money = root.val;
        // 计算爷爷节点 + 四个孙子节点
        if(root.left != null){
            money += rob(root.left.left) + rob(root.left.right);
        }
        if(root.right != null){
            money += rob(root.right.left) + rob(root.right.right);
        }
        // 取爷爷节点 + 四个孙子节点(money) 和 两个儿子节点的最大值rob(root.left) + rob(root.right)
        return Math.max(money,rob(root.left) + rob(root.right));
    }
}

在这里插入图片描述
时间超时了,在计算四个孙子的时候,又计算了两个儿子(这个过程中相当于再次计算四个孙子)
优化方向可以思考记忆化递归!!!
版本二:记忆化递归

class Solution {
    Map<TreeNode,Integer> map = new HashMap<>();
    public int rob(TreeNode root) {
        // 后续遍历dfs
        if( root == null ) return 0;
        // 有记录值 就直接返回
        if(map.containsKey(root)) return map.get(root);
        int money = root.val;
        // 计算爷爷节点 + 四个孙子节点
        if(root.left != null){
            money += rob(root.left.left) + rob(root.left.right);
        }
        if(root.right != null){
            money += rob(root.right.left) + rob(root.right.right);
        }
        // 取爷爷节点 + 四个孙子节点(money) 和 两个儿子节点的最大值rob(root.left) + rob(root.right)
        int res = Math.max(money,rob(root.left) + rob(root.right));
        // 记忆化 记录一下
        map.put(root,res);
        return res;
    }
}

版本三:使用动态规划

class Solution {
    public int rob(TreeNode root) {
        // 每个节点可选择偷或者不偷的状态,相连的节点不能一起偷
        // - 当前节点选择偷,那么两个孩子节点就不能选择偷了
        // - 当前节点选择不偷时,那么孩子节点只需要拿最多的钱出来就行(两个孩子节点偷不偷没关系)
        // 使用一个大小为2的数组来表示 int[]res = new int[2]   0代表不偷 1代表偷

        // 不偷:Max(左孩子不偷,左孩子偷) + Max(又孩子不偷,右孩子偷)
    // root[0] = Math.max(rob(root.left)[0], rob(root.left)[1]) +
    // Math.max(rob(root.right)[0], rob(root.right)[1])
    // 偷:左孩子不偷+ 右孩子不偷 + 当前节点偷
    // root[1] = rob(root.left)[0] + rob(root.right)[0] + root.val;

        int[] res = robAction(root);
        return Math.max(res[0],res[1]);
    }
    public int[] robAction(TreeNode root){
        int res[] = new int[2];
        if(root == null){
            return res;
        }
        // 左孩子状态
        int[] left = robAction(root.left);
        // 右孩子状态
        int[] right = robAction(root.right);
        // 当期节点不偷:Max(左孩子不偷,左孩子偷) + Max(又孩子不偷,右孩子偷)
        res[0] = Math.max(left[0],left[1]) +Math.max(right[0],right[1]);
        // 当前节点偷:左孩子不偷+ 右孩子不偷 + 当前节点偷
        res[1] = root.val + left[0] + right[0];
        // 返回状态集合
        return res;
    }
}

121. 买卖股票的最佳时机

股票问题大汇总!!!重点理解下,为什么使用dp,如何使用dp

这个问题的「状态」有三个,第一个是天数,第二个是允许交易的最大次数,第三个是当前的持有状态(即之前说的 rest 的状态,我们不妨用 1 表示持有,0 表示没有持有)。然后我们用一个三维数组就可以装下这几种状态的全部组合:

dp[i][k][0 or 1]
0 <= i <= n - 1, 1 <= k <= K
n 为天数,大 K 为交易数的上限,01 代表是否持有股票。
此问题共 n × K × 2 种状态,全部穷举就能搞定。

for 0 <= i < n:
    for 1 <= k <= K:
        for s in {0, 1}:
            dp[i][k][s] = max(buy, sell, rest)

在这里插入图片描述

dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
              max( 今天选择 rest,        今天选择 sell       )

解释:今天我没有持有股票,有两种可能,我从这两种可能中求最大利润:

1、我昨天就没有持有,且截至昨天最大交易次数限制为 k;然后我今天选择 rest,所以我今天还是没有持有,最大交易次数限制依然为 k。

2、我昨天持有股票,且截至昨天最大交易次数限制为 k;但是今天我 sell 了,所以我今天没有持有股票了,最大交易次数限制依然为 k。

dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
              max( 今天选择 rest,         今天选择 buy         )

解释:今天我持有着股票,最大交易次数限制为 k,那么对于昨天来说,有两种可能,我从这两种可能中求最大利润:

1、我昨天就持有着股票,且截至昨天最大交易次数限制为 k;然后今天选择 rest,所以我今天还持有着股票,最大交易次数限制依然为 k。

2、我昨天本没有持有,且截至昨天最大交易次数限制为 k - 1;但今天我选择 buy,所以今天我就持有股票了,最大交易次数限制为 k。

class Solution {
    public int maxProfit(int[] prices) {
        /**
        分析:
        其实就是在找最优间距,如果没有接触dp,那么首先想到的就是 暴力法,双重for循环,不断更新res间距值。这个办法不是最优的,也会超时。
        再进一步理解,其实就是在寻找最左最小值和最右最大值,并且只是计算一次结果,因此可以贪心的寻找这两个值
        
         */
         // 贪心寻找最左最小值和最右最大值
         int left = Integer.MAX_VALUE;
         int res = 0;
         for(int i = 0; i < prices.length; i++){
             // 选择最左最小值
             left = Math.min(left,prices[i]);
             // 直接取最大区间利润
             res = Math.max(res,prices[i] - left);
         }
         return res;

    }
}

使用dp

class Solution {
    public int maxProfit(int[] prices) {
        /**
        分析:
        其实就是在找最优间距,如果没有接触dp,那么首先想到的就是 暴力法,双重for循环,不断更新res间距值。这个办法不是最优的,也会超时。
        再进一步理解,其实就是在寻找最左最小值和最右最大值,并且只是计算一次结果,因此可以贪心的寻找这两个值
        再进一步:用动态规划
        what?这个怎么想的到dp呢?其实dp主要是用于求解多阶段决策问题,只要最优解,不问具体解,把约束条件设置为状态,一个约束,就要多一个状态。
        题目只问最大利润,没有问这几天具体哪一天买、哪一天卖,因此可以考虑使用 动态规划 的方法来解决。
        状态i(0-未持股,1-持股)
        dp[i][j]:第i天,持股状态为j,所获取的最大利润
        for(int i=状态1.。。。){
            for(int j=状态2。。。){
                dp[状态1][状态2] = {最佳值};
            }
        }
        初始化
        dp[i][0] =

        
         */
        // 有了dp的思想,现在就开始梳理思路
        // 定义dp[i][j],表示0-i天所获得最大利润,j=0表示未持股状态,j=1表示持股状态
        int[][]dp = new int[prices.length][2];
        // 初始化
        // 第一天,不持股,利润就是0
        dp[0][0] = 0;
        // 第一天持股了,说明花钱买股票了
        dp[0][1] = -prices[0];
        // 遍历状态
        for(int i = 1; i < prices.length; i++){
            //[0,i] ,j=0未持股状态 = [0,i-1],j=0 和 [0,i-1],j=1持股+卖掉了 的最大值
            dp[i][0] = Math.max(dp[i-1][0],dp[i-1][1] + prices[i]);
            //[0,i],j=1 持股状态 = [0,i-1],j=1 和 今天买股票 的最大值
            dp[i][1] = Math.max(dp[i-1][1],-prices[i]);
        }
        // 最后返回最终状态值:未持股
        return dp[prices.length - 1][0];
    }
}

122. 买卖股票的最佳时机 II

贪心真的很巧妙

class Solution {
    public int maxProfit(int[] prices) {
        /**
        分析:
        和前一题类似,也可以使用贪心算法,只要后一天比前一天股票值大,那么就可以贪一波。
         */
         int res = 0;
         for(int i = 1; i < prices.length; i++){
             if(prices[i] > prices[i-1]){
                 // 贪一波 正利润
                 res += prices[i] - prices[i-1];
             }
         }
         return res;

    }
}

使用dp和上一题区别就是在于可以多次购买,所以状态转移方程发生变化

class Solution {
    public int maxProfit(int[] prices) {
        /**
        分析:
        和前一题类似,也可以使用贪心算法,只要后一天比前一天股票值大,那么就可以贪一波。
        使用dp的话,也和上一题思想一致,只不过状态转移方程要变化下(多次买卖交易)
         */
        int[][]dp = new int[prices.length][2];
        // 初始化
        // 第一天 不持股,利润为0
        dp[0][0] = 0;
        // 第一天持股,利润为负数,买了股票
        dp[0][1] = -prices[0];
        for(int i = 1; i < prices.length; i++){
            // [0,i]状态不持股 == [0,i-1]状态不持股 和[0,i-1]持股 + 卖掉价值 
            dp[i][0] = Math.max(dp[i-1][0],dp[i-1][1] + prices[i]);
            // [0,i]状态持股 = [0,i-1]持股 和 [0,i]不持股 + 买新股票
            dp[i][1] = Math.max(dp[i-1][1],dp[i][0] - prices[i]);
        }
        // 返回最后的状态:不持股
        return dp[prices.length - 1][0];

    }
}

714. 买卖股票的最佳时机含手续费

class Solution {
    public int maxProfit(int[] prices, int fee) {
        // 和122.买卖股票的最佳时机II是一样的 只不过加了一个手续费
        int[][]dp = new int[prices.length][2];
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        for(int i = 1;i < prices.length; i++){
            dp[i][0] = Math.max(dp[i-1][0],dp[i-1][1] + prices[i] - fee);
            dp[i][1] = Math.max(dp[i-1][1],dp[i][0] - prices[i]);
        }
        return dp[prices.length - 1][0];
    }
}

123. 买卖股票的最佳时机 III

放弃了,要考虑的边界条件太多了,但是大的思想是不变的定义dp数组
dp[i][j][k]:i是天数,j是持股状态(0-1),k是买卖次数(0,1,2)

188. 买卖股票的最佳时机 IV

和123一样,跳过。定义dp思路一致,加了一个买卖次数的约束

300. 最长递增子序列

现在开始进入子序列问题。。。。。。。。。。。。。。。。。。。。
本题最关键的是要想到dp[i]由哪些状态可以推出来,并取最大值,那么很自然就能想到递推公式:dp[i] = max(dp[i], dp[j] + 1);

class Solution {
    public int lengthOfLIS(int[] nums) {
        /**
        分析:
        刚开始看到这题,想到的是暴力法,也就是回溯了,应该会超时。
        那么题目要求的是最优解,而不是具体解,首先就要想到动态规划。
        dp[i]:以i为结尾的最长严格递增子序列的长度
        初始化:dp数组全部为1,很明显一个元素的最大长度就是1
        递推公式:dp[i]从前往后遍历nums数组,用j来遍历i,从而更新dp[i] = Math.max(dp[i],dp[j] + 1)
        这里尤其要注意,我们取的是dp[j] + 1,因为dp[i]的定义是如此
        更新dp数组后,取dp数组中的最大值就可以了
         */
         int[] dp = new int[nums.length];
         // 初始化为1,表示每个元素的最大长度默认是1
         Arrays.fill(dp,1);
         // 定义结果
         int res = 0;
         // 遍历
         for(int i = 0; i < nums.length; i++){
             for(int j = 0; j < i; j++){
                 // 严格递增
                 if(nums[i] > nums[j]){
                     // 注意取的dp[j] + 1,这是由于dp[i]的定义决定的
                     dp[i] = Math.max(dp[i],dp[j] + 1);
                 }
             }
             // 每次取最大的dp
             res = Math.max(res,dp[i]);
         }
         return res;

    }
}

674. 最长连续递增序列

使用dp做,最关键的是要理解清除,连续和非连续之间递推关系的异同,其实也就是深度理解dp【i】的定义

class Solution {
    public int findLengthOfLCIS(int[] nums) {
        /**
        分析:
        本题和300题的最长上升子序列很相似,只不过这里多了一个条件,那就是要连续才可以,这也就是说递推公式要发生变化.
        概括来说:不连续递增子序列的跟前0-i 个状态有关,连续递增的子序列只跟前一个状态有关
        dp[i]:以下标i为结尾的数组的连续递增的子序列长度为dp[i]。
         */
         int[] dp = new int[nums.length];
         // dp数组初始化为1,默认单个元素的最长长度为1
         Arrays.fill(dp,1);
         // res初始化为1,最少长度是1
         int res = 1;
         for(int i = 1; i < nums.length; i++){
             // 连续递增的子序列只和前一个状态有关
             if(nums[i] > nums[i-1]){
                 dp[i] = dp[i-1] + 1;
             }
             res = Math.max(res,dp[i]);
         }
         return res;

    }
}

贪心比较巧妙,只能积累常识

class Solution {
    public int findLengthOfLCIS(int[] nums) {
        /**
        还可以使用贪心来做,每次遇到连续就+1,遇到不连续就重置为1,每次都贪最大的结果
         */
        // 贪心
        int res = 1;
        int count = 1;
        for(int i = 1; i < nums.length; i++ ){
            if(nums[i] > nums[i - 1]){
                // 连续就++
                count++;
            }else {
                // 否则就是重置为1
                count = 1;
            }
            // 贪心获取
            res = Math.max(res,count);
        }
        return res;

    }
}

718. 最长重复子数组

class Solution {
    public int findLength(int[] nums1, int[] nums2) {
        /**
        分析:
        子数组是必须连续的,首先就是确定使用动态规划(只要最优解,不求具体解)
        定义dp[i][j]:以下标i-1为结尾的A和以下标j-1为结尾的B,最长重复子数组长度为dp[i][j]
        初始化:默认都是0
        递推公式:如果当前结尾的A和B相等,那么判断下前面一个是否相等,如果前面一个相等就在原有基础上+1,如果前面一个不相等,那么就设置为1
        递推过程中不断更新max
         */
         int[][] dp = new int[nums1.length][nums2.length];
         int res = 0;
         for(int i = 0; i < nums1.length; i++){
             for(int j = 0; j < nums2.length; j++){
                // 发现当前结尾的A和B相等
                 if(nums1[i] == nums2[j]){
                    if( i-1 < 0 || j - 1 < 0){
                        // 判断边界,如果前面一个不存在,那么就设置为1
                        dp[i][j] = 1;
                    }else{
                        dp[i][j] = dp[i-1][j-1] + 1;
                    }     
                 }
                 // 更新最大值
                 res = Math.max(res,dp[i][j]);
             }
         }
         return res;

    }
}

1143. 最长公共子序列

似乎有点套路了,其实dp定义一出来,递推公式一推出来,基本问题就解决了

class Solution {
    public int longestCommonSubsequence(String text1, String text2) {
        /**
        分析:
        本题求的是最优解,而不是具体解。可以考虑使用动态规划。和718最长重复数组区别就在于这里不要求连续了,但是要有相对顺序。
        dp[i][j]:i是text1[0,i-1]和text2[0,j-1]所共同拥有的子序列长度
        初始化:很明显,任何一个text和空串的共同拥有子序列为0,所以初始化为0
        递推公式:
        text[i]和text[j]相等,那么dp[i][j] = dp[i-1][j-1] + 1
        若不相等,那么dp[i][j] = Math.max(dp[i-1][j],dp[i][j-1])
         */
         int m = text1.length(),n = text2.length();
         // 这里初始化,多了一维,也就是 m+1和n+1
         int [][]dp = new int[m+1][n+1];
         for(int i = 1; i <= m; i++){
             for(int j = 1; j <= n; j++){
                 if(text1.charAt(i-1) == text2.charAt(j-1)){
                     dp[i][j] = dp[i-1][j-1] + 1;
                 }else{
                     dp[i][j] = Math.max(dp[i-1][j],dp[i][j-1]);
                 }
             }
         }
         return dp[m][n];

    }
}

1035. 不相交的线

和上一题一模一样,关键是能不能根据题意转换为数学语言

class Solution {
    public int maxUncrossedLines(int[] nums1, int[] nums2) {
        /**
        分析:
        只要求解最优解,而不是求解具体解,所以使用动态规划。
        和1143最长公共子序列解法是一模一样的,为什么呢???
        nums1[i]和nums2[j]相等,且直线不能相交,说明在字符串A中找到一个字符串B相同的子序列,且这个子序列不能改变相对顺序,只要相对顺序不改变,链接相同数字的直线就不会相交。
        直接copy上一题代码。。。。。
         */
         int m = nums1.length;
         int n = nums2.length;
         int [][]dp = new int[m+1][n+1];
         for(int i = 1; i <= m; i++){
             for(int j = 1; j <= n; j++){
                 if(nums1[i-1] == nums2[j-1]){
                     // 发现相同
                     dp[i][j] = dp[i-1][j-1] + 1;
                 }else{
                     dp[i][j] = Math.max(dp[i-1][j],dp[i][j-1]);
                 }
             }
         }
         return dp[m][n];
    }
}

53. 最大子数组和

对于dp,其实想出状态定义,基本问题就解决了一半

class Solution {
    public int maxSubArray(int[] nums) {
        /**
        分析:
        暴力法一眼就看出来,但是试试dp吧。因为只要求最优解,而不要求具体解
        dp[i]:以下标i-1的连续子数组最大和
        初始化:dp[i]依赖于dp[i-1],那么初始化dp[0] = nums[0]
        递推公式:dp[i] = Math.max(dp[i-1] + nums[i],nums[i]) // 要么是加入nums[i]要么是从nums[i]重新开始
        
         */
         int[] dp = new int[nums.length];
         dp[0] = nums[0];
         // 定义结果
         int res = dp[0];
         for(int i = 1; i < nums.length; i++){
             dp[i]  = Math.max(dp[i-1]+nums[i],nums[i]);
             // 取dp中的最大值 res
             res = Math.max(res,dp[i]);
         }
         return res;

    }
}

392. 判断子序列

常规思路是使用双指针法

class Solution {
    public boolean isSubsequence(String s, String t) {
        // 这个子序列不是连续的数组,所以不能用滑动窗口解题
        // 很明显是一个双指针问题,在特定情况下s的指针才进行移动
        int p1 = 0;
        int p2 = 0;
        if(s.length() == 0){
            return true;
        }
        if(s.length() > t.length()){
            return false;
        }
        while(p1 < s.length()){
            if(p2 == t.length()){
                // p2到达终点了,p1还没有达到终点
                return false;
            }
            if(s.charAt(p1) == t.charAt(p2)){
                // 找到相同字母
                p1++;
                p2++;
            }else {
                // 字母不相同
                p2++;
            }
        }
        return true;

    }
}

使用dp,是在是有点绕,我会更加喜欢使用双指针!!!

class Solution {
    public boolean isSubsequence(String s, String t) {
        /**
        分析:
        有了前面的基础,现在这道题自然而然就会靠近dp想,只要最优解,不求具体解。判断成功与都就是两个字符串的子序列长度是不是等于s的长度
        dp[i][j]:s中[0,i-1]和t中[0,j-1]的公共子序列长度
        递推公式:s[i] == t[j],那么 dp[i][j] = dp[i-1][j-1] + 1,若 s[i] != t[j],dp[i][j] = dp[i][j-1](其实就是删除t[j]的意思,取前面的匹配子序列长度)
        初始化:dp[0][0]初始化为0,那么默认dp数组都是初始化为0
         */
         int m = s.length(), n = t.length();
         int[][] dp = new int[m+1][n+1];
         for(int i = 1; i <= m; i++){
             for(int j = 1; j <= n; j++){
                 if(s.charAt( i - 1) == t.charAt( j - 1)){
                     dp[i][j] = dp[i-1][j-1] + 1;
                 }else{
                 // 取前面的匹配子序列长度
                     dp[i][j] = dp[i][j-1];
                 }
             }
         }
         // 子序列长度等于 了 s中的长度
         if(dp[m][n] == m){
             return true;
         }
         return false;

    }
}

115. 不同的子序列

好难啊啊啊啊!!!!这个状态和转移方程都没有看懂,终于在评论中看懂了!!!

class Solution {
    public int numDistinct(String s, String t) {
        /**
        分析:
        对于有两个string的题,只要最优解,不求具体解,一般想法就是用个二维数组,两个维度分别对应s和t的字符。那dp的状态就出来了,dp[i][j]表示s前i个字符中包含t的前j个字符的子序列个数(题目要求什么,我们就用dp记录什么)
        递推公式:
        当s[i-1] == t[j-1]时,那么dp[i][j] = dp[i-1][j-1] + dp[i-1][j],第一个表示的是s前i-1个字符中包含t的前j-1个字符的子序列个数(s的子串中 保留s[i-1] ),不保留s[i-1],s前i-1个字符中包含t的前j个字符的子序列个数
        当s[i-1] != t[j-1]时,当前字符不等,那么删除字符后s[i]必定不能被保留,所以dp[i][j] = dp[i-1][j],即s 的前 i 个字符 中 t 的前 j 个字符出现的次数, 等于 s 的前 i-1 个字符 中 t 的前 j 个字符出现的次数
        初始化:
        当s为空,t不为空,那么s在t中出现的个数为0
        当t为空,那么无论s是什么,t总会在s中出现刚好1次(所谓出现次数,就是s中删除某些字符之后刚好得到t的方法个数,t为空,那么删除s中所有字符即可,因此出现次数为1)
        两个都为空,那么就是dp[0][0] = 1
        =========================================================================================
        思路复盘:
        我来解释下2个问题,1: 为啥状态方程这样对? 2:怎么想到这样的状态方程?

我个人习惯dp[i][j] 表示为s[0-i] 和t[0-j]均闭区间的子序列个数,但这样不能表示s和t空串的情况

所以声明 int[][] dp = new int[m + 1][n + 1]; 这样dp[0][x]可以表示s为空串,dp[x][0]同理。

先不扣初始化的细节,假设dp[i][j] 就是s[i] 和t[j] 索引的元素子序列数量

1:为啥状态方程是: s[i] == t[j] 时 dp[i][j] = dp[i-1][j-1] + dp[i-1][j]

s[i] != t[j] 时 dp[i][j] = dp[i-1][j]

先看s[i] == t[j] 时,以s = "rara" t = "ra" 为例,当i = 3, j = 1时,s[i] == t[j]。

此时分为2种情况,s串用最后一位的a + 不用最后一位的a。

如果用s串最后一位的a,那么t串最后一位的a也被消耗掉,此时的子序列其实=dp[i-1][j-1]

如果不用s串最后一位的a,那就得看"rar"里面是否有"ra"子序列的了,就是dp[i-1][j]

所以 dp[i][j] = dp[i-1][j-1] + dp[i-1][j]

再看s[i] != t[j] 比如 s = "rarb" t = "ra" 还是当i = 3, j = 1时,s[i] != t[j]

此时显然最后的b想用也用不上啊。所以只能指望前面的"rar"里面是否有能匹配"ra"的

所以此时dp[i][j] = dp[i-1][j]

2: 怎么想到这样状态方程的?

一点个人经验,见过的很多2个串的题,大部分都是dp[i][j] 分别表示s串[0...i] 和t串[0...j]怎么怎么样 然后都是观察s[i]和t[j]分等或者不等的情况 而且方程通常就是 dp[i-1][j-1] 要么+ 要么 || dp[i-1][j]类似的

类似的题比如有 10:正则表达式匹配 44:通配符匹配 编辑距离 1143:最长公共子序列等等的 还有几道想不起来了
         */
         // 因为增加了空行和空列
         int[][] dp = new int[s.length() + 1][t.length() + 1];
         for(int i = 1; i <= t.length(); i++){
             // s为空时候 dp[0][i] = 0
             dp[0][i] = 0;
         }
         for(int i = 0; i <= s.length(); i++){
             // t为空时候 dp[i][0] = 1,dp[0][0] = 0
             dp[i][0] = 1;
         }
         for(int i = 1; i <= s.length(); i++){
             for(int j = 1; j <= t.length(); j++){
                 if(s.charAt( i - 1) == t.charAt( j - 1)){
                     // 发现相等:保留s[i]和不保留s[i]
                     dp[i][j] = dp[i-1][j-1] + dp[i-1][j];
                 }else{
                     //不保留s[i]
                     dp[i][j] = dp[i-1][j];
                 }
             }
         }
         return dp[s.length()][t.length()];


    }
}

583. 两个字符串的删除操作(字符串的删除操作)

class Solution {
    public int minDistance(String word1, String word2) {
        /**
        分析:有了上一题的基础,115不同的子序列,那么本题也很好理解了。上一题是删除一个字符串,这一题是可以删除两个字符串。
        定义dp状态:
        dp[i][j]是以i-1为结尾的字符串word1和以j-1为结尾的字符串2,想要达到相等,所需要删除元素的最小次数
        递推公式:
        当word1[i-1]和word2[j-1]相等的时候,dp[i][j] = dp[i-1][j-1](取上一轮的)
        当word1[i-1]和word2[j-1]不相等的时候,
        情况一:删word1[i - 1],最少操作次数为dp[i - 1][j] + 1

        情况二:删word2[j - 1],最少操作次数为dp[i][j - 1] + 1

        情况三:同时删word1[i - 1]和word2[j - 1],操作的最少次数为dp[i - 1][j - 1] + 2

        那最后当然是取最小值
        dp[i][j] = Math.min(dp[i-1][j-1]+2,dp[i-1][j]+1,dp[i][j-1]+1)
        初始化:
        dp[i][0]:word2为空字符串,以i-1为结尾的字符串word1要删除多少个元素,才能和word2相同呢,很明显dp[i][0] = i,dp[0][j]的话同理

         */
         int m = word1.length(), n = word2.length();
        int[][] dp = new int[m+1][n+1];
        // 初始化
        for(int i = 1; i <= m; i++){
            dp[i][0] = i;
        }
        for(int j = 1; j <= n; j++){
            dp[0][j] = j;
        }
        for(int i = 1; i <= m; i++){
            for(int j = 1; j <= n; j++){
                if(word1.charAt(i-1) == word2.charAt(j-1)){
                    // 取上一次(两个字符都不删除)
                    dp[i][j] = dp[i-1][j-1];
                }else{
                    // 取三者情况的最小值
                    dp[i][j] = Math.min(dp[i-1][j-1]+2,Math.min(dp[i-1][j]+1,dp[i][j-1]+1));
                }
            }
        }
        return dp[m][n];

    }
}

72. 编辑距离

class Solution {
    public int minDistance(String word1, String word2) {
        /**
        分析:
        这道题其实是前面两道题的变种题型,在原本的删除操作上加入了插入操作和替换操作

        定义:

        dp[i][j]:word1 的前 i 个转换成 word2 的前 j 字符最少需要操作的次数

        dp[i - 1][j - 1]:word1 替换字符操作(word1替换word1[i - 1],使其与word2[j - 1]相同,此时不用增加元素,那么以下标i-2为结尾的word1 与 j-2为结尾的word2的最近编辑距离 加上一个替换元素的操作)

        dp[i - 1][j]:word1 删除字符操作

        dp[i][j - 1]:word1 插入字符操作(本质上删除操作,在word2中添加元素,相当于word1删除元素)

转移方程:
        if (word1[i - 1] == word2[j - 1]) 那么说明不用任何编辑,dp[i][j] 就应该是 dp[i - 1][j - 1],即dp[i][j] = dp[i - 1][j - 1];(等于上一次操作次数,当前字母不做任何操作)
        if (word1[i - 1] != word2[j - 1])那么说明需要编辑,dp[i][j] 就应该是 
        dp[i - 1][j - 1](替换操作,比如 a -> b,就是寻求 dp[0][0] 的操作次数)和dp[i-1][j]和dp[i][j-1]的最小值,最后加1
        dp[i][j] = Math.min(dp[i - 1][j - 1],Math.min(dp[i-1][j],dp[i][j-1])) + 1
        初始化:
        当word1位空,或者word2为空,这个和上面两题是一样的,做删除操作
         */
         int m = word1.length(), n = word2.length();
         int [][] dp = new int[m+1][n+1];
         // 初始化
         for(int i = 1; i <= m; i++){
             dp[i][0] = i;
         }
         for(int j = 1; j <= n; j++){
             dp[0][j] = j;
         }
         // 递推公式
         for(int i = 1; i <= m; i++){
             for(int j = 1; j<= n; j++){
                 if(word1.charAt( i- 1) == word2.charAt(j -1)){
                     // 不做任何编辑,取上一层次数
                     dp[i][j] = dp[i-1][j-1];
                 }else{
                     // 取是三种操作的最小值
                     dp[i][j] = Math.min(dp[i-1][j-1],Math.min(dp[i-1][j],dp[i][j-1])) + 1;
                 }
             }
         }
         return dp[m][n];

    }
}

647. 回文子串

说到底还是这个dp的状态定义,有点难想到。。。还需要积累

class Solution {
    public int countSubstrings(String s) {
        /**
        分析:
        只求最优解不求具体解,可以使用动态规划思想。
        状态方程:
        dp[i][j]:表示的下标 [i,j]之间是否是回文串,返回类型是布尔类型
        递推公式:
        若s[i] != s[j] ,自然不是回文串
        若s[i] == s[j],若 j - i <= 1(j == i 时,就是单个字符, j -i == 1时,就是两个字符)都是回文子串,若 j - i > 1时,那么得判定 dp[i+1][j-1]是不是回文串
        初始化:
        默认的dp初始化时false,刚开始肯定都是不匹配上的,所以是false
        遍历方式:
        由于 j - i > 1时,dp[i][j] 依赖于 dp[i+1][j-1],所以需要从下到上遍历,从左到右遍历
         */
        int res = 0;
        int len = s.length();
        boolean[][] dp = new boolean[len][len];
        for(int i = len - 1; i >= 0; i--){
            // 遍历方式从下到上,从左到右
            for(int j = i; j < len; j++){
                if(s.charAt(i) == s.charAt(j)){
                    if(j -i <= 1){
                        dp[i][j] = true;
                    }else{
                        dp[i][j] = dp[i+1][j-1];
                    }
                }
            }
        }
        // 遍历dp,计算回文串个数
        for(int i = 0; i < len; i++){
            for(int j = 0; j < len; j++){
                if(dp[i][j]) res++;
            }
        }
        return res;


    }
}

516. 最长回文子序列

现在感觉,递推公式和初始化是最难想出来的

class Solution {
    public int longestPalindromeSubseq(String s) {
        /**
        分析:
        本题和上一题的区别,就是在找最长回文串个数(有点像编辑距离中的删除操作)
        状态转移方程:
        dp[i][j]:下标[i,j]之间的最长回文子序列的长度
        递推公式:
        记住一点,在判断回文子串的题目中,关键逻辑就是看s[i]和s[j]是否相同。
        若s[i]和s[j]相同,那么dp[i][j] = d[i+1][j-1] + 2(加入2个长度)
        若s[i]和s[j]不相同,说明加入的s[i]和s[j]不能增加[i,j]区间回文子串的长度,那么分别加入s[i] s[j]看看哪一个可以组成最长的回文子序列
        加入s[i]的回文子序列为 dp[i][j-1]
        加入s[j]的回文子序列为 dp[i+1][j]
        所以 dp[i][j] = Math.max(dp[i][j-1],dp[i+1][j])
        初始化:
        根据题意,当i == j时候,dp[i][j]应该是1,其余情况初始化为0
        遍历顺序,若s[i]和s[j]相同,那么dp[i][j] = dp[i+1][j-1] + 2(加入2个长度),可以知道,dp[i][j]依赖dp[i+1][j-1],所以遍历顺序应该从下到上,从左到右
        
         */
         int [][]dp = new int[s.length()][s.length()];
         for(int i = 0; i < s.length(); i++){
             // 初始化
             dp[i][i] = 1;
         }
         for(int i = s.length() - 1; i >=0; i--){
             // 我们规定 i < j,这样 就可以插入 s[i]和s[j]然后来判断
             for(int j = i + 1; j < s.length(); j++){
                 if(s.charAt(i) == s.charAt(j)){
                     dp[i][j] = dp[i+1][j-1] + 2;
                 }else{
                     dp[i][j] = Math.max(dp[i+1][j],dp[i][j-1]);
                 }
             }
         }
         return dp[0][s.length() - 1];

    }
}
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值