一命通关动态规划dp

news2024/11/25 2:12:12


前言

这篇文章详细概括了动态规划的所有题型,以及各个题型的解决公式方法。如果看完这篇动态规划还是不会做题,我来给你补,我爱说点实话。


动态规划

何为动态规划?

动态规划,有一点暴力求解的感觉。用最通俗的语言来解释,便是按照顺序一步一步往后推导,一直推导到自己想要的值。

举一个最简单的例子:数列

在高中,我们解决数列的问题往往是想办法凑出数列的通项公式,然后直接代入具体的某个数值求解。但是动态规划更暴力,假如我们想求n=100时的值,动态规划便从n=1开始一步步往后推导,一直计算到100得出结果。

再打个比方:牢大打复活赛

如果牢大想打赢复活赛,就必须思考怎么打赢哟西。而打赢哟西的先制条件,便是哟西要打赢孙笑川,而牢大要打赢kmg。但是,我们怎么知道哟西一定会优于孙笑川晋级呢?我们又怎么知道牢大一定可以战胜kmg呢?而动态规划便是在已经知道这些先制条件的情况下,再来推导在这些已知条件下的最优解。 换句话说,动态规划就是一步步为想得到的结果铺垫,一直铺垫到所有条件都已知后,再得到我们想要的答案。

何时用动态规划?

以上只是举了两个具体的例子。但是真实的问题场景肯定不会这么具象,不过大多时候,其实看一眼就能排除是不是动态规划了。 

动态规划适用的场景,是在解决不断出现重复子问题的时候,采用的一种思想。用人话说,就是一段连续的线性空间(比如数组)中,某个未知的值和该位置前的一些值有一定关系,但是那些带有关系的位置的值也是未知的。不过好在,每个位置对应的关系都是固定的,依次根据这个固定的关系求每个位置的值,便叫重复子问题,而解决这个问题的方法,便叫动态规划。

还是以斐波那契数列为例。

  • 我们想求n=100时的值,只需要知道n=99的值和n=98的值,然后将其相加。但是不幸的是,这也是一个未知值。于是,我们必须求n=99和n=98时的值。
  • 求n=99,就要知道n=98和n=97时的值,然后将其相加,不幸的是,他们也是一个未知数。

发现没有,依次往下推,虽然数值不一样,但是其问题都是重复的,这便是重复子问题。 而我们一步步向下,其实只是找到先制条件的源头,但是我们不妨想想,我们直接从已知值这个源头开始,不断为后面的问题创造先制条件,问题是不是便简化了很多。而从这个已知的源头开始向着未知推导,就是动态规划的思想。

所以答案便很显然:

用特殊颜色标记的词语要格外注意,在后面会详细说明。

动态规划的解题步骤

动态规划的解题步骤相对来说较为公式,拿到一道题,首先看可不可以用动态规划的思想来解决,如果可以直接套入公式便可以了。

在说明公式之前,首先介绍几个概念:

  • 什么是dp表? 

就是动态规划所有需要求的值,全部放在一张表里,这个表就叫做dp表。还是以斐波那契为例。

用动态规划解决这个问题的时候,dp表便是这个样子:

填写dp表,便是从n=3开始,依次根据dp表中已有的数值向后计算出其他的数值。

  • 什么是状态? 

在这里,状态这个概念就比较抽象了。状态并不是像牢大一样是起飞还是坠毁,而是数组的每个空间存储的数值,无论其有什么含义,都被称之为状态。

第一步:状态表示

什么是状态表示?也是一句话概括:确定dp表中每个位置所代表的含义。

简单的状态表示比如斐波那契,每个状态表示便是一个简单的数值。但是在解决一些具体的问题时,状态表示便没有这么简单。每个问题的状态表示都不一样,而后面的步骤都是在该状态表示下所展开推导,所以想清楚状态表示是每个问题最重要的一步。

确定状态标识,没有特别统一的方法,但是所有的状态表示可以总结为几种题型,这也会在接下来的汇总中介绍。 

第二步:状态转移方程 

状态转移方程,说人话便是:怎么从dp表中前一段已知的值得到后面未知的值。
而得到状态转移方程的方式也很简单:枚举。

这一个简单的式子,就是状态转移方程——从dp表前两个空间的值,得到该未知空间的值,便是状态转移。 

第三步:初始化dp表

第四步:填写dp表

第五步:返回值

动态规划的重点只在于前两步:确定状态表示和推导状态转移方程。结束了前两步,这道题也便结束了,剩下的便是一些细枝末节的工作。在这三步里,我们只需要注意一些细节:

  1. dp表在初始化时注意开辟空间的大小,避免越界
  2. 填写dp表时注意填写的顺序,一定要线性顺序填写 
  3. 返回值要和自己的状态表示相对应,不同状态表示下的返回值也不同

这些细节,也会在接下来的题型分类中分别讲到。


在以下阶段,只会挑选最具代表性或者最经典的问题来进行讲解,剩余的问题一定可以通过讲解的方法解出来,所以其余问题一定要自己尝试解决一下,动态规划如果不去一次性解决那必然是常看常新。


单状态动态规划

简介

单状态dp是动态规划中最简单的题目,同时也是动态规划的开始。我们采用一道经典的题目来讲解动态规划在具体问题中的应用。 

不是已经被说烂了的斐波那契数列使用最小花费爬楼梯 

746. 使用最小花费爬楼梯 - 力扣(LeetCode)

题目翻译

就是一段楼梯,我们可以选择爬一步或者爬两步,但是每一个台阶上有几托史,我们到了那个台阶就得把该台阶上的史赤干净,我们需要求解赤石最少爬完楼梯的路线。

