线性动态规划 思路/方程合集

1. 前言

对于 DP 类题目,尽量套模板,如果暂时看不出来就有几个变化量设几个状态,之后归纳看看像哪一种模板。如果实在看不出来就写暴力拿部分分。

关于线性DP

最简单的,也可能是最难的,方程通常是当循环到某个地方时,能取/获得的最大/小的值,主要难点如下:

  1. 藏得深,线性 DP 难题的方程通常不会一眼看出,需要找特征。
  2. 取的值可能不会直接在输入中给出,需要预处理。
  3. 有一些附加的值需要计算(如最大长度的数量等)。

一些好题推荐:

洛谷 P2362
洛谷 P1203
洛谷 P1796
洛谷 P1057
洛谷 P1799
CSPJ2020 洛谷 P7074
洛谷 P2028


1.1 LIS(Longest Increasing Subsequence)最长上升子序列

\(O(n^2)\) 做法:设 \(dp(k)\) 表示到第个数时的最长上升子序列的长度。每次寻找时遍历前面的位置,如果那个位置的数比它小且拥有的最长上升子序列数量最大时继承。

#include<bits/stdc++.h>
using namespace std;
int a[5005],dp[5005];
int main(){
	
	int n;
	cin>>n;
	for(int i=1;i<=n;i++){
	    cin>>a[i];
	    dp[i]=1;
	}
	int ans=0;
	for(int i=2;i<=n;i++){
		for(int j=i-1;j>=1;j--){
			if(a[i]>a[j]){//满足上升 
				dp[i]=max(dp[i],dp[j]+1);//继承 
				ans=max(dp[i],ans);//更新答案
			}
		}
	}
	cout<<ans;
	
	return 0;
}

当需要记录最长上升子序列的个数时,只需另安排一个数组,边 DP 边记录即可。将 for 循环内部改为:

//nums[i]表示长度为dp[i]的LIS的个数。
if(a[i]>=a[j]){//满足上升,可以前后连接
	if(dp[i]<dp[j]+1){//比前面长度短,能继承
		dp[i]=dp[j]+1;
		nums[i]=nums[j];//此时长度更优,直接覆盖
	}
	else if(dp[i]==dp[j]+1){
		nums[i]+=nums[j];//此时长度相同,数量增加
	}	
}

输出时,在逐一遍历时找出最长长度,如果 \(dp(i)\) 比当前最长长度大,就把个数更新为 \(nums(i)\),如果相等则加上 \(nums(i)\),如以下程序:

int maxl=0,ans=0;
for(int i=1;i<=n;i++){
	if(dp[i]>maxl){//更优时
		maxl=dp[i];
		ans=nums[i];//更新
	}
	else if(dp[i]==maxl){//相等时,这里要写else if,否则会重复计算
		ans+=nums[i];//增加
	}
}
	cout<<maxl<<" "<<ans;

\(O(n\log{n})\) 做法
本质上已经是一种贪心算法。我们设 \(dp(i)\) 是这个数列中以 \(dp(i)\) 这个数结尾的长度为 \(i\) 的 LIS(即 dp 数组表示的是这个数列的 LIS )。
因为我们要求的是** 最长上升子序列 ** ,所以当序列中的数越小,在接下来才有可能容纳更多上升的数, 且如果我们把大的数替换成了小的数,答案至少不会更差,这便印证了这个算法的正确性。
对于替换的位置,因为这是一个上升子序列,所以内部元素有序,我们可以用二分来确定位置,时间复杂度 \(O(n\log{n})\)

代码(使用 lower_bound 函数二分 ):

#include<bits/stdc++.h>
using namespace std;
int a[5005],dp[5005],nums[5005];
int len=0;
int main(){
	
	int n;
	cin>>n;
	for(int i=1;i<=n;i++){
		cin>>a[i];
	}
	dp[++len]=a[1];//第一个一定可以加入序列
	for(int i=2;i<=n;i++){
		if(dp[len]<a[i]){
			dp[++len]=a[i];//如果比序列头大,则直接加入序列即可
		}
		else{
			int wp=lower_bound(dp+1,dp+1+len,a[i])-dp;//如果不能,二分找第一个大于等于此元素的位置并把它替换,注意二分函数的返回值问题。
			dp[wp]=a[i];
		}
	}
	cout<<len;//输出长度即可
	
	return 0;
}

注意

这样的算法虽然可以正确的求出 LIS 的长度,但是不保证 dp 数组中的值就是 LIS 的值,例如当数据结尾是\(1\)时,\(1\)可能会将 dp 数组中的对应位置替换。尽管\(1\)是这个 LIS 的开头,但并不能与数组中的数组成 LIS,仅是保证未来可能的更优性。而且这种算法无法求解 LIS 的种类数
最长单调不降子序列,最长递减子序列,最长单调不增子序列的解法大致相同,更改一下比较条件即可。


