动态规划及其优化
dp
树形dp
顾名思义,就是树上的 \(dp\)。
例题
-
模板题,关注该节点是否能取。
void dp(int x){
f[x][0]=0,f[x][1]=h[x];
for(int i=head[x];i;i=e[i].next){
int y=e[i].to;
dp(y);
f[x][0]+=max(f[y][0],f[y][1]);
f[x][1]+=f[y][0];
}
}
-
树上背包模板题,注意背包转移时循环的方向。
void dp(int u){
f[u][1]=h[u];
for(int i=head[u];i;i=e[i].next){
int v=e[i].to;
if(v==fa) continue;
dp(v);
for(int j=m+1;j>=1;j--){
for(int k=j-1;k>=0;k--){
f[u][j]=max(f[u][j],f[v][k]+f[u][j-k]);
}
}
}
}
-
二次扫描与换根,先一遍 dfs 求出要用的一些量,然后第二遍求出各个点作为根节点时的答案。
int n,head[maxn],idx,ans;
long long dep[maxn],siz[maxn],f[maxn],x;
void dfs1(int u,int fa){
siz[u]=1;
dep[u]=dep[fa]+1;
for(int i=head[u];i;i=e[i].next){
int v=e[i].to;
if(v==fa) continue;
dfs1(v,u);
siz[u]+=siz[v];
}
}
void dfs2(int u,int fa){
for(int i=head[u];i;i=e[i].next){
int v=e[i].to;
if(v==fa) continue;
f[v]=f[u]+n-siz[v]*2;
dfs2(v,u);
}
}
int main(){
int u,v;
scanf("%d",&n);
for(int i=1;i<n;i++){
scanf("%d%d",&u,&v);
add(u,v),add(v,u);
}
dep[0]=-1;
dfs1(1,0);
for(int i=1;i<=n;i++) f[1]+=dep[i];
dfs2(1,0);
for(int i=1;i<=n;i++) if(x<f[i]) x=f[i],ans=i;
printf("%d\n",ans);
return 0;
}
-
贪心也可以做,\(dp\) 状态设的就很神了。由于要满足 \(dp\) 的无后效性以及正确性,第二维设 \(0/1\) 是不行的。(节点的一个儿子设立,其他儿子都可以不用设立)
\(F[i][0]\) 表示可以覆盖到从节点 \(i\) 向上 \(2\) 层的最小消防站个数。
\(F[i][1]\) 表示可以覆盖到从节点 \(i\) 向上 \(1\) 层的最小消防站个数。
\(F[i][2]\) 表示可以覆盖到从节点 \(i\) 向上 \(0\) 层的最小消防站个数。
\(F[i][3]\) 表示可以覆盖到从节点 \(i\) 向上 \(-1\) 层的最小消防站个数。
\(F[i][4]\) 表示可以覆盖到从节点 \(i\) 向上 \(-2\) 层的最小消防站个数。
显然,第一种初始状态应是 \(F[i][0]=1\),其他均为 \(0\)。
void dfs(int u,int fa){
int flag=0;
dp[u][0]=1;
dp[u][3]=dp[u][4]=0;
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
flag=1;
dfs(v,u);
dp[u][0]+=dp[v][4],dp[u][3]+=dp[v][2],dp[u][4]+=dp[v][3];
}
if(!flag){
dp[u][1]=dp[u][2]=1;
}
else{
dp[u][1]=dp[u][2]=0x7fffffff;
int res1,res2;
for(int i=head[u];i;i=e[i].nxt){
int t=e[i].to;
if(t==fa) continue;
res1=res2=0;
for(int j=head[u];j;j=e[j].nxt){
int s=e[j].to;
if(s==fa) continue;
if(s==t) continue;
res1+=dp[s][3],res2+=dp[s][2];
}
dp[u][1]=min(dp[u][1],res1+dp[t][0]);
dp[u][2]=min(dp[u][2],res2+dp[t][1]);
}
for(int i=1;i<=4;i++) dp[u][i]=min(dp[u][i],dp[u][i-1]);
}
}
数位dp
对于数位上每个数的有约束的各类统计问题,可以考虑用数位 dp 解决。
通常使用记忆化递归实现(更通用),属于比较板子的 dp 了。
在进行记忆化递归时,通常需要考虑三个因素:前导零(有时需要考虑),值域边界限制(必定会有),题面要求限制。
例题
-
版子题,枚举 \(0 \sim 9\) 的数字,按位统计即可。
注意前导零对答案的影响。
代码:
ll a,b,f[15][15][11][2],w[15],n;
ll calc(ll pos,ll k,ll lead,ll limit,ll sum){
if(!lead&&!limit&&f[pos][sum][k][lead]!=-1) return f[pos][sum][k][lead];
if(pos>n) return sum;
ll res=0,up=9;
if(limit) up=w[pos];
for(int i=0;i<=up;i++){
res+=calc(pos+1,k,(lead==1&&i==0)?1:0,(limit==1&&i==up)?1:0,sum+(i==k)-(i==k&&i==0&&lead));
}
if(!limit&&!lead) f[pos][sum][k][lead]=res;
return res;
}
inline ll solve(ll k,ll now){
memset(f,-1,sizeof(f));
n=0;
if(k==0) w[++n]=0;
while(k){
w[++n]=k%10,k/=10;
}
reverse(w+1,w+1+n);
return calc(1,now,1,1,0);
}
-
题目的三个条件易于约束,但是求平方和难以直接转移。
考虑合并时的情况,从简单情况入手,前 \(pos-1\) 位已被固定(递归时进行统计),当前第 \(pos\) 位的数字为 \(i\),对构成数字本身的贡献为 \(a\),递归回来构成的数字为 \(b,c,\dots\)。
那么递归时的答案计算应是 \((a+b)^2+(a+c)^2+\dots\),拆开来则是 \((a^2+2ab+b^2)+(a^2+2ac+c^2)+\dots\)。
那么我们在递归时,就要维护三个量:可以构成的数字个数 \(k_1\)(计算多个 \(a_2\)),当前对数字本身的贡献 \(k_2\)(计算多个类似 \(2ab\) 对答案的贡献),以及当前的数字平方和 \(k_3\)(计算多个平方对答案贡献)。
前两个量合并时直接相加即可,平方和合并便是 \(k_1a^2+2ak_2+k_3\)。
代码:
struct node{
ll sqsum,sum,cnt;
}f[25][10][10];
ll t,l,r,a[25],n,base[25],Base[25];
node calc(ll pos,ll sum,ll now,ll limit){
if(!pos) return {0,0,(sum&&now)};
if(!limit&&f[pos][sum][now].cnt!=-1) return f[pos][sum][now];
node res={0,0,0},tmp;
for(int i=0;i<=9;i++){
if(limit&&a[pos]<i) break;
if(i==7) continue;
tmp=calc(pos-1,(sum+i)%7,(now+i*Base[pos-1]%7)%7,limit&&a[pos]==i);
res.cnt=(res.cnt+tmp.cnt)%mod;
res.sum=(res.sum+tmp.sum+i*tmp.cnt%mod*base[pos-1]%mod);
res.sqsum=((res.sqsum+tmp.sqsum)%mod+2*tmp.sum%mod*i%mod*base[pos-1]%mod)%mod;
res.sqsum=(res.sqsum+tmp.cnt*i%mod*base[pos-1]%mod*i%mod*base[pos-1]%mod)%mod;
}
if(!limit) f[pos][sum][now]=res;
return res;
}
inline ll solve(ll x){
memset(f,-1,sizeof(f));
n=0;
if(x==0) a[++n]=0;
while(x){
a[++n]=x%10,x/=10;
}
return calc(n,0,0,1).sqsum;
}
-
根据题目定义,可以得到一个性质:
如果一个数是杠杆数,它的支点有且仅有一个。因为无论当前支点向左移还是向右移,左右的差必定单调递增,差必不为 \(0\)。
所以,只需要枚举每一个作为支点的位置,进行 dp,状态为三维:位数,支点左右差值,支点位置。最后判断差是否为 \(0\) 即可。
状压dp
对于一些转移或表示很麻烦的 dp,可以通过状态压缩来实现。状态压缩通过将状态的数字串作为 \(n\) 进制的数,用十进制的形式储存下来,使得状态易于表示和转移。
状态压缩其实是一种思想,不一定只局限于 dp 之中,许多题都可以用状态压缩的思想去维护或优化。状压 dp 也算是一种对 dp 的一种优化。
一般来说,状压 dp 的数据量很小,但又会比爆搜可支持的数据范围略大。
二进制状压常用位运算技巧:
- 取出数字 \(x\) 的第 \(pos\) 位上的数字:
x&(1<<pos) - 判断数字 \(x\) 是否有相邻的 \(1\):
x&(x<<1) - 将数字 \(x\) 的第 \(pos\) 位上的数字赋值为 \(1\):
x|(1<<pos)
只是总结了一些常用方式,主要是要根据题目来灵活使用。
例题
-
P1879 [USACO06NOV] Corn Fields G
先将能否种草的信息用状压的方式储存,观察到数据量很小,可以状压。每次枚举本行与上一行的状态,判断是否合法(本行是否有相邻的 \(1\),本行与上一行是否有相同位置上的 \(1\))后转移即可。
for(int i=1;i<=n;i++){
for(int j=m;j>=1;j--){
scanf("%d",&x);
mp[i]+=x*pow(2,j-1);
}
}
for(int i=0;i<(1<<m);i++){
if(!(i&(i<<1))&&!(i&(i>>1))){
cnt[++idx]=i;
}
}
dp[0][0]=1;
for(int i=1;i<=n;i++){
for(int l=1;l<=idx;l++){
int j=cnt[l];
for(int r=1;r<=idx;r++){
int k=cnt[r];
if(!(j&k)&&(mp[i]&j)==j) dp[i][j]=(dp[i][j]+dp[i-1][k])%mod;
}
}
}
for(int i=1;i<=idx;i++) ans=(ans+dp[n][cnt[i]])%mod;
printf("%lld\n",ans);
-
与上题类似,因为要考虑上两行的影响,维度多了上两行的状态,多枚举一维判断即可。
本题的小 trick:可以预先处理本行之内的合法状态并储存,建立状态与编号的映射。枚举时只需要遍历编号,维度的空间也只需要考虑编号的大小。省去了不必要的空间,时间也大幅优化,就不用滚动数组了。
for(int i=0;i<(1<<m);i++){//预处理
if(!(i&(i<<1))&&!(i&(i<<2))) state[++cnt]=i;
}
for(int i=1;i<=n;i++){
for(int j=1;j<=cnt;j++){//本行状态
if((state[j]&mp[i])!=state[j]) continue;
for(int k=1;k<=cnt;k++){//上一行状态
if((state[k]&mp[i-1])!=state[k]||(state[k]&state[j])) continue;
for(int las=1;las<=cnt;las++){//上上行状态
if((state[las]&mp[i-2])!=state[las]||(state[las]&state[j])||(state[las]&state[k])) continue;
dp[i][j][k]=max(dp[i][j][k],dp[i-1][k][las]+count(state[j]));
}
}
}
}
for(int i=1;i<=cnt;i++){
if((state[i]&mp[n])!=state[i]) continue;
for(int j=1;j<=cnt;j++){
if((state[j]&mp[n-1])!=state[j]||(state[i]&state[j])) continue;
ans=max(ans,dp[n][i][j]);
}
}
- yyy洗牌
dp优化
大多数 \(dp\) 的决策并不是能在 \(O(1)\) 的时间之内解决的。根据 \(dp\) 式子的结构特征,运用一些数据结构(线段树,平衡树,单调队列等),算法(倍增,分治),还有经典的前缀和优化,斜率优化,四边形不等式优化等等。这些都可以使转移时更快地做出决策,优化时间复杂度。
当然,对于空间,也有常规的滚动数组优化,以及各种小 \(Trick\):时间换空间,空间换时间等。优化 \(dp\) 需要我们做题时明辨特征,保持创造地去灵活变通。
单调队列优化
如果一个人比你小还比你强,那你就打不过他了。
关于单调队列
一种线性数据结构,可以维护固定区间长度的序列最值。
经典例题:滑动窗口 /【模板】单调队列
优化dp
满足形如 \(dp[i]=min(dp[j]+f)\) 的式子,当 \(f\) 不与 \(i\) 和 \(j\) 的相关量乘积有关且满足决策单调时,可以使用单调队列优化。
应用:单调队列优化多重背包
有一个体积为 \(V\) 的背包,现在有 \(n\) 种物品,第 \(i\) 个物品的体积为 \(v[i]\),数量为 \(c[i]\),价值为 \(w[i]\)。求可以获得的最大价值。
考虑将多重背包转化为 \(01\) 背包求解时,有 \(dp\) 方程:
观察这个式子,我们发现 \(dp[i]\) 会被 \(dp[i-v[i]]\) 影响,而 \(dp[i-v[i]]\) 又会被 \(dp[i-2*v[i]]\) 影响……\(dp[i-(c[i]-1) \times v[i]]\) 会被 \(dp[i-c[i] \times v[i]]\) 影响。这样的影响是跳跃着的,很难去进行直接的优化。
那么,我们想到根据 \(v[i]\) 将体积这一维分组,使得每一组之内相互影响,各个组之间互不影响。这样,我们就可以用单调队列优化了。
如何分组呢?根据我们的发现,我们可以将体积 \(i\) 除以 \(v[i]\) 的余数将 \(0\sim i\) 的体积数分成 \(v[i]\) 组:\(0\sim v[i-1]\)。这样每一组可以满足我们的条件:每一组之内相互影响,各个组之间互不影响。
分组后,对于每一组的体积 \(i,j\)(\(i > j\)),由体积 \(j\) 转移到体积 \(j\) 所需的物品个数为 \((i-j)/v[i]\)。那么有 \(dp\) 方程:
根据条件 \(i-j<c[i]\),很明显这可以作为一个单调队列的限制条件了。
暴力 \(01\) 背包时间复杂度:\(O(nV\sum_{i=1}^{n}c[i])\)
二进制拆分时间复杂度:\(O(nV\sum_{i=1}^{n}\log_2 c[i])\)
单调队列优化多重背包时间复杂度:\(O(nV)\)
代码:
for(int i=1;i<=V;i++) dp[i]=0x7fffffff;
for(int i=1;i<=n;i++){
for(int d=0;d<v[i];d++){
head=1,tail=0;
for(int k=0;d+k*v[i]<=V;k++){
now=d+k*v[i];
while(head<=tail&&q[tail]>=dp[now]-k*w[i]) tail--;
id[++tail]=now,q[tail]=dp[now]-k*w[i];
while(head<=tail&&(now-id[head])/v[i]>c[i]) head++;
dp[now]=min(dp[now],q[head]+k*w[i]);
}
}
}
例题
-
模板题,推出 \(dp\) 式子:
\[dp[i]=\max(dp[k]+a[i]),i-L \le k \le i-R \]\(f\) 只与 \(i\) 有关,决策区间长度不变且单调。很明显这就是一个滑动窗口问题了,上单调队列维护最大值即可。
for(int i=1;i<=n;i++){
if(i>=l){
while(head<=tail&&dp[q[tail]]<=dp[idx]) tail--;
q[++tail]=idx;
while(q[head]+r<i) head++;
if(dp[q[head]]!=-0x7fffffff) dp[i]=a[i]+dp[q[head]];
else dp[i]=-0x7fffffff;
idx++;
}
else dp[i]=-0x7fffffff;
}

浙公网安备 33010602011771号