【刷穿 CSP-S】线性 DP

AcWing 482. 合唱队形

对于时间限制为 1s 的题目,往往时间复杂度 1e8 就能过。
钦定 \(k\) 为第 \(k\) 类合唱队形的最高点,容易发现这种分类方法是不重不漏的。
注意到,\(最少出列的人数 = n - 最多保留的人数\)
由于 \(k\) 以前的点和 \(k\) 之后的点相对独立,所以左边最多保留人数和右边最多保留人数分别为以 \(k\) 结尾的最长单调上升子序列长度和以 \(k\) 开头的最长单调下降子序列长度。这可以用 DP 在 \(O(n^2)\) 的复杂度下预处理。

#include<bits/stdc++.h>
using namespace std;
const int N=108; 
int n,h[N],l[N],r[N];
int main(){
	cin>>n;
	for(int i=1;i<=n;i++)cin>>h[i];
	for(int i=1;i<=n;i++){
		l[i]=1;
		for(int j=1;j<i;j++){
			if(h[j]<h[i])l[i]=max(l[i],l[j]+1);
		}
	}
	for(int i=n;i>=1;i--){
		r[i]=1;
		for(int j=n;j>i;j--){
			if(h[j]<h[i])r[i]=max(r[i],r[j]+1);
		}
	}
	int res=0;
	for(int i=1;i<=n;i++)res=max(res,l[i]+r[i]-1);
	cout<<n-res;
	return 0;
}

AcWing 484. 过河

由朴素方法逐步优化至满分解法是很好的思想。
先考虑朴素做法。
定义 \(dp[i]\) 表示位置为 \(i\) 时最少踩过的石子数,转移方程为:

\[dp[i]=\min_{S\leq j\leq T}(dp[i],dp[i-j]+w[i]) \]

其中 \(w[i]\) 为二元变量,表示位置 \(i\) 是否有石子。时间复杂度为 \(O(10L)\)
定睛一看 \(L \leq 10^9\),需考虑更优解法。
注意到 \(M \leq 100\),猜测其为时间复杂度瓶颈。发现在极端情况下,会出现两个石子之间的间隔非常之大,一个个枚举浪费了时间。如果可以类似离散化的做法,将相邻两个石子间的间隙修改为最小的可达距离,那问题就迎刃而解了。

定理:
对于互质的两个正整数 \(x\)\(y\),通过 \(px+qy \ (p \in \mathbb{N} , q \in \mathbb{N})\) 不能凑成的最大数为 \(xy-x-y\)。可记为 \((x-1)(y-1)-1\)

知道了这条定理,那么闭区间 \([S,T]\) 中最大不可达的距离为 \((10-1)\times(10-1-1)-1=71\),即最大可达距离为 \(72\)。考虑到两个石子与最大可达距离各间隔 \(10\),所以共有 \(92\) 个间隙。不妨视为 \(100\)。再依照上面的思路走,时间复杂度为 \(O(10K)\),其中 \(K=(M+1) \times M=10100\)(包含起终点),可以 AC 了。

#include<bits/stdc++.h>
using namespace std;
const int K=10108;
int stone[108],w[K],dp[K],n,s,t,m;
int main(){
	cin>>n>>s>>t>>m;
	if(s==t){
		int res=0;
		while(m--){
			int x;
			cin>>x;
			if(x%s==0)res++;
		}
		cout<<res;
		return 0;
	}
	for(int i=0;i<m;i++)cin>>stone[i];
	sort(stone,stone+m);
	int lst=0,k=0;
	for(int i=0;i<m;i++){
		for(int j=0;j<min(100,stone[i]-lst);j++){
			w[++k]=0;
		} 
		w[k]=1;
		lst=stone[i];
	}
	for(int i=1;i<=k+t;i++){
		dp[i]=m; 
		for(int j=s;j<=t;j++){
			if(i-j>=0){
				dp[i]=min(dp[i],dp[i-j]+w[i]); 
			}
		}
	}
	int res=m;
	for(int i=k+1;i<=k+t;i++)res=min(res,dp[i]);
	cout<<res;
	return 0;
} 

