力扣刷题记录-动态规划问题总结

百度百科里对于动态规划问题是这样解释的:

在现实生活中,有一类活动的过程,由于它的特殊性,可将过程分成若干个互相联系的阶段,在它的每一阶段都需要作出决策,从而使整个过程达到最好的活动效果。因此各个阶段决策的选取不能任意确定,它依赖于当前面临的状态,又影响以后的发展。当各个阶段决策确定后,就组成一个决策序列,因而也就确定了整个过程的一条活动路线.这种把一个问题看作是一个前后关联具有链状结构的多阶段过程就称为多阶段决策过程,这种问题称为多阶段决策问题。

在多阶段决策问题中,各个阶段采取的决策,一般来说是与时间有关的,决策依赖于当前状态,又随即引起状态的转移,一个决策序列就是在变化的状态中产生出来的,故有“动态”的含义,称这种解决多阶段决策最优化的过程为动态规划方法

基本思想:

动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解

与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。具体的动态规划算法多种多样,但它们具有相同的填表格式

解题步骤:

  1. 确定dp数组,以及dp[i]的含义
  2. 确定递推公式
  3. dp数组初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

注:本文的内容大部分来自对代码随想录公众号的文章的学习总结,方便自己后续复习回顾。


基础问题

力扣509. 斐波那契数

原题链接

相信这道题是很多人学习递归函数的入门题目,不过这个同时也是介绍动态规划很经典的一道题。直接进入动态规划五步走:

  1. 确定dp数组,以及dp[i]的含义

dp[i]的含义是:第i个数的斐波那契数值是dp[i]

  1. 确定递推公式(状态转移方程)

题目已经给出了:dp[i]=dp[i-1]+dp[i-2];

  1. dp数组初始化

也是题目明确的:dp[0]=0;dp[1]=1;

  1. 确定遍历顺序

由于每个dp[i]是依赖其前两个数值进行确定的,所以应当从前往后遍历。

  1. 举例推导dp数组

n=8时:0 1 1 2 3 5 8 13
如果提交代码后发现答案不对,可以打印dp数组,看看是否和推导的不一样;

代码:

时间O(n),空间O(n):

class Solution {
    public int fib(int n) {
        if(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];
    }
}

这题也可以使用更少空间,因为只需要记录按顺序推导,推导过程中更新前两个数就行了,并不需要记住整个序列。

代码:

时间O(n),空间O(1):

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

还有就是很经典的递归代码:

时间O(2n),空间O(n):

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

力扣 70. 爬楼梯

原题链接

2024.05.04 三刷

思路:

这题可以这样看:爬一层楼梯,有1种方法;爬两层楼梯,有2种方法;爬3层的时候,可以从第一层两步到第三层,也可以从第二层一步到第三层;爬4层的时候,可以从第二层两步到第四层,也可以从第三层一步到第四层;以此类推……

可以看出,爬第i层的楼梯时,爬它的方法种数=爬i-1层种数+爬i-2层种数,下面就可以进行动态规划5步走了。

  1. 确定dp数组,以及dp[i]的含义

dp[i]:爬到第i层时,有dp[i]种方法。

  1. 确定递推公式

从前面的分析可以知道dp[i]取决于其前面两层的方法种数,即dp[i]=dp[i-1]+dp[i-2],也就是从i-1层爬1层到第i层,或者从i-2层爬2层到第i层。

  1. dp数组初始化

dp[1]=1,dp[2]=2

  1. 确定遍历顺序

从i=3开始,从小到大

  1. 举例推导dp数组

n=8时,dp=1,2,3,5,8,13,21,34

从以上可以看出,这还是斐波那契数列的问题,只是不用讨论dp[0]为多少

代码:

时间复杂度O(n),空间复杂度O(n)

class Solution {
    public int climbStairs(int n) {
        if(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];
    }
}

动态规划空间优化:

  • 由于每一层的方案数只和前两层相关,因此只需要三个变量即可,分别是sum,dp1,dp2;
  • dp1与dp2初始化,sum记录当前层的方案;
  • 遍历过程中,根据dp1与dp2计算sum,然后将dp1赋值为dp2,dp2赋值为sum,进行下一轮遍历;
  • 时间复杂度O(n),空间复杂度O(1)
class Solution {
    public int climbStairs(int n) {
        if(n<=1)return n;
        int[] dp=new int[3];
        dp[1]=1;
        dp[2]=2;
        int sum=0;
        for(int i=3;i<=n;i++){
            sum=dp[1]+dp[2];
            dp[1]=dp[2];
            dp[2]=sum;
        }
        return dp[2];
    }
}

力扣 746. 使用最小花费爬楼梯

原题链接

  1. 确定dp数组,以及dp[i]的含义

dp[i]:爬到第i层楼梯需要的最低花费。

  1. 确定递推公式

可以从dp[i-1]或者dp[i-2]得到dp[i],但是题目要求的是最低花费,所以需要的是min(dp[i-1],dp[i-2])+cost[i];这里可能会有问题:为什么是加cost[i],而不是加cost[i-1]或者cost[i-2]呢?因为题目是说在第i层支付cost[i]之后就可以获得向上爬1或2层的机会,从题目的示例可以看出,到达最后一层/二层之后,还是需要支付最后一层/二层的花费,才能到达顶层。

  1. dp数组初始化

题目也是给出,可以从第1、2层开始,dp[0]=cost[0],dp[1]=cost[1];

  1. 确定遍历顺序

从前到后遍历cost数组,从i=3开始

  1. 举例推导dp数组

cost=[1,100,1,1,1,100,1,1,100,1]
dp=[1,100,2,3,3,103,4,5,104,6]

代码:

class Solution {
    public int minCostClimbingStairs(int[] cost) {
        int[] dp=new int[cost.length];
        dp[0]=cost[0];
        dp[1]=cost[1];
        for(int i=2;i<cost.length;i++){
            dp[i]=Math.min(dp[i-1],dp[i-2])+cost[i];
        }
        //从倒数第一层走1层台阶或者倒数第二层走两层台阶
        return Math.min(dp[cost.length-1],dp[cost.length-2]);
    }
}

力扣 118. 杨辉三角

原题链接

2024.05.07 一刷
思路:
看图找规律,注意索引下标的界限即可

代码如下:

class Solution {
    public List<List<Integer>> generate(int numRows) {
        List<List<Integer>> res = new ArrayList<>();
        // 有numRows行,i必须从0开始,因为下面要用到res.get(i-1)
        for(int i=1;i<=numRows;i++){
            List<Integer> list = new ArrayList<>();
            for(int j=1;j<=i;j++){
                if(j==1||j==i)list.add(1);
                else{
                    // i和j分别代表当前数字处于杨辉三角中第几行、第几列(从1开始)
                    // 比如当前(i,j)为第3行第2个数,应该是由第2行第1个数+第2行第2个数相加
                    // 由于list下标从0开始计算,第2行下标为1-->i-2;第1列为0-->j-2;第2列为1-->j-1;
                    list.add(res.get(i-2).get(j-2)+res.get(i-2).get(j-1));
                }
            }
            res.add(list);
        }
        return res;
    }
}

力扣 62. 不同路径

原题链接

因为题目规定机器人每次只能向下或者向右边移动一步,这样可以将路径抽象为一棵二叉树,机器人走过的路径就可以抽象为从根节点到达叶子结点(深度最大)的不同路径,这样自然会想到深度优先搜索遍历整棵二叉树,但是这棵抽象二叉树深度是m+n-1,二叉树节点是2^(m+n-1)-1,这样时间复杂度就是O(2的m+n-1次方-1),按照题目的数据范围,这个时间复杂度是会超时的。

再换个想法,到每个位置的路径数,都是由它上面或者左边的位置的路径数相加,这样当前的状态取决于它前面的状态,容易联想到动态规划的解法,所以进行动规五步走:

  1. 确定dp数组,以及dp[i]的含义

dp[i][j]:从(0,0)出发到(i,j)有dp[i][j]种路径

  1. 确定递推公式

dp[i][j]的值一定是从它上方和左方推出,即dp[i][j]=dp[i-1][j]+dp[i][j-1]

  1. dp数组初始化

第一行和第一列初始肯定都是1,因为从(0,0)到他们的位置上都只有一直向左或者向右这样的一条路,即dp[0][j]=0,dp[i][0]=0;

  1. 确定遍历顺序

dp[i][j]的值都是从上方和左方推出,所以从左到右,从上到下一层层遍历就行,这样可以保证推导dp[i][j]的时候,其上方或者左方一定有数值。

  1. 举例推导dp数组

m=3,n=7
dp=
[ 1, 1, 1, 1, 1, 1, 1 ]
[ 1, 2, 3, 4, 5, 6, 7]
[ 1, 3, 6,10,15,21,28]

代码如下:

//动态规划解法,时间复杂度O(mn),空间复杂度O(mn)
class Solution {
    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 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];
    }
}

//节省空间写法,空间复杂度O(n)
class Solution {
    public int uniquePaths(int m, int n) {
        int[] dp=new int[n];
        for(int i=0;i<n;i++)dp[i]=1;
        for(int j=1;j<m;i++)
            for(int i=1;i<n;j++){
            	//相当于把上一层拷贝下来,这一层直接用上一层的dp[i]
            	//dp[i-1]属于这一层已经更新过的,dp[i]是正要更新的
                dp[i]+=dp[i-1];
            }
        return dp[n-1];
    }
}

这题也有另一种效率更高的组合数解法:

因为在移动过程中,会尽力m-1次向下移动,和n-1次向右移动,本质上就是在m+m-2次移动中,合理安排这m-1次向下移动发生的位置,只要计算出从 m+n−2次移动中选择 m−1次向下移动的方案数,就可以得出总路径数了,即:
在这里插入图片描述代码:

//写法1,在计算过程中遇到可以整除的分子分母,先除掉,使分子更小
class Solution {
    public int uniquePaths(int m, int n) {
        long numerator=1;//分子(m+n-2*……*n),总共m-1项,同时存储答案
        int denominator=m-1;//分母(m-1)!,也是m-1项
        int t=m+n-2;//用于控制分子-1
        int count=m-1;//控制计算的次数,共m-1项
        while(count-->0){
            numerator*=(t--);//分子乘
            //分母不为0,并且分子可被分母整除,就先除掉分母,避免乘法溢出
            while(denominator!=0&&numerator%denominator==0){
                numerator/=denominator;
                denominator--;
            }
        }
        return (int)numerator;
    }
}


//解法2,先用long long类型,最后强制转换
class Solution {
    public int uniquePaths(int m, int n) {
        long ans = 1;
        for (int x = n, y = 1; y < m; ++x, ++y) {
            ans = ans * x / y;
        }
        return (int) ans;
    }
}

力扣 63. 不同路径 II

原题链接
在这里插入图片描述

  1. 确定dp数组,以及dp[i]的含义

dp[i][j]:从(0,0)到(i,j)有多少条路径

  1. 确定递推公式

还是dp[i][j]=dp[i-1][j]+dp[i][j-1],不过如果当前位置有障碍物的时候,dp[i][j]=0;

  1. dp数组初始化

第一行和第一列初始化为1,但是注意,遍历过程中如果碰到障碍物,后续的位置初始值应当为0,因为碰到障碍物代表第一行和第一列之后的位置没办法走到

  1. 确定遍历顺序

从左到右一层一层遍历,从(1,1)开始

  1. 举例推导dp数组

[0,0,0]
[0,1,0]
[0,0,0]

对应的dp:
[1,1,1]
[1,0,1]
[1,1,2]

代码如下:

//时间复杂度O(mn),空间复杂度O(mn)
class Solution {
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        int m=obstacleGrid.length,n=obstacleGrid[0].length;
        int[][] dp=new int[m][n];//初值为0
        //给第一行赋初值1,如果碰到障碍,之后的都不能走到
        for(int j=0;j<n&&obstacleGrid[0][j]==0;j++)dp[0][j]=1;
        //同上
        for(int i=0;i<m&&obstacleGrid[i][0]==0;i++)dp[i][0]=1;

        for(int i=1;i<m;i++)
            for(int j=1;j<n;j++){
                if(obstacleGrid[i][j]==1)continue;//碰到障碍,直接跳过
                dp[i][j]=dp[i-1][j]+dp[i][j-1];
            }
        return dp[m-1][n-1];
    }
}

这题同样可以用滚动数组优化空间:

//优化空间写法,空间复杂度O(m)
class Solution {
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        int m=obstacleGrid.length,n=obstacleGrid[0].length;
        int[] dp=new int[n];
        //给第一行赋初值1,如果碰到障碍,之后的都不能走到
        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-1不会小于循环界限
                else if(j-1>=0) dp[j]=dp[j-1]+dp[j];
                //对于j=0的情况:若初始化时dp[0][0]=1,则当后面几行若第一列没有障碍物,直接沿用dp[0],当后面几行第一列有障碍物,直接置0(dp[j]=0);若初始化时dp[0][0]=0,后续沿用也是为0
            }
        return dp[n-1];
    }
}

力扣 343. 整数拆分

原题链接
在这里插入图片描述

  1. 确定dp数组,以及dp[i]的含义

dp[i]:拆分正整数i能得到的最大乘积

  1. 确定递推公式

设j是正整数i拆出的第一个正整数,则i-j是剩余部分,可以继续拆分,也可以不拆分(取决于哪个大)。当i>=2时,有两种拆分方案:

①将 i 拆分成 j和 i−jj 的和,且 i−j不再拆分成多个正整数,此时的乘积是 j×(i−j);

②将 i拆分成 j和 i−j的和,且 i−j 继续拆分成多个正整数,此时的乘积是 j×dp[i−j]j 。

所以当j固定的时候,dp[i]=max(j*(i-j),jdp[i-j]);但是对于同一个i,需要遍历所有的j才可以求出最大的dp[i],所以状态转移方程:dp[i]=Math.max(dp[i],Math.max(j(i-j),j*dp[i-j]));

  1. dp数组初始化

0和1没有讨论的意义,从2开始,可以拆成1+1,dp[2]=1;

  1. 确定遍历顺序

从i=2开始,到i=n;对于每个i,需要将其拆分为j和其他部分,j和i-j,j从1开始到i-1;

  1. 举例推导dp数组

n = 10

dp=[0,0,1,2,4,6,9,12,18,27,36](下标从i=0开始,到下标为10结束)

代码如下:

//动态规划解法:时间复杂度O(n^2),空间复杂度O(n)
class Solution {
    public int integerBreak(int n) {
        int[] dp=new int[n+1];
        dp[2]=1;
        for(int i=3;i<=n;i++)
            for(int j=1;j<i;j++){
                dp[i]=Math.max(dp[i],Math.max(j*(i-j),j*dp[i-j]));
            }
        return dp[n];
    }
}

这题官方题解还有数学方法可以在O(n)时间复杂度和O(1)空间复杂度求解,但是需要数学证明,证明过程较为繁琐,所以暂时不写。


力扣 264. 丑数 II

原题链接

在这里插入图片描述

  1. 确定dp数组,以及dp[i]的含义

dp[i]:第i个丑数是dp[i]。

  1. 确定递推公式

状态转移方程:dp[i]=Math.min(Math.min(num2, num3), num5);

  1. dp数组初始化

dp[1]=1,其他初始化为0.

  1. 确定遍历顺序

从前向后,i从2~n遍历。

具体思路:

观察丑数:x=2×2×…3×3×…5×5×…可见丑数生成必定是某个丑数通过×2或×3或×5生成

因此可以设法每次生成三个有可能的待选丑数

定义dp[i]为第i个丑数的值,dp[1]=1(初始化所有的dp[i]=0)

三个待选丑数必定是紧贴在之前的丑数dp[i-1]后面的

因此定义三个指针ptr2,ptr3,ptr5分别指向还没参与生成丑数的丑数处(初始化为1)

ptr2代表×2生成丑数的列表中,下一个即将要生成的位置
ptr3代表×3生成丑数的列表中,下一个即将要生成的位置
ptr5代表×5生成丑数的列表中,下一个即将要生成的位置

分别计算2×dp[ptr2],3×dp[ptr3],5×dp[ptr5]得到的就是三个候选的丑数

选择最小的添加到dp[i]即可

最后返回dp[n]就是答案

对三个指针的解释:

丑数一定是通过乘2、3、5得到的,当我们已经得到这样一串丑数 1, 2, 3, 4, 5, 6, 8, 9, 10, 12,想知道下一个丑数的话,暴力方法就是用2、3、5分别把每一个数都乘一遍,拿到第一个比 12 大的就好。

但是分别乘 2 的时候,像 2 * 1,2 * 2、2 * 3、2 * 4、2 * 5、2 * 6 已经在这个列表里了,如果要乘以 2 的话,从 8 开始乘就可以了(=16>12)。

类似的乘以 3 的话,从 5 开始,乘 5 的话,从 3 开始,由于是求最小的,所以只需要算 2 * 8, 3 * 5, 5 * 3,求一个最小值就可以了。

求出来是 15,那么下一个丑数就是 15,同时下一次乘 3 就要从 6 开始,乘5 要从 4 开始,乘 2不变,还是从 8 开始。所以要用的三个指针,分别对应的是 2、3、5 乘哪个数可能会得到下一个丑数。

如果当前丑数(dp[i])是ptr所指丑数(dp[ptri])通过乘以与ptr对应的质数(i∈【2,3,5】)所得,那么ptr所指丑数(dp[ptri])不能再与对应质数(i)相乘,ptri需要向后移动,也就意味着当前丑数失去了与该质数相乘的机会

class Solution {
    public int nthUglyNumber(int n) {
        int[] dp=new int[n+1];
        dp[1]=1;//依题意1通常被视为丑数
        int ptr2=1,ptr3=1,ptr5=1;//三个指针
        for(int i=2;i<=n;i++){
            //num2,num3,num5为当前的候选丑数,每次都选择最小的填入
            int num2=dp[ptr2]*2,num3=dp[ptr3]*3,num5=dp[ptr5]*5;
            dp[i]=Math.min(num2,Math.min(num3,num5));
            
            //说明当前的丑数dp[i],是ptr2所指位置的丑数和2相乘的结果
            //为了避免重复,ptr向前移动一位,使得该位的丑数无法再与2相乘,其他同理
            //不能用else if,是因为如果出现2*3或者3*2这两种情形,用if else只有一个指针能前移
            //而另一个指针被排除,无法前移,导致后续计算出现重复
            if(dp[i]==num2)ptr2++;
            if(dp[i]==num3)ptr3++;
            if(dp[i]==num5)ptr5++;
        }
        return dp[n];
    }
}

力扣 313. 超级丑数

原题链接
在这里插入图片描述

这题就是264. 丑数 II的数据加强版,264的质因数只有2、3、5,而这题的质因数数量不定,数值也不定,其实只要将ptr指针变成指针数组,对每一个质因数primes[j]都设置对应的指针即可,代码方面结构与264x

代码如下:

public class Solution {

    public int nthSuperUglyNumber(int n, int[] primes) {
        //ptr[j]与primes[j]相对应,相当于丑数II中的ptr指针的数组版
        int[] ptr = new int[primes.length];
        //ptr指针初始化为1,也就是所有质数可以相乘的丑数都是第一个丑数dp[1]
        for(int i=0;i<primes.length;i++)ptr[i]=1;

        long[] dp = new long[n+1];
        dp[1] = 1;
        for (int i = 2; i <=n; i++) {
            // 因为选最小值,先假设一个最大值
            dp[i] = Integer.MAX_VALUE;

            //用于找出待选丑数中最小的
            for (int j = 0; j < primes.length; j++) {
                //dp[indexes[j]] * primes[j]可能会爆int
                dp[i] = Math.min(dp[i], dp[ptr[j]] * primes[j]);
            }

            // dp[i] 是之前的哪个丑数乘以对应的 primes[j] 选出来的,给它加 1
            for (int j = 0; j < primes.length; j++) {
                if (dp[i] == dp[ptr[j]] * primes[j]) {
                    // 注意:这里不止执行一次,例如选出 14 的时候,2 和 7 对应的最小丑数下标都要加1
                    ptr[j]++;
                }
            }
        }
        //题目保证最终答案在32位内,所以直接强转int
        return (int)dp[n];
    }
}


力扣 96. 不同的二叉搜索树

原题链接
在这里插入图片描述

思路:

如果整数 1 - n 中的 k 作为根节点值,则 1到k-1会去构建左子树,k+1到n会去构建右子树。左子树出来的形态有 a种,右子树出来的形态有 b 种,则整个树的形态有a∗b种。

以 k为根节点的BST种类数 = 左子树BST种类数 * 右子树BST种类数

不管子树中的数字为多少,只要子树的数字数量相同,其能组成的二叉搜索树种类数目就一样,如123 4 567,4为根节点时,123能组成5种形态二叉搜索树,同样的567也是5种。

dp[i] : 1到i为节点组成的二叉搜索树的个数为dp[i]
dp[3],就是元素1为头结点搜索树的数量+元素2为头结点搜索树的数量+元素3为头结点搜索树的数量
元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量
元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量
元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量
有2个元素的搜索树数量就是dp[2]。
有1个元素的搜索树数量就是dp[1]。
有0个元素的搜索树数量就是dp[0]。
所以dp[3] = dp[2] * dp[0] + dp[1] * dp[1] + dp[0] * dp[2]

  1. 确定dp数组,以及dp[i]的含义

dp[i]:编号1到i为结点,组成的二叉搜索树的数量

  1. 确定递推公式

遍历每个元素,分别作为头结点,计算其左右子树结点数量,不同结点数量对应不同左右子树种数,也就是dp[i]+=dp[j-1]*dp[i-j],j是头结点元素,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量,j从1到i

  1. dp数组初始化

dp[0]=1;

  1. 确定遍历顺序

从递推公式dp[i]+=dp[j-1]*dp[i-j]可知,结点数为i的状态是依靠i之前的节点数的状态,所以需要用i遍历1-n的每一个数,计算每个数作为头结点的二叉搜索树种类,用j遍历1-i,求出对应i的二叉搜索树种类数;

  1. 举例推导dp数组

dp[i]:{1,1,2,5,14,42}(i从0开始)

代码如下:

class Solution {
    public int numTrees(int n) {
        int[] dp=new int[n+1];//答案数组(0-n)
        dp[0]=1;
        for(int i=1;i<=n;i++){
            for(int j=1;j<=i;j++){
                dp[i]+=dp[j-1]*dp[i-j];//dp[i] += dp[j-1] * dp[i-j]; ,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量
            }
        }
        return dp[n];
    }
}

背包问题

背包问题大体可以分为01背包、完全背包、多重背包、分组背包,其中找工作笔试面试比较常遇到的是01和完全背包问题。

0-1背包

问题一般是这样的:有n个物品,第i个物品的重量是weight[i],它的价值是value[i],背包容量是w,每个物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

一般题目中不会出现这样标准的描述,而是需要自己将具体问题抽象成这样的背包问题。

下面以一个很简单的例子作为01背包(二维数组)示例讲解:
背包最多能装重量为4的物品:

物品编号重量价值
物品0115
物品1320
物品2430

问:背包能装的最大价值是多少?

关于01背包问题,总的来说就是每个物品的放置策略的组合,因为每个物品只有两个状态:放入和不放入,所以可以从物品0开始讨论放置策略。

继续动规五步走(先从二维的dp数组开始)。

  1. 确定dp数组,以及dp[i][j]的含义

dp[i][j]表示从编号为0-i的物品中不重复的任意选取,放进容量为j的背包中,其最大价值为dp[i][j];

  1. 确定递推公式

dp[i][j]有两个方向推导(第i件物品放置的策略):

①不放第i件物品,问题就转化为只和前i-1件物品有关的问题:“前i-1件物品放入容量为j的背包中,价值为dp[i-1][j]”。

②放第i件物品,问题转化为:“前i-1件物品放入容量为j-weight[i]的背包中,能获得的最大价值是dp[i-1][j-weight[i]],再加上放入i的价值,最终最大价值为:dp[i-1][j-weight[i]]+value[i]”。

物品i选择放入时,背包容量为j-weight[i]的原因是要给物品i留下足够大的空间。

所以最终的递推公式就是:dp[i][j]=max(dp[i-1][j],dp[i-1][j-weight[i]]+value[i]);
只要保证每次对物品放置的策略都是最优的,这样遍历到最后一个物品的时候,背包中的价值就是最大价值。

  1. dp数组初始化

背包容量j为0的时候(dp[i][0]):背包什么都放不下,里面的价值一定是0,所以j=0的那一列全初始化为0;

i为0的时候(dp[0][j]):存放物品0,背包容量为j的时候,背包能存放的最大价值。从递推公式出发,在只有物品0可以选择是否放入时,在背包容量足够放入物品0的前提下,物品0一定选择放入,那么此时dp[0][j]=dp[0][j-weight[0]]+value[0];背包容量j小于weight[0]时,就不选择放入,dp[0][j]=0。

不过对于i=0的初始化有个细节:对j的遍历必须是倒序,即j要从最大背包容量bagWeight开始,递减至weight[0]。

for(int j=bagWeight;j>=weight[0];j--){
	dp[0][j]=dp[0][j-weight[0]]+value[0];
}

那么为什么要倒序遍历呢?以前面的示例数据为例:如果正序遍历:

for(int j=weight[0];j<=bagWeight;j++){
	dp[0][j]=dp[0][j-weight[0]]+value[0];
}

dp[0][1]=15,到了dp[0][2]就为30了,会发现后面每次遍历都会重复把物品0再放入背包进行计算,而倒序遍历的话,可以保证物品0只被放入1次。

对于其他的数组位置的初始化,也要分情况来看:
价值都是正数时:非0下标初始化均为0,这样不会影响取最大值的结果;
价值存在负数时:初始化为负无穷,同样也是不影响取最大值。

  1. 确定遍历顺序

先遍历物品还是先遍历背包容量呢?是都可以的,但是从正常逻辑来看,先遍历物品更好理解。对于物品的遍历,从i=1开始,到i=weight.length结束;对于背包容量的遍历,从j=1开始,到j=bagWeight(最大背包容量)结束。

for(int i=1;i<weight.length;i++)
	for(int j=1;j<=bagWeight;j++){
		//容量如果不够装物品i,那么就只和前面的状态有关
		if(j<weight[i])dp[i][j]=dp[i-1][j];
		//装得下i,也要看是否要装物品i
		else dp[i][j]=max(dp[i-1][j],dp[i-1][j-weight[i]]+value[i]);
}
  1. 举例推导dp数组
物品编号i/背包容量j01234
物品0015151515
物品1015152035
物品2015152035

讲完了二维dp数组的01背包,接下来就可以对二维数组进行空间优化了。
从递推公式:

dp[i][j]=max(dp[i-1][j],dp[i-1][j-weight[i]]+value[i];

可以看出,把第i-1层的数据拷贝到第i层使用,这样递推公式就变成:

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

再进一步,与其将数据拷贝,不如只用一个一维数组,这样只需要对一维数组进行操作,就可以了。

接下来继续动规五步走:

  1. 确定dp数组,以及dp[i]的含义

dp[j]:容量j的背包,所放物品的价值最大可以为d[j];

  1. 确定递推公式

同二维dp数组,只需要将i那个维度去掉即可:

dp[j]=max(dp[j],dp[j-weight[i]]+value[i];
  1. dp数组初始化

dp[j]表示容量为j的背包,所背物品价值最大可以为dp[j],当j=0时,容量为0,价值为0,即dp[j]=0。其他位置的初始化同二维dp的初始化,价值均为正,则初始0,若存在负数,则初始化为负无穷。

  1. 确定遍历顺序
for(int i=0;i<weight.length;i++)
	for(int j=bagWeight;j>=weight[i];j--){
		dp[j]=max(dp[j],dp[j-weight[i]]+value[i];
	}

这里面对j的遍历为逆序,理由和二维dp里的关于第0行dp数组的初始化一样,这样可以保证物品i只被放入一次;而二维dp对j的遍历不用倒序是因为它能存储上一层的数据情况,不会在这一层被覆盖。

  1. 举例推导dp数组

同二维效果。


力扣 416. 分割等和子集

原题链接

2024.05.09 二刷

要求判断能否将正整数数组分成两个元素和相等的子集,其实不用真的去分出两个子集,只要能找出一个元素和为sum/2的子集即可。

这题里面的子集符合01背包的规则,即数组中的元素只能使用一次。接下来开始套入01背包:
①背包容量为sum/2;
②背包放入的物品为nums数组的元素;
③物品的价值为元素的值;
④背包恰好装满的时候,就说明找到了总和为sum/2的一个子集,那么另一个子集也一定为sum/2;

注意:这题其实是求背包是否能刚好装满!

  1. 确定dp数组,以及dp[i]的含义

dp[i]:背包容量是i,可以凑成i的子集元素总和是dp[i];

  1. 确定递推公式

01背包递推公式:

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

在这题中,相当于背包里面放入数值,物品的重量就是nums[i],物品的价值也是nums[i];

所以递推公式:

dp[j]=max(dp[j],dp[j-nums[i]]+nums[i];

  1. dp数组初始化

因为nums数组元素只包含正整数,dp[0]=0,其他非0下标初始化都为0;

  1. 确定遍历顺序
for(int i=0;i<nums.length;i++)
	for(int j=sum/2;j>=nums[i];j--){
		dp[j]=max(dp[j],dp[j-nums[i]]+nums[i]);
	}
	
  1. 举例推导dp数组

nums=[1,5,11,5]---->sum=22;

下标01234567891011
dp[j]01111566661011

代码如下:

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

注意dp数组的大小需要+1,因为最终dp[sum/2]==sum/2就说明可以划分成两个元素和相等的子集,所以数组的位置要比正常多1,否则会超限报错。


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

原题链接
在这里插入图片描述

题目要求从石头数组中每次随机选取两块石头,质量相同时,两块都粉碎消失,质量不同时,两块抵消成为一个新石头,新石头质量为两块石头的差值。需要我们返回最后剩下的石头的最小的可能重量。

其实不必每次取两块进行抵消,我们可以将石头分成两个大堆,并且让两个石头堆的质量尽可能接近(其中一堆尽可能逼近sum/2),这样两堆石头进行抵消,最后剩下的一块石头的质量就会最小。这样就和力扣 416. 分割等和子集差不多。

所以,套入01背包问题:
①背包容量为sum/2;
②背包装入的物品为石头;
③石头的价值就是石头的质量stone[i];
④每个石头占用的背包容量也是石头质量stone[i];
⑤需要让背包尽可能多装石头。

相比力扣 416. 分割等和子集转化为求背包是否能够恰好装满(恰好为sum/2),此题其实是求背包最多可以装多少(最接近sum/2)。

  1. 确定dp数组,以及dp[i]的含义

dp[j]:容量为j的背包,最多可以装入质量为dp[j]的石头;

  1. 确定递推公式

01背包递推公式:

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

stone[i]既是物品重量,也是物品价值:

dp[j]=max(dp[j],dp[j-stone[i]]+stone[i]);

  1. dp数组初始化

dp数组大小为背包容量j的最大值—>sum/2(向下取整),即石头重量总和的一半;

背包容量为0时,最多可以装入的石头容量为0,所以dp[0]=0;因为石头质量均为正整数,所以其它非0下标初值为0;

  1. 确定遍历顺序
for(int i=0;i<stone.length;i++)
	for(int j=sum/2;j>=stones[i];j--){
		dp[j]=Math.max(dp[j],dp[j-stones[i]]+stones[i]);
	}
  1. 举例推导dp数组

stone=[2,4,1,1]—>sum=8;

用stone[0]遍历背包:

下标01234
dp00222

用stone[1]遍历背包:

下标01234
dp00224

用stone[2]遍历背包:

下标01234
dp00224

用stone[3]遍历背包:

下标01234
dp01234

代码如下:

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

最后dp[sum/2]是容量为sum/2的背包,能装入的最大石头质量,所以可以分成两堆,一堆石头质量为dp[sum/2],另一堆就是sum-dp[sum/2]

因为sum/2是向下取整的,所以dp[sum/2]一定小于等于sum/2,所以sum-dp[sum/2]一定大于等于dp[sum/2],所以最后返回的差值就是sum-dp[sum/2]-dp[sum/2]。


力扣 494. 目标和

原题链接
在这里插入图片描述

这题看上去和组合总和问题很像,看起来可以用回溯法解决,不过使用回溯算法的时间复杂度就是O(2^n),会超时。

那么如何思考这道题?

在数组元素前加上正负号,让其最终算术总和等于target,也就是①left组合-right组合=target,将数组元素分为加法组合left,还有减法组合right(该组合内元素选取自nums,均为非负整数,只是它们最后要带负号的)。

此外,②left+right=sum(元素总和),由①+②可得left=(target+sum)/2,而target和sum都是固定的,所以只要求出加法总和left的组合种数,就可以得到组成target的种数了。

所以此题就可以转化成01背包问题:
①nums数组元素就是背包物品,每个元素只能被装入一次;
②元素数值大小就是物品重量,物品价值也是数值大小;
③背包大小
是(target+sum)/2;
④要找出恰好装满背包的方法种数;

这样只需要遍历一遍nums数组,决定每一个元素是否进入加法总和left的组合中即可。

所以进行五步走:

  1. 确定dp数组,以及dp[j]的含义

dp[j]:容量为j的背包恰好装满,有dp[j]种方法。

  1. 确定递推公式

dp[j]有两个方向:

①当遍历到nums[i]的时候,若装入nums[i]后背包恰好装满成j,这样的情况是由由背包容量为j-nums[i]而来,所以这个方向有dp[j-nums[i]]种方法;

②当遍历到nums[i]时,若不装入nums[i],背包恰好装满成j,这样的的情况是背包此时容量就是j而来的,所以这个方向有dp[j]种方法;

所以递推公式:

dp[j]+=dp[j-nums[i]];

  1. dp数组初始化

dp的定义是:容量为j的背包恰好装满,有dp[j]种方法。当容量j为0时,可以选择的方案就是1种,所以dp[0]=1;

  1. 确定遍历顺序

同前面

  1. 举例推导dp数组

nums=[1,1,1,1,1],target=3—>sum=5,bagWeight=4;

用nums[0]遍历背包:

下标01234
dp11000

用nums[1]遍历背包:

下标01234
dp12100

用nums[2]遍历背包:

下标01234
dp13311

用nums[3]遍历背包:

下标01234
dp14641

用nums[4]遍历背包:

下标01234
dp1510105

代码如下:

class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int sum=0;
        for(int i=0;i<nums.length;i++)sum+=nums[i];
        //防止出现target+sum为负数情况,否则创建dp数组会报错
        int bagWeight=Math.abs((target+sum)/2);
        if((target+sum)%2==1)return 0;//'/'是向下取整,如果不能被2整除,就无解
        int[] dp=new int[bagWeight+1];
        dp[0]=1;//初始化
        for(int num:nums)
            for(int j=bagWeight;j>=num;j--){
                dp[j]+=dp[j-num];
            }
        return dp[bagWeight];
    }
}

力扣 474. 一和零

原题链接
在这里插入图片描述

官方题解的三维dp数组解释更容易理解:官方题解

下面展示的是压缩成二维dp数组之后的代码:

这题要从01背包解释的话,将strs字符数组的每个元素看做装入背包的物品,物品的重量就是0和1的个数(所以dp数组要有两个维度,i为0的个数,j为1的个数),物品的价值就是背包里子集的长度(字符串的个数),每个字符串价值都是1。

这题的思想就是遍历strs字符串数组的每个元素,去判断并决定每个字符串是否应该加入最终的子集中。

  1. 确定dp数组,以及dp[i][j]的含义

dp[i][j]:最多有i个0和j个1的 strs的最大子集 的长度为dp[i][j](子集中的每个元素都strs字符数组中的一个字符串)。

  1. 确定递推公式

有两个方向可能可以推出dp[i][j],即当前遍历到的字符串是否加入背包中:

zeroCount为当前遍历到的字符串中0的个数,oneCount为当前遍历到的字符串中1的个数。

①当前遍历到的字符串加入背包:那么就要找到这个字符串还没加入背包的那个状态,即dp[i-zeroCount][j-oneCount],如果当前字符串加入背包后得到“有i个0和j个1”的最大子集,那么“有i个0和j个1”的最大子集长度就+1,即dp[i-zeroCount][j-oneCount]+1。

②当前遍历到的字符串不加入背包:加入这个字符串可能导致最终的子集长度更小,比如加入一个“0011”,肯定没有加入两个“0”和两个“1”最终得来的子集长度长,那么dp[i][j]就保持原样。

所以递推公式:

dp[i][j]=max(dp[i][j],dp[i-zeroCount][j-oneCount]+1);

  1. dp数组初始化

物品价值即子集的字符串的个数,不会是负数,所以初始化为0

  1. 确定遍历顺序

在前面关于一维dp的讲解中,知道外层遍历物品的时候是正循环,内层遍历容量的时候需要倒序遍历(这样不会重复加入物品);这题里面的zeroCount和oneCount(字符串的0和1的个数)就是容量,所以这两个需要倒序遍历。

for(String str:strs){
    int zeroCount=0,oneCount=0;
    for(int i=0;i<str.length();i++){
        char c=str.charAt(i);
        if(c=='0')zeroCount++;
        if(c=='1')oneCount++;
    }
    for(int i=m;i>=zeroCount;i--)
        for(int j=n;j>=oneCount;j--){
            dp[i][j]=Math.max(dp[i][j],dp[i-zeroCount][j-oneCount]+1);
        }
}
  1. 举例推导dp数组
    strs = [“10”, “0001”, “111001”, “1”, “0”], m = 3, n = 3

代码如下:

class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        int[][] dp=new int[m+1][n+1];
        for(String str:strs){
            int zeroCount=0,oneCount=0;
            for(int i=0;i<str.length();i++){
                char c=str.charAt(i);
                if(c=='0')zeroCount++;
                if(c=='1')oneCount++;
            }
            for(int i=m;i>=zeroCount;i--)
                for(int j=n;j>=oneCount;j--){
                    dp[i][j]=Math.max(dp[i][j],dp[i-zeroCount][j-oneCount]+1);
                }
        }
        return dp[m][n];
    }
}