解题步骤

1.状态表示

题目要求什么?到达最终目的时,所需要的最小花费。那我们最先想到的是什么?到达每一个位置时候的最小花费。

为什么我们会这样想?因为dp表中包含了最后一个空间,所以dp表中的每一个状态表示,一定包含着最后一个空间的状态表示。而我们不妨反向去思考,既然最后一个空间的状态表示被所有状态表示包含,那么有没有可能,最后一个空间的状态表示就是所有空间的状态表示? 

这仅仅是一个尝试,也是单状态题目的明显特点——最后一个空间的状态表示,就是每一个空间的状态表示。 

于是,这题的状态表示便轻易推了出来——dp[i]表示在到达台阶i的时候,最小的花费。 

2.状态转移方程 

状态转移方程,是由状态方式推出来的。我们在推导状态转移方程的时候,必须根据题目意思,找到需求的状态和前面的状态之间的关系。 

求解状态转移方程,实际上是一个数学问题。就比如在这个题中,我们求解状态转移方程的方法:

dp[i]之和两个位置有关:dp[i-1]和dp[i-2]。因为在每个台阶,最多只能爬两步,也就是只有dp[i-1]爬一步到达dp[i]和dp[i-2]爬两步到达dp[i]这两种方法。也就是说,dp[i]只有两种可能的值:

  1. dp[i]=dp[i-1]+台阶i对应的花费
  2. dp[i]=dp[i-2]+台阶i对应的花费

同时,题目要求的是最小的花费,所有我们舍取的时候,只需要取其最小的值便可以了

最终得到状态转移方程:
dp[i]=min(dp[i-1],dp[i-2])+cost[i]

3.初始化 

初始化的最重要原则,便是要保证dp表的填写不越界。这个不越界,包括了两个界限:上界和下界。 

就比如在这里,何为上界?上界就是我们需要填到的地方。台阶顶部对应的值为台阶个数+1,所以我们至少要开辟台阶个数+1的dp表空间。

何为下界?下界就是我们在访问dp表空间时,不能访问到负数。比如我们会访问i-1和i-2,那么就必须保证i大于等于2,也就是只能从i=2开始填写。但是,i=0和i=1的空间不能空出,这些空间我们只能手动填写。 

解题代码

class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost) {
        cost.push_back(0);
        int n=cost.size();
        if(n==1||n==2)
            return 0;
        vector<int> dp(n+1);//初始化dp表
        dp[0]=cost[0];
        dp[1]=cost[1];//手动初始化i=0和i=1

        for(int i=2;i<n;i++)//从i=2开始填写
        {
            dp[i]=min(dp[i-1],dp[i-2])+cost[i];//状态转移方程
        }

        return dp[n-1];//返回值
    }
};

类似题目

1137. 第 N 个泰波那契数 - 力扣(LeetCode)

面试题 08.01. 三步问题 - 力扣(LeetCode)

91. 解码方法 - 力扣(LeetCode)


路径问题

简介 

路径问题,实际上也是一种单状态动态规划的问题。但是将其单独列出来,只是想介绍一个新的概念——多维dp表

LCR 099. 最小路径和 - 力扣(LeetCode)

题目翻译

你现在有个键盘,但是W和A被抠了,只剩下S和D,求在这种情况下,你能赤到最少史的数量。

解题步骤

1.状态表示 

还是首先考虑最后一个空间的状态表示——到达最后一个空间的最小和。

但是,这里的dp表会有一定的变化——一维dp表已经无法满足题目条件了,因为题目给的数据已经是二维了。所以,我们在创造dp表的时候,也需要创造出二维dp表。 

所以最终的状态表示:dp[i][j]表示到达第i行第j列时,对应的最小和。

2. 状态转移方程

想到达第i行第j列,只有两种情况:从[i-1][j]下走一步,或者从[i][j-1]向右走一步,分别对应的值也有两种情况:

  1. dp[i][j]=dp[i-1][j]+cost[i][j]
  2. dp[i][j]=dp[i][j-1]+cost[i][j]

因为题目需要求最小值,所以只需要舍取一种情况,对应的状态转移方程:

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

3.初始化 

我们可以和一维时的一样,因为i和j都必须大于等于1,所以i=0和j=0时需要手动初始化。但是这是一个大工程,我们不妨开空间的时候,多开出一行和一列,用来防止访问越界。 

解题代码 

class Solution {
public:
    int minPathSum(vector<vector<int>>& grid) {
        int row=grid.size();
        int col=grid[0].size();
        vector<vector<int>> dp(row+1);
        for(auto& e:dp)
        {
            e.resize(col+1,INT_MAX);
        }//初始化:多开出一行一列

        dp[0][1]=0;

        for(int i=1;i<row+1;i++)
        {
            for(int j=1;j<col+1;j++)
            {
                dp[i][j]=min(dp[i-1][j],dp[i][j-1])+grid[i-1][j-1];//状态转移方程
            }
        }

        return dp[row][col];
    }
};

类似题目

LCR 098. 不同路径 - 力扣(LeetCode)

63. 不同路径 II - 力扣(LeetCode)

931. 下降路径最小和 - 力扣(LeetCode)

174. 地下城游戏 - 力扣(LeetCode)


多状态动态规划

简介

多状态动态规划,是在单状态来进行状态表示时,会有一些欠缺的地方。我们无法一开始便判断其为单状态还是多状态,只有在先用单状态的情况去考虑时,发现无论如何都无法完整表示出状态,此时才用多状态去思考。

而单状态和多状态本质是一样的,都是去寻找某一状态和其他状态的关系,只不过从一个dp表变成了多个dp表。这样说过于抽象,还是从题目来进行理解。

