动态规划及其优化

dp

树形dp

顾名思义,就是树上的 \(dp\)

例题

  1. P1352 没有上司的舞会

    模板题,关注该节点是否能取。

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];
	}
}
  1. P2014 [CTSC1997] 选课

    树上背包模板题,注意背包转移时循环的方向。

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]);
			}
		}
	}
}
  1. P3478 [POI2008] STA-Station

    二次扫描与换根,先一遍 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;
}
  1. *P2279 [HNOI2003] 消防局的设立

    贪心也可以做,\(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 了。

在进行记忆化递归时,通常需要考虑三个因素:前导零(有时需要考虑),值域边界限制(必定会有),题面要求限制。

例题

  1. P2602 [ZJOI2010] 数字计数

    版子题,枚举 \(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);
}
  1. 恨7不成妻

    题目的三个条件易于约束,但是求平方和难以直接转移。

    考虑合并时的情况,从简单情况入手,前 \(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;
}

  1. P1831 杠杆数

    根据题目定义,可以得到一个性质:

    如果一个数是杠杆数,它的支点有且仅有一个。因为无论当前支点向左移还是向右移,左右的差必定单调递增,差必不为 \(0\)

    所以,只需要枚举每一个作为支点的位置,进行 dp,状态为三维:位数,支点左右差值,支点位置。最后判断差是否为 \(0\) 即可。

状压dp

对于一些转移或表示很麻烦的 dp,可以通过状态压缩来实现。状态压缩通过将状态的数字串作为 \(n\) 进制的数,用十进制的形式储存下来,使得状态易于表示和转移。

状态压缩其实是一种思想,不一定只局限于 dp 之中,许多题都可以用状态压缩的思想去维护或优化。状压 dp 也算是一种对 dp 的一种优化。

一般来说,状压 dp 的数据量很小,但又会比爆搜可支持的数据范围略大。

二进制状压常用位运算技巧:

  1. 取出数字 \(x\) 的第 \(pos\) 位上的数字:x&(1<<pos)
  2. 判断数字 \(x\) 是否有相邻的 \(1\)x&(x<<1)
  3. 将数字 \(x\) 的第 \(pos\) 位上的数字赋值为 \(1\)x|(1<<pos)

只是总结了一些常用方式,主要是要根据题目来灵活使用。

例题

  1. 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);
  1. P2704 [NOI2001] 炮兵阵地

    与上题类似,因为要考虑上两行的影响,维度多了上两行的状态,多枚举一维判断即可。

    本题的小 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]);
		}
	} 
  1. 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]=max(dp[i-v[i]]+w[i],dp[i]) \]

观察这个式子,我们发现 \(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\) 方程:

\[dp[i]=dp[j]+(i-j)/v[i]*w[i],0<i-j<c[i] \]

根据条件 \(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]);
			}
		}
	}

例题

  1. P1725 琪露诺

    模板题,推出 \(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;
	}

斜率优化

posted @ 2024-08-06 20:21  dayz_break  阅读(28)  评论(1)    收藏  举报