完全背包

完全背包可以归纳成这样:有N种物品和一个容量最大为W的背包,第i件物品的重量是weight[i],得到的价值是value[i],每种物品的数量是无限的(可以多次放入背包),求解将哪些物品装入背包获得的价值最大。

它和01背包的区别在于完全背包的每种物品是无限数量的,下面仍然用之前的例子讲解:

背包最多能装重量为4的物品:

物品编号重量价值
物品0115
物品1320
物品2430

每个物品都有无限个,求背包能装入的最大价值是多少?

完全背包和01背包代码的不同在于遍历顺序上,回顾01背包的核心遍历代码:

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

01背包为了不重复装入物品,对内层循环对于背包容量的遍历是采用倒序的,但是完全背包是可以重复加入的,所以内层循环对背包容量的遍历是正序的,即:

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

这样dp的状态如下:

物品编号i/背包容量j01234
物品0015304560
物品1015304560
物品2015304560

值得一提的是,对物品和背包容量的遍历顺序,哪个先,哪个后,在完全背包问题中是都可以的,也就是先遍历物品和先遍历背包容量,效果是一样的,只是如果先遍历背包的话,为了避免超出dp数组的下标范围,需要在里面加一点限制:

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

另外,二维dp数组的01背包也是顺序都行,但是一维dp数组的01背包必须先遍历物品,再遍历容量。


力扣 518. 零钱兑换 II

原题链接

在这里插入图片描述
必须要注意的是,题目要求的是硬币组合数,而不是排列数。组合数中,认为{1,2,2}与{2,1,2}是同一个组合,而排列数会区分顺序,认为它们是两个组合。

还有就是转化成背包问题的话,是要求装满背包的情况

  1. 确定dp数组,以及dp[j]的含义

dp[j]:可以凑成金额j的硬币组合数。

  1. 确定递推公式

dp[j]代表的是可以凑成金额j 的硬币组合数,它的上一个状态就是dp[j-coins[i]],也就是恰好差coins[i]金额的那个状态,所以递推公式:dp[j] += dp[j - coins[i]];

求装满背包有几种方法,一般公式都是:dp[j] += dp[j - nums[i]];

  1. dp数组初始化

dp[0]:金额为0的时候,就是一种硬币都不加入,所以只有这一种组合,dp[0]=1.
其它非0下标的dp元素初始化0,这样计算dp[j-coins[i]]的时候不会影响dp[j]。

  1. 确定遍历顺序
    (转自代码随想录)

是外层for循环遍历物品(钱币),内层for遍历背包(金钱总额),还是外层for遍历背包(金钱总额),内层for循环遍历物品(钱币)呢?

前面提到的完全背包的内外层for循环先后顺序都可以,但这题不适用,这是因为纯完全背包求得是能否凑成总和,和凑成总和的元素有没有顺序没关系,即:有顺序也行,没有顺序也行!

而本题要求凑成总和的组合数,元素之间要求没有顺序。

所以纯完全背包是能凑成总和就行,不用管怎么凑的。

本题是求凑出来的方案个数,且每个方案个数是为组合数。

可以先看下两种遍历方式的区别:

外层for循环遍历物品(钱币),内层for遍历背包(金钱总额)的情况

for (int i = 0; i < coins.length; i++) { // 遍历物品
    for (int j = coins[i]; j <= amount; j++) { // 遍历背包容量
        dp[j] += dp[j - coins[i]];
    }
}

假设:coins[0] = 1,coins[1] = 5。

那么就是先把1加入计算,然后再把5加入计算,得到的方法数量只有{1, 5}这种情况。而不会出现{5, 1}的情况。

所以这种遍历顺序中dp[j]里计算的是组合数!

外层for循环遍历背包(金钱总额),内层for遍历物品(钱币)的情况:

for (int j = 0; j <= amount; j++) { // 遍历背包容量
    for (int i = 0; i < coins.size(); i++) { // 遍历物品
        if (j - coins[i] >= 0) dp[j] += dp[j - coins[i]];
    }
}

背包容量的每一个值,都是经过 1 和 5 的计算,包含了{1, 5} 和 {5, 1}两种情况。此时dp[j]里算出来的就是排列数!

  1. 举例推导dp数组

在这里插入图片描述
代码如下:

class Solution {
    public int change(int amount, int[] coins) {
        int[] dp=new int[amount+1];
        dp[0]=1;//初始化dp数组,表示金额为0时只有一种情况,也就是什么都不装
        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];
    }
}

请注意!!!

在求装满背包有几种方案的时候,认清遍历顺序是非常关键的。

如果求组合数就是外层for循环遍历物品,内层for遍历背包
如果求排列数就是外层for遍历背包,内层for循环遍历物品


力扣 377. 组合总和 Ⅳ

原题链接

在这里插入图片描述
注意到题目中说顺序不同的序列视作不同的组合,其实这就是求排列问题。这题的前置题目是要求我们把排列都列出来,这样的情况是要用回溯算法的,但是本题要求的是排列的个数,不用列出所有排列,而且由例子可以看出,每个元素可以重复使用,所以这题可以转化为完全背包问题。

物品是数组的元素nums[i],背包容量就是目标总和j,

  1. 确定dp数组,以及dp[j]的含义

dp[j]:总和为j的排列组合有dp[j]种。

  1. 确定递推公式

dp[j]可以从上一个状态:dp[j-nums[i]]推出,也就是nums[i]不在排列组合时候的排列种数,也可以从自身dp[j]推出,即:

dp[j]+=dp[j-nums[i]];

  1. dp数组初始化

目标总和为0的排列组合只有一种,就是什么都不选,即dp[0]=1。因为数组元素均为正整数,所以其它非0下标dp数组元素初始化为0.

  1. 确定遍历顺序

力扣 518. 零钱兑换 II中讲到过:

如果求组合数就是外层for循环遍历物品,内层for遍历背包
如果求排列数就是外层for遍历背包,内层for循环遍历物品

这题求的是排列数,所以外层for循环应该遍历背包容量,内层循环遍历物品,如果把物品(数组元素)放在外循环,那么nums[i]永远都会在nums[i+1]前面,就没有排列的更多种组合了。

for(int j=0;j<=target;j++)
	for(int i=0;i<nums.length;i++){
		if(j-nums[i]>=0)dp[j]+=dp[j-nums[i]];
	}
  1. 举例推导dp数组

在这里插入图片描述

代码如下:

class Solution {
    public int combinationSum4(int[] nums, int target) {
        int[] dp=new int[target+1];
        dp[0]=1;
        for(int j=0;j<=target;j++)//先遍历背包
	        for(int i=0;i<nums.length;i++){//再遍历物品
		        if(j-nums[i]>=0)dp[j]+=dp[j-nums[i]];
	        }
        return dp[target];
    }
}

力扣 70. 爬楼梯

原题链接

在这里插入图片描述
这题在前面已经讲过了一种思路,也是动态规划思想,但是那时候的递推公式是这样的:dp[i]=dp[i-1]+dp[i-2],也就是从i-1层爬1层到第i层,或者从i-2层爬2层到第i层。

但是这题还可以进阶一下,也就是改成每次可以爬升台阶数的范围是1~m,那这时有多少种方法可以爬到楼顶呢?

这样一改,题目就可以转化为完全背包问题了,物品就是1到m的整数,背包容量就是总的台阶数,相当于每次从编号1到m的物品中选择(每种物品可以重复选择),问最后有多少种方案可以将容量为n的背包装满,并且这还是一个排列问题(先遍历背包容量,再遍历物品),这时候就发现,这样改完之后,和上一题力扣 377. 组合总和 Ⅳ几乎一样

  1. 确定dp数组,以及dp[j]的含义

dp[j]:爬到台阶数为j的楼顶,有dp[j]种方法;

  1. 确定递推公式

dp[j]+=dp[j-i],i从1到m。

  1. dp数组初始化

dp[0]=1,dp[0]是推导的基础,不能等于0,否则无法累加。

  1. 确定遍历顺序

完全背包排列问题,外层for循环正序遍历背包(总台阶数1-n),内层for循环遍历物品(1-m台阶)

  1. 举例推导dp数组
    和上题几乎一样

代码如下:

class Solution {
    public int climbStairs(int n, int m) {
        int[] dp=new int[n+1];
        dp[0]=1;
        for(int j=0;j<=n;j++)//先遍历背包
	        for(int i=1;i<=m;i++){//再遍历物品
		        if(j-i>=0)dp[j]+=dp[j-i];
	        }
        return dp[n];
    }
}

将代码里的m改成2,传参int m去掉,就可以套入爬楼梯这题了。


力扣 322. 零钱兑换

原题链接在这里插入图片描述
这题和前面做过的518. 零钱兑换 II很像:
在这里插入图片描述但是518题求的是可以凑成总金额的硬币的组合数,本题求的是可以凑成总金额的最少的硬币个数,因此,在dp的定义和递推公式方面会有所不同。

  1. 确定dp数组,以及dp[j]的含义

dp[j]:可以凑成金额j的最少硬币个数为dp[j]。

  1. 确定递推公式

