【刷穿 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\) 时最少踩过的石子数,转移方程为:
其中 \(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]\)。
总的转移方程为:
其中 \(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\) 个字串的长度,此时的总方案数即为:
综上,转移方程为:
时间复杂度为 \(O(nm^2k)\)。需要进一步优化。
前缀和优化时间复杂度
观察前一项 \(dp[i-1][j-1][k]\) 的转移方程:
注意到,
所以,可以通过递推的方式维护一个前缀和数组 \(sum[i][j][k]\)。
其递推公式为:
用空间换时间,我们巧妙地把时间复杂度优化到了可接受的 \(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\) 层的状态。
由此看出,利用滚动数组优化空间复杂度是更普遍适用的方式。
基本步骤如下:
- 写出朴素的实现方式
- 将需要压缩的那一维数组大小开为 \(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\) 道菜的方案数。
对答案的总贡献即为:
状态转移
类似于上一题子串,考虑如何划分集合。
不使用第 \(i\) 种方法
显然,由 \(f[i-1][j]\) 转移而来。
使用第 \(i\)种方法
设选用食材的编号为 \(t\)。\(f[i][j]\) 由 \(f[i-1][j-1]\) 转移过来。
分步关系用乘法原理,并列关系用加法原理。所以对答案的总贡献为:
注意到,对于 \(\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\)
- 所用食材不为 \(k\)
注意到,\(\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)\),也可以接受。
代码细节
- \(g\) 数组的大小开够 \(g[N*2][N]\)。下标不能为负数,第一维统一加上偏移量 \(n\) 就可以了。
- 手算空间复杂度发现没必要优化。发散一下思维,\(f\) 数组可以用上一道题将的 01 背包倒序优化,\(g\) 数组可以用滚动数组优化。这样,空间消耗会少很多,代码会跑得慢一些。
- 用到减法的时候,取模之前先加上模数,防止出现负数的情况。
不优化空间复杂度
#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;
}

浙公网安备 33010602011771号