dp笔记:关于DP算法和滚动数组优化的思考

从网上总结了一些dp的套路以及对滚动数组的一些思考,现记录如下,希望以后回顾此类算法时会有所帮助。

1、DP算法经验

1、DP算法核心:

1、确定【DP状态】

2、确定【DP状态转移方程】

其中DP状态又需要考虑到两点:

1、最优子结构

将原有问题化为一个个子问题,即子结构。对于每一个子问题,其最优值均由【更小规模的子问题的最优值】推导过来

2、无后效性

我们只关心子问题的最优值,不关心子问题的最优值是怎样的得到的。

2、DP算法类别以及例题

例1:三步问题

问题描述:楼梯有n阶,一次可上1阶,2阶,或者3阶。问总共偶多少种方案。同时,由于结果很大,请对结果取模MOD;
分析:
1、目标:得到爬到n阶楼梯的总方案数
2、子问题:爬i阶楼梯时的总方案数
3、
【1】定义f[i]为爬i阶楼梯时的总方案数,一般来说,爬第i阶,它可能是由前1阶爬1阶、前2阶爬2阶、前3阶爬3阶得到的。
所以f[i] 的取值取决于:f[i-1]、f[i-2]、f[i-3]。
【2】f[i]取值与f[i-1]、f[i-2]、f[i-3]的数值如何得到无关。
【3】dp状态转移方程:

f[i]=(f[i-1]+f[i-2]+f[i-3])%MOD;

code:

//链接:https://zhuanlan.zhihu.com/p/180443034
class Solution
{
    
    
	public:
	vector<int>f;
	int MOD=10000007;
	int waysToStep(int n)
	{
    
    
		f.reszie(n+1);
		f[0]=1;			//第0阶方案为1
		for(int i=1;i<=n;i++)
		{
    
    
			if(i==1)
				f[i]=f[i-1];	
			else if(i==2)
				f[i]=(f[i-1]+f[i-2])%MOD;	
			else
				f[i]=(f[i-1]+f[i-2]+f[i-3])%MOD;
		}
		return f[n];
	}
};

例2:最小路径和

问题描述:给定一个包含非负整数mxn网格,找出一条从左上角到右下角的路径,只能向右、向下,使路径上的数字综合最小

输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
解释: 因为路径 1→3→1→1→1 的总和最小。

分析:
1、目标:获取网格grid[0][0]到grid[m][n]的最小数字和:f[m][n]
2、子问题:获取网格grid[0][0]到grid[i][j]的最小数字和:f[i][j]
3、由题意可知到达网格grid[i][j]有两种方法:一种是从它的左边过来,一种是从它的上边过来(因为只能向右、向下移动)
所以f[i][j] 的取值取决于:f[i-1][j] (左边)、f[i][j-1] (上边)
【2】f[i][j] 的取值取决于:f[i-1][j] (左边)、f[i][j-1] (上边)如何得到无关。
注意:若改为可以向上向下向左向右,且不能重复格子,则f[i][j-1]到f[i+1][j]所对应的具体路径会影响f[i][j]取值,则会不符合无后效性
【3】dp状态转移方程:

f[i][j]=min(f[i-1][j],f[i][j-1])+grid[i][j];		//且f[0][0]=grid[0][0],并且还要注意边界条件

code:

//链接:https://zhuanlan.zhihu.com/p/180443034
class Solution
{
    
    
	public:
		int minPathSum(vector<vector<int>> &grid)
		{
    
    
			for(int i=0;i<grid.size();i++)
			{
    
    
				for(int j=0;j<grid[0].size();j++)
				{
    
    
					if(i==0 && j==0) continue;		//grid[0][0]不需要修改
					int tp=1e9;
					//从左边和上边中选取一个较小的,作为路径
					if(i>0) tp = min(tp,grid[i-1][j]);
					if(j>0) tp = min(tp,grid[i][j-1]);	
					grid[i][j]+=tp;
				}
			}
			return grid[grid.size()-1][grid[0].size()-1];
		}
};
//注意,这里为了节约空间,不新开辟数组,而是在grid数组的基础上进行修改,这样就是在grid的基础上加上左、上邻值(最小路径和)的最小值。

推导:f[2][2] = f[2][1]+1=f[2][0]+2=f[1][0]+3=f[0][0]+6=1+6=7