dp[j]可以由其上一个状态dp[j-coins[i]推出,只要在上一个状态基础上,加上当前遍历到的coins[i]数量为1,既可以得到dp[j];同时题目要求的是最少的硬币个数,所以最终的递推公式为:

dp[j]=min(dp[j],dp[j-coins[i]]+1);

  1. dp数组初始化

可以凑成金额0的硬币个数为0,此外,题目要求的是最小的硬币数,其他非0下标的dp数组元素值必须初始化为int的最大值,否则递推公式中的dp[j]=min(dp[j],dp[j-coins[i]]+1)可能会被覆盖。

  1. 确定遍历顺序

求的是组合数,所以内外层循环先后顺序都可以,采用外层遍历物品(硬币),内层遍历背包(总金额);每种硬币数量没有限制,是完全背包问题,内循环正序;

  1. 举例推导dp数组

在这里插入图片描述dp[11]为最终答案。

代码如下:

class Solution {
    public int coinChange(int[] coins, int amount) {
        int[] dp=new int[amount+1];
        for(int i=1;i<=amount;i++)dp[i]=Integer.MAX_VALUE;
        for(int i=0;i<coins.length;i++)
            for(int j=coins[i];j<=amount;j++){
                //这是为了防止dp[j-coins[i]]=int最大值,如果这时候+1,会超限
                if(dp[j-coins[i]]!=Integer.MAX_VALUE)
                    dp[j]=Math.min(dp[j],dp[j-coins[i]]+1);
            }
        if(dp[amount]==Integer.MAX_VALUE)return -1;
        return dp[amount];
    }
}

力扣 279. 完全平方数

原题链接

在这里插入图片描述

这题可以把目标总和n视为背包最大容量,完全平方数(数量无限)就是物品,需要凑满正整数n的背包,求凑满这个背包最少可以用多少个物品?

这样一描述,就很像上一题322.零钱兑换差不多了,也都是求最少需要用多少个物品凑满背包。

  1. 确定dp数组,以及dp[j]的含义

dp[j]:凑成总和为j的完全平方数最少可以有dp[j]个。

  1. 确定递推公式

同样的,从dp[j]的前一个状态推出,也就是还没装入完全平方数ii的时候–>dp[j-ii],这个状态+1就可以凑成dp[j]。

因为要选择最小的dp[j],所以递推公式dp[j]=min(dp[j-i*i]+1,dp[j]);

  1. dp数组初始化

dp[0]:凑成金额0的完全平方数最少可以有0个(强行解释,一切为了递推,因为n是从1开始的),其他非0下标元素初始化为最大值。

  1. 确定遍历顺序

物品数量不限–>完全背包;

如果求组合数就是外层for循环遍历物品,内层遍历背包;
如果求排列数就是外层遍历背包,内层遍历物品;

但是这题只是求最少的数量,所以这两种遍历都可以,

  1. 举例推导dp数组
    在这里插入图片描述

代码如下:

class Solution {
    public int numSquares(int n) {
        int[] dp=new int[n+1];
        dp[0]=0;
        for(int i=1;i<n+1;i++)dp[i]=Integer.MAX_VALUE;

        for(int i=1;i*i<=n;i++)//遍历物品
            for(int j=i*i;j<=n;j++){//遍历背包容量
                dp[j]=Math.min(dp[j-i*i]+1,dp[j]);
            }
        return dp[n];
    }
}

力扣 139. 单词拆分

原题链接

在这里插入图片描述
这题可以把字符串s看做背包,字典中的单词看做物品,并且这个单词还是可重复的,所以可以视为完全背包问题,转化后其实就是问能否用字典中的单词去填满/匹配字符串。

  1. 确定dp数组,以及dp[j]的含义

dp[i]:表示字符串s的下标从0到i-1即s[0…i-1]这一段(前i个字符组成的字符串)能否被拆分为若干词典上的单词。为true则表示可以被拆分。

  1. 确定递推公式

利用j作为分割点,如果dp[j]=true(字符串s的下标0到j-1这一段可以被拆分为若干字典上的单词),那么只要保证字符串s的下标的j到i-1这一段(s[j…i-1])出现在字典里,那么这两段拼接后也一定合法。

所以递推公式:

if(dp[j]==true&&s[j…i-1]在字典wordDict中出现过)dp[i]=true;

至于判断某一段字符串s[j…i-1]是否在字典wordDict中出现过,可以先把这一段子串取出,再用哈希表来快速判断该子串其是否出现在字典中:

Set<String> wordDictSet =new HashSet(wordDict);
if(dp[j]&&wordDictSet.contains(s.substring(j,i))){
	dp[i] = true;
	break;
}

这题里面字典中的单词是存放在List里的,list也有contions方法即:wordDict.contains(s.substring(j,i))也行,不过鉴于HashSet的查找效率要快得多,所以先建一个HashSet再去查找会更合适,只是占用空间相对多了一些;

  1. dp数组初始化

从递推公式可以知道,dp[i]的状态依赖于dp[j]是否为true,dp[0]就是递推的根基,它必须为true(强行解释:默认空字符串出现在字典里)。

其它非0下标初始化为false;

  1. 确定遍历顺序

前面已经确定这是一个完全背包问题,现在需要讨论两层for循环的顺序问题。

外层for循环用i遍历整个字符串s,内层for循环用j作为切割点,扫一遍字符串s从0到i-1下标进行切割。

for (int i = 1; i <= s.length(); i++)
    for (int j = 0; j < i; j++) {
        if (dp[j] && wordDictSet.contains(s.substring(j, i))) {
            dp[i] = true;
            break;
        }
    }
  1. 举例推导dp数组

在这里插入图片描述
代码如下:

public class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        Set<String> wordDictSet = new HashSet(wordDict);
        boolean[] dp = new boolean[s.length() + 1];
        dp[0] = true;
        for (int i = 1; i <= s.length(); i++)
            for (int j = 0; j < i; j++) {
                if (dp[j] && wordDictSet.contains(s.substring(j, i))) {
                    dp[i] = true;
                    break;
                }
            }
        return dp[s.length()];
    }
}

多重背包

好像力扣上没有多重背包的题?

多重背包问题抽象出来就是这样描述:有N种物品,背包容量为W,第i种物品有nums[i]件,占用容量为weight[i],价值为value[i],求将哪些物品装入背包可以让背包总价值最大?

例如背包容量为10

重量价值数量
物品01152
物品13203
物品24302
求背包最大价值可以是多少?

其实这个问题也可以转化,就将每个物品数量平摊开:

重量价值数量
物品01151
物品01151
物品13201
物品13201
物品13201
物品24301
物品24301

这样就可以转化成01背包问题了,每个物品只用一次。

public void testMultiPack1(){
    // 版本一:改变物品数量为01背包格式
    //时间复杂度:O(m × n × k),m:物品种类个数,n背包容量,k单类物品数量
    List<Integer> weight = new ArrayList<>(Arrays.asList(1, 3, 4));
    List<Integer> value = new ArrayList<>(Arrays.asList(15, 20, 30));
    List<Integer> nums = new ArrayList<>(Arrays.asList(2, 3, 2));
    int bagWeight = 10;

    for (int i = 0; i < nums.size(); i++) {
        while (nums.get(i) > 1) { // 把物品展开为i
            weight.add(weight.get(i));
            value.add(value.get(i));
            nums.set(i, nums.get(i) - 1);
        }
    }

    int[] dp = new int[bagWeight + 1];
    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        for(int j = bagWeight; j >= weight.get(i); j--) { // 遍历背包容量
            dp[j] = Math.max(dp[j], dp[j - weight.get(i)] + value.get(i));
        }
        System.out.println(Arrays.toString(dp));
    }
}

public void testMultiPack2(){
    // 版本二:改变遍历个数
    //时间复杂度:O(m × n × k),m:物品种类个数,n背包容量,k单类物品数量
    int[] weight = new int[] {1, 3, 4};
    int[] value = new int[] {15, 20, 30};
    int[] nums = new int[] {2, 3, 2};
    int bagWeight = 10;

    int[] dp = new int[bagWeight + 1];
    for(int i = 0; i < weight.length; i++) { // 遍历物品
        for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
            // 以上为01背包,然后加一个遍历个数
            for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) { // 遍历个数
                dp[j] = Math.max(dp[j], dp[j - k * weight[i]] + k * value[i]);
            }
            System.out.println(Arrays.toString(dp));
        }
    }
}

背包问题总结

转自代码随想录

问能否能装满背包(或者最多装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); ,对应题目如下:

416. 分割等和子集
1049. 最后一块石头的重量 II

问装满背包有几种方法:dp[j] += dp[j - nums[i]] ,对应题目如下:

494. 目标和
518. 零钱兑换 II
377. 组合总和 Ⅳ
70. 爬楼梯

问背包装满最大价值:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); ,对应题目如下:

474. 一和零

问装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j]); ,对应题目如下:

322. 零钱兑换
279. 完全平方数

关于01背包:

  • 二维dp数组01背包先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。
  • 一维dp数组01背包只能先遍历物品再遍历背包容量,且第二层for循环是从大到小遍历。

关于完全背包:

  • 纯完全背包的一维dp数组实现,先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。
  • 但是仅仅是纯完全背包的遍历顺序是这样的,题目稍有变化,两个for循环的先后顺序就不一样了。

如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。

  • 相关题目如下

求组合数:518.零钱兑换II

求排列数:377. 组合总和 Ⅳ 、70. 爬楼梯进阶版(完全背包)

  • 如果求最小数,那么两层for循环的先后顺序就无所谓了,相关题目如下:

求最小数:322. 零钱兑换 、279.完全平方数

最后是一个思维导图:
在这里插入图片描述


打家劫舍

力扣 198. 打家劫舍

原题链接

2024.05.08 三刷

打家劫舍三连问是动态规划经典问题:

  1. 确定dp数组,以及下标的含义

dp[i]:下标i之内的房屋,最多可以偷到的金额为dp[i];

  1. 确定递推公式(状态转移方程)

第i间有两种选择,偷或不偷:

  • 偷第i间,第i-1就不能偷–>nums[i]+dp[i-2];
  • 不偷第i间,最大金额就是dp[i-1];
    取二者之间的最大值即可;

所以递推公式:dp[i]=max(dp[i-1],dp[i-2]+nums[i]);

  1. dp数组初始化

由递推公式可以看出,需要初始化dp[0]和dp[1]:

do[0]:只有0号房屋的时候,最多金额就是nums[0];

dp[1]:选择0和1号房屋金额更高的那一个,即dp[1]=max(nums[0].nums[1]);

  1. 确定遍历顺序

从2号房屋开始遍历到最后一个房屋i-1;

  1. 举例推导dp数组
    在这里插入图片描述
    代码如下:
class Solution {
    public int rob(int[] nums) {
        if(nums.length==1)return nums[0];
        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-1],dp[i-2]+nums[i]);
        }
        return dp[nums.length-1];
    }
}

空间优化:
每个dp[i]只和前两个值相关,用两个变量存储并在遍历过程中更新即可。

代码如下:

// 动态规划,空间优化
class Solution {
    public int rob(int[] nums) {
        int n=nums.length;
        if(n==1)return nums[0];
        int first = nums[0];
        int second = Math.max(nums[0],nums[1]);
        for(int i=2;i<n;i++){
            int cur = Math.max(nums[i]+first,second);
            first = second;
            second = cur;
        }
        return second;
    }
}

力扣 213. 打家劫舍 II

原题链接

2024.03.10 二刷
在这里插入图片描述
这题相比于上一题,多了一个条件,就是房子是成环首尾相连的,也就是如果选了第0间,那么第i-1间就不能选,如果选了第i-1间,那么第0间就不能选。

所以可以分两种情况:
①不考虑最后一间房子,打家劫舍范围规定在nums[0…i-2]

②不考虑首间房子,打家劫舍范围规定在nums[1…i-1]

这样运用两次上一题的逻辑,从①和②的返回值中选取最大的就是最终结果了。

注意:对于数组成环一般有三种情况:
①考虑不包含首尾元素:下标从1到i-2;
②考虑首元素,不包含尾元素:下标从0到i-2;
③考虑尾元素,不包含首元素:下标从1到i-1;

在这题里面情况②③已经包括了情况①,所以只考虑情况②③就行了。

代码如下:

class Solution {
    public int rob(int[] nums) {
        if(nums.length==1)return nums[0];
        int sum1=robRange(nums,0,nums.length-2);
        int sum2=robRange(nums,1,nums.length-1);
        return Math.max(sum1,sum2);
    }
    //同打家劫舍第一题一样
    public int robRange(int[] nums,int startIndex,int endIndex){
        if(startIndex==endIndex)return nums[startIndex];
        int[] dp=new int[nums.length];
        dp[startIndex]=nums[startIndex];
        dp[startIndex+1]=Math.max(nums[startIndex],nums[startIndex+1]);
        for(int i=startIndex+2;i<=endIndex;i++){
            dp[i]=Math.max(dp[i-1],dp[i-2]+nums[i]);
        }
        return dp[endIndex];
    }

}

力扣 337. 打家劫舍 III

原题链接

在这里插入图片描述
对这道题解释一下:一棵二叉树上每个结点都有自己的权值,每个结点都可以选择要这个权值和不要这个权值,但是整棵树中不能同时父子结点都选择要权值,求解如何选择结点的权值,使这棵二叉树被选择的结点总权值最大。

想要做这道题,就先要弄清楚这题的遍历方式,二叉树的遍历方式有层序遍历,以及前中后序遍历。这题如果想用动态规划的思想解决的话,显然根节点
是否被选中不能在一开始就确定(不能用层序,前中序遍历),因为二叉树的结构导致根节点一旦被选中或不选中,在后续无法根据当前的总金额回去调整它的大小。所以要使用后序遍历,在对孩子结点处理好后,根据孩子结点的情况,决定当前的根节点是否选择。

动态规划最重要的就是有一个数组,能够对遍历过程中的状态进行记录,而这题每个结点就是偷与不偷两种,所以可以用一个长度为2的数组res用来记录当前结点偷与不偷所能获得的最大金额。

因为后序遍历涉及到递归,所以用递归三部曲:

1. 确定递归函数的参数和返回值:

很显然,递归函数的返回值就是记录了当前结点偷或不偷能获得的最大金额的数组res,这样res通过递归一层层传递上去,可以让上一层的结点进行判断。

而res数组其实就是dp数组,res[0]表示不偷当前结点所能获得最大的金额,res[1]表示偷当前结点所能获得的最大金额。

递归函数的参数就是当前的结点root。

public int[] robMoney(TreeNode root){
	int[] res=new int[2];
	……
	return res;
}

2. 确定终止条件:

显然碰到空节点的时候就直接返回res={0,0};

if(root==null)return res;

3. 确定遍历顺序:

后序遍历,先遍历左右孩子,再处理自己,注意需要用两个长度为2的数组用来接住左右孩子返回的res:

int[] left=robMoney(root.left);
int[] right=robMoney(root.right);

4. 确定单层递归逻辑:

left和right接住左右孩子返回值之后,就需要处理当前结点,算出res[0]和res[1]的大小了。

  • 不偷当前结点的话,那么所能获得的最大金额显然来自于左右孩子所能获得的最大金额总和,左孩子的最大金额,就是left[0]和left[1]的最大值,右孩子同理:
res[0]=Math.max(left[0],left[1])+Math.max(right[0],right[1]);
  • 偷当前结点的话,它的孩子结点肯定不能偷,所以所能获得的最大金额就是当前结点的值加上left[0]+right[0]:
res[1]=root.val+left[0]+right[0];

5. 举例推导:

在这里插入图片描述
代码如下:

/**
 * 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) {
        //结果数组,存放当前结点选择偷或不偷的两种金额结果;
        //res[0]表示不偷当前结点所能获得的最高金额
        //res[1]表示偷当前结点所能获得最高金额
        int[] res=new int[2];

        res=robMoney(root);
        return Math.max(res[0],res[1]);
    }
    //返回值
    public int[] robMoney(TreeNode root){
        int[] res=new int[2];
        if(root==null)return res;

        //后序遍历,先遍历左右孩子
        int[] left=robMoney(root.left);
        int[] right=robMoney(root.right);

        //再处理当前结点
        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. 买卖股票的最佳时机

原题链接

2024.03.12 二刷
在这里插入图片描述一、暴力解法
最容易想到的肯定是暴力解法,就是双重for循环,把所有买卖情况能获得的利润全都算出,找出最大的就行,但是时间复杂度O(n^2),会超时。

//暴力解法(O(n^2),超时)
class Solution {
    public int maxProfit(int[] prices) {
        int max=0;
        for(int i=0;i<prices.length-1;i++)
            for(int j=i+1;j<prices.length;j++){
                max=Math.max(max,prices[j]-prices[i]);
            }
        return max;
    }
}

二、贪心思想
只允许买卖一次,那么就是要在最低点买入,最高点卖出。可以在遍历的过程中,用min记录遍历过的最低价格,然后在遍历到当前价格的时候,求取利润,用max记录最大利润,随着遍历不断更新min和max。

//贪心思想:时间O(n),空间O(1)
class Solution {
    public int maxProfit(int[] prices) {
        int res=0;
        int min=10001;
        for(int i=0;i<prices.length;i++){
            min=Math.min(min,prices[i]);
            res=Math.max(res,prices[i]-min);
        }
        return res;
    }
}

三、动态规划

其实贪心思想也就是动态规划,只是表现形式不一样:

未优化的动态规划:

class Solution {
    public int maxProfit(int[] prices) {
        //前i-1天的最大收益,第i天的价格-前i-1天中的最小价格
        //所以dp[i]保存两个状态
        //dp[i][0]第i天的最大收益
        //dp[i][1]前i天中的最小价格
        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(prices[i]-dp[i-1][1],dp[i-1][0]);
            dp[i][1]=Math.min(dp[i-1][1],prices[i]);
        }
        return dp[prices.length-1][0];
    }
}

优化空间后的动态规划:

class Solution {
    public int maxProfit(int[] prices) {
        //前i-1天的最大收益,第i天的价格-前i-1天中的最小价格
        //所以dp[i]保存两个状态
        //dp[0]第i天的最大收益
        //dp[1]前i天中的最小价格
        int[] dp=new int[2];
        dp[0]=0;
        dp[1]=prices[0];
        for(int i=1;i<prices.length;i++)
        {
            dp[0]=Math.max(prices[i]-dp[1],dp[0]);
            dp[1]=Math.min(dp[1],prices[i]);
        }
        return dp[0];
    }
}

这里面的dp[0]就相当于贪心代码里的res,dp[1]相当于min。


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

原题链接

2024.03.12 三刷
在这里插入图片描述

这题在贪心专题的时候已经做过一次了:原文链接
附上贪心代码:

//贪心思想
class Solution {
    public int maxProfit(int[] prices) {
        int res=0;
        for(int i=1;i<prices.length;i++)
            res+=Math.max(prices[i]-prices[i-1],0);
        return res;
    }
}

也就是把正利润拆分成每一天都进行买入卖出,但是最后效果等同于在买入之后,间隔多天再卖出。

动态规划

这题允许多次交易,但是手里同时最多只有一支股票,每一天手里只会有两种状态,也就是持有股票或不持有股票。因此可以用一个二维数组记录每天这两种状态能收获的最大收益。

1.确定dp数组,以及下标的含义:

dp[i][0]:第i天交易完成后未持有股票的最大利润;
dp[i][1]:第i天交易完成后持有股票的最大利润;

2.确定递推公式(状态转移方程)

dp[i][0]可以由两种情况推出:
①前一天(第i-1天)手里就没有股票,到了第i天仍然没有购买股票,此时收益就是dp[i][0]=dp[i-1][0];
②前一天手里有股票,第i天卖出去了,获得了利润prices[i],所以dp[i][0]=dp[i-1][1]+prices[i];

dp[i][1]同理:
①前一天手里就有股票,第i天没有卖出去,dp[i][1]=dp[i-1][1];
②前一天手里没有股票,第i天买入了,支出prices[i],所以dp[i][1]=dp[i-1][0]-prices[i];

综上:
dp[i][0]=max(dp[i-1][0],dp[i-1][1]+prices[i]);
dp[i][1]=max(dp[i-1][1],dp[i-1][0]-prices[i]);

3.dp数组初始化

第0天手里未持有股票收益dp[0][0]=0;
第0天持有股票收益dp[0][1]=-prices[0];

4.确定遍历顺序

i从0到prices.length-1,遍历完prices数组。

5.举例推导dp数组

略……

代码如下:

//动态规划(未优化空间)
class Solution {
    public int maxProfit(int[] prices) {
        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]);
            dp[i][1]=Math.max(dp[i-1][1],dp[i-1][0]-prices[i]);
        }
        //因为最后一天不持有股票肯定比持有股票收益高,所以直接返回它
        return dp[prices.length-1][0];
    }
}

可以观察到,每一天的状态都只和前一天有关,所以不用记录全部的状态,只要在遍历中用两个变量记住dp[i−1][0] 和 dp[i−1][1]的情况,并且用在计算第i天的状态就可以了。

代码如下:

//动态规划(空间优化)
class Solution {
    public int maxProfit(int[] prices) {
        int dp0=0,dp1=-prices[0];
        for(int i=1;i<prices.length;i++){
            dp0=Math.max(dp0,dp1+prices[i]);
            dp1=Math.max(dp1,dp0-prices[i]);
        }
        return dp0;
    }
}

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

原题链接

在这里插入图片描述
这题是在上一题的基础上,对交易次数做了限制,规定最多只能交易两次

因为最多只能交易两次,所以在每一天的最后,都有五种可能的状态:
0.未操作过
1.进行第一次买操作;
2.进行第一次卖操作(完成第一次交易)
3.进行第二次买操作(第一次交易完成的前提下)
4.进行第二次卖操作(完成第二次交易)

1.确定dp数组,以及下标的含义

dp[i][j]:第i天,是状态j(j为0-4)所能获得的最大收益;

2.确定递推公式(状态转移方程)

  • dp[i][0]不进行操作的话,收益一定是0

  • dp[i][1]:如果第i天是第一次买入操作状态(并不是说第i天进行买入操作,而是说第i天还卡在第一次买入操作的状态,所以有可能前几天就进行了第一次买入,后面没有进行别的操作),那么有两种情况会导致它:

①第i天其实没有进行操作,它只是保持了前面的状态,那么dp[i][1]=dp[i-1][1];

②第i天进行第一次买入操作(以prices[i]价格买入),说明前面没有进行过买入操作,那么第i天利润为-prices[i];

综上:dp[i][1]=max(dp[i-1][1],-prices[i]);

  • dp[i][2]:第i天是第一次卖出操作的状态,也是有两种情况:

①第i天没有进行过操作,只是保持着前面发生的第一次卖出的状态:dp[i][2]=dp[i-1][2];

②第i天进行第一次卖出操作(以prices[i]价格卖出),它是建立在前面的第一次买入操作的前提下的,所以dp[i][2]=dp[i-1][1]+prices[i];

综上,dp[i][2]=max(dp[i-1][2],dp[i-1][1]+prices[i]);

  • dp[i][3]:第i天是第2次买入状态,同理也有两种情况可能导致它:

①第i天只是保持着前面的第二次买入状态,没有进行操作,那么dp[i][3]=dp[i-1][3];

②第i天进行了第二次买入操作,那么它是建立在前面的第一次卖出的前提下,以prices[i]价格买入,所以dp[i][3]=dp[i-1][2]-prices[i];

综上:dp[i][3]=max(dp[i-1][3],dp[i-1][2]-prices[i]);

  • dp[i][4]:第i天是第2次卖出状态,可能导致它的两种情况:

①第i天只是保持着前面的第二次卖出状态,没有进行操作,dp[i][4]=dp[i-1][4];

②第i天进行了第二次卖出操作,它是建立在前面的第二次买入操作的前提下的,并且以prices[i]卖出,所以dp[i][4]=dp[i-1][3]+prices[i];

综上,dp[i][4]=max(dp[i-1][4],dp[i-1][3]+prices[i]);

3.dp数组初始化

  • 第0天,不作操作,收益就是0,dp[0][0]=0;
  • 第0天,第一次买入,dp[0][1]=-prices[0];
  • 第0天,第一次卖出,相当于在第0天先买入再卖出,收益为0,dp[0][2]=0;
  • 第0天,第二次买入,相当于第一次买入的状态,dp[0][3]=-prices[0];
  • 第0天,第二次卖出,相当于没有收益,dp[0][4]=0;

4.确定遍历顺序

正序遍历,i从1到prices.length-1,利润最大的状态一定是第二次卖出的状态,所以dp[price.length-1][4]是最大利润;

5.举例推导dp数组

prices = [1,2,3,4,5]
在这里插入图片描述代码如下:

//动态规划(未优化空间)
class Solution {
    public int maxProfit(int[] prices) {
        int[][] dp=new int[prices.length][5];
        dp[0][1]=-prices[0];
        dp[0][3]=-prices[0];
        for(int i=1;i<prices.length;i++){
            dp[i][1]=Math.max(dp[i-1][1],-prices[i]);
            dp[i][2]=Math.max(dp[i-1][2],dp[i-1][1]+prices[i]);
            dp[i][3]=Math.max(dp[i-1][3],dp[i-1][2]-prices[i]);
            dp[i][4]=Math.max(dp[i-1][4],dp[i-1][3]+prices[i]);
        }
        return dp[prices.length-1][4];
    }
}

与上一题一样,这里的每种状态都只和它对应的上一个状态相关,而且状态0都保持为0的状态,不参与状态变化。所以可以用4个变量来记住当前的状态,并且参与到下一轮的状态转变计算中。

代码如下:

//空间优化
class Solution {
    public int maxProfit(int[] prices) {
        //从1-4,分别表示第1次买入,第1次卖出,第2次买入,第2次卖出状态
        int dp1=-prices[0],dp2=0,dp3=-prices[0],dp4=0;

        for(int i=1;i<prices.length;i++){
            dp1=Math.max(dp1,-prices[i]);
            dp2=Math.max(dp2,dp1+prices[i]);
            dp3=Math.max(dp3,dp2-prices[i]);
            dp4=Math.max(dp4,dp3+prices[i]);
        }
        return dp4;
    }
}

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

原题链接

在这里插入图片描述
这题相比123. 买卖股票的最佳时机 III,就是把最多进行两次交易改成了最多进行k次交易,解题的思想还是差不多的,可以用二维数组dp[i][j]表示在第i天状态为j的情况下,最大收益是dp[i][j]。状态有这些:
状态0:不操作
状态1:第一次买入
状态2:第一次卖出
状态3:第二次买入
状态4:第二次卖出
……
状态2k-1:第k次买入
状态2k:第k次卖出

所以一共有2k+1种状态,除0之外,奇数都是买入状态,偶数都是卖出状态。所以dp数组就是prices.length行,2k+1列。

1.确定dp数组,以及下标的含义

dp[i][j]表示在第i天状态为j的情况下,最大收益是dp[i][j];

2.确定递推公式(状态转移方程)

先看一下最多交易两次的转移方程:

dp[i][1]=Math.max(dp[i-1][1],-prices[i]);
dp[i][2]=Math.max(dp[i-1][2],dp[i-1][1]+prices[i]);
dp[i][3]=Math.max(dp[i-1][3],dp[i-1][2]-prices[i]);
dp[i][4]=Math.max(dp[i-1][4],dp[i-1][3]+prices[i]);

从上一题可以知道,无论i等于多少,只要处在状态0,dp[i][0]=0,所以为了让代码规整一些,dp[i][1]=Math.max(dp[i-1][1],dp[i-1][0]-prices[i]),即:

dp[i][1]=Math.max(dp[i-1][1],dp[i-1][0]-prices[i]);
dp[i][2]=Math.max(dp[i-1][2],dp[i-1][1]+prices[i]);
dp[i][3]=Math.max(dp[i-1][3],dp[i-1][2]-prices[i]);
dp[i][4]=Math.max(dp[i-1][4],dp[i-1][3]+prices[i]);

可以知道,无论是买入还是卖出状态,它们都有一种情况是保持前一天的状态,此外就要区别买入和卖出状态了,买入状态是依赖于它前一天的前置状态(比如第二次买入的前置状态就是第一次卖出,第二次卖出的前置状态就是第二次买入),再减去当天的股票价格;卖出状态是依赖于它前一天的前置状态,再加上当天的股票价格。

前面得出j从0到2k表示2k+1种状态,j为奇数表示买入状态,j为偶数表示卖出状态,所以状态转移可以这样:

for(int i=1;i<prices.length;i++)
     for(int j=1;j<=2*k-1;j+=2){
         dp[i][j]=Math.max(dp[i-1][j],dp[i-1][j-1]-prices[i]);
         dp[i][j+1]=Math.max(dp[i-1][j+1],dp[i-1][j]+prices[i]);
     }

3.dp数组初始化

从递推公式可以看出,dp[i][j]都来自于第i-1的状态,所以,第0天的状态是基础,第0天的状态0,收益一定是0,因为没有进行任何操作;第0天的状态1,收益是-prices[0],因为进行了买入操作;第0天的状态1,收益一定也是0,因为在买入后又进行了卖出;……所以第0天的奇数状态,收益都是-prices[0],偶数状态收益都是0;

for(int j=1;j<=2*k-1;j+=2)dp[0][j]=-prices[0];

4.确定遍历顺序

都是正序的,双重for循环,外层i从1到prices.length-1;内层j从1 到2k-1(因为偶数用j+1表示就行,所以不用遍历完整,每次j+2);

5.举例推导dp数组

k=2,prices = [1,2,3,4,5]
在这里插入图片描述

代码如下:

class Solution {
    public int maxProfit(int k, int[] prices) {

        if(prices.length==0)return 0;
        
        int[][] dp=new int[prices.length][2*k+1];

        for(int j=1;j<=2*k-1;j+=2)dp[0][j]=-prices[0];

        for(int i=1;i<prices.length;i++)
            for(int j=1;j<=2*k-1;j+=2){
                dp[i][j]=Math.max(dp[i-1][j],dp[i-1][j-1]-prices[i]);
                dp[i][j+1]=Math.max(dp[i-1][j+1],dp[i-1][j]+prices[i]);
            }
        
        return dp[prices.length-1][2*k];
    }
}

还有一种空间复杂度只要O(n)的:

这里面的buy[j]表示到了第i天,处在第j次交易的买入状态;sell[j]表示到了第i天,处在第j次交易的卖出状态,其他的可以类比二维dp的写法进行理解。

class Solution {
    public int maxProfit(int k, int[] prices) {
        int n = prices.length;
        if (n == 0) return 0;
        int[] buy = new int[k + 1];
        int[] sell = new int[k + 1];
        Arrays.fill(buy, -prices[0]);
        for (int i = 1; i < n; i++) {
            for (int j = 1; j <= k; j++) {
                buy[j] = Math.max(buy[j], sell[j - 1] - prices[i]);
                sell[j] = Math.max(sell[j], buy[j] + prices[i]);
            }
        }
        return sell[k];
    }
}

力扣 309. 最佳买卖股票时机含冷冻期

原题链接
在这里插入图片描述
这题是在122. 买卖股票的最佳时机 II的基础上,在每次交易之后设置了一个冷冻期,也就是交易完成的后一天,不能进行交易。

在122这题中的dp数组是用第i天交易完成后,手里是否持有股票来区分状态,dp[i][0/1]是表示第i天交易完成后,手里未持有/持有股票所获得的最大收益;而这题多了冷冻期,比122题的状态会更多。

以下状态划分来自题解中的这位同学

1.确定dp数组,以及下标的含义

dp[i][j]表示在第i天,状态为j的情况下,最大收益是dp[i][j];

dp状态可以分为如下3个状态:

  • dp[i][0]–状态0:持有股票
    ······前一天就持有股票,今日无操作(前一天是状态1);
    ······是今天刚买入的,那么就要保证前一天是未持有状态,并且前一天没有卖出(否则今天就是冷冻期,无法买入),也就是前一天必须是状态2;

  • dp[i][1]–状态1:不持有股票,原因是之前持有,本日卖出(下一天就是冷冻期,不能买入)
    ······前一天就持有股票(前一天是状态1);

  • dp[i][2]–状态2:不持有股票,原因是之前就不持有,本日无操作(下一天不是冷冻期,可以买入)
    ······前一天也是状态2,今天继续保持;
    ······前一天卖出,并且没操作(前一天是状态1);

2.确定递推公式(状态转移方程)

在1中就把可能的情况都写出来了,对它们进行代码翻译:

//持有股票:1.前一日也持有,今日保持;2.前一日未持有,今日买入(前一日为状态2)
//情况2前一日必须为状态2是因为不持有股票的状态只有状态2和3,如果是3的话今日就是冷冻期,不能买入
dp[i][0]=Math.max(dp[i-1][0],dp[i-1][2]-prices[i]);

//未持有股票(前面持有,今日卖出):1.前一日持有状态,今日卖出
dp[i][1]=dp[i-1][0]+prices;

//未持有股票(前面就不持有,今日无操作):1.前一天也是状态2,今日继续保持  
//2.前面一天卖出,今日为冷冻期,无操作(前一天是状态1)
dp[i][2]=Math.max(dp[i-1][2],dp[i-1][1]);

3.dp数组初始化

dp[0][0]:第0天持有股票,收益只能是-prices[0];
dp[0][1]:第0天不持有股票,是本日卖出所致,收益为0;
dp[0][2]:第0天不持有股票,是原来就不持有,本日无操作所致,收益也是0;

4.确定遍历顺序

for(int i=1;i<prices.length;i++){
}

代码如下:

//未优化空间
class Solution {
    public int maxProfit(int[] prices) {
        if (prices.length==0) return 0;
        int[][] dp=new int[prices.length][3];
        dp[0][0]=-prices[0];
        for(int i=1;i<prices.length;i++){
            dp[i][0]=Math.max(dp[i-1][0],dp[i-1][2]-prices[i]);
            dp[i][1]=dp[i-1][0]+prices[i];
            dp[i][2]=Math.max(dp[i-1][2],dp[i-1][1]);
        }
        // 最后一天还持有股票是没有意义的,肯定是不持有的收益高,不用对比 dp[len-1][0]
        return Math.max(dp[prices.length-1][1], dp[prices.length-1][2]);
    }
}

上面的状态转移方程中,f[i][…]只与 f[i−1][…]有关,而与 f[i−2][…] 及之前的所有状态都无关,因此我们不必存储这些无关的状态。也就是说,我们只需要将 f[i−1][0],f[i−1][1],f[i−1][2]存放在三个变量中,通过它们计算出 f[i][0],f[i][1],f[i][2]并存回对应的变量,以便于第 i+1 天的状态转移即可。

代码如下:

//优化空间
class Solution {
    public int maxProfit(int[] prices) {
        if (prices.length==0) return 0;
        int dp0=-prices[0];
        int dp1=0,dp2=0;
        for(int i=1;i<prices.length;i++){
            int newdp0=Math.max(dp0,dp2-prices[i]);
            int newdp1=dp0+prices[i];
            int newdp2=Math.max(dp2,dp1);
            dp0=newdp0;
            dp1=newdp1;
            dp2=newdp2;
        }
        return Math.max(dp1, dp2);
    }
}

注意,优化代码不能简单修改,因为这题的状态转移方程中间会用到彼此,所以在每一轮循环中都要设定三个新的中间变量来承接结果,然后再将结果转移到三个dp变量中去。


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

原题链接

在这里插入图片描述这题最先是通过贪心思想做的,在贪心算法那一篇文章里已经写了具体的求解方法。

在动态规划方面,这题其实也只是在122. 买卖股票的最佳时机 II的基础上添加了一个手续费操作,所以大体状态设置和122差不多,只是在不持有股票的状态里,卖出股票的时候需要扣掉手续费。

1.确定dp数组,以及下标的含义

dp[i][0]:第i天交易完成后未持有股票的最大利润;
dp[i][1]:第i天交易完成后持有股票的最大利润;

2.确定递推公式(状态转移方程)

dp[i][0]可以由两种情况推出:
①前一天(第i-1天)手里就没有股票,到了第i天仍然没有购买股票,此时收益就是dp[i][0]=dp[i-1][0];
②前一天手里有股票,第i天卖出去了,需要扣掉手续费,并且获得了利润prices[i],所以dp[i][0]=dp[i-1][1]-fee+prices[i];

dp[i][1]同理:
①前一天手里就有股票,第i天没有卖出去,dp[i][1]=dp[i-1][1];
②前一天手里没有股票,第i天买入了,支出prices[i],所以dp[i][1]=dp[i-1][0]-prices[i];

综上:
dp[i][0]=max(dp[i-1][0],dp[i-1][1]-fee+prices[i]);
dp[i][1]=max(dp[i-1][1],dp[i-1][0]-prices[i]);

3.dp数组初始化

第0天手里未持有股票收益dp[0][0]=0;
第0天持有股票收益dp[0][1]=-prices[0];

4.确定遍历顺序

i从0到prices.length-1,遍历完prices数组。

5.举例推导dp数组

略……

代码如下:

//未优化空间
class Solution {
    public int maxProfit(int[] prices, int fee) {
        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]-fee+prices[i]);
            dp[i][1]=Math.max(dp[i-1][1],dp[i-1][0]-prices[i]);
        }
        return dp[prices.length-1][0];
    }
}

同样的,这题里的i的状态也是之和i-1状态有关,可以使用更少的空间:

//优化空间
class Solution {
    public int maxProfit(int[] prices, int fee) {
        int dp0=0,dp1=-prices[0];
        for(int i=1;i<prices.length;i++){
            int newdp0=Math.max(dp0,dp1-fee+prices[i]);
            int newdp1=Math.max(dp1,dp0-prices[i]);
            dp0=newdp0;
            dp1=newdp1;
        }
        return dp0;
    }
}


子序列问题

不连续子序列

力扣 300. 最长递增子序列

原题链接

在这里插入图片描述
1.确定dp数组,以及下标的含义

dp[i]表示nums数组中,以nums[i]结尾的最长上升子序列长度为nums[i];

2.确定递推公式(状态转移方程)

位置i的最长递增子序列等于j从0遍历到i-1各个位置的最长递增子序列 + 1 的最大值,因为:
①如果nums[i]>nums[j],说明nums[i]可以放在nums[j]后面,那么最长子序列长度就为dp[j]+1;

②如果nums[i]<=nums[j],则nums[i]不能放在nums[j]后面,不构成上升子序列,跳过(j++);

3.dp数组初始化

dp[i] 所有元素置 1,含义是每个元素都至少可以单独成为子序列,此时长度都为 1。

4.确定遍历顺序

dp[i]由0到i-1各个位置的最长升序子序列推导而来,所以从前向后遍历。

5.举例推导dp数组
在这里插入图片描述
代码如下:

class Solution {
    public int lengthOfLIS(int[] nums) {
        int[] dp=new int[nums.length];
        int res=1;
        for(int i=0;i<nums.length;i++)dp[i]=1;
        for(int i=1;i<nums.length;i++)
            for(int j=0;j<i;j++){
                if(nums[j]<nums[i])dp[i]=Math.max(dp[i],dp[j]+1);
                res=Math.max(res,dp[i]);
            }
        return res;
    }
}

力扣 1143. 最长公共子序列

原题链接

2024.04.22 二刷

思路:

这题和718的区别在于,公共子序列是可以不连续的,所以在状态转移方程方面就有些不同,也就是在text1.charAt(i-1)和text2.charAt(j-1)不相同的时候,不是直接为0,而是要找text1或text2回退一个下标位置,所能获得的最长公共子序列的值。

1.确定dp数组,以及下标的含义

dp[i][j]:text1[0:i-1] 和 text2[0:j-1] 的最长公共子序列。 (注:text1[0:i-1] 表示的是 text1 的 第 0 个元素到第 i - 1 个元素,两端都包含)

之所以 dp[i][j] 的定义不是 text1[0:i] 和 text2[0:j] ,是为了方便当 i = 0 或者 j = 0 的时候,dp[i][j]表示的为空字符串和另外一个字符串的匹配,这样 dp[i][j] 可以初始化为 0.

2.确定递推公式(状态转移方程)

主要就是判断的text1[i-1]和text2[j-1]是否相等。

①text1[i-1]==text2[j-1]时,说明两个子字符串的最后一位相等,所以最长公共子序列又增加了 1,所以 dp[i][j] = dp[i - 1][j - 1] + 1;

②text1[i - 1] != text2[j - 1] 时,说明两个子字符串的最后一位不相等,因为这一位两字符不相等,所以要么是text1考虑全,text2这一位不考虑;要么text2考虑全,text1这一位不考虑;二者情况取最大值,那么此时的状态 dp[i][j] 应该是 dp[i - 1][j] 和 dp[i][j - 1] 的最大值。

3.dp数组初始化

当 i = 0 时,dp[0][j] 表示的是 text1中取空字符串 跟 text2 的最长公共子序列,结果肯定为 0.

当 j = 0 时,dp[i][0] 表示的是 text2中取空字符串 跟 text1 的最长公共子序列,结果肯定为 0.

4.确定遍历顺序

i 和 j 的遍历顺序肯定是从小到大的

5.举例推导dp数组
在这里插入图片描述代码如下:

class Solution {
    public int longestCommonSubsequence(String text1, String text2) {
        int len1=text1.length(),len2 = text2.length();
        int[][] dp = new int[len1+1][len2+1];
        for(int i=1;i<=len1;i++){
            for(int j=1;j<=len2;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[len1][len2];
    }
}

可以从递推关系看出,dp[i][j]只和dp[i-1][j-1]、dp[i-1][j]、dp[i][j-1]有关系,也就是二维数组的左上、正上方、左方三个数据相关;

其中上方数据在滚动数组中是可以直接使用的,并且左方的数据由于j是从小到大按顺序遍历,在一维滚动数组中不会产生被新数据覆盖的问题,也可以直接使用;

唯一要注意的就是左上方的数据!!!

举例:在当前行i=2遍历的时候,比如此时遍历到j=3,dp[3]的值通过递推公式已经确定了,在j+1=4的时候,如果直接使用一维数据的dp[4]=dp[j-1]+1=dp[3]+1,这里使用的dp[3],就不是逻辑上的“左上方”了,而是在这一层遍历中刚刚确定了值的“左方”的数据;

因此,在每一层遍历中,需要设置一个中间变量,用于暂存“左上方”的数据,以便后面递推公式的时候可以直接使用。

代码如下:

//优化空间(O(n))
class Solution {
    public int longestCommonSubsequence(String text1, String text2) {
        int[] dp=new int[text2.length()+1];
        for(int i=1;i<=text1.length();i++){
            //在每一层中,upLeft扮演dp[i-1][j-1](左上)的角色
            //每一层的开始初始化为dp[0](最左边的值)
            int upLeft=dp[0];

            for(int j=1;j<=text2.length();j++){
                //在这一层中,用cur记录当前的dp[j](相当于记住dp[i-1][j])
                //用于更新这一层遍历中,dp[j+1]会用到的“左上”的值
                int cur=dp[j];
                
                if(text1.charAt(i-1)==text2.charAt(j-1))
                    //这里千万不能是dp[j-1]+1,因为dp[j-1]就是上一次的dp[j],值可能被修改过了
                    //所以用了upLeft来暂存
                    //upLeft相当于dp[i-1][j-1]
                    dp[j]=upLeft+1;
                else
                    //这里可以直接压缩,而不用中间变量暂存
                    //是因为左边和上方的不会被覆盖掉,所以直接用
                    //括号里的dp[j]相当于dp[i-1][j];dp[j-1]相当于dp[i][j-1]  
                    dp[j]=Math.max(dp[j],dp[j-1]);
                                 
                //更新dp[i-1][j-1],为本层循环遍历中的下一个j(j+1)做准备
                upLeft=cur;
            }
        }
        return dp[text2.length()];
    }
}

此外,还通过他人的题解发现,先将字符串转字符数组,再进行遍历,在力扣中效率会快得多,代码如下:

//先转字符数组
class Solution {
    public int longestCommonSubsequence(String text1, String text2) {
        char[] char1=text1.toCharArray();
        char[] char2=text2.toCharArray();
        int[] dp=new int[text2.length()+1];
        for(int i=1;i<=char1.length;i++){
            int upLeft=dp[0];
            for(int j=1;j<=char2.length;j++){
                int cur=dp[j];
                if(char1[i-1]==char2[j-1])
                    dp[j]=upLeft+1;
                else
                    dp[j]=Math.max(dp[j],dp[j-1]);
                                 
                upLeft=cur;
            }
        }
        return dp[text2.length()];
    }
}

力扣 1035. 不相交的线

原题链接

在这里插入图片描述
这题其实和1143. 最长公共子序列差不多,要在nums1和nums2里面找一样的数字连线,又要求连线不能相交,其实就是找两个数组的公共子序列(不需要连续),只要找出最长的公共子序列,那么它的长度就是最多的连线数。

1.确定dp数组,以及下标的含义

dp[i][j]:nums1[0:i-1] 和nums2[0:j-1] 的最长公共子序列。 (注:nums1[0:i-1] 表示的是 nums1 的 第 0 个元素到第 i - 1 个元素,两端都包含)

之所以 dp[i][j] 的定义不是nums1[0:i] 和nums2[0:j] ,是为了方便当 i = 0 或者 j = 0 的时候,dp[i][j]表示的为空数组和另外一个数组的匹配,这样 dp[i][j] 可以初始化为 0.

2.确定递推公式(状态转移方程)

主要就是判断的text1[i-1]和text2[j-1]是否相等。

①nums1[i-1]==nums2[j-1]时,说明两个子字符串的最后一位相等,所以最长公共子序列又增加了 1,所以 dp[i][j] = dp[i - 1][j - 1] + 1;

②nums1[i - 1] !=nums2[j - 1] 时,说明两个子字符串的最后一位不相等,那么此时的状态 dp[i][j] 应该是 dp[i - 1][j] 和 dp[i][j - 1] 的最大值。

3.dp数组初始化

当 i = 0 时,dp[0][j] 表示的是 nums1 中取空数组 跟nums2的最长公共子序列,结果肯定为 0.

当 j = 0 时,dp[i][0] 表示的是nums2中取空数组 跟nums1的最长公共子序列,结果肯定为 0.

4.确定遍历顺序

i 和 j 的遍历顺序肯定是从小到大的

5.举例推导dp数组
略……

代码如下:

class Solution {
    public int maxUncrossedLines(int[] nums1, int[] nums2) {
        int[][] dp=new int[nums1.length+1][nums2.length+1];
        for(int i=1;i<=nums1.length;i++)
            for(int j=1;j<=nums2.length;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[nums1.length][nums2.length];
    }
}

连续子序列

力扣 674. 最长连续递增序列

原题链接

在这里插入图片描述
这题只比上一题多了一个子序列必须是连续的限制,加了这个限制,对求解其实是简化了。

动态规划解法:

1.确定dp数组,以及下标的含义

dp[i]表示以nums[i]结尾的最长连续递增子序列的长度为dp[i];

2.确定递推公式(状态转移方程)

当nums[i]>nums[i-1]的时候,说明nums[i]可以接在nums[i-1]后面,成为连续递增子序列,此时dp[i]=dp[i-1]+1;

3.dp数组初始化

dps数组初始时应该都为1,表示每个nums[i]自身都为一个长度为1的递增子序列。

4.确定遍历顺序

这题因为是求连续的递增子序列,一次正向遍历就足够遍历完所有状态。

5.举例推导dp数组

在这里插入图片描述代码如下:

//动规
class Solution {
    public int findLengthOfLCIS(int[] nums) {
        int[] dp=new int[nums.length];
        int res=1;
        for(int i=0;i<nums.length;i++)dp[i]=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;
    }
}

此外,这题用贪心也可以快速求解,只要设置count在遍历过程中记录递增子序列长度,并用max记录最大的count,中间碰到不连续的,把count重新置1再继续遍历即可。

代码如下:

//贪心
class Solution {
    public int findLengthOfLCIS(int[] nums) {
        int count=1;
        int max=1;
        for(int i=1;i<nums.length;i++){
            if(nums[i]>nums[i-1]){
                count++;
                max=Math.max(max,count);
            }else 
                count=1;
        } 
        return max;
    }
}

力扣 718. 最长重复子数组

原题链接

在这里插入图片描述

这题最容易想到的暴力解法就是第一重for循环用i遍历A数组,第二重for循环用j遍历B数组,在二重循环里面再用一个k记录每一对i和j的最长重复子数组,这样的时间复杂度是O(n3),这样明显时间会超限。

这时候就可以尝试用动态规划了。

1.确定dp数组,以及下标的含义

dp[i][j]:以下标i-1结尾的数组A和以下标j-1结尾的数组B的最长的重复子数组长度为dp[i][j]。

为啥是i-1和j-1,而不是i和j呢,因为递推的时候,是需要判断nums1[i-1]和nums2[j-1]是否相等的,如果相等,那么dp[i][j]=dp[i-1][j-1]+1。这样只是为了方便递推

2.确定递推公式(状态转移方程)

从1 的解释可以得出dp[i][j]=dp[i-1][j-1]+1(建立在nums1[i-1]==nums2[j-1]的基础上)。

注意,遍历的时候i和j要从1开始(否则i-1和j-1超限)

3.dp数组初始化

从递推公式和dp数组含义出发,i或j为0的时候,显然没有意义,但是为了递推公式顺利进行,dp[i][0]和dp[0][j]就初始化为0。

4.确定遍历顺序

从dp数组含义来说,i和j都是从1开始,到数组AB末尾结束的

5.举例推导dp数组

在这里插入图片描述代码如下:

class Solution {
    public int findLength(int[] nums1, int[] nums2) {
        int res=0;
        int[][] dp=new int[nums1.length+1][nums2.length+1];
        for(int i=1;i<=nums1.length;i++)
            for(int j=1;j<=nums2.length;j++){
                if(nums1[i-1]==nums2[j-1])dp[i][j]=dp[i-1][j-1]+1;
                res=Math.max(res,dp[i][j]);
            }
        return res;
    }
}

从前面的dp状态推导可以看出,每一个dp[i][j]都是dp[i-1][j-1]推出,那么就可以压缩成一维数组,即dp[j]由d[j-1]推出,这样就相当于把上一层的数据拷贝到下一层使用。

需要注意的是,内部for循环遍历数组B 的时候,要从后往前遍历,否则会重复覆盖数值,影响下一轮的赋值。

另外,由于数据只有一维,不像二维那样可以记录每一层的情况,遇到nums1[i-1]!=nums2[j-1]的时候,要将dp[j]置0,如果不置0,再往下几层的时候,这一层可能就用成了前面几层的非0数据。

//空间优化
class Solution {
    public int findLength(int[] nums1, int[] nums2) {
        int res=0;
        int[] dp=new int[nums2.length+1];
        for(int i=1;i<=nums1.length;i++)
            for(int j=nums2.length;j>=1;j--){
                if(nums1[i-1]==nums2[j-1])dp[j]=dp[j-1]+1;
                else dp[j]=0;
                res=Math.max(res,dp[j]);
            }
        return res;
    }
}

力扣 53. 最大子数组和

原题链接
在这里插入图片描述
2023.06.02 三刷

贪心思想

这题之前已经用贪心思想做过了,大体就是在for循环遍历过程的时候,用一个count记录累加和,再用一个maxSum记录count的最大值,当count<0的时候,说明加上这个nums[i]会拖累前面的整体,那这个肯定不能加,那就要从nums[i+1]重新累加计算count,最后返回maxSum即可。

动态规划:

1.确定dp数组,以及下标的含义

dp[i]:以nums[i]结尾的最大连续子序列和为dp[i];

2.确定递推公式(状态转移方程)

其实只有两种情况,就是nums[i]是加入连续子数组,还是nums[i]另起炉灶,重新开始累加。前面的dp[i-1]和0的关系,有两个方面可以得到dp[i]:

①如果dp[i-1]>0,那么把nums[i]加入前面的连续子数组,dp[i]=dp[i-1]+nums[i]
②如果dp[i-1]<=0,那么nums[i]会被前面拖累,它直接另起炉灶,重新累加;

即dp[i]=max(nums[i],dp[i-1]+nums[i]);

3.dp数组初始化

dp[0]=nums[0];

4.确定遍历顺序

从前往后

5.举例推导dp数组

在这里插入图片描述

代码如下:

/*
f(i)表示以nums[i]为结尾的“连续子数组的最大和”,所以只要求出每个nums[i]对应的f(i),返回最大的f(i)即可,但这样需要O(n)的空间,所以可以在计算出每个f(i)的同时,用一个maxSum来记录当前最大的f(i),这样遍历到最后输出maxSum即可,只需要O(1)空间复杂度。。

dp[i]和dp[i-1]的递推关系为:dp[i]=Math.max(dp[i-1]+nums[i],nums[i])=nums[i]+Math.max(dp[i-1],0);这样其实和贪心的思想差不多。

可以看出,每个dp[i]只和dp[i-1]以及nums[i]相关,所以只需要维护一个pre作为dp即可,每遍历一个新的nums[i]就按递推公式更新pre,即pre=nums[i]+Math.max(pre,0);这样当i=0时,pre初始化为0即可。
时间O(n)--1ms,100%
空间O(1)--56.2MB,14.54%
*/