不过在这里, 挑选了两类题目,一类作为入门,而另一类则是多状态的终极题目。

打家劫舍系列

LCR 089. 打家劫舍 - 力扣(LeetCode)

题目翻译

假如你想卢关了,那么如果今天进行手艺活,明天必须要戒掉一天否则身体吃不消。但是,明天戒掉了不代表后天必须卢关,只要不是相邻的两天卢关,其他想戒多少天就可以戒多少天。

而我们需要求在一段时间里,卢关的最大收益。

解题步骤

1.状态表示 

按照往常的经验,我们尝试一下,dp[i]表示到达i位置的时候,最大的收益。

但是通过这个状态表示,我们在推导状态转移方程的时候会发现一个问题:我们并不知道dp[i-1]是i-1天卢关的情况还是i-1天没有卢关的情况。用式子更直观去看:

  • 如果i-1天卢关了,那么第i天一定不能卢关,于是第i天没有产生任何收益,即dp[i]=dp[i-1]
  • 但是第i-1天没有卢关,那么第i天可以卢关,为了产生最大收益,dp[i]=dp[i-1]+cost[i] 

不过,这显然一个dp表无法表示这么多状态,我们只能用多个dp表来表示多个dp状态,这便是多状态的动态规划问题。

在这个问题中,有着两个状态:第i天休息和第i天打劫。所以我们需要开辟两个dp表:fdp和gdp。

为什么要这样取名?和数学函数的f(x)和g(x)一样,dp表也可以按照这个规律取名。

  • fdp[i]表示第i家没有打劫的时候,产生的最大收益
  • gdp[i]表示第i家打劫了的时候,产生的最大收益 
 2.状态转移方程

多个dp表之间不是独立的。既然创立了多个dp表,其意义也是多个dp表的相互联系,所以我们也要找到dp表之间的联系。

在第i家的时候,只有两种情况:第i-1家没有打劫;第i-1家打劫了。而这两种情况也会分别对第i家产生不同的影响。

  1. 假如第i-1家打劫了,则第i家一定没有被打劫,于是此时fdp[i]=gdp[i-1]
  2. 假如第i-1家没有打劫,则第i家可以打劫也可以不打劫,于是此时fdp[i]=fdp[i-1],gdp[i]=fdp[i-1]+cost[i] 

但是,题目要求的是最大收益,所以把这些情况总结一下便是:

fdp[i]=max(fdp[i-1],gdp[i-1])

gdp[i]=fdp[i-1]+cost[i]

3.初始化 

和单状态一样,唯一的区别只是我们开两个dp表fdp和gdp,然后注意是否会越界。

解题代码

注意:这里fdp和gdp换了个顺序,但是不影响解题

class Solution {
public:
    int rob(vector<int>& nums) {
        int n=nums.size();
        vector<int> fdp(n+1);
        vector<int> gdp(n+1);

        fdp[0]=nums[0];
        gdp[0]=0;

        for(int i=1;i<n;i++)
        {
            fdp[i]=gdp[i-1]+nums[i];
            gdp[i]=max(fdp[i-1],gdp[i-1]);
        }

        return max(gdp[n-1],fdp[n-1]);
    }
};

思考

 还是打家劫舍,但是如果置换一下条件,将所有屋子变成环形,那么和线性的有什么区别?

LCR 090. 打家劫舍 II - 力扣(LeetCode) 

 在这里,我们会发现这个问题:

为什么动态规划只能解决线性的问题

如果屋子是环形的,那么在偷第一件屋子的时候,会参考最后一间屋子的状态。同样,最后一间屋子的状态会再向前参考,一直再回到第一间屋子,进入了一个死循环。这就是为什么说,一定要按顺序去填dp表,因为我们只能参考已知的状态,不能参考未知的状态,否则很容易陷入死循环。

但是解决这个问题的方案也很简单——将非线性变为线性。同时,这也是解决非线性动态规划的第一思路:变为线性的情况。

在这里应该怎么做?我们去思考改变带来的影响是什么——最后一间屋子和第一间屋子多了一个限制,所以,我们只要把最后一间屋子和第一间屋子单独拎出来,便成为了普通的打家劫舍。

 这就是非线性变为线性的思想。

class Solution {
public:
    int robRange(vector<int>& nums, int start, int end) {
        int first = nums[start], second = max(nums[start], nums[start + 1]);
        for (int i = start + 2; i <= end; i++) {
            int temp = second;
            second = max(first + nums[i], second);
            first = temp;
        }
        return second;
    }

    int rob(vector<int>& nums) {
        int length = nums.size();
        if (length == 1) {
            return nums[0];
        } else if (length == 2) {
            return max(nums[0], nums[1]);
        }
        return max(robRange(nums, 0, length - 2), robRange(nums, 1, length - 1));
    }
};

股票系列

股票系列是多状态的终极问题。并不是股票系列难,而是股票系列可以产生很多状态,而且不同的题目分析状态的方法也不同。但是,既然是多状态问题,其解题的方法一定是固定的,仍是举出所有的状态,然后理清状态之间的关系,无非是多几个状态转移方程。 

188. 买卖股票的最佳时机 IV - 力扣(LeetCode) 

题目翻译

股票的价格每天都在波动,买入和卖出都是当天的价格。但是在一天只能进行买入或者卖出之一的操作,不能当天买入当天卖出,并且手上只能有一支股票。问在只买卖k次的情况下,能获得的最大收益。

解题步骤

1.状态表示 

不用多解释,单状态肯定是表示不出来的。因为如果我们用dp[i]表示第i天的最大收益,我们不知道第i天已经买卖了多少次,后续的状态也无法推导。
那我们应该怎么去补充状态呢? 