1.2 LCS(Longest Common Subsequence)最长公共子序列

给定两个数组(或是字符串),求两者的最长公共子序列。
对比 LIS ,我们发现 LCS 需要对两个数组进行操作,根据我们的做题原则,此时要设立两个状态。
因为数组有两个,因此只设立一种状态是无法完成的。
我们不妨设 \(dp(i,j)\) 表示数组1到第 \(i\) 位,数组2到第 \(j\) 位时的 LCS。考虑转移。我们知道当 \(i\) , \(j\) 指向的位置的数据相同时才会增加长度,我们不妨从这里入手。因为 \(i\) , \(j\) 相同,所以我们必须把他们收入囊中,于是有

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

注意,此时不能从 \(dp(i-1,j)\)\(dp(i,j-1)\) 转移,因为我们要将这两个相同元素拿取,就必须将 \(i\) , \(j\) 同时前进,如果这样转移就相当于自己不动(指没有减一的元素),另一个数前进。这样相当于重复计算
当不相等时,状态从 \(dp(i-1,j)\)\(dp(i,j-1)\)\(dp(i-1,j-1)\) 转移即可,为什么此时可以从 \(dp(i-1,j-1)\) 转移呢,因为这个状态代表收入囊中,两个不相等的数被收入囊中对我们的答案没什么影响(但是不建议写上)。
代码如下 -时间复杂度 \(O(nm)\)\(n\),\(m\) 分别为两数组的长度 )

#include<bits/stdc++.h>
using namespace std;
int a[10000],b[10000];
int dp[2000][2000];
int main(){
	
	int n,m;
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		cin>>a[i];
	}
	for(int i=1;i<=m;i++){
		cin>>b[i];
	}
	for(int i=1;i<=n;i++){
		for(int j=1;j<=m;j++){
			if(a[i]==b[j]){
				dp[i][j]=max(dp[i][j],dp[i-1][j-1]+1);//相同时的方程
			}
			else{
				dp[i][j]=max(dp[i-1][j],dp[i][j-1]);//不同时的方程
			}
		}
	}
	cout<<dp[n][m];//输出答案
	
	return 0;
}

对于 LCS 的统计,由于长度的变化仅在状态 \(dp(i,j)=max(dp(i,j),dp(i-1,j-1)+1)\) 时发生改变,所以我们仅需在这里增加判断即可,具体原理与计数排序相同。仅需在 for 循环中的 max 函数后增加一行:

num[dp[i][j]]++;

这样对于任意的 LCS 长度都可以随时访问了,如果仅是找最大,可以只用一个变量,这里不再演示。


1.3 LCS特殊数据的 \(O(n\log{n})\) 求法

当题目中提到每个字串中没有相同字符的情况下,有求 LCS 的 \(O(n\log{n})\) 方法。
例如这个题,题目中有一句话:

\[\text{接下来两行,每行为 $n$ 个数,为自然数 $1,2,\ldots,n$ 的一个排列。} \]

排列 的意思便是没有相同的数字。
我们把这样的问题转化为 LIS。
首先看两组如上文的排列数,我们要求 LCS:

\[List\ 1:\ \ \ \ \ \ \ \ \ 1,2,3,4,5,6,7 \]

\[List\ 2:\ \ \ \ \ \ \ \ \ 0,1,2,4,5,6,3 \]

我们将 \(List \ 1\) 中的数字位置映射到 \(List \ 2\) 中去,如下:

\[List\ 1:\ \ \ \ \ \ \ \ \ 1_{1},2_{2},3_{3},4_{4},5_{5},6_{6},7_{7} \]

\[List\ 2:\ \ \ \ \ \ \ \ \ 0_{del},1_{1},2_{2},4_{4},5_{5},6_{6},3_{3} \]

其中 \(List \ 1\) 的角标表示这个数在数组中的位置,\(List \ 2\) 的角标表示其在 \(List \ 1\) 中的位置。
因为 \(0\) 没有其对应在 \(List \ 1\) 中的值,故一定不会对 LCS 有贡献,因此可以直接删掉。
由此我们可以发现,当一个 \(List \ 2\) 的子序列和 \(List \ 1\) 构成公共子序列时,\(List \ 2\) 中对应的下标一定是单调递增的!
于是套用 LIS 的 \(O(n\log{n})\) 模板,问题成功解决。
具体代码看这里


小结

最长\(\cdots\)字串等一类问题只需考虑从上一个转移,相对于 LIS,LCS 更为简单,这里不做演示。


1.4 背包问题

€€£ 最喜欢放在 PJ-T4 了。

1.4.1 01背包

万恶之源
给一些东西的体积,价值。又给一个有体积的背包,问在背包体积限制下能拿多大价值的东西。
不想解释了qwq,代码:

cin>>n>>mw;//数量与背包容积
for(int i=1;i<=n;i++){
	cin>>w[i]>>v[i];
} 
for(int i=1;i<=n;i++){
	for(int j=mw;j>=w[i];j--){
		dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
	}//压空间
}
cout<<dp[mw];

注意

有些时候价值的数据范围会很大,这时候可以考虑将 dp 数组的表示变成拿到....价值时所需的最小重量 。例如这个

1.4.2 多重背包

可以拿多次了,思路有两种:

  1. 把多重背包每个物品可以拿的次数拆成一个一个物品的形式,成为一个01背包。但经常 MLE。
  2. 通常使用二进制优化,因为二进制只用0与1即可表示所有整数,与背包动态规划取与不取的状态正好符合。且对于 int 范围内的整数,最多只需 \(32\) 个下标位置即可存下,极大节省空间。

也很简单的代码:

cin>>n>>w;
for(int i=1;i<=n;i++){
	cin>>vi>>pi>>si;
	int f=1;
	while(f<=si){
		k++;
		wei[k]=vi*f;
		pri[k]=pi*f;
		si-=f;
		f=f*2;
	}//简单但很容易错的二进制部分 
	if(si>0){
		k++;
		wei[k]=vi*si;
		pri[k]=pi*si;
	}//记得把剩下的也加上 
}
for(int i=1;i<=k;i++){
	for(int j=w;j>=wei[i];j--){
		dp[j]=max(dp[j],dp[j-wei[i]]+pri[i]);//普通的01背包 
	}
}
cout<<dp[w];

1.4.3 完全背包

可以拿无限次了,思路有两种:

  1. 把每个物品可以拿的次数一个一个枚举直到背包装不下,成为一个01背包。但 \(O(n^3)\),经常 TLE。
  2. 01背包倒过来枚举即可。
    证明:当要更新 \(dp(i,j)\) 时,一定会从 \(dp(i,j-w[i])\) 处比较大小并更新。同理,当更新 \(dp(i,j-w[i])\) 时,一定从 \(dp(i,j-w[i] \times 2)\) 处比较大小并更新。乘以二的关系,又构成了二进制的性质,便可以计算出所有物品拿所有个数的情况了。

代码:

cin>>n>>mw;//数量与背包容积
for(int i=1;i<=n;i++){
	cin>>w[i]>>v[i];
} 
for(int i=1;i<=n;i++){
	for(int j=w[i];j<=mw;j++){
		dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
	}//倒过来
}
cout<<dp[mw];

1.4.4 分组背包

背包中的物品被分成了许多组,每个组中只能拿一个物品,求最大价值。
01背包的变式,只需多一重 for 循环枚举组数,转移时从上一组的情况转移即可。
转移时同样可以滚动优化,代码:

for(int i=1;i<=n;i++){//枚举组数
	for(int j=m;j>=0;j--){//倒过来滚动优化,同01背包
		for(int k = 1;k<=s[i];k++){//s[i]记录每一组的物品个数,也可以用vector存储,.size()函数访问更方便
			if(j>=v[i][k])//第i组的第k个物品
				f[j] = max(f[j],f[j-v[i][k]]+w[i][k]);
		}
	}
}

1.4.5 混合背包

更简单,对于01和多重背包,二进制优化一下倒着跑一遍循环,完全背包就正着跑。
代码:

#include<bits/stdc++.h>
using namespace std;
int p[20005],v[20005],s[20005];//s[]数组用来标记这个数据的类型(是01多重,还是完全) 
int dp[2000005];
int n,vv,t1=1;
int main(){
	cin>>n>>vv;//物品数,背包容积 
	int p1,v1,s1;
	for(int i=1;i<=n;i++){
		cin>>v1>>p1>>s1;
		if(s1==-1){
			p[t1]=p1;
			v[t1]=v1;
			s[t1]=s1;
			t1++;
		}//是01类型,直接存入并标记 
		else if(s1==0){
			p[t1]=p1;
			v[t1]=v1;
			s[t1]=s1;
			t1++;
		}//是完全类型,直接存入并特殊标记 
		else{
			//二进制优化一下,存入并标记,标记的数和01相同,这里省略 
		}
	}
	for(int i=1;i<=t1-1;i++){
		if(s[i]==0){//是完全类型 
			for(int j=v[i];j<=vv;j++){//正着跑循环 
				dp[j]=max(dp[j],dp[j-v[i]]+p[i]);
			}
		}
		else{//是01,多重 
			for(int j=vv;j>=v[i];j--){//倒着跑循环 
				dp[j]=max(dp[j],dp[j-v[i]]+p[i]);
			}
		}
	}
	cout<<dp[vv];
	
	return 0;  
}

迁移自洛谷
posted @ 2025-02-04 11:47  hm2ns  阅读(27)  评论(0)    收藏  举报