class Solution {
    public int maxSubArray(int[] nums) {
        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]);
            res=Math.max(res,dp[i]);
        }
        return res;
    }
}

这里的每个dp[i]只和dp[i-1]有关系,可以只用一个变量 pre 来维护对于当前 f(i)的 f(i−1) 的值是多少,从而让空间复杂度降低到 O(1):
代码如下:

//动规:优化空间O(1)
class Solution {
    public int maxSubArray(int[] nums) {
        int pre = nums[0], maxSum = nums[0];
        for (int i=1;i<nums.length;i++) {
    		//pre相当于f(i),不过此题计算f(i)只需要知道f(i-1)与nums[i]即可
        	//所以不用把所有的f(i)记录,只需记录前一个,然后更新覆盖即可
            pre = Math.max(pre + nums[i], nums[i]);
            maxSum = Math.max(maxSum, pre);
        }
        return maxSum;
    }
}


LeetCode 918. 环形子数组的最大和

原题链接

2024.03.10 一刷

本题为「53. 最大子数组和」的进阶版,设数组长度为 nnn,下标从 000 开始,在环形情况中,答案可能包括以下两种情况:
在这里插入图片描述
第一种情况的求解方法与求解普通数组的最大子数组和方法完全相同。对于第二种情况,我们可以找到普通数组最小的子数组 nums[i:j] 即可,因为当出现第二种情况时,中间的一定是最小的子数组,只要用total(全局总和)减去最小子数组,就可以得到全局最大子数组。