还是和打家劫舍的思路一样,多状态是多个单状态,多个dp表只是为了补充单状态的缺失情况。单状态缺少的是第i天交易了几次的状态,那我们只需要用多张dp表,分别来表示第i天交易了j次的状态,那所有的状态都可以正常表示了。 

但是,如果采用打家劫舍的思想,分别用fdp和gdp来表示两种状态,会出现一个问题:我们不知道究竟要开几张dp表。因为如果按照刚刚的状态表示,如果有3次买卖机会,那么就是4张dp表(0,1,2,3次);如果5次机会便是6张,这个dp表的数量是不固定的。 
这个时候一个显而易见的思路——用二维数组的方式。因为二维数组的开辟空间是可变的。 

状态表示:

  • fdp[i][j]表示第i天已经交易了j次,此时手上没有持有股票,可以获得的最大收益。
  • gdp[i][j]表示第i天已经交易了j次,此时手上持有股票,可以获得的最大收益。
 2.状态转移方程

股票系列的最大难度,就在于状态繁多。这个时候,我们可以用一个新概念解决这个问题——状态机。 
这个词就不必过多深入了解,用人话来解释状态机便是——枚举。

 枚举出来后,再用式子来分别表示:

  1. fdp[i][j]=fdp[i-1][j]
  2. fpd[i][j]=gpd[i-1][j-1]+cost[i]
  3. gdp[i][j]=gdp[i-1][j]
  4. gdp[i][j]=fdp[i-1][j]-cost[i]

但是题目的要求是最大值,所以只需要取最大

最终的状态表示便为:

fdp[i][j]=max(fdp[i-1][j],gdp[i-1][j-1]+cost[i])gdp[i][j]=max(gdp[i-1][j],fdp[i-1][j]-cost[i])

3.初始化 

初始化部分已经在状态表示中说明了,此时只需要注意一个点:j-1不要越界。

并且在这里介绍一个神奇的数字:-0x3f3f3f3f;

这个数字会在算法题中经常见到。0x3f3f3f3f是INT_MAX的一半,但是在算法题中,如果我们直接把最大值定为INT_MAX,可能会导致最大值加上一个数后,变为了负数最小值。为了这个避免这个问题,用0x3f3f3f3f便可以表示可进行计算的最大值,同样,-0x3f3f3f3f自然就成为了可理论计算的最小值。

解题代码

class Solution {
public:
    int MIN=-0x3f3f3f3f;

    int maxProfit(int k, vector<int>& prices) {
        int day=prices.size();

        vector<vector<int>> ondp(day);
        vector<vector<int>> offdp(day);

        for(int i=0;i<day;i++)
        {
            ondp[i].resize(k+1);
            offdp[i].resize(k+1);
        }

        ondp[0][0]=-prices[0];
        offdp[0][0]=0;

        for(int i=1;i<k+1;i++)
        {
            ondp[0][i]=MIN;
            offdp[0][i]=MIN;
        }

        for(int i=1;i<day;i++)
        {
            for(int j=0;j<k+1;j++)
            {
                ondp[i][j]=max(ondp[i-1][j],offdp[i-1][j]-prices[i]);
                offdp[i][j]=offdp[i-1][j];
                if(j>=1)
                    offdp[i][j]=max(offdp[i-1][j],ondp[i-1][j-1]+prices[i]);
            }
        }

        int ret=0;
        for(auto e:offdp.back())
        {
            if(e>ret)
                ret=e;
        }

        return ret;
    }
};

类似题目

面试题 17.16. 按摩师 - 力扣(LeetCode) 

LCR 091. 粉刷房子 - 力扣(LeetCode)

309. 买卖股票的最佳时机含冷冻期 - 力扣(LeetCode)


子数组问题

简介 

子数组问题的套路更加固定,状态表示几乎只有一种:以第i个元素为结尾,能满足条件的数组。因为子数组一定是连续的,以第i个元素为结尾,无论前有多少个元素,一定包含着第i个元素,所以看到子数组问题,直接列出dp表,dp[i]表示以第i个元素为结尾时满足的条件,然后再推导状态表示方程。

在这里,我挑选了一道最为经典和简单的子数组问题,其他的无非是在子数组的基础上加入多状态。

53. 最大子数组和 - 力扣(LeetCode) 

题目翻译 

有一串数组,子数组是其中连续的一串,求所有子数组中最大的和。

解题步骤 

1.状态表示 

和在简介中说的一样,dp[i]表示以i为结尾所有子数组中,最大子数组和。

比如题目翻译中的数组,以哟西结尾的数组中,只有:牢大和哟西;哟西;两个子数组。

2.状态转移方程 

从dp[i]前面的状态推导出dp[i],只有两种情况:子数组包含i-1,子数组不包含i-1。因为如果包含了i-1,就是要求以i-1为结尾的所有子数组中最大和,然后再加上num[i],便是以i为结尾的所有子数组最大和。此时,以i-1为结尾的所有子数组中最大和恰好就是dp[i-1]的状态表示,所以我们可以直接列出式子:

  1.  dp[i]=num[i](不包含i-1)
  2. dp[i]=dp[i-1]+num[i](包含i-1)

因为题目要求最大值,所以状态转移方程为:

dp[i]=max(0,dp[i-1])+num[i]

3.初始化

没有太多要注意的点

解题代码 

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int n=nums.size();

        vector<int> dp(n);
        dp[0]=nums[0];
        for(int i=1;i<n;i++)
        {
            dp[i]=max(0,dp[i-1])+nums[i];
        }

        int ret=dp[0];
        for(auto e:dp)
            ret=max(e,ret);

        return ret;
    }
};