例3:乘积最大子数组

问题描述:一个整数数组nums,找出数组中乘积最大的连续子数组(该子数组最少包含一个数字),并返回子数组对应的乘积

输入:[2,3,-2,4]
输出: 6
解释: 因为路径 2 * 3=6
输入:[-2,0,-1]
输出: 0

分析:
1、目标:在左端点为0,右端点为nums.size的连续区间中找到最大乘积
2、子问题:在左端点为0,右端点为i的连续区间中找到最大乘积
3、分析状态定义是否符合最优子结构
nums=[2,-1,-2]
对应的f[i]=[2,-1,4]
f[3] =4 != nums[3] !=f[2] * nums[3],所以f[i]与f[i-1]无关。
即DP转台最优质的无法由更小的规模的DP状态最优值推出,不符合【最优子结构】原则。
出错原因:若nums[i]为负,则f[i] * nums[i]只会越来越小,因此需要分正负情况讨论。
【1】、nums[i]>0

f[i] = max(nums[i],f[i-1]*nums[i]);

【2】、nums[i]<=0

f[i] = max(nums[i],以i-1为右端点的连续区间的最小乘积*nums[i])

这样就需要引入新的DP状态
maxn[i]:表示以右端点的连续区间最大乘积
minn[i]:表示以右端点的连续区间最小乘积
maxn[i]、minn[i]由maxn[i-1]、minn[i-1]推导而出。
code:

//链接:https://zhuanlan.zhihu.com/p/180443034
class Solution
{
    
    
	public:
		vector<int> maxn,minn;
		int maxProduct(vector<int> &nums)
		{
    
    
			int n=nums.size(),ans=nums[0];
			maxn.resize(n);
			minn.resize(n);
			maxn[0]=minn[0]=nums[0];
			for(int i=1;i<n;++i)
			{
    
    
				if(nums[i]>0)
				{
    
    
					maxn[i] =max(nums[i],nums[i]*maxn[i-1]);
					minn[i] =min(nums[i],nums[i]*minn[i-1]);
				}
				else
				{
    
    
					maxn[i] =max(nums[i],nums[i]*minn[i-1]);
					minn[i] =min(nums[i],nums[i]*maxn[i-1]);
				}
				ans = max(ans,maxn[i]);		//在每个不同长度的区间中分别找到最大值,并且取这些最大值中的最大值
			}
			return ans;
		}
};

例4:[线性DP]最长上升子序列(LIS)

题目描述:给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:

输入:[10,9,2,5,3,7,101,18]
输出:4
解释:最长的上升子序列是[2,3,7,101],它的长度是4

思路:
1、先从小规模入手:
序列长度为1时=>ans=1
序列长度为2时=>Y[1]>=Y[2]的话,长度为1;Y[1]<Y[2],长度为2
DP状态:f[i]表示仅考虑前i个数,以第i个数结尾的最长上升子序列的最大长度。
这种方法就是每一次尝试寻找“可以接下去的”那一个数,换句话说,设原序列为a,则
当aj<ai(j<i)且f[j]+1>f[i]时,f[i]=f[j]+1。
对于每一个数,他都是在“可以接下去”的中,从前面的最优值+1转移而来。通俗的来说,你肯定就是在所有能找到的里面取最好的一个。
因此,这个算法是可以求出正确答案的。复杂度很明显,外层i枚举每个数,内层j枚举目前i的最优值,即 O(n2)
code:

//链接:https://zhuanlan.zhihu.com/p/180443034
class Solution {
    
    
public:
    int lengthOfLIS(vector<int>& nums) {
    
    
        int sz = nums.size(), ans = 0;
        vector<int> f(sz, 0);
        for(int i = 0; i < sz; i++) {
    
    
            int tmp = 1;
            for(int j = i-1; j >= 0; j--) {
    
    
                if(nums[i] > nums[j])
                    tmp = max(tmp, f[j]+1);
            }  
            f[i] = tmp;
            ans = max(ans, tmp);
        }
        return ans;
    }
};

例5:[线性DP]俄罗斯套娃信封(排序降维后视作LIS)

题目描述:
给定一些标记了宽度和高度的信封,宽度和高度以整数对形式 (w, h) 出现。当另一个信封的宽度和高度都比这个信封大的时候,这个信封就可以放进另一个信封里,如同俄罗斯套娃一样。请计算最多能有多少个信封能组成一组“俄罗斯套娃”信封(即可以把一个信封放到另一个信封里面)。
示例:(不允许旋转信封)