AcWing 275. 传纸条

经典的方格取数问题。本题为 2 取方格数。\(k\) 取方格数(\(k\) 较大)可以用费用流解决。
考虑如何避免两条路径中有重复格子。路径从 \((1,1)\) 走到 \((n,m)\),需要走 \(n+m-2\) 步。若钦定两条路径都从起点开始,可以发现路径中相交的格子一定在同一步数内。

状态定义

\(dp[k][i][j]\) 表示两条路径都从起点开始走了 \(k\) 步,第一条路径结尾在 \((i,k-i)\) 处,第二条路径结尾在 \((j,k-j)\) 处的所有方案的最大值。

状态转移

以第一条路径向下走,第二条路径向右走(简记为“下、右”,后同)为例分析转移方程:
第一条路径末尾 \((i,k-i)\)\((i-1,k-i)\) 转移过来,第二条路径末尾 \((j,k-j)\)\((j,k-j-1)\) 转移而来。根据状态定义,上一点的步数为 \((i-1)+(k-i)=k-1\) 步,第一条路径上一点横坐标为 \(i-1\),第二条路径上一点横坐标为 \(j\)。故 \(dp[k][i][j]\) 可以由 \(dp[k-1][i-1][j]\) 转移而来。
类似地,“下、下”对应 \(dp[k-1][i-1][j-1]\);“右、右”对应 \(dp[k-1][i][j]\);“右、下”对应 \(dp[k-1][i][j-1]\)
总的转移方程为:

\[dp[k][i][j]=max_{i\neq j}({dp[k-1][i][j],dp[k-1][i-1][j],dp[k-1][i][j-1],dp[k-1][i-1][j-1]})+w[i][k-i]+w[j][k-j] \]

其中 \(w[a][b]\) 为点 \((a,b)\) 的权值。
另外,由于 \(i+J=k\)\(J\leq m\) (横坐标小于总列数)可推出 \(i\geq k-m\)。对于 \(j\) 是同理。

#include<bits/stdc++.h>
using namespace std;
const int N=58;
int n,m,w[N][N],dp[N*2][N][N];
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		for(int j=1;j<=m;j++){
			cin>>w[i][j];
		}
	}
	for(int k=2;k<=n+m;k++){
		for(int i=max(1,k-m);i<=n&&i<=k;i++){
			for(int j=max(1,k-m);j<=n&&j<=k;j++){
				if(i!=j||k==2||k==n+m){
					dp[k][i][j]=max({dp[k-1][i-1][j],dp[k-1][i-1][j-1],
					dp[k-1][i][j],dp[k-1][i][j-1]})
					+w[i][k-i]+w[j][k-j];
				}
			}
		}
	}
	cout<<dp[n+m][n][n];
	return 0;
}

AcWing 312. 乌龟棋

对于空间限制很小的题目,考虑算空间复杂度可不可以接受。

状态表示

\(dp[A][B][C][D]\) 表示分别用了 \(A,B,C,D\) 张四种卡片的最大分值。
单个卡片最多数量不超过 \(40\),我们多开一位,所以时间和空间复杂度均为 \(O(41^4)\)
\(41^4\) 个 int,每个 int \(4\) 个字节(Byte),每 \(1024\) 个字节是 \(1\)KB,每 \(1024\) 个 KB 是 \(1\) MB。估算为 \(41^4 \times 4 \div 1024 \div 1024 \approx 10.24\)MB。可以接受。

状态转移

只要当前状态用过某种卡片 \(k\),就可以从 \(k-1\) 转移过来。

Trick

存引用变量,少打字。