类似题目

918. 环形子数组的最大和 - 力扣(LeetCode) 

152. 乘积最大子数组 - 力扣(LeetCode)

139. 单词拆分 - 力扣(LeetCode)


子序列问题

简介 

子序列问题其实和子数组问题大差不差。子序列指的是保持元素的排列顺序不变,在其中可以不连续地选取元素。而因为其不连续性,所以无法只看i-1的情况,反之必须将从0到i-1全部遍历一遍,不过状态表示仍为dp[i]表示以i为结尾满足题目条件的子序列。

同样,在此只挑选出子序列最经典的题目,其他题目只不过是在子序列的基础上参入其他问题,解题方法还是一样的。

300. 最长递增子序列 - 力扣(LeetCode)

题目翻译

在保持元素顺序不变的情况下,从中随意挑选元素使其保持递增,求挑选元素最多的个数。

解题步骤

1.状态表示 

和简介中一样,dp[i]表示以i为结尾,最长子序列的长度。

2.状态转移方程 

和子数组不同的是,子序列不一定要求连续。也就是说,从0到i-1的任何dp[k],都可以推导出dp[i]。此时,我们只能遍历所有的0到i-1,然后把所有的可能都列举出来

  1. 如果num[i]>num[k],则其可以作为子序列的最后一个元素,dp[i]=dp[k]+1
  2. 如果num[i]<=num[k],则其不能作为子序列的最后一个元素,dp[i]=1 

因为题目要求最大值,所以状态转移方程为:
if(num[i]>num[k])

        dp[i]=max(dp[i],dp[k]+1)

解题代码

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        int n=nums.size();
        
        vector<int> dp(n);
        dp[0]=1;

        for(int i=1;i<n;i++)
        {
            int ins=0;
            for(int j=0;j<i;j++)
            {
                if(nums[i]>nums[j])
                {
                    ins=max(ins,dp[j]);
                }
            }
            dp[i]=ins+1;
        }

        int ret=0;
        for(auto e:dp)
        {
            if(e>ret)
                ret=e;
        }

        return ret;
    }
};

 类似题目

376. 摆动序列 - 力扣(LeetCode) 

646. 最长数对链 - 力扣(LeetCode)

446. 等差数列划分 II - 子序列 - 力扣(LeetCode)


回文串问题

简介 

动态规划并不是回文串问题最好的解决方法。但是,动态规划是对回文串问题的预处理,其目的是判断其中所有的子串是不是回文串,然后在预处理的基础上解决问题。遇见回文串问题,直接无脑写入代码预处理子串,然后再根据题目要求来解决接下来的问题。

而所有的回文串预处理都是同样的代码:dp[i][j]表示从i到j是不是回文串,遍历所有的i和j就可以知道所有的子串是否为回文串。

而状态转移方程也很简单。如果s[i]==s[j],那么只需要满足dp[i+1][j-1]是回文串,两边再加上同样的字符,自然新的子串也是回文串。不过,i+1和j-1可能会出现i+1>j-1的情况,其意义是两个字符中间没有任何字符。但是这也满足回文串的要求,所以其也为一个回文串。

通用代码

//假如给的是一个字符串s
int n=s.size();

vector<vector<bool>> dp(n+1,vector<bool>(n+1));
//初始化dp表,因为是从i到j,所以必须要二维

//从后往前遍历,如果从前往后则参考了未知状态
for(int i=n;i>0;i--)
{
    for(int j=i;i<n+1;j++)
    {
        if(s[i-1]==s[j-1])
        {
            //如果i+1<=j-1,表示中间还有字符,需要判断dp[i+1][j-1]
            //否则,中间没有字符,其直接为一个回文串
            dp[i][j]=i+1<j-1?true:dp[i+1][j-1];
        }
    }
}
//预处理完毕,可以知道从i到j的所有子串是否为回文串

所以题目看到是回文串就可以把以上代码先丢上去了。

挑选一道经典的题目:LCR 020. 回文子串 - 力扣(LeetCode) 

 (因为过于简单就不翻译了)

解题步骤

看一眼题目,是不是回文子串?食德,直接预处理:

int countSubstrings(string s) {
        int n=s.size();
        vector<vector<bool>> dp(n,vector<bool>(n));

        for(int i=n-1;i>=0;i--)
        {
            for(int j=i;j<n;j++)
            {
                if(s[i]==s[j])
                {
                    dp[i][j]=i+1<j?dp[i+1][j-1]:true;
                }
            }
        }
    }

然后,我们便知道所有子串是否为回文串。之后,我们再去看题目要求:求回文子串的数目。既然都已经知道了所有子串是否为回文串,那只需要再遍历一遍dp表,然后如果dp[i][j]==true就多计数一次,最终得到的值就是回文子串的个数。

int countSubstrings(string s) {
        int ret=0;

        for(int i=n-1;i>=0;i--)
        {
            for(int j=i;j<n;j++)
            {
                if(dp[i][j]==true)
                    ret++;
            }
        }

        return ret;
    }

而且,这两步其实可以同时进行,不过对解题没有影响。最终得到的代码为:

解题代码

class Solution {
public:
    int countSubstrings(string s) {
        int n=s.size();
        int ret=0;

        vector<vector<bool>> dp(n,vector<bool>(n));

        for(int i=n-1;i>=0;i--)
        {
            for(int j=i;j<n;j++)
            {
                if(s[i]==s[j])
                {
                    dp[i][j]=i+1<j?dp[i+1][j-1]:true;
                }
                if(dp[i][j])
                    ret++;
            }
        }

        return ret;
    }
};

