一命通关动态规划dp


前言

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


动态规划

何为动态规划?

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

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

在高中,我们解决数列的问题往往是想办法凑出数列的通项公式,然后直接代入具体的某个数值求解。但是动态规划更暴力,假如我们想求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)


总结

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


相关推荐

  1. DP动态规划(下)

    2024-02-21 20:26:01       8 阅读
  2. LeetCode 每日题 Day 7(dp动态规划)

    2024-02-21 20:26:01       40 阅读
  3. dp动态规划的基本

    2024-02-21 20:26:01       23 阅读

最近更新

  1. TCP协议是安全的吗?

    2024-02-21 20:26:01       18 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-02-21 20:26:01       19 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-02-21 20:26:01       18 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-02-21 20:26:01       20 阅读

热门阅读

  1. QT day2

    QT day2

    2024-02-21 20:26:01      30 阅读
  2. linux 创建全局快捷方式

    2024-02-21 20:26:01       29 阅读
  3. gin源码实战 day2

    2024-02-21 20:26:01       25 阅读
  4. 【LeetCode-139】单词拆分(回溯&动归)

    2024-02-21 20:26:01       30 阅读
  5. vue实现滚动加载

    2024-02-21 20:26:01       30 阅读
  6. 比较两个文本文件是否相等(C语言)

    2024-02-21 20:26:01       25 阅读
  7. MySQL单表查询

    2024-02-21 20:26:01       29 阅读