输入: envelopes = [[5,4],[6,4],[6,7],[2,3]]
输出: 3
解释: 最多信封的个数为 3, 组合为: [2,3] => [5,4] => [6,7]。

分析:
1、简要概括题意,求一组二维上升子序列 在这里插入图片描述
,同时满足:
在这里插入图片描述
由于装入信封需要满足两个条件,而且信封的顺序是无序的并且是可以调整顺序的。所以我们可以先限制一维例如w,使信封按照w的大小,从小到大排列好,这样就可以确定以w为准则的话后面的总是可以将前面的包含。就只需要考虑h维度了。
接下来就和LIS步骤类似了:给定一个无序的整数数组(h维度上无序),找到其中最长上升子序列的长度。
code:

//链接:https://zhuanlan.zhihu.com/p/180443034
class Solution {
    
    
public:
    int maxEnvelopes(vector<vector<int>>& envelopes) {
    
    
        sort(envelopes.begin(), envelopes.end());
        int n = envelopes.size(), ans = 0;
        vector<int> f(n, 0);
        for(int i = 0; i < n; i++) {
    
    
            int tmp = 0;
            for(int j = 0; j < i; j++) {
    
    
                if(envelopes[j][1] < envelopes[i][1] && envelopes[j][0] < envelopes[i][0])
                    tmp = max(tmp, f[j]);
            }
            f[i] = tmp + 1;
            ans = max(f[i], ans);
        }
        return ans;
    }
};

对这个问题有疑惑的,可以转到我的记录:
C++中的sort函数对二维数组排序是按照什么准则?

例6:[线性DP]最长公共子序列(LCS、最基本的双串匹配模型)

题目描述:
给定两个字符串text1和text2,返回两个字符串中的最长公共子序列长度。
例:

“ace”为"abcde"的子序列,若两个字符串无公共子序列,返回0
IN: text1=“abcde”,text2=“ace”
OUT:3
IN:text1=“abc”,text2=“def”
OUT:0
tips:1<=text.length<=1000
1<=tex2.length<=1000
text中均为小写英文字母

分析:
首先确定线性DP特点DP状态沿着各个维度线性增长。
目标:获取长度为n1的字符串与长度为n2的字符串的最长公共子序列
子问题:获取长度为i的字符串与长度为j的字符串的最长公共子序列
确定dp状态:f[i][j]:表示第一个字符串前i个字符与第二个字符串前j个字符的最长公共子序列长度
确定状态转移方程:
text1[i]与text2[j]有两种结果,一个是相同,一个是不相同
1、如果text1[i]与text2[j]不相同 , f[i][j] 从f[i-1][j],f[i][j-1]选出最大的继承下来,此时并没有出现增长
f[i][j]=max(f[i-1][j],f[i][j-1]);
2、如果text1[i]与text2[j]相同,说明我们可以增加一种方式,且f[i][j]:表示第一个字符串前i个字符与第二个字符串前j个字符的最长公共子序列长度,所以f[i][j]是在f[i-1][j-1]的基础上的来的。
f[i][j]=f[i-1][j-1]+1;
这样的话复杂度为O(nm),n,m分别为text1和text2的长度

class Solution
{
    
    
	public:
		int LongestCommonSubsequence(string text1,string text2)
		{
    
    
			int n=text1.length,m=text2.length;
			vector<vector<int>> f(n+1,vector<int>(m+1,0));		//状态数组
			for(int i=1;i<=n;i++)		//防止数组越界
			{
    
    
				for(int j=1;j<=m;j++)
				{
    
    
					//i-1:text中的第i个字符			
					if(text[i-1] == text[j-1]) f[i][j] = max(f[i][j],f[i-1][j-1]+1);
					else f[i][j] = max(f[i-1][j],f[i][j-1]);
					
				}
			}
			return f[n][m];
		}
}

2、滚动数组优化与背包问题

1、01背包问题

一共有N件物品,从第i(i从1开始)件物品的重量为w[i],价值为v[i].在总重量不超过背包承载上限W的情况下,求出能够装入背包的最大价值是多少?
分析步骤:
1、获取总目标:将N件物品装进限重为W的背包可以获得的最大价值