类似题目

1745. 分割回文串 IV - 力扣(LeetCode)

LCR 094. 分割回文串 II - 力扣(LeetCode) 

516. 最长回文子序列 - 力扣(LeetCode)


两个数组的dp问题

 简介

两个数组的动态规划,解题也比较公式化,状态表示也经常只有一种:以第一个数组的i为结尾,第二个数组的j为结尾,满足题目条件。不过,两个数组的dp问题最难的地方在于条件杂多,其思考部分甚至不如单状态的dp,但是不断枚举很容易将人写红温。

 当然,我们不挑这个私人题目,我们还是从最经典的入手

1143. 最长公共子序列 - 力扣(LeetCode)

题目翻译 

有两个数组,求其所有子序列组合中,最长的相同子序列长度。

解题步骤

1.状态表示 

和简介中说的一样,我们需要开辟二维dp表,其中dp[i][j]表示以第一个数组第i元素为结尾,第二个数组第j元素为结尾, 满足条件即完全相同的最长子序列长度。我们很容易发现,这又转换成了一个子序列的小问题,这也是两个数组dp问题最常见的现象——套入公式,然后变成其他的问题,而往往其他问题也是公式解决。

2. 状态转移方程

既然变成了子序列的问题,那我们免不了用子序列的思想去思考问题。但是,题目给我们的条件也不能忽略:子序列必须要相同。 

第一个数组以i为结尾,第二个数组以j为结尾,那就会分为两种情况:test1[i]是否等于test2[j]

如果test1[i]等于test2[j],那么只要在dp[i-1][j-1]的基础上加上这个公共字符,就是新的最长公共序列。

反之,如果不等于,则其无法组成新的最长公共序列,只能从旧的已知最长值中取最大值

列为式子为:

  1. 如果test[i]==test[j],dp[i][j]=dp[i-1][j-1]+1;
  2. 否则,dp[i][j]=max(dp[i-1][j],dp[i][j-1],dp[i-1][j-1])

同时,我们发现在不等的情况,dp[i-1][j-1]被其他两种情况已经包含了,所以最终的状态转移方程为:
if(test1[i]==test2[j])

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

else

        dp[i][j]=max(dp[i-1][j],dp[i][j-1])

解题代码 

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        int n=text1.size();
        int m=text2.size();

       vector<vector<int>> dp(n+1,vector<int>(m+1));

        for(int i=1;i<n+1;i++)
        {
            for(int j=1;j<m+1;j++)
            {
                if(text1[i-1]==text2[j-1])
                {
                    dp[i][j]=dp[i-1][j-1]+1;
                }
                else
                {
                    dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
                }
            }
        }

        return dp[n][m];
    }
};

类似题目

1035. 不相交的线 - 力扣(LeetCode) 

44. 通配符匹配 - 力扣(LeetCode)

10. 正则表达式匹配 - 力扣(LeetCode)(私人题目,慎做)

LCR 096. 交错字符串 - 力扣(LeetCode)


背包问题

简介

背包问题是动态规划里最重要的问题! 

背包问题在动态规划里的地位相当于3D区里的蒂法,不仅特别常考,而且也是难度最大的一类问题。

如果背包问题再往深了讲,可能会涉及到禁赛的难度,所以在此只介绍两个问题——01背包和完全背包。

背包问题,用人话来解释,就是0元购,但是背包的容量有限,要从这些有限的容量中找到最优解。其中又分为01背包和完全背包:

  • 01背包是每个商品只有一个
  • 完全背包是每个商品的供应是无限的

但是01背包和完全背包虽然被分为了两类题目,其解题却是没有什么区别的,而且解题方法也很套路化。其中会用到一些数学推导,但是只要文化水平比丁真高一定能看得懂。

01背包 

LCR 101. 分割等和子集 - 力扣(LeetCode) 

题目翻译

给一个数组,将数组分为两个部分,使两个数组的和相同。

或者换句话说,数组的和必须为偶数,并且从数组中挑取元素,看能否有一种组合使得和刚好为数组和的二分之一。 

解题步骤

1.状态表示 

背包问题的状态表示很固定,虽然题目给出的是一维的,但是我们往往需要开出给出的维度+1的多维dp表。

说人话就是,题目给出了一维数据,我们则需要开辟二维dp表;题目给二维数据,我们就要开二维dp表。

为什么?

因为我们不仅要表示价值,我们还要表示一个隐藏的限制条件——背包的容量。 
就比如在这里,dp[i][j]表示,只取数组中前i个数,能否达到和为j。

但是我们将题目换一下,用简介中给出的情况

那么dp[i][j]表示,只取前i个物品,最大容积为j的时候,能取到最大的价值。

找到状态表示的共性了吗?

背包问题的特性便是给出很多选择,然后在一定的限制条件下挑选。我们可以分选择和限制条件两方面来思考,其中我们从最少的选择开始,一直到所以的选择,而限制条件我们一样从最局限的条件开始,慢慢宽松。最少的选择和最局限的条件一定是最容易求解的,而在慢慢扩大的时候,我们便可以借用之前已经求解到的条件,推导出最后的答案。

虽然我们的状态表示看似和题目没有什么关系,但其实只是从问题的特性由小到大推导,这便是背包问题的隐藏条件。

 2.状态转移方程

状态转移方程在想明白了状态表示的隐藏条件后,便容易推导了许多。

想到达dp[i][j]这个状态,肯定是从dp[i-1]最好得到。但是到达了dp[i],也会有两种情况:元素i到底取不取呢?

如果不取,则很简单,dp[i][j]=dp[i-1][j]

