[笔记]线性dp常见模型及拓展
本文主要用于记录\(dp\)学习中的一些线性模型(模板问题讲解较少,只有结论性内容和代码,而拓展会有较详细的讲解)。
\(dp\)的线性模型指的是状态转移有明显线性顺序(如一维二维数组、队列、栈等)的\(dp\),包括背包问题也是线性\(dp\)。
具体定义见https://blog.csdn.net/qq_33164724/article/details/104428502。
🍎 最长上升子序列(LIS)
LIS 最长上升子序列 / LNDS 最长不下降子序列 / LDS 最长下降子序列 / LNIS 最长不上升子序列
用\(f[i]\)表示长度为\(i\)的上升子序列,最后一个元素的最小值,其长度为\(len\),初始为\(0\)。
- 如果\(f\)为空,则直接加入该元素。
- 如果该元素\(>f[len]\),则直接加入到\(f\)的最后,\(len\)加\(1\)。
- 如果该元素\(\leq f[len]\),则在前面找第一个\(\geq f[len]\)的位置,修改为当前元素。
注意到\(f\)是单调的,所以查找这一步可以用二分,总时间复杂度\(O(nlogn)\)。
注:如果要找最长不下降子序列,就在该元素\(\geq f[len]\)时直接加入,否则找第一个\(>f[len]\)的修改。
Code
LIS 最长上升子序列(输出长度)
#include<bits/stdc++.h>
using namespace std;
int n,a[5010],f[5010];
int main(){
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
f[0]=INT_MIN;
int len=0;
for(int i=1;i<=n;i++){
if(a[i]>f[len]){
f[++len]=a[i];
}else{
int pos=lower_bound(f+1,f+1+len,a[i])-f;
f[pos]=a[i];
}
}
cout<<len;
return 0;
}
LNDS 最长不下降子序列(输出长度)
#include<bits/stdc++.h>
using namespace std;
int n,a[5010],f[5010];
int main(){
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
f[0]=INT_MIN;
int len=0;
for(int i=1;i<=n;i++){
if(a[i]>=f[len]){
f[++len]=a[i];
}else{
int pos=upper_bound(f+1,f+1+len,a[i])-f;
f[pos]=a[i];
}
}
cout<<len;
return 0;
}
LDS 最长下降子序列(输出长度)
#include<bits/stdc++.h>
using namespace std;
int n,a[5010],f[5010];
bool cmp(int a,int b){
return a>=b;
}
int main(){
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
f[0]=INT_MAX;
int len=0;
for(int i=1;i<=n;i++){
if(a[i]<f[len]){
f[++len]=a[i];
}else{
int pos=lower_bound(f+1,f+1+len,a[i],cmp)-f;
f[pos]=a[i];
}
}
cout<<len;
return 0;
}
LNIS 最长不上升子序列(输出长度)
#include<bits/stdc++.h>
using namespace std;
int n,a[5010],f[5010];
bool cmp(int a,int b){
return a>b;
}
int main(){
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
f[0]=INT_MAX;
int len=0;
for(int i=1;i<=n;i++){
if(a[i]<=f[len]){
f[++len]=a[i];
}else{
int pos=upper_bound(f+1,f+1+len,a[i],cmp)-f;
f[pos]=a[i];
}
}
cout<<len;
return 0;
}
🍐 最大连续子段和
用\(cur\)记录下到当前这一个连续段的和,\(ans\)记录最大值。
遍历每一个数值,如果说\(cur+a[i]<a[i]\),说明前面的选上只会徒增负担,故舍弃,从\(a[i]\)开始一个新的子段。每次结束后更新\(ans\)即可。
注:不需要用数组存,现读现算即可。
思考:长度最少为$2$的最大连续子段和怎么求呢?
一样的思路,只需要每次比较两个值,初始值为$a[1]+a[2]$即可。
思考:长度最少为$k$的最大连续子段和怎么求呢?
还是一样的思路,只需要每次比较$k$个值,初始值为$a[1]+a[2]+……+a[k]$即可。
需要注意的是,如果遍历$k$个元素的和,会导致无法$O(n)$完成。因此我们需要维护一个前缀和。
拓展:如果不是取$1$段,而是$m$段,又该怎么办呢?
详见此文。
Code
最大连续子段和
#include<bits/stdc++.h>
using namespace std;
int n,a,ans=INT_MIN;
int main(){
cin>>n;
int cur;
for(int i=1;i<=n;i++){
cin>>a;
if(i==1) cur=a;
else cur=max(a,cur+a);
ans=max(ans,cur);
}
cout<<ans;
return 0;
}
长度至少为$2$的最大连续子段和
#include<bits/stdc++.h>
using namespace std;
int n,a[200010],dp[200010];
int main(){
cin>>n;
for(int i=1;i<=n;i++){
cin>>a[i];
}
int cur=a[1]+a[2],ans=cur;
for(int i=3;i<=n;i++){
cur+=a[i];
if(cur<a[i]+a[i-1]) cur=a[i]+a[i-1];
if(cur>ans) ans=cur;
}
cout<<ans;
return 0;
}
长度至少为$k$的最大连续子段和
#include<bits/stdc++.h>
using namespace std;
int n,k,a[200010],b[200010],dp[200010];
int main(){
cin>>n>>k;
for(int i=1;i<=n;i++){
cin>>a[i];
b[i]=b[i-1]+a[i];
}
int cur=b[k],ans=cur;
for(int i=k+1;i<=n;i++){
cur+=a[i];
if(cur<b[i]-b[i-k]) cur=b[i]-b[i-k];
if(cur>ans) ans=cur;
}
cout<<ans;
return 0;
}
🥭 最大上升子序列和
用\(f[i]\)表示到第\(i\)个元素的最大上升子序列和。
遍历每一个元素。对于每一个位置,遍历其前面所有的元素,如果遇到\(a[j]<a[i]\)的,就用\(f[j]\)更新最大值。
最后别忘了加上本身\(a_i\)。
Code
最大上升子序列和
#include<bits/stdc++.h>
using namespace std;
int n,a[100010],f[100010],ans=INT_MIN;
int main(){
cin>>n;
for(int i=1;i<=n;i++){
cin>>a[i];
}
for(int i=1;i<=n;i++){
for(int j=1;j<i;j++){
if(a[i]>a[j]) f[i]=max(f[i],f[j]);
}
f[i]+=a[i];
ans=max(ans,f[i]);
}
cout<<ans<<endl;
return 0;
}
🫐 最长公共子序列(LCS)
用\(f[i][j]\)表示\(A[1\sim i]\)和\(B[1\sim j]\)的LCS长度。递推公式:
最终答案就是\(f[n][m]\)。
若要输出路径(如Atcoder dp_f LCS),需要用另一个二维数组记录上一个格子在上方、左方还是左上方。从最右下角开始回溯,遇到往左上方走的就添加\(a[i]\)或\(b[j]\)(此时它们是相等的)入\(ans\)字符串。最后反序输出即可。如下图(网上找的):
拓展:有$O(m^2\log n)$和$O(m^2+n\Sigma)$($\Sigma$是字符集大小)的做法。
对于长度为$n$的$S$与长度为$m$的$T$,设$f[i][j]$为考虑$T$的前$i$个元素,LCS长度为$j$时,$S$匹配到的最小下标,若匹配不到为$+\infty$。
则\(f[i][j]\)可以被\(f[i-1][j]\)和\(k\)更新,其中\(k\)是\((f[i-1][j-1]+1)\sim n\)中满足\(S[x]=j\)的最小\(x\),可以二分求出。这样时间复杂度是\(O(m^2 \log n)\)。
我们花费\(O(n\Sigma)\)的复杂度来维护一个\(nxt[i][j]\),表示\(i\sim n\)中满足\(S[x]=j\)的最小\(x\)。转移时就不用二分了,直接调用\(nxt\)即可,时间复杂度\(O(m^2+n\Sigma)\)。
这样我们可以应对\(m\)较小,\(n\)较大的情况,具体可以根据字符集大小来决定使用哪一种,字符集超过\(m\log n\)使用前者。
点击查看代码
#include<bits/stdc++.h>
#define N 1000010
#define M 1010
#define C 26
using namespace std;
string s,t;
int n,m,f[M][M],cur[C],nxt[N][C];
//f[i][j]:T匹配到i,LCS长为j,S最少用到哪里
signed main(){
cin>>s>>t;
n=s.size(),m=t.size(),s=' '+s,t=' '+t;
memset(cur,0x3f,sizeof cur);
for(int i=0;i<C;i++) nxt[n+1][i]=cur[0];
for(int i=n;i>=1;i--){
cur[s[i]-'a']=i;
for(int j=0;j<C;j++) nxt[i][j]=cur[j];
}
memset(f,0x3f,sizeof f);
int INF=f[0][0];
for(int i=0;i<=m;i++) f[i][0]=0;
for(int i=1;i<=m;i++){
for(int j=1;j<=i;j++){
f[i][j]=f[i-1][j];
if(f[i-1][j-1]!=INF)
f[i][j]=min(f[i][j],nxt[f[i-1][j-1]+1][t[i]-'a']);
}
}
for(int i=m;i>=1;i--){
if(f[m][i]!=INF){
cout<<i<<"\n";
return 0;
}
}
return 0;
}
拓展:还有
bitset
压位解法。时间复杂度是$O(\frac{nm}{\omega})$,$\omega$为字长。这个还没学,就先不放了(逃
拓展:没有重复元素且两序列元素集合相同时可以转化为LIS问题。
详见此文。
拓展:如何计算LCS个数?
详见此文。
Code
LCS输出路径
#include<bits/stdc++.h>
using namespace std;
string a,b;
int n,m,f[3010][3010];
char d[3010][3010];
int main(){
cin>>a>>b;
n=a.size(),m=b.size();
a=' '+a,b=' '+b;
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
if(a[i]==b[j]){
d[i][j]='+';
f[i][j]=f[i-1][j-1]+1;
}else{
if(f[i-1][j]>f[i][j-1]){
d[i][j]='U';
f[i][j]=f[i-1][j];
}else{
d[i][j]='L';
f[i][j]=f[i][j-1];
}
}
}
}
string ans="";
for(int x=n,y=m;x>0&&y>0;){
if(d[x][y]=='+'){
ans+=a[x];
x--,y--;
}else if(d[x][y]=='U'){
x--;
}else{
y--;
}
}
reverse(ans.begin(),ans.end());
cout<<ans;
return 0;
}
\([Fin.]\)
如果有任何建议或疑问,请在评论区告诉我,我会不断改进。谢谢!