CWOI 动态规划杂题
mzx 的动态规划杂题选讲。sto
又名三道题带你重新认识动态规划。
1.ARC153 D - Sum of Sum of Digits
题意
定义 \(f(x)\) 为 \(x\) 十进制意义下各位数字之和,如 \(f(1)=1\),\(f(123)=6\)。
给定长度为 \(n\) 的序列 \(a\),请找到一个非负整数 \(x\) 使得 \(\sum\limits_{i=1}^nf(a_i+x)\) 最小,并输出这个最小值。
\(1\le n\le2\times10^5\),\(1\le a_i<10^9\)。
解法
首先因为 \(a_i<10^9\),选超过 \(A=10^9\) 的 \(x\) 一定更劣。
假如我们选择的数是 \(x\),可以发现答案就是 \(nf(x)+\sum\limits_{i=1}^nf(A_i)-9c\),其中 \(c\) 是进位次数。
发现答案和进位次数有关,这启发我们从低到高逐位 DP。定义 \(f_{i,j}\) 表示考虑前 \(i\) 位,选的数为 \(j\) 的答案。转移可以枚举当前一位填多少。现在,我们只用考虑 \(i-1\) 位向 \(i\) 位进位的问题。
发现根据后 \(i-1\) 位从大到小排序后,每次进位的一定是一段前缀。复杂度优化到 \(O(9A\log n+9n\log n)\)。
其实,发现我们并不需要知道后 \(i-1\) 位具体的值,只用知道会进多少位即可。因为进位的是一段前缀,我们可以简化一下状态的定义:定义 \(f_{i,j}\) 表示考虑前 \(i\) 位,这一位会进 \(j\) 次位的答案。
(注意:这里的答案指的是只考虑前 i 位的答案。也就是说,如果它进到了下一位,我们也不会把它算进去。所以最后统计答案时,需要单独加上一个可能进位到超过最大位的贡献。这一点在代码中也有体现。)
显然只会是低 \(i\) 位最小的 \(j\) 个数进位,答案计算类似。时间复杂度 \(O(9n\log n+9^2n)\)。
code:
点击查看代码
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int inf=1e18;
inline int read(){
int x=0,f=1;char ch=getchar();
while (!isdigit(ch)){if (ch=='-') f=-1;ch=getchar();}
while (isdigit(ch)){x=x*10+ch-48;ch=getchar();}
return x*f;
}
int o,pw[15],cnt[15],f[15][200005],a[200005];
int cmp(int x,int y){
return (x%pw[o-1])>(y%pw[o-1]);
}
signed main(){
pw[0]=1;for(int i=1;i<=10;i++)pw[i]=pw[i-1]*10;
int n=read();for(int i=1;i<=n;i++)a[i]=read();
for(int i=0;i<=9;i++)for(int j=0;j<=n;j++)f[i][j]=inf;
f[0][0]=0;
for(int i=1;i<=9;i++){
o=i;sort(a+1,a+n+1,cmp);
for(int j=0;j<=10;j++)cnt[j]=0;
for(int j=1;j<=n;j++)cnt[a[j]/pw[o-1]%10]++;
for(int j=0;j<=n;j++){
if(j)cnt[a[j]/pw[o-1]%10]--,cnt[a[j]/pw[o-1]%10+1]++;
int p=cnt[10],num=0,val=0;
for(int k=0;k<=9;k++)num+=k*cnt[k];
for(int k=0;k<=9;k++){
f[i][p]=min(f[i][p],f[i-1][j]+num+n*k-val);
p+=cnt[9-k],val+=cnt[9-k]*10;
}
}
}
int ans=inf;
for(int j=0;j<=n;j++)ans=min(ans,f[9][j]+j);
printf("%lld\n",ans);
return 0;
}
总结
-
动态规划的 DP 数组下标需要记录两个东西,阶段和状态。
-
动态规划的值记录的是满足最优子结构性质的答案。
-
本题通过精简状态数量来达到优化复杂度的目的,我们可以考察计算答案所需的信息来精简状态。
-
在本题中,计算答案并不需要记录 \(x\) 的低位究竟具体是多少,只需要知道它的相对大小,也就是到底让哪些 \(a_i\) 产生了进位。相对大小相同的 \(x\),归入同一个状态。
-
对于同一个状态,我们不关心具体是哪种基本情况(不关心 \(x\) 具体是多少),同一个状态的基本情况的所有转移完全相同。
-
-
考虑动态规划时,状态的设计不应该局限于基本情况。
2.P7152 [USACO20DEC] Bovine Genetics G
题意:
有一个长为 \(n\),仅由 'A','C','G','T' 组成的字符串 \(s\)。通过下列步骤对其进行编辑:
-
在所有连续相同字符之间的位置将当前 \(s\) 切开。
-
反转所有得到的子串。
-
按原先的顺序将反转的子串进行连接。
现在给定编辑后的 \(s\)(可能会出现 '?',表示可能是 'A','C','G','T' 之一),求可能的开始时的 \(s\) 的数量,对 \(10^9+7\) 取模。\(n\le10^5\)。
解法
唉,想不到。痛苦。
定义 \(f_{i,a,b,c}\) 表示考虑前 \(i\) 个字符,第 \(i\) 个字符为最后一段的结尾,最后一段开头字符为 \(a\),结尾字符为 \(b\),上一段开头字符为 \(c\) 的方案数。下面说转移:
-
当 \(a\neq d\),即 \(i\) 和 \(i-1\) 可以为同一段,\(f_{i-1,a,b,c}\) 可以转移到 \(f_{i,d,b,c}\)。
-
当 \(a=c\),即 \(i\) 和 \(i-1\) 可以不为同一段,\(f_{i-1,a,b,c}\) 可以转移到 \(f_{i,d,d,b}\)。
统计答案和初始化就不写了。
code:
点击查看代码
#include<bits/stdc++.h>
#define int long long
using namespace std;
const char ch[]={'A','G','C','T'};
const int inf=1e18,mod=1e9+7;
inline int read(){
int x=0,f=1;char ch=getchar();
while (!isdigit(ch)){if (ch=='-') f=-1;ch=getchar();}
while (isdigit(ch)){x=x*10+ch-48;ch=getchar();}
return x*f;
}
int f[100005][4][4][4];char o[100005];
int change(int pos,int c){
return (o[pos]=='?'||o[pos]==ch[c]);
}
signed main(){
scanf("%s",o+1);int n=strlen(o+1);
for(int a=0;a<4;a++){
if(!change(1,a))continue;
for(int b=0;b<4;b++){
f[1][a][a][b]=1;
}
}
for(int i=2;i<=n;i++){
for(int a=0;a<4;a++){
for(int b=0;b<4;b++){
for(int c=0;c<4;c++){
for(int d=0;d<4;d++){
if(!change(i,d))continue;
if(d!=a)f[i][d][b][c]=(f[i][d][b][c]+f[i-1][a][b][c])%mod;
if(a==c)f[i][d][d][b]=(f[i][d][d][b]+f[i-1][a][b][c])%mod;
}
}
}
}
}
int ans=0;
for(int a=0;a<4;a++){
for(int b=0;b<4;b++){
ans=(ans+f[n][a][b][a])%mod;
}
}
printf("%lld\n",ans);
return 0;
}
总结
-
一种合法的基本情况和一个合法的转移序列一一对应时才能使用动态规划的方式来统计方案数。放到本题中,同一种原始序列有且仅有一种变换后的序列,一个状态可以由上个阶段的多个状态转移来,这些状态之间天然是不重不漏的,因此只需要保证转移是正确的,就能保证动态规划计算出的方案总数是正确的。
-
这个性质可能是计数类动态规划种被忽视最多的点,如果计数动态规划出现了错误,不妨思考一下你设计的状态和阶段是否满足这个性质。
-
一类计数类动态规划转移的本质是线性变换,对加法满足分配律。同一类动态规划,在阶段和状态均相同时,转移也相同(线性变换相同),可以使用运算律整体处理。
-
可以把动态规划大的转移拆成小的转移来降低思考难度,本题中,解法一的转移跨度很大,转移时需要枚举的量较多,优化起来有一定难度,解法二则是一步一步转移,跨度小,枚举的量较小,做起来相对容易。
mzx の 解法一:
- 这种划分的形式很明显的提示应该一段一段的考虑新串的生成过程,自然的,可以将子串的每一段作为阶段来设计动态规划,由于动态规划的过程中涉及对 '?' 的决策,因此按照新串的下标顺序考虑相对方便。
- 设 \(dp[i][s]\) 表示考虑到新串前 \(i\) 个字符,且第 \(i\) 个字符是一段划分的结尾,状态为 \(s\) 时,能够对应的不同原串个数。
- 转移时枚举 \(j\),考虑从 \(j\) 到 \(i\) 的转移要哪些信息,首先需要知道 \(j+1,i\) 上的字符,这是应该枚举的,然后需要知道 \(j\) 对应端的开头字符(这个字符需要和 \(i\) 位置的字符相同),这个信息应该被记录在状态里。其次需要知道在固定 \(i,j+1\) 的字符后 \((j+1,i)\) 一共有多少种填法,我们设这个值为 \(w(j+1,i,c)\),\(s,c\) 是转移时必须要记录的状态,那么总的方案数应该是 \(\sum\limits_{j=1}^{i-1}\sum\limits_c\sum\limits_{s}dp_{j,s}\times w(j+1,i,c)\)。
- 不妨将 \(c,s\) 看成一个复合参数,于是单个方案数可以写为 \(b(j,i,s)\),总的方案数可以写为 \(dp_{i,s'}=\sum\limits_{j=1}^{i-1}\sum\limits_{s}b(j,i,s)\)。
- 当 \(i\) 改变时,注意到 \(b(j,i,s)\) 的两部分中只有 \(w(j+1,i,c)\) 会改变,而单独的一个 \(w(j,i,c)\) 是可以通过 \(w(j,i-1,c)\) 动态规划转移过来的,动态规划转移的本质是线性变换,因此对加法满足分配律。
- 换句话说,我们随着 \(i\) 的变化可以维护 \(\sum\limits_{j=1}^{i-1}\sum\limits_{s}b(j,i,s)\) 的结果,这个结果的转移和单独的 \(w(j+1,i,c)\) 的转移是一致的,另外需要额外加上当前位置的贡献即可。
3.CF1542E2 Abnormal Permutation Pairs (hard version)
题意
给定 \(n,m\),求有多少对长度为 \(n\) 的排列 \(p,q\),满足以下条件:
-
\(p\) 的字典序小于 \(q\);
-
\(p\) 的逆序对个数严格大于 \(q\);
答案对 \(m\) 取模。\(1\le n\le 500\),\(1\le m\le 10^9\),不保证 \(m\) 为素数。
解法
发现这个题需要满足的条件有两个,直接一起做的话很困难,可以先思考满足其中一个条件时怎么做。
发现求的 \(p,q\) 是一个排列,而排列有一个很好的性质:在删去一些数后,因为我们只关心它们的相对大小关系,所以剩下的数也可以看做一个排列,这就是一个子问题了。
-
只用满足第一个条件:
- 这个字典序的限制很经典,可以枚举前多少位相同,前面的一样,后面的随便选。
-
只用满足第二个条件:
-
定义 \(f_{i,j}\) 表示有多少个 \(i\) 的排列刚好有 \(j\) 个逆序对。
-
如果你枚举每个位置放哪个数,就需要在状态中记录一个指数级别的维度表示哪些数被选了,这并不利于我们的转移;
-
考虑把这个看做在一个 \(i-1\) 的排列中插入一个 \(i\)。如果插在第 \(k\) 个数之后,就会增加 \(i-1-k\) 个逆序对。转移就是 \(f_{i,j}=\sum\limits_{k=0}^{i-1}f_{i-1,j-(i-1-k)}\)。这个转移是从连续的一段转过来的,可以前缀和优化,时间复杂度 \(O(n^3)\)。
-
现在我们同时考虑两个条件。假设 \(p,q\) 的前 \(i-1\) 位相同。此时,排列可以分成三部分:前 \(i-1\) 位,第 \(i\) 位,后 \(n-i\) 位。注意到 \(p,q\) 第一部分完全相同,所以其与二三部分的逆序对数相同,可以忽略。因此,我们需要考虑的逆序对数只由两部分构成:
-
第 \(i\) 位与后 \(n-i\) 位间的逆序对;
-
后 \(n-i\) 位中的逆序对;
因为排列的性质,后 \(n-i+1\) 位已经是独立的了。
-
假设 \(p,q\) 第 i 位填的是 \(k_1,k_2\),则 \(p\) 就比 \(q\) 少了 \(k_2-k_1\) 个逆序对。
-
令 \(d=k_2-k_1\),因为在取出第 \(i\) 位填的数后后 \(n-i\) 位与 \(i\) 位填的数的值没有关系,而逆序对数量只需要满足 \(p\) 比 \(q\) 多至少 \(d\) 个,我们只用枚举第 \(i\) 位的差值 \(d\)。此时,设 \(p\) 第三部分的逆序对数位 \(j\),则 \(q\) 可选的方案共有 \(\sum\limits_{k=0}^{j-d-1}f_{n-i,k}\) 种。
整理一下,答案就是
其中 \(A_n^{i-1}\) 表示排列数。
这是一个 \(O(n^6)\) 的柿子,我们考虑优化。记 \(g_{i,j}=\sum\limits_{k=0}^jf_{i,k}\),\(s_{i,j}=\sum\limits_{k=0}^jk\times g_{i,k}\),\(t_{i,j}=\sum\limits_{k=0}^jg_{i,k}\)。
发现前面可以用 \(t\) 数组优化,后面可以用 \(s\) 数组优化。总时间复杂度 \(O(n^3)\)。
\(n^3\) 的数组开不下,需要一边求 \(f,g,s,t\) 的同时统计答案。
实际操作时需要考虑越界等无解的情况,处理比较繁琐,建议分段运算。
code:
点击查看代码
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int inf=1e18;
inline int read(){
int x=0,f=1;char ch=getchar();
while (!isdigit(ch)){if (ch=='-') f=-1;ch=getchar();}
while (isdigit(ch)){x=x*10+ch-48;ch=getchar();}
return x*f;
}
int n,p,ans,dm[505][505],f[200005],g[200005],s[200005],t[200005];
int askG(int l,int r){
if(l>r)return 0;
if(r<0)return 0;
if(l-1<0)return g[r];
else return (g[r]-g[l-1]+p)%p;
}
int askS(int l,int r){
if(l>r)return 0;
if(r<0)return 0;
if(l-1<0)return s[r];
else return (s[r]-s[l-1]+p)%p;
}
int askT(int l,int r){
if(l>r)return 0;
if(r<0)return 0;
if(l-1<0)return t[r];
else return (t[r]-t[l-1]+p)%p;
}
signed main(){
n=read(),p=read();
for(int i=0;i<=n;i++){
dm[i][0]=1;
for(int j=1;j<=i;j++)dm[i][j]=dm[i][j-1]*(i-j+1)%p;
}
f[0]=1,g[0]=1,s[0]=0,t[0]=1;
for(int i=0;i<=0;i++){
for(int j=0;j<=i+1;j++){
ans=(ans+dm[n][n-i-1]*f[j]%p*askS(0,j-2)%p)%p;
}
for(int j=0;j<=i+1;j++){
ans=(ans+dm[n][n-i-1]*(i-j+2)%p*f[j]%p*askT(0,j-2)%p)%p;
}
for(int j=i+2;j<=i*(i-1)/2;j++){
ans=(ans+dm[n][n-i-1]*f[j]%p*askS(-i+j-1,j-2)%p)%p;
}
for(int j=i+2;j<=i*(i-1)/2;j++){
ans=(ans+dm[n][n-i-1]*(i-j+2)%p*f[j]%p*askT(-i+j-1,j-2)%p)%p;
}
}
for(int i=1;i<=n;i++){
for(int j=0;j<=i*(i-1)/2;j++){
f[j]=askG(j-i+1,j);
}
g[0]=f[0];
for(int j=1;j<=(i+1)*((i+1)-1)/2;j++){
g[j]=(g[j-1]+f[j])%p;
}
s[0]=0*g[0];
for(int j=1;j<=(i+1)*((i+1)-1)/2;j++){
s[j]=(s[j-1]+j*g[j]%p)%p;
}
t[0]=g[0];
for(int j=1;j<=(i+1)*((i+1)-1)/2;j++){
t[j]=(t[j-1]+g[j])%p;
}
if(i<n){
for(int j=0;j<=i+1;j++){
ans=(ans+dm[n][n-i-1]*f[j]%p*askS(0,j-2)%p)%p;
}
for(int j=0;j<=i+1;j++){
ans=(ans+dm[n][n-i-1]*(i-j+2)%p*f[j]%p*askT(0,j-2)%p)%p;
}
for(int j=i+2;j<=i*(i-1)/2;j++){
ans=(ans+dm[n][n-i-1]*f[j]%p*askS(-i+j-1,j-2)%p)%p;
}
for(int j=i+2;j<=i*(i-1)/2;j++){
ans=(ans+dm[n][n-i-1]*(i-j+2)%p*f[j]%p*askT(-i+j-1,j-2)%p)%p;
}
}
}
printf("%lld\n",(ans+p)%p);
return 0;
}
总结
-
对于多条件计数问题,通过分类讨论、枚举等方式降低条件的耦合度,独立解决低耦合度的问题并合并成最终问题的答案。
-
设计动态规划时要考虑阶段本身的转移是否容易,本题转化后的基本问题中,如果按下标位置动态规划,阶段转移需要知道前面比该位置小的数的个数,需要记录的状态为指数级。如果按值的大小从小到大动态规划,转移时只需要枚举当前的数放在当前的哪个下标,需要记录的状态数为常数级。
-
看推柿子的思路。