但是如果取了,就代表原本可以组合产生和为j的元素组,现在可以使和为j+nums[i];我们可以反向思考,如果现在可以组合产生和为j的元素组,并且这个元素组包含nums[i],则只要dp[i-1]可以和为j-num[i],就能满足条件。即dp[i][j]=dp[i-1][j-nums[i]]

用式子表示,便是

  1. dp[i][j]=dp[i-1][j]
  2. dp[i][j]=dp[i-1][j-nums[i]]

因为只要满足一种条件便可以,所以状态转移方程为:
dp[i][j]=dp[i-1][j] | dp[i-1][j-nums[i]]

同时要注意,j-nums[i]不能越界。

3.初始化 

数组的大小n自然不可少。同时,再带上一个隐藏条件体积,也就是数组元素和的二分之一,所以我们初始化dp表为横为数组大小n,纵为数组元素和二分之一的dp表。

解题代码

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int n=nums.size();
        int sum=0;
        for(auto e:nums)
            sum+=e;
        if(sum%2==1)
            return false;
        
        int target=sum/2;
        vector<vector<bool>> dp(n+1,vector<bool>(target+1));

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

        for(int i=1;i<n+1;i++)
        {
            for(int j=1;j<target+1;j++)
            {
                dp[i][j]=dp[i-1][j]|((j-nums[i-1]>=0)&&(dp[i-1][j-nums[i-1]]));
            }
        }

        return dp[n][target];
    }
};

 类似题目

LCR 102. 目标和 - 力扣(LeetCode) 

1049. 最后一块石头的重量 II - 力扣(LeetCode) 

完全背包

LCR 103. 零钱兑换 - 力扣(LeetCode) 

解题步骤

1.状态表示 

完全背包和01背包几乎一模一样,dp[i][j]表示只用前i种零钱,组成和为j的最少钞票数。 

2.状态转移方程 

也通过同样的方式推导,不过完全背包会多出几种情况: 

一个钞票,01背包我只能取一张,但是完全背包我可以取好多张,也就是说

所以上下两个式子,虽然形式不同,但是其表示的意义都是完全一样的。为了避免使用太多的k,我们直接使用下面的式子dp[i][j]=dp[i][j-nums[i]]+1

而实际上,这个规律可以推广到所有完全背包的状态转移方程中,即相比01背包,我们可以从同行的数据中找到状态转移的关系。这也成完全背包问题的一大解题技巧。

最终的状态表示为:

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

其中要保证j-nums[i]不越界 

类似题目

518. 零钱兑换 II - 力扣(LeetCode)

279. 完全平方数 - 力扣(LeetCode)


总结

而以上,就是动态规划的所有题型以及相应的应对策略。在看到一个动态规划题目,首先去思考是哪类动态规划的问题,然后套入那类题目的公式,再根据题目要求推出状态转移方程。只从笔试面试的角度来看,动态规划逃不出这几种大题型,也就是说等把文章里的题目全部做完,


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1461238.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

利用nbsp设置空格

想要实现上面效果&#xff0c;一开始直接<el-col :span"8" >{{ item.name }} </el-col> 或者<el-col :span"8" >{{ item.name }}</el-col>或者<el-col :span"8" >{{ item.name }}</el-col> 都无…

【办公类-16-07-02】“2023下学期 周计划-户外游戏 每班1周五天相同场地,6周一次循环”(python 排班表系列)

背景需求&#xff1a; 又到了开学季&#xff0c;新的自主游戏&#xff08;户外游戏&#xff09;安排表出炉了。 这张是贴在美术活动室的安排表&#xff0c;我需要转换成班级为单位的安排表&#xff0c;便于批量制作周计划。 设计思路&#xff1a; 1、一个班级每周轮到的一个场…

网工内推 | 上市公司、外企网工,厂商认证优先,最高18K*13薪

01 北京电旗通讯技术股份有限公司 招聘岗位&#xff1a;网络运维工程师 职责描述&#xff1a; 1.负责内部核心网络的运行监控、故障处理。 2.对网络性能进行监控和调优。 3.负责网络设备的规划选型。 4.设计网络架构拓扑图,制定网络规范。 5.分析处理复杂网络故障。 6.负…

sizeof()的易错点

你也可以传入一个变量的名字&#xff08;而不只是类型&#xff09;给 sizeof()&#xff0c;但在一些情况下&#xff0c;可能得不到你要的结果&#xff0c;所以要小心使用。例如&#xff0c;看看下面的代码片段&#xff1a; 在第一行&#xff0c;我们为 10 个整数的数组声明了空…

【前端】前端三要素之JavsScript基础

写在前面&#xff1a;本文仅包含JavaScript内容&#xff0c;DOM知识传送门在这里&#xff0c;BOM传送门在这里。 本文内容是假期中刷的黑马Pink老师视频&#xff08;十分感谢Pink老师&#xff09;&#xff0c;原文保存在个人的GitLab中&#xff0c;如果需要写的网页内容信息等可…

笔记:torch.roll

最近在准备写 swin transformer 的文章&#xff0c;记录下 torch.roll 的用法&#xff1a; >>> x torch.tensor([1, 2, 3, 4, 5, 6, 7, 8]).view(4, 2) >>> x tensor([[1, 2],[3, 4],[5, 6],[7, 8]]) 第0维度向下移1位&#xff0c;多出的[7,8]补充到顶部 &g…

【Flink网络通讯(一)】Flink RPC框架的整体设计

文章目录 1. Akka基本概念与Actor模型2. Akka相关demo2.1. 创建Akka系统2.2. 根据path获取Actor并与之通讯 3. Flink RPC框架与Akka的关系4.运行时RPC整体架构设计5. RpcEndpoint的设计与实现 我们从整体的角度看一下Flink RPC通信框架的设计与实现&#xff0c;了解其底层Akka通…