#include<bits/stdc++.h>
using namespace std;
const int M=41,N=358;
int n,m,s[5],dp[M][M][M][M],w[N];
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++)cin>>w[i];
	for(int i=1,x;i<=m;i++){
	    cin>>x;
	    s[x]++;
	}
	for(int A=0;A<=s[1];A++){
		for(int B=0;B<=s[2];B++){
			for(int C=0;C<=s[3];C++){
				for(int D=0;D<=s[4];D++){
					int &v=dp[A][B][C][D]; // 小trick:存引用,少打字 
					int x=w[1+A+B*2+C*3+D*4];
					v=x;
					if(A)v=max(v,dp[A-1][B][C][D]+x);
					if(B)v=max(v,dp[A][B-1][C][D]+x);
					if(C)v=max(v,dp[A][B][C-1][D]+x);
					if(D)v=max(v,dp[A][B][C][D-1]+x);
				}
			}
		}
	}
	cout<<dp[s[1]][s[2]][s[3]][s[4]];
	return 0;
}

AcWing 520. 子串

“纸上得来终觉浅,绝知此事要躬行。”
——题记

从朴素的方法出发

状态表示

\(dp[i][j][k]\) 表示用 \(A\) 中的前 \(i\) 个字符,凑出 \(B\) 中的前 \(j\) 个字符,分出了 \(k\) 个子串的总方案数。

状态转移

不难发现,可以选择第 \(i\) 个字符的充要条件是 \(A[i]=B[j]\)

先按选择 \(i\) 与否划分集合。

  • 不选 \(i\)
    此时的总方案数为 \(dp[i-1][j][k]\)

  • \(i\)
    此时 \(i\) 即为第 \(k\) 个字串当前的末尾。设 \(t\) 为第 \(k\) 个字串的长度,此时的总方案数即为:

\[\sum_{t=1}^j dp[i-t][j-t][k-1] \]

综上,转移方程为:

\[dp[i][j][k]=dp[i-1][j][k]+\sum_{t=1}^j dp[i-t][j-t][k-1] \]

时间复杂度为 \(O(nm^2k)\)。需要进一步优化。

前缀和优化时间复杂度

观察前一项 \(dp[i-1][j-1][k]\) 的转移方程:

\[dp[i-1][j-1][k]=dp[i-2][j-1][k]+\sum_{t=1}^{j-1} dp[i-t-1][j-t-1][k-1] \]

注意到,

\[\sum_{t=1}^{j-1} dp[i-t-1][j-t-1][k-1]+dp[i-1][j-1][k-1]=\sum_{t=1}^j dp[i-t][j-t][k-1] \]

所以,可以通过递推的方式维护一个前缀和数组 \(sum[i][j][k]\)
其递推公式为:

\[sum[i][j][k]=\begin{cases} 0, &a[i]\neq b[j] \\ sum[i-1][j-1][k]+dp[i-1][j-1][k-1], &a[i]=b[j] \end{cases}\]

用空间换时间,我们巧妙地把时间复杂度优化到了可接受的 \(O(nmk)\)

优化空间复杂度的两种方法

然而,这道题的套路仍没有结束。定睛一看,内存限制只有 \(128\) MB。我们开的两个三维数组总计为 \(2\times 1000 \times 200 \times 200 \times 4 \div 1024 \div 1024 \approx 320\)MB。会罕见地报错 MLE。

这时候我们有两种考察方向:一种是一般性的滚动数组优化,另一种是类似与01背包的倒序枚举。

01 背包倒序枚举思想

充要条件:第 \(i\) 层的状态转移只依赖上一层 \(i-1\) 层的状态,转移所需要下标均比当前下标更小。

不难发现本题转移方程满足充要条件,可以类似 01 背包做空间复杂度上的优化。

更进一步地,我们发现枚举 \(j\)\(k\) 的时候不需要枚举等于 \(0\) 的情况。

对于动态规划转移方程而言,\(k=0\) 时方程为恒等式;对于前缀和递推方程而言,\(k=0\) 时下标为负数,无意义。

