从暴力递归到动态规划
1 从暴力递归到动态规划概述
百度整理
动态规划是从暴力递归中来的,两者相辅相成,离开暴力递归去谈动态规划是不可能的。这是因为,动态规划的一定会牵涉到递归的思维。比如斐波那契数组问题,我们首先想到的肯定是从后往前推导。而不是直接就开始动态规划,并且动态规划的base_case正好是递归的base_case。 动态规划本质上可以看做是对暴力递归的一种优化,内含着递归。
为什么要从暴力递归转化到动态 规划呢,原因就是这类问题往往存在很多重叠子问题,暴力穷举的效率会十分低下。所以需要“备忘录”或者“DP数组”来优化穷举问题,避免计算重复问题。
一个很重要的关系就是:暴力递归的递归函数和动态规划的dp数组的含义是一致的。明白这个很重要。以斐波那契数组为例,递归函数int fib(int n)完成的任务就是求n的斐波那契数,而这正是dp[n]数组的含义。
另一个很重要的关系就是:递归逻辑实质上就是状态转移方程的由来。
2 例子
例一:爬楼梯问题T70
1)法一:暴力递归法(从顶向下)
所有的递归都可以通过多叉树来进行分析!
分析:
- 递归的方式:f(n)=f(n-1)+f(n-2)
- base_case:n=1或者是n=2,也就是递归到了最深处
public:
int climbStairs(int n)
{
return dp(n);
}
int dp(int n)
{
int total;
if(n==1 ||n==2)
total=n;
else
total=dp(n-1)+dp(n-2);
return total;
}
};
2)法二:带备忘录的递归解法
这个方法是对暴力递归的一种优化。和动态规划本质上是一样的,唯一不同的点在于这个方法是自顶向下。
class Solution {
public:
int climbStairs(int n)
{
vector<int> vec(n+1);
return dp(vec,n);
}
int dp(vector<int>& vec,int n)//如果是按照复制传递参数就会超时,引用则不会超时。很多题目都是这样,超时的时候一定要检查这个地方能不能用引用。
{
if(n==1 ||n==2)
return n;
if(vec[n]!=0) return vec[n];
vec[n]=dp(vec,n-1)+dp(vec,n-2);
return vec[n];
}
};
3)法三:动态规划(用dp数组来自底向上优化暴力递归)
自底向上法
状态转移方程(来自于暴力递归):
状态转移方程的由来实际上就是暴力递归的递归过程。
class Solution {
public:
int climbStairs(int n)
{
vector<int> dp(n+1);
dp[0]=dp[1]=1;
for(int i=2;i<=n;i++)
{
dp[i]=dp[i-1]+dp[i-2];
}
return dp[n];
}
};
4)法四:动态规划的状态压缩法
例二 :不同路径T62
1)暴力递归
递归的思路十分简单,某一个格子的路径总数一定等于他上面格子的路径加左面格子的路径。按照这个方法递归下去即可。
class Solution {
public:
int uniquePaths(int m, int n)
{
if(m==1) return 1;
if(n==1) return 1;
int res=uniquePaths(m-1,n)+uniquePaths(m,n-1);
return res;
}
};
2)用备忘录优化暴力递归
用一个数组优化暴力递归。本质是和动态规划是一致的,唯一的区别就是这里是自顶向下。。。
//法二:递归使用备忘录优化
class Solution {
public:
int uniquePaths(int m, int n)
{
vector<vector<int>> vec(m,vector<int> (n,0));
int res=dp(vec,m,n);
return res;
}
int dp(vector<vector<int>> vec,int m,int n )
{
if(n==1|| m==1) return 1;
if(vec[m-1][n-1]!=0) return vec[m-1][n-1];
vec[m-1][n-1]=dp(vec,m-1,n)+dp(vec,m,n-1);
return vec[m-1][n-1];
}
};
3)动态规划(用dp数组自底向上优化暴力递归)
区别于备忘录的一个特点是:自底向上。
状态转移方程(来自与暴力递归):
//法三:动态规划
class Solution {
public:
int uniquePaths(int m, int n)
{
vector<vector<int>> dp(m,vector<int> (n,0));
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=i;j<n;j++)
{
dp[i][j]=dp[i-1][j]+dp[i][j-1];
}
}
return dp[m-1][n-1];
}
};
例三:不同路径II -T63
1)法一:暴力递归
//法一:暴力递归:提交后超出时间限制
class Solution1 {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid)
{
int m=obstacleGrid.size();
int n=obstacleGrid[0].size();
return dp(obstacleGrid,m-1,n-1);
}
int dp(vector<vector<int>> &Grid,int m,int n)
{
//base_case
if(Grid[m][n]==1) return 0;
if(m==0 && n==0) return 1;
if(m==0) return dp(Grid,m,n-1);
if(n==0) return dp(Grid,m-1,n);
//递归逻辑
int res=dp(Grid,m-1,n)+dp(Grid,m,n-1);
return res;
}
};
2)法二:备忘录优化暴力递归
//法二:备忘录优化暴力递归
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid)
{
int m=obstacleGrid.size();
int n=obstacleGrid[0].size();
vector<vector<int>> dp(m,vector<int>(n,0));
return dp_loop(obstacleGrid,dp,m-1,n-1);
}
int dp_loop(vector<vector<int>>& Grid,vector<vector<int>>& dp,int m,int n)
{
//base_case
if(Grid[m][n]==1) return 0;
if(m==0 && n==0) return 1;
if(m==0) return dp_loop(Grid,dp,m,n-1);
if(n==0) return dp_loop(Grid,dp,m-1,n);
if(dp[m][n]!=0) return dp[m][n];
dp[m][n]=dp_loop(Grid,dp,m-1,n)+dp_loop(Grid,dp,m,n-1);
return dp[m][n];
}
};
3)法三:动态规划(dp数组自底向上优化暴力递归)
//法三:动态规划
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid)
{
int m=obstacleGrid.size();
int n=obstacleGrid[0].size();
vector<vector<int>> dp(m,vector<int>(n,0));
for(int i=0;i<m && obstacleGrid[i][0]==0;i++) dp[i][0]=1;
for(int j=0;j<n && obstacleGrid[0][j]==0;j++) dp[0][j]=1;
for(int i=1;i<m;i++)
{
for(int j=1;j<n;j++)
{
if(obstacleGrid[i][j]==1)
{
dp[i][j]==0;
}
else
dp[i][j]=dp[i-1][j]+dp[i][j-1];
}
}
return dp[m-1][n-1];
}
};
例四:整数拆分
这个例子,说明了几点:
- 暴力递归的递归逻辑实质上就是状态转移方程的由来。
- 动态规划中一定是有至少一层循环的,因为通过这个循环进行dp数组的填充。但是如果状态转移方程(也可以说是递归逻辑的设计)是通过循环实现的,那么动态规划里就是两层循环。
1 ) 暴力递归法
比较简单,但是运行会超时
//法一;暴力递归法
class Solution {
public:
int integerBreak(int n)
{
if(n==1) return 1;//base-case
int res=0;
for(int i=1;i<n;i++)
{
res=max(res,max(integerBreak(n-i)*i,i*(n-i)));//递归逻辑--(状态转移方程的由来)
}
return res;
}
};
2) 使用备忘录优化暴力递归
//法二:使用备忘录优化暴力递归
class Solution {
public:
int integerBreak(int n)
{
vector<int > dp(n+1);
return dp_digui(dp,n);
}
int dp_digui(vector<int> & dp,int n)
{
if(n==1) return 1;
if(dp[n]!=0) return dp[n];
int res=0;
for(int i=1;i<=n;i++)
{
res=max(res,max(dp_digui(dp,n-i)*i,i*(n-i)));
}
dp[n]=res;
return dp[n];
}
};
3) 动态规划
状态转移方程:是用循环表达的状态转移方程
//法二:动态规划
class Solution {
public:
int integerBreak(int n)
{
vector<int> dp(n+1);
dp[1]=1;
for(int i=2;i<=n;i++)
{
for(int j=1;j<i;j++)
{
dp[i]=max(dp[i],max(j*(i-j),dp[i-j]*j));
}
}
return dp[n];
}
};
例五:不同的二叉搜索树
通过这个例子,要说明的是:
- 递归表达式是可以推导的。
- 暴力递归的递归表达式就是动态规划里面的状态转移公式。
题目解析:
这个没题目最重要的其实是递归公式的推导,这个推导过程代表了很多题目递归过程的分析。要把这个过程搞熟悉了。
得到了递归表达式,也就得到了动态规划的状态转移方程。这两个东西实质上是一回事~
1) 法一:暴力递归(运行会超时)
//法一:暴力递归:提交后会超出时间限制
class Solution {
public:
int numTrees(int n)
{
if(n==0 || n==1) return 1;
int res=0;
for(int i=1;i<=n;i++)
{
res=res+numTrees(i-1)*numTrees(n-i);
}
return res;
}
};
2) 用备忘录优化暴力递归
//法二:暴力递归的备忘录优化
class Solution {
public:
int numTrees(int n)
{
vector<int> dp(n+1);
return dp_loop(dp,n);
}
int dp_loop(vector<int> & dp,int n)
{
if(n==0 ||n==1)return 1;
if(dp[n]!=0) return dp[n];
for(int i=1;i<=n;i++)
{
dp[n]+=dp_loop(dp,i-1)*dp_loop(dp,n-i);
}
return dp[n];
}
};
3)动态规划
class Solution {
public:
int numTrees(int n)
{
vector<int> dp(n+1);
dp[0]=dp[1]=1;
for(int i=2;i<=n;i++)
{
for(int j=1;j<=i;j++)
{
dp[i]=dp[i]+dp[j-1]*dp[i-j];
}
}
return dp[n];
}
};
3 补充:关于贪心和动态规划的区别
动态规划本质上是暴力递归,也就是穷举法,只不过这个穷举可以分解为若干个子问题来解决。
而贪心算法是有某种策略的,也就是在每一步的选择上都是有依据的,从而每一步都是朝着正确的方向前进。
4 关于01 背包问题
背包问题是本质上就是二维动态规划问题。与其他的二维dp别无二致。而且他还是一个可以用一维dp数组优化的二维dp问题。
与上面说的一样,递归解法中有一层循环,动态规划解法中有两层循环。
这两层循环本质上是两个状态。
4.1 从递归到动态规划
递归
递归的方式很多,上面所说的有一层循环的递归解法见其他的资料,这里给出一种其他的递归思路:
首先,我们要定义一个函数B(K,W),它的意思是,从下标为0-K的物品中选择,背包承重为W的情况下,能得到的最大总价值。
比如说:B(3,11),背包容量为11,在下标为0-3的物品中取物品放入背包,得到的最大价值。
这个B(K,W)怎么得到的呢?
首先,我们分析,在从第0件物品遍历到第K件物品,在放第K件物品的时候,我们就只有两种选择,将这个物品放入还是不放入背包。注意,这里放第K件物品的时候,0~K-1标号的物品我们已经选择完毕。
- 当不将第K件物品放入背包的时候,也就是背包的容量放不下的时候或者是我们就是不想放进去的时候,我们得到的总价值实际上就是B(K-1,W)。
- 当我们选择将第K件物品放入背包的时候,我们得到的总价值是B(K-1,W-weight[K])+value[K]。
所以能得到的递归公式是:B( K , W ) = max(B( K-1, W ),B( K-1 , W-weight[K] + value[K] ) );
下面是代码实现:
#include<iostream>
#include<windows.h>//max函数
using namespace std;
#define N 3
int W[N] = { 1,3,4};//为了让下标与藏品数量一致,我们在这里填充一个0
int V[N] = { 15,20,30};
int sumWeight = 20;
int totalNumber = 2;//0~2三件物品
//递归函数,总结
/*1.递归的终点就是对所有物品都进行了放还是不放的选择
2.每拿一次分为放或者不放,放不下三种情况
3.每一次递归返回较大值达到总价值的最大累积*/
int recursion(int index, int remainWeight) {//将0~index的物品进行选择放或不放得到的最大价值
int result = 0;//定义该次递归结果 //remainWeight 背包剩余承重量
if (index < 0) return 0;//base_case
if (W[index] > remainWeight)
result = recursion(index - 1, remainWeight);//放不下该物品,跳过,对下一件物品递归
else //放得下,对放或不放两种情况产生的不同总价值进行比较,取较大的返回递归
result = max(recursion(index - 1, remainWeight - W[index]) + V[index], recursion(index - 1, remainWeight));
return result;
}
int main() {
int value = recursion(totalNumber, sumWeight);
cout << value << endl;
return 0;
}
4.2 动态规划----1不使用辅助行和辅助列的二维动态规划
1、首先确定dp数组以及下标的含义
背包问题实质上是一个二维DP问题,这里使用dp[i][j],他的含义是:从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
2、 确定递推公式
和上面的递归公式是一样的。(这里要区分递归公式和状态转移方程,。。状态转移方程可以看做是递归公式加base_case,也就是说,状态转移方程是有初始条件的。)
3、base_case的确定(初始化dp数组)----最关键的一步,要考虑周到
这是一个二维的DP问题,从递推公式中可以看出,在最底层,base_case一定是dp[ i ][ 0 ] 和 dp[ 0 ][ j ]。下面来确定这两个值–也就是第一行和第一列。首先,来确定dp[ i ][ 0 ],也就是将物品放入容量为0的背包中,很明显,得到的最大价值是0;然后是dp[ 0 ][ j ],也就是将标号0之前的物品(也就是编号0的物品)放入容量为j的背包中得到的最大价值是多少,显而易见,这个值的确定和第0个物品的重量以及第j个背包的容量有关。那么很明显当 j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。当j >= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。
dp数组的初始化如下图所示,这里的15是第0个物品的价值,第0个物品的重量为1。
(注:这里base_case实际上只是第一行,第一列的初始化可有可无!)
实际上,题目中图省事可以按照上面初始化,实际上,从递归公式中可以看出dp[i][j]只是依赖于前一行,与前一列是没有关系的。所以初始化的时候完全可以只初始化第一行。
4、遍历顺序
先遍历物品比较好理解。
动态规划的cpp代码如下所示:
void test_2_wei_bag_problem1() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
// 二维数组
vector<vector<int>> dp(weight.size(), vector<int>(bagWeight + 1, 0));
// 初始化--base_case。
for (int j = weight[0]; j <= bagWeight; j++) {
dp[0][j] = value[0];
}
// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品--物品0是当做base_case,所以从1开始遍历
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
cout << dp[weight.size() - 1][bagWeight] << endl;
}
int main() {
test_2_wei_bag_problem1();
}
4.3 动态规划—2 使用辅助行和辅助列的二维动态规划
所谓的辅助行和辅助列,就是在二维dp数组上各加一行和一列,使其初始化更为简单。但是理解起来,可能有点难度。
对于经典的01背包问题,实际上使用不到辅助列,因为背包容量为0的时候我们在上面已经考虑到了。
辅助行就是:当我们什么都不选的时候,背包容量分别为0~bagweight的时候装的下的最大的价值。
这个时候,dp[i][j]的含义也要变了,这个时候代表的就是从下标为0~i-1的物品中取,背包容量为j所能取到的最大价值。
所以在遍历的时候,要特别注意这一点变化。
这个时候,base_case变得简单了,虽然base_case与上面不使用辅助行的时候是一样的(都是第一行和第一列),但是从他的含义来看,都是0,所以执行默认初始化即可。
下面是cpp代码:
//二维dp---使用辅助行和辅助列--这里只使用辅助行就行了
class Solution
{
public:
int test_01bag(vector<int> weight,vector<int> value,int bagweight)
{
int n=weight.size();
//dp[i][j]代表的是从下标0~i-1的物品中选,容量为j的背包所能装的下的最大价值
vector<vector<int>>dp(n+1,vector<int>(bagweight+1));
//base_case:默认初始化即可,因为都是0
for(int i=1;i<=n;i++)
{
for(int j=0;j<=bagweight;j++)
{
if(j>=weight[i-1])
dp[i][j]=max(dp[i-1][j],dp[i-1][j-weight[i-1]]+value[i-1]);
else
dp[i][j]=dp[i-1][j];
}
}
return dp[n][bagweight];
}
};
4.4 01背包问题的优化—使用一维dp数组优化(详细 的分析见下面的例1)
上面提到过,从背包问题的递推公式中可以看出dp[ i] [ j ]只是依赖于上一行,也就是仅仅是依赖于dp[i-1]。所以可以按照普通的动态规划那样的状态压缩,将空间复杂度进一步降低。
这里我们仅仅使用一个一维数组表示dp数组。也就是使用dp[j],来表示容量为j的背包所能容纳的最大价值。
(先写出基础版的动态规划,再在上面进行修改!,不要直接用一维数组进行分析,会很抽象)
(这个方法完全是由不使用辅助行和辅助列的动态规划方法得到的。不管是dp[j]的含义,还是递归表达式,都与不使用辅助行的动态规划法是一模一样的。所以要进行匹配好,不要匹配到使用辅助行的那个方法上了)
1、确定dp数组和下标的含义(详细的见下面例1的分析。)
在一维dp数组中,dp[j]表示:容量为j的背包,(在0~i中选物品)所背的物品价值可以最大为dp[j]。实际上是和未优化版的是一致的。
2、确定递推公式
递归公式的确定是关键,我们可以通过上面的二维公式将一维的递推公式确定下来。
所以递归公式就是:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
3、base_case的确定
这时的base_case只有一个,那就是当什么物品都不选的时候,背包所能装的最大价值!这有点类似于上面使用了辅助行的二维dp数组的第一行!显而易见,dp[0]代表的就是什么物品都不选,背包容量为0的情况下所能装的最大的价值,很明显,dp[0]=0;同理,当j=1~j的时候,dp[j]也是0。
需要注意的是。就像未优化版的动态规划要初始化二维dp的第一行一样,优化版的动态规划也要初始化dp[ j ]数组的所有的内容,因为后面要在这个基础上进行迭代更新!(见例1的分析)
4、dp数组的遍历顺序
规定先遍历物品,后遍历背包容量。
并且物品的遍历是从0开始的,这是与二维dp不一样的地方。因为二维dp的第0个物品是当做了base_case(不使用辅助行时候)。
而这里的base_case是什么物品都不选的时候,背包所能装的最大价值。所以物品就是从物品0开始遍历。
二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。
下面给出CPP代码:
void test_1_wei_bag_problem() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
// 初始化
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品--这里就从0开始了
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_1_wei_bag_problem();
}
5 01背包问题的例子
首先需要注意的是:背包问题,不像之前的那些动态规划的问题一样,他不需要从暴力递归开始考虑,如果判断出一个问题是背包问题,那么直接从动态规划本身去考虑即可。下面给出的暴力递归的解法只是为了证明能够这样做而已。但是背包问题本质上也是动态规划问题,所以在判断一个问题是否是动态规划要使用暴力递归的思维。所以背包问题要不要使用暴力递归的思维是一个辩证的问题。
5.1 T 416分割等和子集
这道题可以换一种表述:给定一个只包含正整数的非空数组 nums,判断是否可以从数组中选出一些数字,使得这些数字的和等于整个数组的元素和的一半。因此这个问题可以转换成「0-1 背包问题」。这道题与传统的「0−1 背包问题」的区别在于,传统的「0−1 背包问题」要求选取的物品的重量之和不能超过背包的总容量,这道题则要求选取的数字的和恰好等于整个数组的元素和的一半。类似于传统的「0−1 背包问题」,可以使用动态规划求解。
1)法一:暴力递归
这里使用的是不使用循环的方式,完全可以使用一个循环进行递归。
这里递归函数的含义是:在0~index中选取一些数字,相加的和能否得到target。也就是说,对某一个数字,就两种选择,选取还是不选。关于递归公式的推导见下面动态规划解法。
//递归函数,总结
/*1.递归的终点,也就是bse_case,就是index=0,或者是target=0的时候。
2.每拿一次分为放或者不放两种情况
3.每一次递归返回的是0~index之间选择树,相加能不能到target
*/
int recursion(vector<int >& nums,int index, int target) {
int result = 0;//初始化为该次递归结果0=false
//base_case
if (target==0) return 1;
if(index==0)
{
if(index==target) return 1;
else return 0;
}
if (target>=nums[index])
result = recursion(nums,index - 1, target) ||recursion(nums,index - 1, target-nums[index]);
else if(target<nums[index])
result=recursion(nums,index - 1, target);
return result;
}
int main() {
vector<int> nums{1,2,3,5};
int n = nums.size();
if (n < 2) {
return false;
}
int sum = accumulate(nums.begin(), nums.end(), 0);
int maxNum = *max_element(nums.begin(), nums.end());
if (sum & 1) {
return false;
}
int target = sum / 2;
if (maxNum > target) {
return false;
}
int value = recursion(nums,n-1, target);
cout << value << endl;
return 0;
}
2)法二:动态规划–不使用辅助行和辅助列
1、首先要明确的是dp数组以及下标的含义
这里使用经典的二维数组来描述dp数组。dp[i][j]代表的是从下标为0~i中选取物品,能否实现选取的数字的和为j。
2、确定递归公式
对于从0~i中选取数字,选择无非就两种,选或者不选。我们想的是如何通过dp数组中的i-1和j-1推出dp[i][j]。这里分两种情况:
- 如果j<nums[i],也就是说,要实现累加和为j,但是当前的数字nums[i]要比这个j要大,所以为了尽量相加得到j,所以我们一定是不选nums[i]的。也就是dp[i][j]=dp[i-1][j]。
- 如果j>=nums[i],则对于当前的数字nums[i],可以选取也可以不选取,两种情况只要有一个为true,就有dp[i][j]=true。如果不选取 nums[i],则 dp[i][j]=dp[i−1][j];如果选取 nums[i],则 dp[i][j]=dp[i−1][j−nums[i]]
3、确定base_case
base_case的确定要根据递归公式来确定。看递归的尽头是什么,这也正是递归函数中base_case的确定方法。
从递归公式可以看出,dp[ i ][ j ]仅仅是依赖于dp[ i-1 ],严格的说,仅仅是依赖于dp二维数组中dp[ i ][ j ]的左上方的位置。所以base_case就是dp[ 0 ][ j ],也就是dp二维数组的第一行!
而第一行的含义是:从下标为0之前的位置取数字,能不能得到和为j。也就是说,只考虑nums[0],选择或者不选择这个数能不能得到 j。很明显这取决于nums[0]。
所以base_case如下所示:
dp[0][nums[0]] = true;
dp[0][0]=true;
当然,如果考虑的更全面一些,或者说为了省事,完全可以也把第一列初始化,也当做base_case。
for (int i = 0; i < n; i++)
{
dp[i][0] = true;
}
dp[0][nums[0]] = true;
4、确定遍历顺序与遍历的开始下标
当选择只把第一行当做base_case的时候,i的遍历是从1开始,j的遍历是从0开始。
当选择将第一行和第一列同时作为base_case的时候,i的遍历是从1开始,j的遍历是从1开始。
下面是C++代码:
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = nums.size();
if (n < 2) {
return false;
}
int sum = accumulate(nums.begin(), nums.end(), 0);
int maxNum = *max_element(nums.begin(), nums.end());
if (sum & 1) {
return false;
}
int target = sum / 2;
if (maxNum > target) {
return false;
}
vector<vector<int>> dp(n, vector<int>(target + 1, 0));
//base_case,将第一行和第一列座位base-case
for (int i = 0; i < n; i++) {
dp[i][0] = true;
}
dp[0][nums[0]] = true;
//下面是只将第一行作为base_case的代码
//dp[0][nums[0]] = true;
//dp[0][0]=true;
for (int i = 1; i < n; i++) {//先遍历物品,后遍历背包容量
int num = nums[i];
for (int j = 1; j <= target; j++) {
if (j >= num) {
dp[i][j] = dp[i - 1][j] | dp[i - 1][j - num];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
/*只将第一行作为base_case,那么j就从0开始遍历
for (int i = 1; i < n; i++) {//先遍历物品,后遍历背包容量
int num = nums[i];
for (int j = 0; j <= target; j++) {
if (j >= num) {
dp[i][j] = dp[i - 1][j] | dp[i - 1][j - num];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
*/
return dp[n - 1][target];
}
};
3)动态规划—使用辅助行和辅助列
天然有辅助列,只需补充辅助行即可。
1、确定dp[i][j]的含义:从下标为0~i-1的物品中选,能否得到和为j。
所以最终我们的目标就是求dp[n][target]
2、确定递推公式
和上面的一样,只不过将nums[i]换成nums[i-1]。
对于从0~i-1中选取数字,选择无非就两种,选或者不选nums[i-1]。我们想的是如何通过dp数组中的i-1和j-1推出dp[i][j]。这里分两种情况:
- 如果j<nums[i-1],也就是说,要实现累加和为j,但是当前的数字nums[i]要比这个j要大,所以为了尽量相加得到j,所以我们一定是不选nums[i]的。也就是dp[i][j]=dp[i-1][j]。
- 如果j>=nums[i-1],则对于当前的数字nums[i-1],可以选取也可以不选取,两种情况只要有一个为true,就有dp[i][j]=true。如果不选取 nums[i-1],则 dp[i][j]=dp[i−1][j];如果选取 nums[i-1],则 dp[i][j]=dp[i−1][j−nums[i]]
3、确定base_case
就是第一行,(完全也可以同时将第一列初始化)。第一行的意义是:什么都不选的时候,能否凑出0~j。很明显,只有当j==0的时候,可以凑出。所以base_case就是dp[0][0]=1,其余都是0。
4、确定遍历顺序。
第一行作为了base_case,所以物品从第二行开始遍历
如果将第一列也作为base_case,那么容量也从第二列开始遍历,否则从第一列开始。
//法三:动态规划---使用辅助行和辅助列
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = nums.size();
if (n < 2) {
return false;
}
int sum = accumulate(nums.begin(), nums.end(), 0);
int maxNum = *max_element(nums.begin(), nums.end());
if (sum & 1) {
return false;
}
int target = sum / 2;
if (maxNum > target) {
return false;
}
vector<vector<int>> dp(n+1, vector<int>(target + 1, 0));
//base_case
dp[0][0]=1;
for (int i = 1; i <=n; i++)
{//先遍历物品,后遍历背包容量
for (int j = 0; j <= target; j++)
{
if (j >= nums[i-1]) {
dp[i][j] = dp[i - 1][j] | dp[i - 1][j - nums[i-1]];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[n][target];
}
};
4)法四:动态规划的优化 ---- 使用一维dp数组
优化的前提是二维dp的递归公式中,每一行dp[i][j]的状态只与上一行有关。这个题目满足这个条件,所以可以进行优化。
当时用优化方法的时候,一定要先写出上面的基本版本的动态规划!再上面的基础 上改成优化版本。否则要是直接写的话,理解上会有一些困难。
改的时候,注意下面几点就可以了:
1、dp数组本质上就是去除i维度,只留下j维度。并且要明白dp数组和下标的含义。这里dp数组代表的是(从0~i中选)能否实现数字的和为j。实质上是和基础版的一样的。
我们初始化这个dp的数组实际上初始化的是,我们什么物品都不选,能否实现数字的和为0~j!!
要明白的是,优化版本的动态规划,也要进行物品的遍历。过程是,先选下标为0的物品,然后遍历0~j,看看是否能得到和为j。再选物品0和物品1,然后再遍历0 ~j,看看能否得到和为j。以此进行下去。
不像未优化版本的动态规划,那里我们是将物品0,背包容量0 ~ j这一行作为了base-case。而这里,我们是从物品0开始遍历的,所以我们的base_case就是,什么物品也不选,背包容量0 ~ j的状态,这也正是循环开始前的dp数组的含义。
通常要特别注意dp[0],以这个题目为例,什么都不选的时候,可以得到和为0,其他的1 ~j都是得不到的,所以这里的base-case就是:dp[0]=1,dp[1] ~dp[j]都是0。
注意:但循环开始前后,dp数组的含义是不一样的。循环开始前,dp数组代表的什么物品也不选,能否得到和为j。而循环开始后,dp数组代表的是,从0~i中取物品,能否得到和为j。所以可知,基础版的动态规划中,不管是对于i还是对于j的遍历,每次遍历都会对应dp[][]二维数组 中的一个值。而优化版的动态规划中,每次遍历一个物品i,都是对之前值的一个更新,换句话说dp[j]是不断的更新迭代的,每次遍历到一个新的i,dp[j]的值都要更新一次,dp[j]的含义也要变化一次。
2、关于递归公式,还是和基础版的一样。
3、关于base_case,要仔细考虑,特别要重视dp[0]。要明确,我们的base_case的含义就是,什么物品都不选的情况下,能否得到和分别为0 ~j。
base——case要将dp数组中的元素全部初始化一遍!因为后边从0开始遍历物品的时候,要在这个的基础上进行迭代!所以在循环之前要将dp[]中的所有元素都要初始化!
4、关于循环的方式,先遍历物品,后遍历背包容量。并且背包的遍历从后往前开始
下面试代码:
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = nums.size();
if (n < 2) {
return false;
}
int sum = accumulate(nums.begin(), nums.end(), 0);
int maxNum = *max_element(nums.begin(), nums.end());
if (sum & 1) {
return false;
}
int target = sum / 2;
if (maxNum > target) {
return false;
}
vector<int> dp(target + 1);
//base_case只需初始化dp[0]
dp[0]=1;
for (int i = 0; i < n; i++) {//先遍历物品,后遍历背包容量
for (int j = target; j >=0;j--)
{
if (j >= nums[i]) {
dp[j] = dp[j] || dp[j - nums[i]];
} else {
dp[j] = dp[j];
}
}
/*简化写法
for (int j = target; j >=nums[i];j--)
{
dp[j] = dp[j] || dp[j - nums[i]];
}
*/
}
return dp[target];
}
};
5.2 T494目标和
题目怎么转化为背包问题这里不多做解释。
按照上面的总结,一旦判断是背包问题,不再从暴力递归着手,而是直接考虑动态规划。但是背包问题也属于动态规划,所以在判断是否是动态规划问题的时候,是需要暴力递归思维的。这是一个辩证的关系。
1)法一暴力递归(这里省略)
2)法二:动态规划
1、首先确定dp[ i ][ j ]数组和下标的含义
这里的dp数组代表的是从给定的nums前i个数中任意的取数并相加,能得到的和为j的方案的个数。
2、确定递归公式
按照01背包的规律,很容易得到递归公式。对于当前的数字nums[i],选择就两种,选择还是不选择。不选的时候得到的方案树是dp[ i ][ j ]=dp[i-1][j],不选的时候方案数数dp[i-1][j-nums[i]]。将这两个相加就是前i个数中得到j的方案数。考虑到有一个j-nums[i]再分类讨论一下即可。
3、确定base_case
这里十分需要说明的是,从递归公式中可以看出,dp[ i ][ j ]实际上只依赖于dp[ i-1 ],再严格的来讲,只是依赖于二维表格中,dp[ i ][ j ]的左上方位置。因此base_case只需确定dp[ 0 ][ j ]即可,也就是第一行。
这个题目存在一个陷阱,就是当nums[0]为0的时候,对于dp[0][j]的确定。以dp[0][0]为例,要想获得和为0,实际上有两种选择! 一种是选择nums[0],一种是不选择nums[0]。
另外需要注意的是,若想图省事,同时将第一列作为base_case是很困难的,因为要考虑nums中i之前的所有数字,所以这里只给出将第一行作为base_case的解法。
4、确定遍历顺序和遍历起始的位置
还是先遍历物品,因为这里是将第一行作为了base_case,所,j的遍历就是从0开始遍历。
下面是CPP代码
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target)
{
//向背包问题进行转化
int sum=accumulate(nums.begin(),nums.end(),0);
int neg=(sum-target);
if(neg<0 ||neg%2!=0)
{
return 0;
}
int n=nums.size();
int dif=neg/2;
vector<vector<int>> dp(n,vector<int>(dif+1));//dp数组
//base_case,这里要十分注意,当nums[0]是0的时候,要从前0个数中取,得到结果j为0的方案有两种
//一种是取,一种是不取!!这是这个题的陷阱
if(nums[0]==0) dp[0][0]=2;
else
{
dp[0][0]=1;
}
for(int k=1;k<=dif;k++)
{
if(nums[0]==k) dp[0][k]=1;
}
for(int i=1;i<n;i++)//本质上只是从上一行中取信息!取的信息都是来自于左上方。
{
for(int j=0;j<=dif;j++)
{
if(j>=nums[i])
{
dp[i][j]=dp[i-1][j]+dp[i-1][j-nums[i]];
}
if(j<nums[i])
{
dp[i][j]=dp[i-1][j];
}
}
}
return dp[n-1][dif];
}
};
3)动态规划–使用辅助行和辅助列
1、确定dp[i][j]和下标的含义。
代表的是从下标0~i-1中选,能得到和为j的方案的个数。
2、确定递推公式
类似于经典的背包问题。对于当前的数字nums[i-1],选择就两种,选择还是不选择。不选的时候得到的方案树是dp[ i ][ j ]=dp[i-1][j],不选的时候方案数数dp[i-1][j-nums[i-1]。将这两个相加就是前i个数中得到j的方案数。考虑到有一个j-nums[i-1]再分类讨论一下即可。
3、确定base_case
使用辅助行就是为了能够简化base_case的初始化。这里第一行代表的是什么都不选,得到的和为0~j的方案的个数。
很明显,第一行中,只有dp[0][0]=1,其他都是0。
4、遍历顺序
与上面一致。
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target)
{
//向背包问题进行转化
int sum=accumulate(nums.begin(),nums.end(),0);
int neg=(sum-target);
if(neg<0 ||neg%2!=0)
{
return 0;
}
int n=nums.size();
int dif=neg/2;
vector<vector<int>> dp(n+1,vector<int>(dif+1));//dp数组
//base_case:什么都不选,得到的和分别为0~j的方案的个数
dp[0][0]=1;
for(int i=1;i<=n;i++)//本质上只是从上一行中取信息!取的信息都是来自于左上方。
{
for(int j=0;j<=dif;j++)
{
if(j>=nums[i-1])
{
dp[i][j]=dp[i-1][j]+dp[i-1][j-nums[i-1]];
}
if(j<nums[i-1])
{
dp[i][j]=dp[i-1][j];
}
}
}
return dp[n][dif];
}
};
3)法三:动态规划的一维dp数组优化版本
分析见上一节和例1,这里不再赘述。
CPP代码:
//法四:动态规划的优化版本
//dp[j]代表的是(前i个数中取值),得到的和为j的方案的个数。
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target)
{
//向背包问题进行转化
int sum=accumulate(nums.begin(),nums.end(),0);
int neg=(sum-target);
if(neg<0 ||neg%2!=0)
{
return 0;
}
int n=nums.size();
int dif=neg/2;
vector<int> dp(dif+1);//dp数组
//base_case,当什么数都不选的时候,相加和分别为0~dif的方案数,很明显,dp[0]=1,其他的为0;
dp[0]=1;
for(int i=0;i<n;i++)
{
for(int j=dif;j>=nums[i];j--)
{
dp[j]=dp[j]+dp[j-nums[i]];
}
}
return dp[dif];
}
};
5.3 T1049最后一块石头的重量
怎么转换为01背包问题的这里不再解释。
直接开始动态规划。
1)法一:暴力递归
略
2)法二:动态规划
1、确定dp数组和下标的含义
dp[ i] [ j ]的含义是:从下标中0~i中取任意取数,能否得到和为j。
2、确定递归公式
3、确定base_case
base_case的确定才是动态规划的最需要注意的点。在这个地方失误好多次了。
确定二维数组的第一行即可。第一行的含义是:对第0个元素作取舍能否得到和为j。很明显,这个题目的base_case和上一题一样。这里给定的stones数组中的元素不会为0,所以dp[0][0]为1,另外,满足stones[0]==j的dp[0][j]也为1。
这里需要说明的是,对于base_case,一定要单独考虑dp[0][0],这个太重要了!
4 、遍历顺序,还是先遍历物品
下面是CPP代码:
class Solution {
public:
int lastStoneWeightII(vector<int>& stones)
{
int sum=accumulate(stones.begin(),stones.end(),0);
int target=sum/2;
int n =stones.size();
vector<vector<int>> dp(n,vector<int>(target+1,0));
//base_case--dp[0][0]要额外考虑!!!!
if(stones[0]==0)
{
dp[0][0]=2;
}
else
dp[0][0]=1;
for(int k=1;k<=target;k++)
{
if(stones[0]==k)
{
dp[0][k]=1;
}
}
for(int i=1;i<n;i++)
{
for(int j=0;j<=target;j++)
{
if(j>=stones[i])
{
dp[i][j]=dp[i-1][j] || dp[i-1][j-stones[i]];
}
else if(j<stones[i])
{
dp[i][j]=dp[i-1][j];
}
}
}
for(int i=target;i>=0;i--)
{
if(dp[n-1][i]==1)
return sum-2*i;
}
return 0;
}
};
3)动态规划–使用辅助行和辅助列
这里也是自然就有辅助列,只需添加辅助行即可。
1、确定dp[i][j]和下标的含义
dp[i][j]代表的是,从下标0~i-1的物品中选,能否得到和为j。
2、确定递推公式
对于遍历到的nums[i-1],只有选和不选两种选择。这两种选择中,只要有一种能够满足条件就行了。
所以递推公式是:dp[i][j]=dp[i-1][j] || dp[i-1][j-nums[i-1]]
3、确定base_case
也就是第一行,也就是当什么都不选的时候的,能否凑得和为0~j。
很明显,只有第一行中,只有dp[0][0]=1,其他都是0。可以看出,这这个方法使base_case简单了很多。
4、遍历顺序
与上面相同
class Solution {
public:
int lastStoneWeightII(vector<int>& stones)
{
int sum=accumulate(stones.begin(),stones.end(),0);
int target=sum/2;
int n =stones.size();
vector<vector<int>> dp(n+1,vector<int>(target+1,0));
//base_case--也就是当什么都不选的时候的,能否凑得和为0~j
dp[0][0]=1;
for(int i=1;i<=n;i++)
{
for(int j=0;j<=target;j++)
{
if(j>=stones[i-1])
{
dp[i][j]=dp[i-1][j] || dp[i-1][j-stones[i-1]];
}
else if(j<stones[i-1])
{
dp[i][j]=dp[i-1][j];
}
}
}
for(int i=target;i>=0;i--)
{
if(dp[n][i]==1)
return sum-2*i;
}
return 0;
}
};
4)法四:动态规划的一维dp优化版本
分析见例1,这里直接给出代码:
//动态规划的优化版本
class Solution {
public:
int lastStoneWeightII(vector<int>& stones)
{
int sum=accumulate(stones.begin(),stones.end(),0);
int target=sum/2;
int n =stones.size();
vector<int> dp(target+1);
//base_case--初始化dp
dp[0]=1;
for(int i=0;i<n;i++)
{
for(int j=target;j>=stones[i];j--)
{
dp[j]=dp[j] || dp[j-stones[i]];
}
}
for(int i=target;i>=0;i--)
{
if(dp[i]==1)
return sum-2*i;
}
return 0;
}
};
6 完全背包问题
6.1二维dp
概述:完全背包和01背包的区别就是,完全背包问题中,物品的数量是无限的。
1、dp数组和下标的含义,dp[ i ][ j ]:表示前i件物品放入容量为v的容量中时的最大收益。
2、递推式
dp[ i ][ j ] = max(dp [ i - 1 ][ v ], dp[ i - K * weight [ i ] ] + K * Value[ i ]); 其中 1 <= K * weight[i] <= v,(v指此时背包容量),可以转化为dp[ i ][ j ] = max(dp [ i - 1 ][ v ], dp[ i ][ v - weight[ i ]] + value[ i ])
这里给出递推式的由来:
详细的推到过程见:链接: link.
总而言之,记住如下递推公式即可:
3、关于base——case
单从最后的递推公式来看,对二维dp数组来说,似乎从某个状态是从二维数组的上一行和上一列来的,也就是说,递归最后的尽头是第一行和第一列。但是,从递推公式的推导过程来看,某个状态只是受上一行影响的,也就是说,实际的递归的尽头是第一行。base_case是要根据题目的不同进行不同的编写的。
第一行的含义是:只在下标为0的物品中选,背包容量分别为0~bagweight的时候,背包所能装的最大价值。
第一列的含义是:当背包容量为0的时候,在物品的下标0~i之间选物品所能得到的最大价值。
所以我们的base_case就是第一行! 如果不够放心,也可以像01背包一样,同时将第一列初始化,只不过遍历顺序稍改一下即可。
4、遍历顺序
像01数组的遍历顺序是一模一样的,先遍历物品,后遍历背包,都是从小到大开始遍历。
如果只将二维数组的第一行作为base_case,那么物品从下标为1的开始遍历,背包容量从0开始遍历。
如果同时将第一行和第一列作为base_case,那么物品从下标为1开始遍历,背包容量从1开始遍历。
下面是二维dp动态规划的c++代码:
class solution//二维数组的dp
{
public:
int maxvalue(vector<int>& weight,vector<int>& value,int& bagweight)
{
int n=weight.size();
vector<vector<int>> dp(n,vector<int>(bagweight+1));
//base_case,只从下标为0的物品中选择,背包容量分别为0~bagweight的时候背包所装的最大价值
for(int i=0;i<=bagweight;i++)
{
dp[0][i]=value[0]*(i/weight[0]);
}
//先遍历物品,后遍历背包
for(int i=1;i<n;i++)
{
for(int j=0;j<=bagweight;j++)
{
if(j>=weight[i])
{
dp[i][j]=max(dp[i-1][j],dp[i][j-weight[i]]+value[i]);
}
else
{
dp[i][j]=dp[i-1][j];
}
}
}
/*下面为以第一行和第一列作为base——case的代码,区别只是背包从1开始遍历
for(int i=0;i<=bagweight;i++)
{
dp[0][i]=value[0]*(i/weight[0]);
}
//先遍历物品,后遍历背包
for(int i=1;i<n;i++)
{
for(int j=1;j<=bagweight;j++)
{
if(j>=weight[i])
{
dp[i][j]=max(dp[i-1][j],dp[i][j-weight[i]]+value[i]);
}
else
{
dp[i][j]=dp[i-1][j];
}
}
}
*/
return dp[n-1][bagweight];
}
};
int main()
{
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
solution slu;
cout<<"res is :"<<endl;
int res=slu.maxvalue(weight,value,bagWeight);
cout<< res<<endl;
return 0;
}
6.2 二维dp-使用辅助行和辅助列
自然就有辅助列,只需添加辅助行即可。
1、确定dp[i][j]的含义
dp[i][j]代表的是从下标0~i-1的物品中选,背包容量为j的时候的最大的价值。
2、确定递推公式
对于遍历到的nums[i-1],我们就只有两种选择–选这个还是不选这个。因为这是个完全背包问题,所以当选这个物品的时候,还有特殊性。递推方程就和上面是一样的:dp[i][j]=max(dp[i-1][j],dp[i][j-weight[i-1]]+value[i-1])。也就是将上面普通解法的weight[i]和value[i]变为weight[i-1]和weight[i-1]即可。
3、确定base_case
base_case同样也是第一行,但是在这个解法的dp数组的含义下,这个base_case代表的是什么都不选时,背包容量分别为0~j时装的最大容量。很明显,第一行的值全部都是0。和上一种解法比,可以看出这将base_case简化了不少。
4、确定遍历顺序
和6.1的遍历顺序是一样的。
//二维dp-使用辅助行和辅助列
class solution2//二维数组的dp
{
public:
int maxvalue(vector<int>& weight,vector<int>& value,int& bagweight)
{
int n=weight.size();
vector<vector<int>> dp(n+1,vector<int>(bagweight+1));
//base_case,什么都不选,背包容量为j的情况下得到的最大价值,都是0,所以默认即可
//先遍历物品,后遍历背包
for(int i=1;i<=n;i++)
{
for(int j=0;j<=bagweight;j++)
{
if(j>=weight[i-1])
{
dp[i][j]=max(dp[i-1][j],dp[i][j-weight[i-1]]+value[i-1]);
}
else
{
dp[i][j]=dp[i-1][j];
}
}
}
return dp[n][bagweight];
}
};
6.3 二维dp的优化–使用一维dp数组进行优化
上面说到过,递推公式看起来是依赖于上一行和上一列的,但是实际上只是依赖于上一行的,所以我们完全可以按照01背包的优化方式来优化完全背包。
递推公式是和上面一样的,base——case也是和01背包完全一样的,即什么都不选的情况下,背包容量分别为0~j时候的所能装的最大价值。
具体的优化原理见01背包上的讲解。
与01背包优化的唯一区别就是背包的便利的顺序!这里是正序遍历的。
与01背包相同,建议先写出二维的,再进行优化,而不要直接上来就进行一维的优化写法。
下面是CPP代码的实现。
class solution2
{
public:
int maxvalue(vector<int>& weight,vector<int>& value,int& bagweight)
{
int n=weight.size();
vector<int> dp(bagweight+1);
//base_case 什么物品都不选的情况下,背包容量为0~j的最大容量
//因为都是0,所以默认即可
for(int i=0;i<n;i++)//从0开始遍历
{
for(int j=0;j<=bagweight;j++)
{
if(j>=weight[i])
{
dp[j]=max(dp[j],dp[j-weight[i]]+value[i]);
}
}
}
return dp[bagweight];
}
};
int main()
{
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
solution2 slu1;
cout<<"res2 is:"<<endl;
int res2=slu1.maxvalue(weight,value,bagWeight);
cout<< res2<<endl;
return 0;
}
7 完全背包的几个例子
7.1 T518零钱兑换II
这是个很明显的完全背包问题
1)法一:二维动态规划
1、确定dp数组以及下标的含义
dp[i][j]代表的是从下标0~i的硬币中选,能凑到总和为j的方案的个数。
2、确定递推公式
选当前遍历到的nums[i]或者不选,求这两种方案的和即可。
dp[i][j]=dp[i-1][j]+dp[i-1][j-coins[i]]+dp[i-1][j-2coins[i]]+dp[i-1][j-3coins[i]]+…+…
从上面完全背包的推导过程来看,这个式子可以直接写成:
dp[i][j]=dp[i-1][j]+dp[i][j-coins[i]]!!!!
3、确定base_case
只需要确定二维dp数组的第一行即可,当然也可以同时初始化第一列,只是遍历的时候有一点的不一样。
第一行也就是,当选0号硬币的时候,凑出0 ~ amount金额分别有多少中方案。
4、确定遍历顺序
先遍历硬币,后遍历金额
都是从小到大遍历
下面是CPP代码:
class Solution {
public:
int change(int amount, vector<int>& coins)
{
int n=coins.size();
vector<vector<int>> dp(n,vector<int>(amount+1));
//base_case,只选下标为0的物品,要凑出0~amount,方案分别是多少
for(int i=0;i<=amount;i++)
{
if(i%coins[0]==0)
dp[0][i]=1;
}
for(int i=1;i<n;i++)
{
for(int j=0;j<=amount;j++)
{
if(j>=coins[i])
dp[i][j]=dp[i-1][j]+dp[i][j-coins[i]];
else
dp[i][j]=dp[i-1][j];
}
}
return dp[n-1][amount];
}
};
2)动态规划–使用辅助行和辅助列
自然有辅助列,只需要添加辅助行即可。
1、dp[i][j]及下标的含义
代表的是从0~i-1的物品中选,能得到和为j的方案的个数。
2、确定递推公式
将上面解法中的coins[i]转化为coins[i-1]即可。
3、确定base_case
就是第一行,也就是什么都不选,凑到和为0~j的方案的个数。所以只有dp[0][0]为1。
4、确定遍历顺序
与上面是一样的。
class Solution {
public:
int change(int amount, vector<int>& coins)
{
int n=coins.size();
vector<vector<int>> dp(n+1,vector<int>(amount+1));
//base_case,什么都不选的时候,要凑出0~amount,方案分别是多少
dp[0][0]=1;
for(int i=1;i<=n;i++)
{
for(int j=0;j<=amount;j++)
{
if(j>=coins[i-1])
dp[i][j]=dp[i-1][j]+dp[i][j-coins[i-1]];
else
dp[i][j]=dp[i-1][j];
}
}
return dp[n][amount];
}
};
3) 用一维dp数组优化后的二维动态规划
(就像前面说的,即使是用一维dp数组进行优化,但他本质上还是二维dp问题)
建议是先分析二维dp,写出代码,然后再修改成一维dp数组的解法。
下面还是给出直接分析一维dp的过程。
1、确定dp数组以及下标的含义
这里的dp[j]代表的是(从0~i的硬币中选),所能凑到j的方案的个数。就像之前在01背包中所说的一样,这里的dp[j]实际上是不断迭代更新的。随着遍历到的物品 的下标的不同,这个会在上一个j的基础上进行迭代更新。
2、递推公式的确定
还是像二维dp数组中的一样,直接转化一下就可以了:
这里讲一下这是怎么转化的。如果直接看这个式子的话,即便是结合dp数组和下标的含义也很难看出这是什么意思。所以这就是为什么要先写出二维dp解法的原因。在二维dp中我们的递归公式是:
这个公式的由来在上面讲了。转化为一维,可以看出dp[i-1][j]是可使用dp[j]来表示的,这是因为,在一维dp中,dp[j]是不断迭代的,在遍历到第i个物品的时候,dp[j]的值实际上就是遍历到上一个i-1个物品的时候的值。而dp[i][j-coins[i]],可以看做是在二维数组中的dp[i][j]的左侧的值。在遍历一维的时候,这个值也被计算过了,正是dp[j-coins[i]]的值。
3、base_case的确定
base_case就是当什么物品都不选的时候的,所能凑到的0~amount的方案的个数。同一维的01背包一样,这里要把dp[amount+1]中没一个元素都要初始化。
4、遍历顺序
先物品,后背包,都是从大到小。
cpp代码如下:
class Solution {
public:
int change(int amount, vector<int>& coins)
{
int n=coins.size();
vector<int> dp(amount+1);
//base_case,什么物品都不选,要凑出0~amount,方案分别是多少
//很明显,只有dp[0]是1,其余的都是0
dp[0]=1;
for(int i=0;i<n;i++)
{
for(int j=0;j<=amount;j++)
{
if(j>=coins[i])
dp[j]=dp[j]+dp[j-coins[i]];
}
}
return dp[amount];
}
};
7.2 例二:T322零钱兑换
这很明显是一个完全背包问题,与上一个问题不同的点在于,递推的方程可能不同。
实际上这是属于背包问题的两个不同的递归方程的两种,详见:https://blog.51cto.com/u_15069487/3728145
上一个题目一看就是相加问题,而这个题目是求最值问题。
下面按部就班来解决
1)法一:二维动态规划法
1、确定dp数组和下标的含义
dp[i][j]表示的是从0~i下标的硬币中选,能凑到和为j的方案的中,所需硬币的最小数量。
2、确定递推方式
dp[i][j]=min(dp[i-1][j],dp[i-1][j-coins[i]]+1,dp[i-1][j-2coins[i]]+2,dp[i-1][j-3coins[i]]+3)…
根据上面的推导,这实际上可以转化为:
dp[i][j]=min(dp[i-1][j],dp[i][j-coins[i]]+1)
注意这里是+1,也就是说,我们只关心所需要的硬币的数量。在https://blog.51cto.com/u_15069487/3728145中也有总结。
3、确定base_case
base_case就是当只选择第0个物品的时候,凑到和为0~amount所需硬币的最小的个数。很明显,只有能凑出和不能凑出的问题!当i能够整除coins[0]的时候,就能凑出,否则则不能凑出。
这里需要注意的是,当不能凑出的其他的dp[0][i],应该初始化成什么??因为递推公式是求min,所以不能初始化成-1!!!这里初始化为INT_MAX是最适合的,但是INT_MAX+1会溢出,所以我们初始化为一个尽量大的数就行了,这个题目来说,amount+1就够了。
4、确定遍历顺序
同上。
cpp代码:
class Solution {
public:
int coinChange(vector<int>& coins, int amount)
{
int n=coins.size();
vector<vector<int>> dp(n,vector<int>(amount+1));
//base-case,只选择0号硬币的时候,要想凑到0~amount所需要的最少硬币
for(int k=0;k<=amount;k++)
{
if(k%coins[0]==0)
{
dp[0][k]=k/coins[0];
}
else
{
dp[0][k]=amount+1;
}
}
for(int i=1;i<n;i++)
{
for(int j=0;j<=amount;j++)
{
if(j>=coins[i])
dp[i][j]=min(dp[i-1][j],dp[i][j-coins[i]]+1);
else
dp[i][j]=dp[i-1][j];
}
}
int res = dp[n - 1][amount]==(amount+1)?-1:dp[n - 1][amount];
return res;
}
};
2)法二:二维动态规划–使用辅助行和辅助列
自然有辅助行,只需要辅助列即可。
1、确定dp[i][j]即下标的含义
代表的是从下标0~i-1的硬币中选,能凑到和为j的需要硬币的最小数量,若无法凑到,则为-1。
2、确定递推公式
和上面是一样的,只是将coins[i]替换为coins[i-1]即可。
3、确定base_case
也就是当什么都不选的时候,获得和为j的使用的硬币数量的最小值。很明显,只有dp[0][0]=0,其余的都是凑不到的,也就是说,第一行的其他值都是INT_MAX。
4、遍历顺序
和上面的解是一样的。
class Solution {
public:
int coinChange(vector<int>& coins, int amount)
{
int n=coins.size();
vector<vector<int>> dp(n+1,vector<int>(amount+1));
//base-case,什么都不选的时候,要想凑到0~amount所需要的最少硬币
dp[0][0]=0;
for(int i=1;i<=amount;i++)
{
dp[0][i]=amount+1;
}
for(int i=1;i<=n;i++)//先遍历物品
{
for(int j=0;j<=amount;j++)
{
if(j>=coins[i-1])
dp[i][j]=min(dp[i-1][j],dp[i][j-coins[i-1]]+1);
else
dp[i][j]=dp[i-1][j];
}
}
int res = dp[n][amount]==(amount+1)?-1:dp[n][amount];
return res;
}
};
3)法三:使用一维数组进行优化
1、确定dp数组和下标的含义
dp[j]代表的是,(从0~i的硬币中选)凑到和为j所需硬币的最小数量。
2、确定递推公式
dp[j]=min(dp[j],dp[j-coins[i]]+1)
3、确定base_case
base_case就是当什么都不选的时候,凑到和分别为0~j所需硬币的最小数量。
很明显只能凑出dp[0],并且所需的硬币数量为0,即dp[0]=0。
其他的dp[j]都是凑不出来的,像二维一样,凑不出来也不能初始化为0,而是最好初始化为INT_MAX,因为递推公式是求min。
4、确定遍历顺序
常规遍历顺序。
cpp代码:
class Solution {
public:
int coinChange(vector<int>& coins, int amount)
{
int n=coins.size();
vector<int> dp(amount+1);
//base-case,什么都不选的时候,要想凑到0~amount所需要的最少硬币
dp[0]=0;
for(int i=1;i<=amount;i++)
{
dp[i]=amount+1;
}
for(int i=0;i<n;i++)
{
for(int j=0;j<=amount;j++)
{
if(j>=coins[i])
dp[j]=min(dp[j],dp[j-coins[i]]+1);
else
dp[j]=dp[j];
}
}
int res = dp[amount]==(amount+1)?-1:dp[amount];
return res;
}
};
7.3 例三:279完全平方数
经典的完全背包问题。与上面不同的是,物品数组不是以数组的形式给出的,而是以已知值给出的,这有点像下面的8.2节。
1)二维dp的解法
要想使用二维dp,首先就是要确定物品数组的大小,直接使用创建一个物品数组的方法,这样一举两得,物品数组有了,大小也知道了。
1、确定dp数组和下标的含义
dp[i][j]代表的是从0~i的物品中选,得到的和为j的所需数字的个数的最小值。
2、确定递推公式
dp[i][j]=min(dp[i-1][j],dp[i-1][j-nums[i]]+1,dp[i-1][j-2nums[i]]+2,…)
按照上面的分析,这可以直接写成:
dp[i][j]=min(dp[i-1][j],dp[i][j-nums[i]]+1)
3、确定base_case
base_case就是当只选第一个物品的时候,要想凑到和为0~j所需的数字的个数的最小值。因为递推公式使用了min,所如果nums[0]凑不出某个数字,那么这个dp[0][j]=int_max。
因为nums[0]一定是1,所以不存在凑不出的情况!
4、确定遍历的顺序
常规遍历顺序,先遍历物品,后遍历背包
cpp代码:
//二维dp的解法
class Solution {
public:
int numSquares(int n)
{
vector<int> nums;
//手动创建物品数组
for(int k=1;k*k<=n;k++)
{
nums.push_back(k*k);
}
int m=nums.size();
vector<vector<int>> dp(m,vector<int>(n+1));
//base_case,c从第0个物品中选,能得到的和为0~n的所需的数字的最小的数量
for(int i=0;i<=n;i++)
{
dp[0][i]=i;
}
for(int i=0;i<m;i++)//先遍历物品
{
for(int j=0;j<=n;j++)//后遍历背包
{
if(j>=nums[i])
dp[i][j]=min(dp[i-1][j],dp[i][j-nums[i]]+1);
else
dp[i][j]=dp[i-1][j];
}
}
return dp[m-1][n];
}
};
2)动态规划–使用辅助行
1、确定dp数组及下标的含义
dp[i][j]代表的是从下标0~i-1的物品中选,所能得到的和为j的所需的最少的数字的个数。
2、确定递推公式
递推公式和上面是一样的,但是由于dp[i][j]的含义变了,所以只需将nums[i]变为nums[i-1]即可。
3、确定base_case
basecase就是什么都不选,所得到的和分别为0~j的所需的数字个数的最小值。很明显,dp[0][0]=0;除此之外,都是凑不到的,所以第一行的其他的值初始化为INT_MAX即可。
4、遍历顺序
和上面一样。
//:法二:二维dp的解法--使用辅助行和辅助列(这里只需要添加辅助列)
class Solution {
public:
int numSquares(int n)
{
vector<int> nums;
for(int k=1;k*k<=n;k++)
{
nums.push_back(k*k);
}
int m=nums.size();
vector<vector<int>> dp(m+1,vector<int>(n+1));
//base_case,什么都不选,能得到的和为0~n的所需的数字的最小的数量
//很明显,这种情况下,只有dp[0][0]是0,第一行的其他值都是INT_MAX,表示选不到。
dp[0][0]=0;
for(int i=1;i<=n;i++)
{
dp[0][i]=INT_MAX;
}
for(int i=1;i<=m;i++)//先遍历物品
{
for(int j=0;j<=n;j++)//后遍历背包
{
if(j>=nums[i-1])
dp[i][j]=min(dp[i-1][j],dp[i][j-nums[i-1]]+1);
else
dp[i][j]=dp[i-1][j];
}
}
return dp[m][n];
}
};
3)使用一维dp的优化解法
因为我们的一维dp数组不需要物品数组的大小信息,所以这里不再手动创建物品数组。
1、dp数组以及下标的含义
dp[j]表示的是(从0~i)中选物品,所能凑到和为j的所需的数字的数量的最小值。
2、确定递推公式
dp[j]=min(dp[j],d[j-nums[i]]+1)
3、确定base_case
base_case就是当什么也不选的时候,要想凑到和为0~j的所需的数字的数量的最小值。很明显,只有dp[0]=0,其他的j都是凑不出来的,又因为递推公式是min,所以我们把无法凑出来的初始话为INT_MAX。
4、确定遍历的顺序
常规遍历顺序,特别要注意物品的遍历,因为我们并没有物品数组,而是知道了物品数组中的值,所以要把之前的nums[i]的形式用值来代替,比如说nums[0]就是1,所以我们从1开始遍历!!!
cpp代码
class Solution {
public:
int numSquares(int n)
{
vector<int> dp(n+1);
dp[0]=0;
for(int i=1;i<=n;i++)
{
dp[i]=INT_MAX;
}
for(int i=1;i*i<=n;i++)
{
for(int j=0;j<=n;j++)
{
if(j>=i*i)
dp[j]=min(dp[j],dp[j-i*i]+1);
else
dp[j]=dp[j];
}
}
return dp[n];
}
};
8 排列背包问题(考虑顺序)–属于完全背包问题
排列背包与其他背包的问题详见https://blog.51cto.com/u_15069487/3728145
组合背包实际上是完全背包问题,也就是说,从n个物品中选,一定容量的背包所能获取的最大价值。但是要额外考虑取物品时候的顺序。
这关系到排列还是组合的问题。普通的完全背包,取物品的时候不需要考虑顺序,就是取一个组合。而排列背包需要考虑顺序。
当遇到这种类型的题目的时候,就别分析了,直接使用一维dp套路解题:
外循环target,内循环nums,target正序且target>=nums[i] , 也就是说,先遍历背包,后遍历物品。!!!
1、dp[j]及下标的含义是:背包容量为将j的时候,所能装的最大价值。(i是物品,j是背包)
2、递推公式为:dp[j]+=dp[j-num[i]]。
3、base_case为:当什么物品都不选的时候,背包容量分别为0~bagweight所能装的最大价值。
4、遍历顺序:这是与其他背包问题不同的地方,外循环target,内循环nums,target正序且target>=nums[i]
(需要死记的只是递归公式和遍历顺序,其他的都和普通的完全背包的含义是一样的!见上一节)
8.1 例一:T377组合总和IV
1、dp数组及下标的含义
dp[j]: 从(0~i的物品中选)凑成目标正整数为j的排列个数为dp[j]。只要是一维的dp,dp数组都是迭代的。
2、确定递推公式
dp[j]=dp[j]+dp[j-nums[i]]。-记住即可
(可以从普通的完全背包中推导一下!)
3、确定base_case
base_case就是当什么物品都不选的情况下,所能得到的凑到0~i的排列的个数。(dp数组是不断迭代的)。
在这里,很明显,只有dp[0]是1,代表什么都不选的情况下,所能凑到和为0的方案个数是1。dp的其他元素都是0。
4、遍历顺序
先遍历背包(j),后遍历物品(i)。都是从大到小。而且都是从0开始。
cpp代码:
class Solution {
public:
int combinationSum4(vector<int>& nums, int target)
{
int n=nums.size();
vector<int>dp(target+1);
//base-case
dp[0]=1;
for(int j=0;j<=target;j++)//和普通的完全背包一样,物品和背包都是从0开始遍历
{
for(int i=0;i<n;i++)
{
if(j>=nums[i] && (dp[j]<INT_MAX-dp[j-nums[i]]))
dp[j]+=dp[j-nums[i]];
else
dp[j]=dp[j];
}
}
return dp[target];
}
};
8.2 T70爬楼梯的进阶版
T70原先是从0爬n上第n层楼梯,一次只能爬一层或者两层,有几种方案。
现在修改成为,现在一次可以爬1,2,3…m层,一共有多少方案。
进阶版的爬楼梯实际上就是一种完全排列背包问题。物品就是1,2,3…m,从这个物品中选,要得到和为n的方案的个数。
按照完全背包问题的套路进行分析:
1、确定dp数组和下标的含义
dp[j] 代表的是(从整数1~m中选),能得到和为n的排列方案的个数。
2、确定递推公式
dp[j]=dp[j]+dp[j-i] ,因为这个没有给出物品数组,而是从已知的整数中选,所以没有了nums[i]。换句话说,我们已经知道了nums[i]的值,所以直接使用nums[i]的值即可。
3、确定base_case
base_case就是什么 物品都不选的情况下,分别所能爬到0~m层台阶的方案数。很明显,只有dp[0]是1,其他都是0。
4、确定遍历顺序
先遍历背包,后遍历物品。这一步需要注意的是,我们的物品,不是以数组的形式给出的,所以第一个元素不是0,而是nums[0],也就是1!这里nums[0]也就相当于1。
cpp代码:
class Solution {
public:
int climbStairs(int n) {
vector<int> dp(n + 1, 0);
dp[0] = 1;
for (int i = 0; i <= n; i++) { // 遍历背包
for (int j = 1; j <= m; j++) { // 遍历物品,这里也是要从nums[0]开始遍历,只不过nums[0]的值是1
if (i - j >= 0) dp[i] += dp[i - j];
}
}
return dp[n];
}
};
9 动态规划的一维的其他题目–第二章题目例子的后序
9.1 T198打家劫舍
1)法一:暴力递归
这个题目一看就是使用暴力递归解决的经典的题目。
使用递归的时候,也要遵循几步走。
1、明确递归函数的含义,设计递归函数的参数和返回值
递归函数dp(nums,index)的含义是:考虑下标index(包括index)以内的房屋,最多可以偷窃的金额为递归函数的返回值!
2、确定单层递归逻辑
在index上,无非就是偷与不偷,单层递归逻辑可以写成:ans=max(dp(nums,index-1),dp(nums,index-2)+nums[index]);
3、根据单层递归逻辑,确定base_case,也就是递归的终止条件
//法一:暴力递归解法:提交会超出时间限制
class Solution {
public:
int rob(vector<int>& nums)
{
int n=nums.size();
if(n==0) return 0;
if(n==1) return nums[0];
int res=dp(nums,n-1);
return res;
}
int dp(vector<int> nums,int index)
{
int ans;
if(index==0)
return nums[0];
if(index==1)
return max(nums[0],nums[1]);
ans=max(dp(nums,index-1),dp(nums,index-2)+nums[index]);
return ans;
}
};
2)用备忘录优化暴力递归
待补充
3)动态规划
//法三:动态规划法
class Solution {
public:
int rob(vector<int>& nums)
{
int n=nums.size();
if(n==0) return 0;
if(n==1) return nums[0];
vector<int> dp(n);
dp[0]=nums[0];
dp[1]=max(nums[1],nums[0]);
for(int i=2;i<n;i++)
{
dp[i]=max(dp[i-2]+nums[i],dp[i-1]);
}
return dp[n-1];
}
};
9.2 T300最长递增子序列
看起来是一个一维DP的问题实际上是二维DP的问题。也就是说在暴力递归中的递归函数中有一个循环!!!在动态规划解法中有两个循环。可以将其理解为将二维dp优化成一维dp后的解法。但是实质上还是二维动态规划问题。
当然,按照另外一种说法来讲。这属于是有两种状态的动态规划!
这里说明一下一维和二维dp的区别:
- 一维dp一般是使用一维的数组,并且进行的是一层循环遍历。(时间复杂度O(n))
- 二维dp一般是使用二维dp数组,并且进行的是两层的循环遍历。(时间复杂度O(n2))
- 但是有一些二维dp问题,可以使用一维dp数组来优化(最典型的是背包问题),但是实际上进行的还是两层的循环遍历。
- 上面所讲的那些背包问题的一维dp数组的解法,本质上还是二维dp,只不过是用一维数组进行了优化!这个关系要理清
- 所以说:使用一维dp数组解决的问题不一定是一维dp问题! !!!
1)暴力递归解法(略)
2)动态规划解法
使用一维dp数组的时候,往往初始化base_case的时候,要将dp数组中的内容全部进行初始化。(这也就相当于在二维dp数组中初始化第一行)
这个题目唯一需要注意的是dp数组以及下标的含义。如果我们dp数组额含义不同的话,我们写出的状态转移方程也将会不一样。通过这个题目理清确定dp数组含义的重要性。
这个题目的dp数组有两种选择:
- dp数组代表的是i之前(包括i)的最长递增子序列的长度。这样的话我们最终的答案就是:dp[n-1]
- dp数组代表的是以i为结尾的,最长递增子序列的长度。这样的话,我们最终的答案要遍历整个dp数组,找到dp数组中的最大值就是我们的答案。
可以看出这两种是完全不一样的,根据什么来确定选哪一种呢,就是根据能否写出状态转移方程。如果我们使用第一种,实际上是不能由dp[i-1]推出dp[i]的。而第二种是完全可以写出状态转移方程的。
注:这个题目dp数组含义的确定,是和T53是一样的!
cpp代码:
class Solution {
public:
int lengthOfLIS(vector<int>& nums)
{
int n=nums.size();
vector<int> dp(n,1);//dp数组的含义是,dp[i]代表的是i之前(包括i)的最长递增子序列的长度。
//base_case:dp数组全部初始化为1
for(int i=1;i<n;i++)
{
for(int j=0;j<i;j++)
{
if(nums[i]>nums[j])
dp[i]=max(dp[i],dp[j]+1);
}
}
int res=0;
for(int i=0;i<n;i++)
{
res=max(res,dp[i]);
}
return res;
}
};
9.3 T674 最长连续递增序列
1)暴力二重循环
//法一:暴力解法-而二重循环
//时间复杂度是O(n2)
class Solution {
public:
int findLengthOfLCIS(vector<int>& nums)
{
int res=1;
int n=nums.size();
for(int i=0;i<n;i++)
{
int count=1;
for(int j=i+1;j<n;j++)
{
if(nums[j]>nums[j-1])
count++;
else
break;
}
res=max(res,count);
}
return res;
}
};
时间复杂度是:O(N2)
如果可以使用一维动态规划优化的话,就能使时间复杂度降为O(n)。
2)法二:动态规划
//法二:动态规划-使用一维dp,时间复杂度控制在O(n)
class Solution {
public:
int findLengthOfLCIS(vector<int>& nums)
{
int n=nums.size();
vector<int> dp(n);
int res=1;
//base_case
dp[0]=1;
for(int i=1;i<n;i++)
{
if(nums[i]>nums[i-1])
{
dp[i]=dp[i-1]+1;
}
else
dp[i]=1;
}
for(int i=0;i<n;i++)
{
res=max(res,dp[i]);
}
return res;
}
};
时间复杂度O(n)。
9.4 T53最大子序和
这个题目和上面的T300是类似的。
唯一需要注意的是,dp数组和下边的含义的确定。
通常我们会想到两种选择:
- dp[i]代表的是下标i(包括i)之前的最大子序和。这样我们的结果就是dp[n-1]。
- dp[i]代表的是以i为结尾的子序列的最大子序和。这样我们要遍历dp[i],找到最大值就是我们的最后的结果。
第一种方案行不通,因为无法写出状态转移方程。
另外需要注意的是:这是一个普通一维问题。这和使用一维dp数组优化二维问题还不一样。前者完全可以只初始化dp[0],而后者是将dp数组中全部都要初始化(因为都会用到)。
cpp代码如下:
//法三:动态规划
class Solution {
public:
int maxSubArray(vector<int>& nums)
{
int n=nums.size();
if(n==0) return 0;
vector<int> dp(n);
int res=INT_MIN;
//这是一个普通一维问题。这和使用一维dp数组优化二维问题还不一样。前者完全可以只初始化dp[0],而后者是将dp数组中全部都要初始化(因为都会用到)。
dp[0]=nums[0];//只初始化dp[0]就可以了,因为递归中只用到这个。
for(int i=1;i<n;i++)
{
dp[i]=max(dp[i-1]+nums[i],nums[i]);
}
for(int i=0;i<n;i++)
{
res=max(res,dp[i]);
}
return res;
}
};
10 二维dp的其他的一些问题–第二章题目的后序
二维问题是要特别注意的。尤其是背包问题本质上也是一个二维问题。背包问题呈现出来的特点,在普通的二维dp问题上也有一定的体现,也就是说,普通二维问题同样存在使用辅助行和辅助列、以及使用一维数组进行优化的可能。
10.1 T931下降路径的最小和
通过这个题目,试图将二维dp的问题一般化,将背包问题的那些理论搬到普通的二维dp中来。背包问题实际上是二维dp的特殊的一种问题。
这里说明一下一维和二维dp的区别:
- 一维dp一般是使用一维的数组,并且进行的是一层循环遍历。(时间复杂度O(n))
- 二维dp一般是使用二维dp数组,并且进行的是两层的循环遍历。(时间复杂度O(n2))
- 但是有一些二维dp问题,可以使用一维dp数组来优化(最典型的是背包问题),但是实际上进行的还是两层的循环遍历。
- 上面所讲的那些背包问题的一维dp数组的解法,本质上还是二维dp,只不过是用一维数组进行了优化!这个关系要理清
- 所以说:使用一维dp数组解决的问题不一定是一维dp问题! !!!
1)暴力递归法
递归函数的含义:从第⼀⾏(matrix[0][…])向下落,落到位置 matrix[i][j] 的最⼩路径和为 dp(matrix, i, j)。
因此,我们要找的就是最后一行dp函数返回值的最小值。而某一个位置处的dp函数依赖于上一行,以此递归,递归到第一行(base_case)即可。
//法一 暴力递归解法---提交后会超时
class solution
{
public:
int minFallingPathSum(vector<vector<int>>& matrix)
{
int n=matrix.size();
int res=INT_MAX;
for(int i=0;i<n;i++)
{
res=min(res,dp(matrix,n-1,i));
}
return res;
}
int dp(vector<vector<int>> & matrix,int i,int j)
{
int res;
int n=matrix.size();
//base_case
if(i==0)
return matrix[0][j];
if(j==0)
res=matrix[i][j]+min(dp(matrix,i-1,j),dp(matrix,i-1,j+1));
else if(j==n-1)
res=matrix[i][j]+min(dp(matrix,i-1,j-1),dp(matrix,i-1,j));
else
res=matrix[i][j]+min(dp(matrix,i-1,j-1),min(dp(matrix,i-1,j),dp(matrix,i-1,j+1)));
return res;
}
};
2)动态规划
有了上面的状态转移方程,动态规划就容易写了。dp数组和下标的含义和暴力递归的dp函数是一样的。
这里的base_case也和递归是一样的。就是第一行。
//法二:动态规划法
//dp数组的含义
class Solution {
public:
int minFallingPathSum(vector<vector<int>>& matrix)
{
int n=matrix.size();
int res=INT_MAX;
vector<vector<int>> dp(n,vector<int>(n));
for(int i=0;i<n;i++)
{
dp[0][i]=matrix[0][i];
}
for(int i=1;i<n;i++)
{
for(int j=0;j<n;j++)
{
if(j==0)
{
dp[i][j]=matrix[i][j]+min(dp[i-1][j],dp[i-1][j+1]);
}
else if(j==n-1)
{
dp[i][j]=matrix[i][j]+min(dp[i-1][j-1],dp[i-1][j]);
}
else
dp[i][j]=matrix[i][j]+min(dp[i-1][j-1],min(dp[i-1][j],dp[i-1][j+1]));
}
}
for(int i=0;i<n;i++)
{
res=min(res,dp[n-1][i]);
}
return res;
}
};
3)法三:用一维dp数组进行优化
上面的二维dp数组的解法中,每一个dp[][]都是独立的。都有各自的含义。并且我们的base_case只需要初始化第一行即可。因为其他的数据都是从第一行推出来的,与其他行没有关系。
之前在背包问题中提到过,如果在二维dp数组的解法中,dp[i][j]的状态只与上一行有关,那么我们就可以使用一维dp数组进行优化。很明显,这个题目复合这个条件!
而在一维dp数组的解法中,dp数组中的内容是不断迭代的!这一点很重要。
这个题目中,将这种理清“一维dp数组中的内容是迭代的”变得十分重要!
在这个解法的遍历中:
for(int i=1;i<n;i++)
{
int prev=INT_MAX;
for(int j=0;j<n;j++)
{
int temp=dp[j];//记录下dp[i-1][j-1]!!
if(j==0)
{
dp[j]=matrix[i][j]+min(dp[j],dp[j+1]);
}
else if(j==n-1)
{
dp[j]=matrix[i][j]+min(prev,dp[j]);
}
else
dp[j]=matrix[i][j]+min(prev,min(dp[j],dp[j+1]));
prev=temp;
}
}
在j==n-1的时候,我们不能我们不能写成:dp[j]=matrix[i][j]+min(d[j-1],dp[j])!!!
这是因为,我们原本想要这里的dp[j-1]是二维dp数组中上一行的j-1个数据,也就是遍历到前一个i的时候的第j-1个数据。但是在本层i循环遍历到j之前,dp[j-1]已经被覆盖了!已经不在是i-1循环中的dp[j-1]了,而是i循环中的dp[j-1]!!!
所以我们要设立辅助变量,先记录下dp[i-1][j-1]。
完整代码如下:
class Solution {
public:
int minFallingPathSum(vector<vector<int>>& matrix)
{
int n=matrix.size();
int res=INT_MAX;
vector<int> dp(n);
for(int i=0;i<n;i++)
{
dp[i]=matrix[0][i];
}
for(int i=1;i<n;i++)
{
int prev=INT_MAX;
for(int j=0;j<n;j++)
{
int temp=dp[j];
if(j==0)
{
dp[j]=matrix[i][j]+min(dp[j],dp[j+1]);
}
else if(j==n-1)
{
dp[j]=matrix[i][j]+min(prev,dp[j]);
}
else
dp[j]=matrix[i][j]+min(prev,min(dp[j],dp[j+1]));
prev=temp;
}
}
for(int i=0;i<n;i++)
{
res=min(res,dp[i]);
}
return res;
}
};
10.2 T718最长重复子数组
分析:这个题目的核心是要转化为最长公共前缀,或最长公共后缀。为了之前的保持一致,这里我们使用后缀组做法。
1)法一:暴力解法
也就是通过暴力循环,将所有的序列找出来。但是时间复杂度为O(n3)
但是暴力解法也得有一个指标,也就是我们要找的是什么。
这里使用双指针,找最长公共前缀。
//法一:双指针法实现暴力三重循环----(提交会超出时间限制)
//原理:遍历寻找最长的前缀即可
class Solution {
public:
int findLength(vector<int>& nums1, vector<int>& nums2)
{
int n1=nums1.size();
int n2=nums2.size();
int res=0;
for(int i=0;i<n1;i++)
{
for(int j=0;j<n2;j++)
{
int count=0;
int index1=i;//指针1
int index2=j;//指针2
while(nums1[index1]==nums2[index2] && index1<n1 && index2<n2)
{
count++;
index1++;
index2++;
}
res=max(count,res);
}
}
return res;
}
};
2)法二:动态规划–(找公共后缀法)
这个题目的核心是确定dp数组和下标的含义。也就是说,我们通过什么手段,来确定最长公共子数组。
1、dp数组及下标的含义
这里我们设计dp[i][j]为在nums1中以第i个数据为结尾,nums2中以第j个数据为结尾(包括i和j),包含的最最长公共后缀的长度。
2、 递推公式的确定
dp[i][j]=dp[i-1][j-1]+1。(在nums1[i]==nums2[j]的前提下,其他情况下都是0)
3、base_case的确定
从上面的递推公式中可以看出,base_cases就是第一行和第一列。
4、遍历顺序
常规
下面是CPP代码:
注意:找dp[i][j]的最大值–注意这个循环不能放到遍历的循环里面,因为那个循环是不全的!!
class Solution3 {
public:
int findLength(vector<int>& nums1, vector<int>& nums2)
{
int n1=nums1.size();
int n2=nums2.size();
int res=0;
vector<vector<int>>dp(n1,vector<int>(n2));
//base_case的设计
for(int i=0;i<n1;i++)
{
if(nums2[0]==nums1[i])
{
dp[i][0]=1;
}
res=max(res,dp[i][0]);
}
for(int i=0;i<n2;i++)
{
if(nums1[0]==nums2[i])
dp[0][i]=1;
}
for(int i=1;i<n1;i++)//从第二行开始遍历
{
for(int j=1;j<n2;j++)//从第二列开始遍历
{
if(nums1[i]==nums2[j])
{
dp[i][j]=dp[i-1][j-1]+1;
}
}
}
//找dp[i][j]的最大值--注意这个循环不能放到遍历的循环里面,因为那个循环是不全的!!
for(int i=0;i<n1;i++)
{
for(int j=0;j<n2;j++)
{
res=max(res,dp[i][j]);
}
}
return res;
}
};
3)动态规划的另外一种思路–使用辅助行和辅助列
背包问题普遍只会用到辅助行,这里辅助行和辅助列都用到了。
方法二给出的方法,base_case却确定过于麻烦。
这里我们将dp数组的大小设计为dp[n1+1][n2+1],这样base_case就容易确定了,并且找dp[i][j]最大值的时候也不用重新遍历了。
class Solution4 {
public:
int findLength(vector<int>& nums1, vector<int>& nums2)
{
int n1=nums1.size();
int n2=nums2.size();
int res=0;
vector<vector<int>>dp(n1+1,vector<int>(n2+1));
for(int i=1;i<=n1;i++)//从第二行开始遍历
{
for(int j=1;j<=n2;j++)//从第二列开始遍历
{
if(nums1[i-1]==nums2[j-1])
{
dp[i][j]=dp[i-1][j-1]+1;
}
res=max(res,dp[i][j]);
}
}
return res;
}
};
4)动态规划的一维dp数组优化
从上面的递推公式中可以看出,dp[i][j]仅仅依赖于上一行,所可以进行一维数组的优化处理。只不过,和T931的这种解法一样,要考虑值被覆盖的情况,所以要设一个临时变量。
实际写的时候,temp变量的设置存在一些问题,所以带后续补充。
10.3 T1143.最长公共子序列
这个题目和上一个区别很大,主要区别就是,这个要求是不用连续的,所以说dp数组的定义不用和上一题一样。而是采用传统的dp数组的定义。
也就是说,dp[i][j]不再代表以i j为结尾的最长公共子序列,而是i,j之前的最长公共子序列!!!
1)法一:暴力递归解法
见labuladong笔记
2)法二:动态规划—不使用辅助行和辅助列
1、dp数组及下标的含义
dp[i][j]表示text1的前i个字符,text2的前j个字符中(i ,j 都是下标),最长的公共子序列的长度。
很明显,我们最后要求的就是dp[n1-1][n2-1]。
2、 确定递推公式
不使用辅助行和辅助列的时候,dp[i][j]的含义很简单,唯一复杂的就是初始化第一行和第一列的时候。
3、确定base_case
从递推公式中可以看出,base_case就是第一行和第一列
4、确定遍历顺序
常规遍历顺序。
cpp代码:
class Solution {
public:
int longestCommonSubsequence(string text1, string text2)
{
int n1=text1.size();
int n2=text2.size();
int mark=0;
vector<vector<int>> dp(n1,vector<int>(n2));
//base_case初始化第一行和第一列
for(int i=0;i<n1;i++)//初始化第一列
{
if(mark)
dp[i][0]=1;
if(text1[i]==text2[0])
{
dp[i][0]=1;
mark=1;
}
}
mark=0;
for(int i=0;i<n2;i++)//初始化第一行
{
if(mark)
dp[0][i]=1;
if(text2[i]==text1[0])
{
dp[0][i]=1;
mark=1;
}
}
for(int i=1;i<n1;i++)
{
for(int j=1;j<n2;j++)
{
if(text1[i]==text2[j])
dp[i][j]=dp[i-1][j-1]+1;
else
dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
}
}
return dp[n1-1][n2-1];
}
};
3)法三:动态规划—使用辅助行和辅助列
使用辅助行和辅助列的时候,就是dp数组的含义稍微的改变一下就行了。这样的话,初始化第一行和第一列的时候就会简单很多。
1、dp数组和下标的含义
dp[i][j]代表的是text1的0 ~ i-1中,以及text2的0 ~ j-1中,两者的最长公共子序列的长度。
因此来看的话,dp[0][j],也就是第一行,代表的是,从text1中什么都不选,从text2中选0j-1中,两者的最长公共子序列的长度。dp[i][0],也就是第一行列,代表的是,从text2中什么都不选,从text1中选0i-1中,两者的最长公共子序列的长度。很明显,第一行和第一列的值都是0。
2、确定递推表达式
递推表达式和法二是一样的。
3、base_case的确定
上面说了,base_case就是第一行和第一列,不过都是0,所以默认初始化即可。
dp[0][j],也就是第一行,代表的是,从text1中什么都不选,从text2中选0j-1中,两者的最长公共子序列的长度。dp[i][0],也就是第一行列,代表的是,从text2中什么都不选,从text1中选0i-1中,两者的最长公共子序列的长度。很明显,第一行和第一列的值都是0。
4、确定遍历顺序
常规遍历顺序
下面是cpp代码:
public:
int longestCommonSubsequence(string text1, string text2)
{
int n1=text1.size();
int n2=text2.size();
int mark=0;
vector<vector<int>> dp(n1+1,vector<int>(n2+1));
//base_case初始化第一行和第一列,代表的是,text1中什么都不选的时候,从text2的0——n2-1中选,得到的最长公共子序列的长度。
//很明显,都是0,所以执行默认初始化即可。
for(int i=1;i<=n1;i++)
{
for(int j=1;j<=n2;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[n1][n2];
}
};
4)一维dp数组优化
很明显,这个题目不满足使用一维dp数组优化条件:dp[i][j]只能由上一行推出。所以不能进行一维dp数组的优化。
11 动态规划总结
1)动态固话本质上是从暴力递归中来的。也就是说,动态规划只是一种进行暴力尝试找到所有可能的一种优化方法。对于一些题目,看到可能进行暴力搜寻才能做,但是搜寻的时候存在很多重复子问题的时候,要想到使用动态规划。
2 ) 明确动态规划的做题的步骤。–主要就是那四步
3)一维的动态规划,唯一的难点就是确定递推公式。
4)对于二维的动态规划,思路是唯一的,但是方法比较多。这里进行一下总结:
- 尽量使用不使用辅助行和辅助列的动态规划做法。如非必要,不使用辅助行和辅助列。这么做是因为这个方法简单,思路比较清晰,不用记很多东西。
- 当使用一维数组进行优化的时候,要先写出普通版的二维动态规划解法,再在上面的基础上进行修改。使用一维数组进行优化的方法,是和使用了辅助行的解法完全没有联系的。所以这也是第一步只记不使用辅助行的解法的原因。