JS进阶——解构赋值

数组解构 基本&#xff1a; let [a, b, c] [1, 2, 3]; // a 1 // b 2 // c 3 可嵌套 let [a, [[b], c]] [1, [[2], 3]]; // a 1 // b 2 // c 3 可忽略 let [a, , b] [1, 2, 3]; // a 1 // b 3 不完全解构 let [a 1, b] []; // a 1, b undefined 剩余运…

基于RHEL8部署Zabbix6.0,监控不再困难!

&#x1f4e2;&#x1f4e2;&#x1f4e2;&#x1f4e3;&#x1f4e3;&#x1f4e3; 哈喽&#xff01;大家好&#xff0c;我是【IT邦德】&#xff0c;江湖人称jeames007&#xff0c;10余年DBA及大数据工作经验 一位上进心十足的【大数据领域博主】&#xff01;&#x1f61c;&am…

5G——物理层仿真

1.前置条件 2.仿真流程 1.填写搜索过程 解&#xff1a; 2.填写每一步细节 2.2.1 准备 解&#xff1a; &#xff08;1&#xff09;BCH &#xff08;2&#xff09;BCCH 解析&#xff1a;因为PBCH是物理广播信道&#xff0c;BCCH是用于广播系统控制信息的下行信道&#…

vulfocus靶场搭建

vulfocus靶场搭建 什么是vulfocus搭建教程靶场配置场景靶场编排靶场优化 什么是vulfocus Vulfocus 是一个漏洞集成平台&#xff0c;将漏洞环境 docker 镜像&#xff0c;放入即可使用&#xff0c;开箱即用&#xff0c;我们可以通过搭建该靶场&#xff0c;简单方便地复现一些框架…

数据库专题——分库分表

一. 分库分表介绍二. 分库分表实践 一. 分库分表介绍 1.1 分库分表解决了什么问题 先说分库&#xff1a; 《高性能MySQL》中提到了两种数据库扩展方式&#xff1a;垂直扩展和水平扩展。前者意味着买更多性能强悍的硬件&#xff0c;但是总会达到扩展的天花板&#xff0c;且成本…

​LeetCode解法汇总105. 从前序与中序遍历序列构造二叉树

目录链接&#xff1a; 力扣编程题-解法汇总_分享记录-CSDN博客 GitHub同步刷题项目&#xff1a; https://github.com/September26/java-algorithms 原题链接&#xff1a;力扣&#xff08;LeetCode&#xff09;官网 - 全球极客挚爱的技术成长平台 描述&#xff1a; 给定两个整…

js 多对象去重(多属性去重)

需求中发现后端可能没有处理重复数据&#xff0c;这个时候前段可以直接解决。 在 JavaScript 中&#xff0c;可以使用 Set 数据结构来进行多对象的去重。Set 是 ES6 新引入的集合类型&#xff0c;其特点是元素不会重复且无序。 下面是一个示例代码&#xff0c;展示如何通过 S…

python基础教程—总结篇

这篇是Python基础教程系列的总结篇&#xff0c;这里这个专栏的地址&#xff1a;《Python教程》 首先必须声明一点&#xff0c;这是基础教程&#xff0c;所以面向的是新人&#xff0c;没有学过Python的同学&#xff0c;所以这套教程并没有涉及到比较难的并发编程模块&#xff0…

【Git教程】(二)入门 ——关于工作区与版本库、版本提交、查看信息、克隆、推送与拉回的简单介绍 ~

Git教程 入门 1️⃣ 准备Git 环境1.1 创建版本库1.2 首次提交1.3 显示历史 2️⃣ Git 的协作功能2.1 克隆版本库2.2 从另一版本库中获取修改2.3 从任意版本库中取回修改2.4 创建共享版本库2.5 用 push 命令上载修改2.6 Pull 命令&#xff1a;取回修改 &#x1f33e; 总结 如果…

GitHub使用记录

1.创建仓库 2.删除仓库 翻到最下面 3.将本地文件同步到云端库上 Github——git本地仓库建立与远程连接&#xff08;最详细清晰版本&#xff01;附简化步骤与常见错误&#xff09;_将本地仓库与远程仓库关联-CSDN博客 第三步参考&#xff1a;Github——git本地仓库建立与远程连…

聚道云软件连接器:高科技企业财务自动化,提升效率准确性!

客户介绍&#xff1a; 某互联信息技术有限公司是一家专业从事信息技术服务的高科技企业&#xff0c;在业内享有较高的知名度和影响力。近年来&#xff0c;公司业务快速发展&#xff0c;对信息化建设提出了更高的要求。 客户痛点&#xff1a; 在传统情况下&#xff0c;该公司的…

XML Map 端口进阶篇——常用关键字和格式化器详解

XML Map 端口是用于在不同XML之间建立关系映射的工具&#xff0c;允许通过拖拽操作实现源XML和目标 XML之间的数据字段映射&#xff0c;除此之外&#xff0c;XML Map 端口还提供了其它丰富多彩的功能&#xff0c;使用户能够更加灵活和高效的处理XML 数据映射任务&#xff0c;让…

Ubuntu20.04安装Carla0.9.15

文章目录 环境要求下载Carla解压Carla运行Carla测试官方用例创建python环境安装依赖包案例&#xff1a;生成车辆案例&#xff1a;测试自动驾驶 参考链接 环境要求 系统配置要求&#xff1a; 至少3G显存的GPU&#xff0c;推荐3060及以上的显卡进行Carla拟真。预留足够的硬盘空…