#include<bits/stdc++.h>
using namespace std;
const int M=208,p=1e9+7;
int n,m,K,dp[M][M],sum[M][M];
string a,b;
int main(){
	cin>>n>>m>>K>>a>>b;
	a=' '+a;b=' '+b;
	dp[0][0]=1; 
	for(int i=1;i<=n;i++){
		for(int j=m;j>=1;j--){
			for(int k=1;k<=K;k++){
				if(a[i]!=b[j])sum[j][k]=0;
				else sum[j][k]=sum[j-1][k]+dp[j-1][k-1];
				sum[j][k]%=p;
				dp[j][k]=dp[j][k]+sum[j][k];
				dp[j][k]%=p;
			}
		}
	}
	cout<<dp[m][K];
	return 0;
}

滚动数组

充要条件:第 \(i\) 层的状态转移只依赖上一层 \(i-1\) 层的状态。

由此看出,利用滚动数组优化空间复杂度是更普遍适用的方式。

基本步骤如下:

  1. 写出朴素的实现方式
  2. 将需要压缩的那一维数组大小开为 \(2\),在后面的操作中,被压缩维的所有下标做 &1 操作。

此时,钦定下标从 \(0\) 开始往往是更简单的做法。

#include<bits/stdc++.h>
using namespace std;
const int M=208,p=1e9+7;
int n,m,K,dp[2][M][M],sum[2][M][M];
string a,b;
int main(){
	cin>>n>>m>>K>>a>>b;
	a=' '+a;b=' '+b;
	dp[0][0][0]=1; 
	for(int i=1;i<=n;i++){
		for(int j=m;j>=0;j--){
			for(int k=0;k<=K;k++){
				if(a[i]!=b[j])sum[i&1][j][k]=0;
				else{
					if (j){
						sum[i&1][j][k]=sum[i-1&1][j-1][k]; 
						if(k){
							sum[i&1][j][k]+=dp[i-1&1][j-1][k-1];
						}
					}
					sum[i&1][j][k]%=p;
			    }
				dp[i&1][j][k]=dp[i-1&1][j][k]+sum[i&1][j][k];
				dp[i&1][j][k]%=p;
			}
		}
	}
	cout<<dp[n&1][m][K];
	return 0;
}

AcWing 1155. Emiya 家今天的饭

“正难则反”的补集思想在很多时候都大有用处。

答案要求同时满足给定的 3 个条件,可以转化为 \(满足条件 1 和条件 2 的方案 - 不满足条件 3 的方案\),分别来求。

先求满足部分条件的方案数

状态表示

\(f[i][j]\) 表示前 \(i\) 种方法,做出了 \(j\) 道菜的方案数。

对答案的总贡献即为:

\[\sum_{t=1}{n} f[n][t] \]

状态转移

类似于上一题子串,考虑如何划分集合。

不使用第 \(i\) 种方法

显然,由 \(f[i-1][j]\) 转移而来。

使用第 \(i\)种方法

设选用食材的编号为 \(t\)\(f[i][j]\)\(f[i-1][j-1]\) 转移过来。

分步关系用乘法原理,并列关系用加法原理。所以对答案的总贡献为:

\[f[i-1][j-1] \times \sum_{t=1}^m a[i][t] \]

注意到,对于 \(\sum_{t=1}^m a[i][t]\) 可以预处理一个前缀和数组 \(s[i]\),计算输入矩阵中每一行的和。

这一部分时间复杂度为 \(O(n^2)\)

再求不满足条件 3 的方案数

注意到,所有不合法的方案必定有且仅有一种食材在过半数的菜中被使用,我们称之为越界食材。

因此,我们可以在最外层枚举这个越界食材的编号 \(k\)

状态表示

\(g[i][j]\) 表示用前 \(i\) 种方法,$用第 \(k\) 种食材的菜数 - 用其余食材的菜数 = j$ 的方案数。

存差值是个小 trick。因为我们只关心差值,而不关心具体的用某种食材的菜数,所以只存差值可以少开一维数组。