而求解普通数组最小子数组和的方法与求解最大子数组和的方法完全相同。

注意:如果数组中的元素全小于0,minSum将包括数组中的所有元素,导致我们实际取到的子数组为空。在这种情况下,我们只能取 maxSum作为答案。

代码如下:

class Solution {
    public int maxSubarraySumCircular(int[] nums) {
        int total=0;
        int minSum=nums[0];// 记录全局最小的 子数组
        int maxSum=nums[0];// 记录全局最大的 子数组
        int curMax=0;//统计以当前位置结尾的子数组最大值
        int curMin=0;// 统计以当前位置结尾的子数组最小值
        for(int num : nums){
            total += num;// 统计总和
            // 每遍历到一个位置,只要考虑要不要和前面连成子数组
            // 如果前面一段小于0(对当前num起负面作用),则以当前num作为起点,直接另起一段
            curMax = Math.max(curMax+num,num);
            maxSum = Math.max(maxSum,curMax);
            // 与上面统计以当前位置结尾的子数组最大值差不多
            // 只有前面一段比0小,才会和当前num连起来,记录以当前位置结尾的子数组最小值
            curMin = Math.min(curMin+num,num);
            minSum = Math.min(minSum,curMin);
        }
        if(total-minSum==0){
            return maxSum;
        }else{
            return Math.max(maxSum,total-minSum);
        }
    }
}

编辑距离

力扣 392. 判断子序列

原题链接
在这里插入图片描述

双指针
可以设置sIndex和tIndex两个指针,分别指向字符串s和t的起始位置,只有当s和t的指针指向的字符是一样的时候,sIndex才向后移动一步,而tIndex是固定每次移动一步。
代码如下:

//双指针
class Solution {
    public boolean isSubsequence(String s, String t) {
        int sIndex=0,tIndex=0;
        while(sIndex<s.length()&&tIndex<t.length()){
            if(s.charAt(sIndex)==t.charAt(tIndex))++sIndex;
            ++tIndex;
        }
        return sIndex==s.length();
    }
}

动态规划

1.确定dp数组,以及下标的含义

dp[i][j]:以下标i-1结尾的字符串s和以下标j-1结尾的字符串t的公共子序列长度为dp[i][j];

2.确定递推公式(状态转移方程)

只需要考虑s[i-1]与t[j-1]是否相等即可:

①s[i-1]=t[j-1],意味着s与t的公共子序列长度在原来的基础上+1,即dp[i][j]=dp[i-1][j-1]+1;

②s[i-1]!=t[j-1],当前遍历到的字符不相同,那么公共子序列长度其实和s的索引不变,t的索引回退一步,结果是一样的,所以此时的dp[i][j]=dp[i][j-1];

3.dp数组初始化

从递推公式可知,dp[0][0]和dp[i][0]都需要初始化,当i或j为0时,dp数组的值是没有实际意义的,不过为了递推公式的进行,都初始化为0;

4.确定遍历顺序

递推公式中,dp[i][j]都和dp[i-1][j-1]以及dp[i][j-1]有关,所以需要从上到下,从左到右遍历二维数组;

5.举例推导dp数组
在这里插入图片描述

代码如下:

//动态规划
class Solution {
    public boolean isSubsequence(String s, String t) {
        int[][] dp=new int[s.length()+1][t.length()+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))dp[i][j]=dp[i-1][j-1]+1;
                else dp[i][j]=dp[i][j-1];
            }
        return dp[s.length()][t.length()]==s.length();

    }
}

力扣 115. 不同的子序列

原题链接

在这里插入图片描述

1.确定dp数组,以及下标的含义

dp[i][j]:以s[i-1]结尾的s的子序列(可以不连续)中,出现以t[j-1]结尾的t的子序列的个数

注意这题说的是个数,而不是长度,这个在递推公式里面会有所体现。

2.确定递推公式(状态转移方程)

还是分为两种情况,只看s[i-1]能否和t[j-1]匹配上:

s[i-1]==t[j-1],一定要注意,这里面其实也有两种情况而且两种情况是相加 的关系,例如: s:bagg 和 t:bag ,s[3]=g 和 t[2]=g是相同的,但是字符串s也可以不用s[3]来匹配,即用s[0]s[1]s[2]组成的bag。

当然也可以用s[3]来匹配,即:s[0]s[1]s[3]组成的bag。

······s[i-1]用于匹配t[j-1]:个数为dp[i - 1][j - 1](因为dp数组值表示的是出现的个数,就算匹配上,但是双方是同时匹配上的,所以匹配上的个数还是没变,等于上一个状态的dp[i-1][j-1])

······s[i-1]不用于匹配t[j-1]:个数为dp[i - 1][j](因为不用s[i-1]进行匹配,这种情况的个数,等效于s的索引为i-2,j的索引不变的个数)

所以dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];(是否用于匹配,s和t的这一位字符都已经相等了,只是多了可能性,所以个数是相加)

s[i-1]!=t[j-1]:s和t的i-1位字符不相同,是无法用于匹配的,那么s的这一位就不参与匹配,等效于i回退一位到i-2,j不用回退,保持j-1,最终个数为dp[i - 1][j]

3.dp数组初始化

从递推公式看出dp[i][0]和dp[0][j]都需要初始化

dp[i][0]:s中以s[i-1]为结尾的子序列中,出现空字符串的个数,显然是1,因为只有空字符串=空字符串这个一种。

dp[0][j]:s中取空字符串,出现t中以t[j-1]结尾的子序列的个数,显然是0个,因为空字符串怎么都取不出别的子序列。

还需要特别注意dp[0][0],s的空字符串中出现t中的空字符串的个数,显然是1。

4.确定遍历顺序

dp数组从上到下,从左到右;

5.举例推导dp数组

在这里插入图片描述代码如下:

//动态规划(时间O(mn),空间O(mn))
class Solution {
    public int numDistinct(String s, String t) {
        int[][] dp=new int[s.length()+1][t.length()+1];
        for(int i=0;i<=s.length();i++)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))dp[i][j]=dp[i-1][j-1]+dp[i-1][j];
                else dp[i][j]=dp[i-1][j];
            }
        return dp[s.length()][t.length()];
    }
}

当然,dp数组状态是可以利用滚动数组压缩的,定义一个长度为t.length()+1的dp一维数组,如果s[i-1]=t[j-1],dp[i][j]=dp[i-1][j-1]+dp[i-1][j];就可以优化为dp[j]+=dp[j-1]; ,如果不相等,原来是:dp[i][j]=dp[i-1][j];,也就是等于上一行同一列的值,换成一维dp数组,就相当于保留原来的值,无需加语句。

不过需要注意的是原来双重for循环内层对t的遍历是正向的,改成一维dp后,需要逆向遍历t,这是为什么呢?

核心原因是:dp[j]+=dp[j-1];这一句,原来二维dp中,当前行的值是通过上一行数据相加的,正序不会影响当前行数据正确性,但是用了一维dp后,dp[j]在自身基础上,还需要加上dp[j-1],但是要注意的是,此时的dp[j],在j++后,就相当于下一个j的j-1了,也就是下一个dp[j]会被当前已经改变了值的dp[j]影响,这在二维dp中是不会出现的。

代码如下:

//空间优化O(s.length())
class Solution {
    public int numDistinct(String s, String t) {
        int[] dp=new int[t.length()+1];
        dp[0] = 1;
        for(int i=1;i<=s.length();i++){ 
            for(int j=t.length();j>0;j--){
                if(s.charAt(i-1)==t.charAt(j-1)) dp[j]+=dp[j-1];
            }
        }
        return dp[t.length()];
    }
}

力扣 583. 两个字符串的删除操作

原题链接

在这里插入图片描述这题有两种解法:

一、最长公共子序列

这题要使两个字符串经过删除后相同,并且删除的步数最少。这也就是要求删除后剩余的子串是它们两个的最长的公共子序列,这样剩下的字符尽可能多,删除的步数也就最少,所以问题就回到了1143. 最长公共子序列这里,用1143的方法求出最长公共子序列的长度后,再将word1和word2的长度分别减去最长公共子序列长度,相加就是最终所求最少步数。

1143. 最长公共子序列的具体求解在本篇目录可找到,不再做赘述。

代码如下:

//最长公共子序列(时间O(mn),空间O(mn))
class Solution {
    public int minDistance(String word1, String word2) {
        int[][] dp=new int[word1.length()+1][word2.length()+1];
        for(int i=1;i<=word1.length();i++)
            for(int j=1;j<=word2.length();j++){
                if(word1.charAt(i-1)==word2.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 word1.length()+word2.length()-2*dp[word1.length()][word2.length()];
    }
}

当然,把字符串先转成数组再遍历、二维dp降一维优化都是可以做的,优化思路也是参考本篇1143题的解法:

//最长公共子序列(优化版)
class Solution {
    public int minDistance(String word1, String word2) {
        char[] char1=word1.toCharArray();
        char[] char2=word2.toCharArray();
        int len1=word1.length();
        int len2=word2.length();
        int[] dp=new int[len2+1];
        for(int i=1;i<=len1;i++){
            int upLeft=dp[0];
            for(int j=1;j<=len2;j++){
                int cur=dp[j];
                if(char1[i-1]==char2[j-1])dp[j]=upLeft+1;
                else dp[j]=Math.max(dp[j],dp[j-1]);
                upLeft=cur;
            }
        }
        return len1+len2-2*dp[len2];
    }
}

其实这题用最长子序列的优化版本已经可以做到时间O(mn),空间O(n),m为word1长度,n为word2长度,效率也很不错,那为什么还要再做一种针对这题的动态规划解法呢?

因为后面有一题编辑距离的题目,属于这种题目的进阶版,所以先弄懂这题会对后面有帮助。

二、直接动态规划

1.确定dp数组,以及下标的含义

dp[i][j]:表示以word1[i-1]结尾的字符串word1,和以word2[j-1]结尾的字符串word2,想要相等,所需要的最少删除操作次数。

之所以dp[i][j]表示i-1和j-1结尾,是为了给空字符串留下dp[i][0]和dp[0][j]方便初始化;

2.确定递推公式(状态转移方程)

这类题目基本都有一个共同点,那就是考虑递推公式的时候,基本都是根据当前遍历到的两个字符/数字是否相等进行状态区分:

  • word1[i-1]==word2[j-1],当它们相同的时候,说明是公共字符,不用进行删除,所以最少删除操作次数可以不变,保持dp[i-1][j-1]就行了,即dp[i][j]=dp[i-1][j-1];

  • word1[i-1]!=word2[j-1],当它们不同的时候,有两种情况:
    ①删除word1[i-1],那么相当于在“使i-2和j-1结尾的字符串相等状态,所需最少删除操作次数”的基础上+1次删除word1[i-1]的操作,即dp[i-1][j]+1;
    ②删除word2[j-1],相当于在“使i-1和j-2结尾的字符串相等状态,所需最少删除操作次数”的基础上+1次删除word2[j-1]的操作,即dp[i][j-1]+1;

两种情况取最小值,即dp[i][j]=Math.min(dp[i-1][j],dp[i][j-1])+1;

3.dp数组初始化

dp[i][0]:word2为空串时,word1需要删除的字符数量就是i

dp[0][j]:同理,word1为空串,word2需要删除字符数量是j

4.确定遍历顺序

递推公式得:从上到下,从左到右

5.举例推导dp数组
在这里插入图片描述
代码如下:

//直接动态规划
class Solution {
    public int minDistance(String word1, String word2) {
        int len1=word1.length();
        int len2=word2.length();
        char[] char1=word1.toCharArray();
        char[] char2=word2.toCharArray(); 
        int[][] dp=new int[len1+1][len2+1];
        //初始化
        for(int i=0;i<=len1;i++)dp[i][0]=i;
        for(int j=0;j<=len2;j++)dp[0][j]=j;
        //遍历dp数组
        for(int i=1;i<=len1;i++)
            for(int j=1;j<=len2;j++){
                if(char1[i-1]==char2[j-1])dp[i][j]=dp[i-1][j-1];
                else dp[i][j]=Math.min(dp[i-1][j]+1,dp[i][j-1]+1);
            }
        return dp[len1][len2];
    }
}

这个解法同样也可以把二维压缩成一维进行空间优化,优化思想参考前面的1143题的优化方法:

//直接动态规划(优化空间版本O(n),n为word2长度)
class Solution {
    public int minDistance(String word1, String word2) {
        int len1=word1.length();
        int len2=word2.length();
        char[] char1=word1.toCharArray();
        char[] char2=word2.toCharArray(); 
        int[] dp=new int[len2+1];
        //初始化第一行
        for(int j=0;j<=len2;j++)dp[j]=j;
        //遍历dp数组
        for(int i=1;i<=len1;i++){
            int upLeft=dp[0];
            dp[0]=i;//相当于dp[i][0](每一行的第一个)
            for(int j=1;j<=len2;j++){
                int cur=dp[j];
                if(char1[i-1]==char2[j-1])dp[j]=upLeft;
                else dp[j]=Math.min(dp[j],dp[j-1])+1;
                upLeft=cur;
            }
        }
        return dp[len2];
    }
}

不过有一点不一样,就是第一层for循环下的dp[0]=i;,因为这题对dp[i][0]和dp[0][j]都需要初始化,第0行的初始化已经在for循环前完成了。但是第0列—每行的第1个值也需要初始化,因此在每一行遍历开始时初始化:dp[0]=i;


力扣 72. 编辑距离

原题链接
在这里插入图片描述

这题是583. 两个字符串的删除操作的进阶版本,在只能通过删除操作使两个字符串一样的基础上,增加了插入和替换操作,同样是求最少的操作次数。

1.确定dp数组,以及下标的含义

dp[i][j]:以word1[i-1]为结尾的字符串word1,以word2[j-1]为结尾的字符串word2,想要成为相等的字符串,最少的操作次数为dp[i][j];

2.确定递推公式(状态转移方程)

同样是以word1[i-1]与word2[j-1]是否相等作为状态区分:

  • word1[i-1]==word2[j-1]:要使操作尽可能少,那么字符相同的情况下就不操作;这种时候dp[i][j]=dp[i-1][j-1];

  • word1[i-1]!=word2[j-1]:字符不相同时有三种操作可以选择:增(插入)、删、换;
    下面对这三种操作的理解来自力扣题解:原作者

在这里插入图片描述
所以,当word1[i-1]!=word2[j-1]时,有三种如下操作可能:

    • ①对word1进行插入操作:dp[i][j]=dp[i][j-1]+1; dp[i][j-1]表示word1前i个字符与word2前j-1个字符相等,最少需要dp[i][j-1]次操作,那么要使word2的前j个字符与word1前i个字符相等,只需要在原来基础上,在word1的末尾增加一个与word2第j个字符相同的字符即可;
    • ②对word2进行插入操作:dp[i][j]=dp[i-1][j]+1; 对word2的增加操作其实等价于对word1的删除操作。dp[i-1][j]表示word1前i-1个字符与word2前j个字符相等,最少需要dp[i-1][j]次操作,那么要使word1的前i个字符与word2前j个字符相等,只需要在原来基础上,在word2的末尾增加一个与word1第i个字符相同的字符即可;
    • ③对word1进行替换操作:dp[i][j]=dp[i-1][j-1]+1; dp[i-1][j-1]表示word1前i-1个字符与word2前j-1个字符相等,最少需要dp[i-1][j-1]次操作,那么要使word1的前i个字符与word2前j个字符相等,只需要在原来基础上将word1的第i个字符替换成word2的第j个字符;

所以,dp[i][j]=Math.min(dp[i-1][j-1],Math.min(dp[i][j-1],dp[i-1][j]))+1;

3.dp数组初始化

dp[0][j]:word1为空串,word2前j个字符,两者想要相等,最少需要经过j次操作(word1增加j个与word2相同的字符)。

dp[i][0]:word1前j个字符,word2为空串,两者想要相等,最少需要经过i次操作(word2增加i个与word1相同的字符)。

4.确定遍历顺序

dp[i][j]由dp[i-1][j-1]、dp[i][j-1]、dp[i-1][j]推出,所以从上到下,从左到右遍历二维数组。

5.举例推导dp数组

代码如下:

//动态规划(未优化空间O(mn))
class Solution {
    public int minDistance(String word1, String word2) {
        int len1=word1.length(),len2=word2.length();
        char[] char1=word1.toCharArray(),char2=word2.toCharArray();
        int[][] dp=new int[len1+1][len2+1];
        for(int i=0;i<=len1;i++)dp[i][0]=i;
        for(int j=0;j<=len2;j++)dp[0][j]=j;
        for(int i=1;i<=len1;i++)
            for(int j=1;j<=len2;j++){
                if(char1[i-1]==char2[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[len1][len2];
    }
}

这题同样可以进行一维化,原理和上一题相同,主要针对“左上”的数据进行保存,防止覆盖,代码如下:

//动态规划(优化空间O(n),n为word2长度)
class Solution {
    public int minDistance(String word1, String word2) {
        int len1=word1.length(),len2=word2.length();
        char[] char1=word1.toCharArray(),char2=word2.toCharArray();

        int[] dp=new int[len2+1];

        for(int j=0;j<=len2;j++)dp[j]=j;

        for(int i=1;i<=len1;i++){
            //先给upLeft赋值再给dp[0]初始化,因为upLeft指代的是左上方数据
            int upLeft=dp[0];
            dp[0]=i;
            for(int j=1;j<=len2;j++){
                int cur=dp[j];
                if(char1[i-1]==char2[j-1])dp[j]=upLeft;
                else dp[j]=Math.min(upLeft,Math.min(dp[j],dp[j-1]))+1;
                upLeft=cur;
            }
        }
        return dp[len2];
    }
}

同时发现,网上大部分观点认为在for循环中遍历string字符串时,使用charAt(i)比先用str.toCharArray()转字符数组,再遍历字符数组会更快,但是在力扣提交时结果是相反的。以及尽量不要在for循环中使用方法(如nums.length这样的),这样开销会更小。


回文

力扣 647. 回文子串

原题链接
在这里插入图片描述首先明确题目要求的子串是连续的

双指针解法
这题用双指针会更快一些,双指针的思想大致是:枚举每一个可能的回文中心,然后用两个指针分别向左右两边拓展,当两个指针指向的元素相同的时候就拓展,否则停止拓展

需要注意的是,一个元素可以作为中心点,两个元素也可以:

//双指针(时间O(n^2),空间O(1))
class Solution {
    public int countSubstrings(String s) {
        int len=s.length();
        char[] str=s.toCharArray();
        int result=0;
        for(int i=0;i<len;i++){
            result+=extend(str,i,i,len);//中心点为一个
            result+=extend(str,i,i+1,len);//中心点为两个
        }
        return result;
    }
    //计算从每个位置向两端扩散可以
    public int extend(char[] str,int i,int j,int n){
        int res=0;
        while(i>=0&&j<n&&str[i]==str[j]){//字符相同就向两边扩展
            ++res;
            --i;
            ++j;
        }
        return res;
    }
}

当然也可以把两种情况合在一起,具体如何合在一起的,参考力扣官方题解:链接在此,代码如下:

class Solution {
    public int countSubstrings(String s) {
        int n = s.length(), ans = 0;
        for (int i = 0; i < 2 * n - 1; ++i) {
            int l = i / 2, r = i / 2 + i % 2;
            while (l >= 0 && r < n && s.charAt(l) == s.charAt(r)) {
                --l;
                ++r;
                ++ans;
            }
        }
        return ans;
    }
}

动态规划解法

1.确定dp数组,以及下标的含义

dp[i][j]:表示从下标i到下标j闭区间范围内的连续子串是否是回文串,如果是,dp[i][j]为true,否则为false;

2.确定递推公式(状态转移方程)

核心还是借用中心扩展的思想,即利用回文中心向两侧扩展,外侧的dp[i][j]利用内侧是否为回文串进行判断。

还是和之前差不多,用s[i]和s[j]是否相等进行区分:

  • s[i]!=s[j]:dp[i][j]=false;
  • s[i]==s[j]:有三种情形需要进行判断:
    • ①i=j,即下标相同,那么肯定是回文串,dp[i][j]=true;
    • ②i+1=j,即i与j相邻时字符相同,例如ss,肯定是回文串,dp[i][j]=true;
    • ③下标i与j相差大于1,如cabac,外侧的c相同,要判断它是否为回文串,就要看内侧的aba是不是回文串,也就是看i+1到j-1区间是不是回文串,也就是if(dp[i+1][j-1])dp[i][j]=true;

所以递推关系:

if(str[i]==str[j]){
    if(j-i<=1){
        dp[i][j]=true;
        ++res;
    }else if(dp[i+1][j-1]){
        dp[i][j]=true;
        ++res;
    }
}

3.dp数组初始化

初始全为false

4.确定遍历顺序

dp[i][j]依赖于dp[i+1][j-1],即依赖于二维数组当前位置“左下方”的数据,所以要从左下开始遍历完全部。i从s.length()开始,递减到0,而j要从i开始(因为要从中心向两边扩展)。

for(int i=len-1;i>=0;i--)
	for(int j=i;j<len;j++){

	}

5.举例推导dp数组

输入“aaa”

在这里插入图片描述true的数量就是回文子串的数量。

代码如下:

class Solution {
    public int countSubstrings(String s) {
        int len=s.length();
        char[] str=s.toCharArray();
        boolean[][] dp=new boolean[len][len];
        int res=0;
        for(int i=len-1;i>=0;i--)
            for(int j=i;j<len;j++){
                if(str[i]==str[j]){
                    if(j-i<=1){
                        dp[i][j]=true;
                        ++res;
                    }else if(dp[i+1][j-1]){
                        dp[i][j]=true;
                        ++res;
                    }
                }
            }
        return res; 
    }
}

中间的判断语句可以整合到一起,更加简洁:

//动规简洁写法(时间O(n^2),空间O(n^2))
class Solution {
    public int countSubstrings(String s) {
        int len=s.length();
        char[] str=s.toCharArray();
        boolean[][] dp=new boolean[len][len];
        int res=0;
        for(int i=len-1;i>=0;i--)
            for(int j=i;j<len;j++){
                if((j-i<=1||dp[i+1][j-1])&&str[i]==str[j]){
                    dp[i][j]=true;
                    ++res;
                }
            }
        return res; 
    }
}

力扣 516. 最长回文子序列

原题链接
在这里插入图片描述
这题求的是回文子序列,和647. 回文子串是不一样的,647的回文子串是连续的,这题的子序列可以不连续。

直接动态规划解法

1. 确定dp数组,以及下标的含义

dp[i][j]😒[i]到s[j]闭区间内最长回文子序列的长度为dp[i][j];(i<=j)

2.确定递推公式(状态转移方程)

还是从s[i]是否等于s[j]出发:

  • s[i]==s[j],这两个字符相等,只要找到它们内部字符串最长回文子序列长度,再把这两个字符长度加上,就是下标i到下标j的最长回文子序列长度了,即dp[i][j]=dp[i+1][j-1]+1;
  • s[i]!=s[j],两字符已经不相等了,则 s[i]和 s[j]不可能同时作为同一个回文子序列的首尾,要求最长的回文子序列,要么只考虑s[i],要么只考虑s[j],取两者最大值,即dp[i][j]=Math.max(dp[i][j-1],dp[i+1][j]);

3.dp数组初始化

按照递推公式的走向,对于每一个i,它所考虑的j都是从j=i+1开始的,这样的话dp[i][i]就无法赋值但是每个字符本身就是长度为1回文子序列,所以所有的dp[i][i]=1;

4.确定遍历顺序

dp[i][j]来自于dp[i+1][j-1]、dp[i+1][j]、dp[i][j-1],在二维数组中的位置分别是左下方、左方,正下方。所以想要得到dp[i][j],就必须先把左下方数据求出来,也就是要从下到上,从左到右遍历,也就是i从s.length()-1到0,并且j从i+1到s.length()-1;

5.举例推导dp数组

在这里插入图片描述

代码如下:

//直接动态规划(时间O(n^2),空间O(n^2))
class Solution {
    public int longestPalindromeSubseq(String s) {
        char[] str=s.toCharArray();
        int len=str.length;
        int[][] dp=new int[len][len];
        for(int i=0;i<len;i++)dp[i][i]=1;//初始化
        for(int i=len-1;i>=0;i--)
            for(int j=i+1;j<len;j++){
                if(str[i]==str[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][len-1];
    }
}

因为每次dp[i][j]又是只和dp[i+1][j-1]、dp[i+1][j]、dp[i][j-1]相关,也就是左下方、左方、正下方数据,并且从下到上,从左到右遍历,这就和之前几题从上到下,从左到右遍历二维数组,且dp[i][j]只和左方、上方、左上方数据有关的状态压缩同理,为了防止左下方数据赋值后,新的dp[j]不是用的“左下方”数据,而是用“左边”的这一轮才处理好的数据当做“左下方”数据,所以要用一个变量记录“左下方”数据,距离代码和前面的状态压缩类似:

//直接动态规划-状态压缩(时间O(n^2),空间O(n))
class Solution {
    public int longestPalindromeSubseq(String s) {
        char[] str=s.toCharArray();
        int len=str.length;
        int[] dp=new int[len];
        
        for(int i=len-1;i>=0;i--){
            int downLeft=dp[0];//左下方数据初始化
            dp[i]=1;//相当于在每一行初始化dp[i][i]=1
            for(int j=i+1;j<len;j++){
                int cur=dp[j];//记住当前数据,在后面赋给downLeft,防止被覆盖
                if(str[i]==str[j])dp[j]=downLeft+2;
                else dp[j]=Math.max(dp[j],dp[j-1]);
                downLeft=cur;
            }
        }
        return dp[len-1];
    }
}

最长公共子序列解法

这题还有一种比较巧妙的解法,就是可以借用1143. 最长公共子序列的解题代码,也就是先把字符串s翻转成字符串t,然后找和t的最长公共子序列,那么这个长度就是s的最长回文子序列长度。这个方法借用了回文串首尾对应位置字符相同的特性,s翻转后,和t对应的位置其实就是回文串的对应位置。

这种方法还可以小优化一下,就是不用新创一个t字符串,可以直接在s上进行最长公共子序列的代码逻辑,因为1143. 最长公共子序列是分别在两个字符串上分别用i和j从1开始遍历字符串找最长公共子序列,就算翻转s成为t,也是要在s和t上进行同样的遍历,那么i继续从s首端开始遍历,让j从s的尾端开始倒序遍历,也起到同样的效果。

代码如下:

//1143最长公共子序列(时间O(n^2),空间O(n^2))
class Solution {
    public int longestPalindromeSubseq(String s) {
        char[] str=s.toCharArray();
        int len=str.length;
        int[][] dp=new int[len+1][len+1];
        //求最长公共子序列
        for(int i=1;i<=len;i++){
            for(int j=1;j<=len;j++){
                //比较s的首尾对应位置,递推公式用最长公共子序列逻辑
                if(str[i-1]==str[len-j])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[len][len];
    }
}

但是这种方法效率会比直接动态规划差一些,因为直接动态规划不用把所有二维数组位置遍历完,j是从i+1开始的,而这个方法需要把所有位置都遍历完。

同样的,可以进行状态压缩:

//1143最长公共子序列-状态压缩(时间O(n^2),空间O(n))
class Solution {
    public int longestPalindromeSubseq(String s) {
        char[] str=s.toCharArray();
        int len=str.length;
        int[] dp=new int[len+1];
        //求最长公共子序列
        for(int i=1;i<=len;i++){
            int upLeft=dp[0];//保存“左上方”数据
            for(int j=1;j<=len;j++){
                int cur=dp[j];
                if(str[i-1]==str[len-j])dp[j]=upLeft+1;
                else dp[j]=Math.max(dp[j],dp[j-1]);
                upLeft=cur;
            }
        }
        return dp[len];
    }
}

1. 确定dp数组,以及下标的含义

2.确定递推公式(状态转移方程)

3.dp数组初始化

4.确定遍历顺序

5.举例推导dp数组

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值