2、子问题:将前i个物品装进限重为j的背包可以获得的最大价值,0<=i<=N,0<=j<=W;

3、dp状态定义以及检查无后效性:dp[i][j]:表示将将前i个物品装进限重为j的背包可以获得的最大价值,0<=i<=N,0<=j<=W;

再次分析:dp[i][j]由两个部分组成:不装入第i个物品、装入第i个物品(假如能够装入的话)

即dp[i][j]只与dp[i-1][j] (不装入)以及dp[i-1][j-w[i]]+v [i] (装入第i个物品)有关,并且与子状态的具体怎么得来无关,符合无后效性

4、dp状态转移方程定义:
已知dp[i][j]只与dp[i-1]j以及dp[i-1][j-w[i]] (装入第i个物品)有关,那么具体是怎样的关系:

dp[i][j]指的是将前i个物品装进限重为j的背包可以获得的最大价值

dp[i-1][j]指的是将前i-1个物品装进限重为j的背包可以获得的最大价值

dp[i-1][j-w[i]+v[i]指的是将前i-1个物品装进限重为j的背包可以获得的最大价值+将第i个物品装入的总价值.

dp[i][j]是在上述两种可能的情况中较优的一种,也就是价值更大的一种,所以可以这样描述:

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

接下来介绍一种在DP算法比较常用的手法:滚动数组优化

在上述的dp状态方程中我们可以发现:dp[i][j] 只与dp[i-1][0,…,j-1]有关,也就是说dp状态的二维数组的第一维在此时是浪费的,我们用到的只有第二维。所以可以采用滚动数组优化,去掉dp中的第一维,变为如下形式:

dp[j] = max(dp[j],dp[j-w[i]+v[i])

滚动数组是DP中的一种编程思想。简单的理解就是让数组滚动起来,每次都使用固定的几个存储空间,来达到压缩,节省存储空间的作用。起到优化空间,主要应用在递推或动态规划中(如01背包问题)。因为DP题目是一个自底向上的扩展过程,我们常常需要用到的是连续的解,前面的解往往可以舍去。所以用滚动数组优化是很有效的。利用滚动数组的话在N很大的情况下可以达到压缩存储的作用。
参考:《滚动数组》—滚动数组思想,运用在动态规划当中

此时滚动的方向尤其重要。
例如在递推dp[j]时应按W到0的顺序,这样才能保证推dp[j]时dp[j-w[i]]保存的是状态dp[i-1,j-w[i]]的值
因为我们进行操作的时候,是用一维数组dp同时存储这一状态和上一状态的值的,从W到0递推也就是说从后往前将i-1状态更新为i状态。
当推导到j时,j往后的是i状态。而j之前的是i-1状态。要符合优化之前的状态方程,所以01背包问题的优化数组滚动方向必然是从后往前的。
(不知道解释的清不清楚。。。)
优化后的核心代码如下:需要注意的是滚动数组更新的结束条件是j>=w[i],意思就是是背包限重必须大于w[i],也就是限重必须大于第i个物品的质量(这是我们之前在谈论状态方程说过的,dp[i][j]由两个部分决定,一个是不装入第i件物品,一个是装入第i件物品(前提是装的下),这里就是为了满足装得下)

int[] dp = new int[W + 1];
for (int i = 0; i < N; i++) {
    
    
    // 滚动数组优化 倒序枚举j
    for (int j = W; j >= w[i]; j--) {
    
    
        dp[j] = Integer.max(dp[j], dp[j - w[i]] + v[i]);
    }
}

2、完全背包问题

问题描述:
每种物品有无限多个:一共有N种物品,每种物品有无限多个,从第i(i从1开始)种物品的重量为w[i],价值为v[i].在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值为多少?

1、分析总目标:将N物品装进限重为W的背包可以获得的最大价值(注意这里是种而不是件)

2、分析子状态:将前i物品装进限重为j的背包可以获得的最大价值,0<=i<=N,0<=j<=W;

3、dp状态定义以及检查无后效性:dp[i][j]:表示将将前i物品装进限重为j的背包可以获得的最大价值,0<=i<=N,0<=j<=W;

再次分析:dp[i][j]由两个部分组成:不装入第i种物品、装入第i种物品(假如能够装入的话)

即dp[i][j]只与dp[i-1][j] (不装入)以及dp[i][j-w[i]]+v[i] (装入第i种物品)有关,并且与子状态的具体怎么得来无关,符合无后效性
注意:这里使用的是dp[i][j-w[i]]+v[i],而不是dp[i-1][j-w[i]]+v[i],两者有什么区别呢?

首先确定一点:装入第i种商品后还可以再继续装入第i种商品。

dp[i][j]指的是将前i种物品装进限重为j的背包可以获得的最大价值

dp[i-1][j]指的是将前i-1种物品装进限重为j的背包可以获得的最大价值

dp[i-1][j-w[i]+v[i]指的是将前i-1种物品装进限重为j的背包可以获得的最大价值+将一个第i种物品装入的总价值.

dp[i][j-w[i]+v[i]指的是将前i种物品装进限重为j的背包可以获得的最大价值+将一个第i种物品装入的总价值
也就是说,当我们装入第1个第i种物品后,假设让dp[i][j]=dp[i][j-w[i]+v[i],接下来再次将第2个第i中国物品装入,此时i不改变,改变的是j,也就是背包容量。
得到的dp状态方程为:

dp[i][j]=max(dp[i-1][j],dp[i][j-w[i]]+v[i])			//j>=w[i]

现在我们对完全背包进行滚动数组优化

与01背包类似,同样二维状态数组可以优化成1维状态数组,不同的是这里的数组滚动方向只能是正向,因为这里的max的第二项是dp[i],而不是01背包的dp[i-1],这里就需要覆盖,而01背包是要避免覆盖的。
分析一下:
例如在递推dp[j]时应按w[i]到W的顺序,这样才能保证推dp[j]时dp[j-w[i]]保存的是状态dp[i,j-w[i]]的值
因为我们进行操作的时候,是用一维数组dp同时存储这一状态和上一状态的值的,从w[i]到W递推也就是说从前往后将i-1状态更新为i状态。
当推导到j时,j往后的是i-1状态。而j之前的是i状态。要符合优化之前的状态方程,所以01背包问题的优化数组滚动方向必然是从后往前的,注意没对第j项进行赋值时,第j项也是i-1状态,所以这样显然就符合了状态方程,优化如下:

dp[j]=max(dp[j],dp[j-w[i]]+v[i])		//等式左边是更新完的,等式右边是更新之前的

核心代码如下;

int[] dp = new int[W + 1];
for (int i = 0; i < N; i++) {
    
    
    // 滚动数组优化 正序枚举j
    for (int j = w[i]; j <= W; j--) {
    
    
        dp[j] = Integer.max(dp[j], dp[j - w[i]] + v[i]);
    }
}

3、多重背包问题

问题描述:
一共有N种物品,从第i(i从1开始)种物品的数量为n[i],重量为w[i],价值为v[i].在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值为多少?
分析:
从装第i种物品出发,装入第i种物品0件、1件、…n[i]件(并且要满足不超过限重)
dp状态方程为:

//k为装入第i种物品的件数,k<=min(n[i],j/w[i]),前者为i类物品数目,第二个为背包中能装入的i类物品的数目,取两者的较小值
dp[i][j]=max(dp[i-1][j-k*w[i]]+k*v[i])		//遍历每个k

接下来进行滚动数组优化,将状态数组的第1维度消除,同时注意应该逆序操作,同样的解释思路,参见01背包。
核心代码:

int[] dp = new int[W + 1];
for (int i = 0; i < N; i++) {
    
    
    // 滚动数组优化 逆序枚举j
    for (int j =W; j >=  w[i]; j--) {
    
    
    	for(int k=0;k<=min(n[i],j/w[i])){
    
    
    		dp[i][j]=max(dp[i-1][j-k*w[i]]+k*v[i])
    	}
    }
}

关于背包问题以及其他的优化思路,这里不做扩展。

3、参考

这些文章对我理解背包问题以及动态规划有比较大的帮助:

1、滚动数组优化
2、《滚动数组》—滚动数组思想,运用在动态规划当中
3、动态规划之背包问题系列
4、九章算法 | 背包问题
5、怎样学好动态规划?
6、动态规划算法3——最长上升子序列
7、我的知乎收藏

猜你喜欢

转载自blog.csdn.net/qq_42604176/article/details/108876793
今日推荐