状态转移

同理,按照是否用第 \(i\) 种方法分类。

不使用第 \(i\) 种方法

显然,由 \(g[i-1][j]\) 转移而来。

使用第 \(i\) 种方法

进一步地,我们按照所用食材是否为 \(k\) 再分类。

  • 所用食材为 \(k\)

\[g[i-1][j-1] \times a[i][k] \]

  • 所用食材不为 \(k\)

\[g[i-1][j+1] \times \sum_{t=1,t\neq k}^m a[i][t] \]

注意到,\(\sum_{t=1,t\neq k}^m a[i][t]\) 可以用前面的 \(s[i]\) 数组维护,即:
\(\sum_{t=1,t\neq k}^m a[i][t] = \sum_{t=1}^m a[i][t] - a[i][k]\)

时间复杂度为 \(O(mn^2)\),也可以接受。

代码细节

  1. \(g\) 数组的大小开够 \(g[N*2][N]\)。下标不能为负数,第一维统一加上偏移量 \(n\) 就可以了。
  2. 手算空间复杂度发现没必要优化。发散一下思维,\(f\) 数组可以用上一道题将的 01 背包倒序优化,\(g\) 数组可以用滚动数组优化。这样,空间消耗会少很多,代码会跑得慢一些。
  3. 用到减法的时候,取模之前先加上模数,防止出现负数的情况。

不优化空间复杂度

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=108,M=2008,p=998244353;
int n,m,a[N][M],f[N][N],g[N][2*N],s[N];
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		for(int j=1;j<=m;j++){
			cin>>a[i][j];
			s[i]=(s[i]+a[i][j])%p;
		}
	} 
	f[0][0]=1;
	for(int i=1;i<=n;i++){
		for(int j=0;j<=i;j++){
			f[i][j]=(f[i-1][j]+(ll)f[i-1][j-1]*s[i])%p;
		}
	}
	int ans=0;
	for(int i=1;i<=n;i++){
		ans=(ans+f[n][i])%p;
	}
	for(int k=1;k<=m;k++){
		memset(g,0,sizeof(g));
		g[0][n]=1;
		for(int i=1;i<=n;i++){
			for(int j=1;j<=n+i;j++){
				g[i][j]=(g[i-1][j]+(ll)g[i-1][j-1]*a[i][k]
				+(ll)g[i-1][j+1]*(s[i]-a[i][k]))%p;
			}
		}
		for(int j=n+1;j<=2*n;j++){
			ans=(ans-g[n][j]+p)%p;
		}
	}
	cout<<ans;
	return 0;
}

优化空间复杂度

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=108,M=2008,p=998244353;
int n,m,a[N][M],f[N],g[2][2*N],s[N];
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		for(int j=1;j<=m;j++){
			cin>>a[i][j];
			s[i]=(s[i]+a[i][j])%p;
		}
	} 
	f[0]=1;
	for(int i=1;i<=n;i++){
    	for(int j=i;j>=1;j--){
			f[j]=(f[j]+(ll)f[j-1]*s[i])%p;
		}
	}
	ll ans=0;
	for(int i=1;i<=n;i++){
		ans=(ans+f[i])%p;
	}
	for(int k=1;k<=m;k++){
		memset(g,0,sizeof(g));
		g[0][n]=1;
		for(int i=1;i<=n;i++){
			for(int j=1;j<=n+i;j++){
				g[i&1][j]=(g[i-1&1][j]+(ll)g[i-1&1][j-1]*a[i][k]
				+(ll)g[i-1&1][j+1]*(s[i]-a[i][k]))%p;
			}
		}
		for(int j=n+1;j<=2*n;j++){
			ans=(ans-g[n&1][j]+p)%p;
		}
	}
	cout<<ans;
	return 0;
}
posted @ 2026-08-19 17:29  zheyutao  阅读(2)  评论(0)    收藏  举报