区间DP

CF1509C The Sports Festival

每个人有一个速度,可以用任意的顺序让人去跑,每次跑完都会做出一个\(max(A)-min(A)\)的花费,其中,A表示已经跑过步的人的速度的集合。

考虑贪心,先排个序,每次我肯定不会从两边开始跑,我一定是从两个相邻的数往外扩展的,从小区间扩展到大区间,容易想到区间DP,这个数据范围\(n\le 2000\)也是正好可以\(n^2\)转移的,直接区间DP就好了。很水,不到5分钟就写完了,代码就不放了。

CF1728D Letter Picking

多测。每次给你一个长度为偶数的字符串,两个人每次都会拿两端的字符,并且把该字符放到自己所有的字符串的开头,也就是说最后拿的字符在最前面,两人都采取最优策略,字符串字典序小的可以获胜,问对于所给的字符串,谁会赢,或者是平局。

根据这个选字符串的性质,可以知道先选的人一定不会输,至少也是平局,因为如果只剩下两个字符且它们不同,我肯定选小的那个,那么我就赢了;如果两个字符相同,那之前我选的肯定也不劣,也是要么我赢要么平,所以第一个人一定不会输。

根据这个分析也容易想到区间DP,也是从小区间推到大区间,那么我们只要考虑谁会赢就好了,发现:
1.对于一个回文,每次第二个人都和第一个人选的一样,那么就会平局
2.对于形如\(aabb\)这样的字符,每次也是第一个人选什么我也跟着选,也会平局
这时,有人就觉得这很对,直接开码,10min写完自信一交,发现WA飞了,为什么呢?
3.对于一个回文,外面接上\(aa\)和\(bb\)是不合法的,因为虽然到最后回文部分两人字典序一样,但是在前面两人一定是不会选相同字符的,第一个人可以采取更优策略来避免平局,自己多错几次应该就理解我在说什么了。(也就是下面的转移必须都要满足,不能拆开分别判断,不然判不掉上面的反例)

那么现在是真的做完了。

	
	f[l][r]=1;
	if(((!f[l+1][r-1]&&s[l]==s[r])||(!f[l][r-2]&&s[r]==s[r-1]))&&
	((!f[l+1][r-1]&&s[l]==s[r])||(!f[l+2][r]&&s[l]==s[l+1]))) f[l][r]=0;
	
	printf(f[1][n]?"Alice\n":"Draw\n");

CF1666J Job Lookup

根据二叉搜索树的性质,加上其进行中序遍历,可知对于一个子树一定是一段连续的区间,同样自己手玩一下就可知其正确性,所以可以进行区间DP,每次枚举区间l~r的根节点是谁,然后统计答案取min即可。

那么现在的关键点就是这个花费怎么求,边权*路径长度是不好做的,我们可以转换为每条路径的经过次数,那么这个可以通过区间内和区间外的连边个数求得,这是显然的吧,那么可以维护一个二维前缀和,每次转移时枚举完根节点之后分别计算左右子树的花费,取完min之后把子树的父亲都标记为自己,最后递归输出即可。

这个题还是很考验细节和思维的。

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=210;
int read(){
	int x;scanf("%d",&x);return x;
}
int n,fa[N],y[N][N];
ll a[N][N],f[N][N],sum[N][N];
void get(int x,int l,int r){
	if(l==r) return ;
	fa[y[l][x-1]]=x;
	fa[y[x+1][r]]=x;
	if(x>1) get(y[l][x-1],l,x-1);
	if(x<n) get(y[x+1][r],x+1,r);
}
int main(){
	n=read();
	for(int i=1;i<=n;i++)
		for(int j=1;j<=n;j++)
			a[i][j]=read();
	for(int i=1;i<=n;i++)
		for(int j=1;j<=n;j++)
			sum[i][j]=sum[i-1][j]+sum[i][j-1]-sum[i-1][j-1]+a[i][j];
	memset(f,0x3f,sizeof f);
	for(int i=1;i<=n;i++) f[i][i]=0,y[i][i]=i;
	for(int len=2;len<=n;len++){
		for(int l=1,r=len;r<=n;l++,r++){
			for(int k=l;k<=r;k++){
				ll ls=sum[k-1][n]-sum[l-1][n]-(sum[k-1][k-1]-sum[l-1][k-1]-sum[k-1][l-1]+sum[l-1][l-1]);
				ll rs=sum[r][n]-sum[k][n]-(sum[r][r]-sum[k][r]-sum[r][k]+sum[k][k]);
				ll L=f[l][k-1],R=f[k+1][r];
				if(k==l) L=0,ls=0;
				if(k==r) R=0,rs=0;
				if(f[l][r]>=ls+rs+L+R){
					f[l][r]=ls+rs+L+R;
					y[l][r]=k;
				}
			}
		}
	}
	get(y[1][n],1,n);
	for(int i=1;i<=n;i++) printf("%d ",fa[i]);
	return 0;
}

P2339 [USACO04OPEN] Turning in Homework G

题意是:感觉原题面就很清晰了,直接看原题吧。

首先是有一个贪心的:对于一个区间l~r,我们不会提前专门去把区间里的一个点k拿完之后再跑这个区间,因为我先拿是没有任何好处的,反正都要跑一遍这个区间,不如等经过它的时候再拿走,根据这个,每次我们都可以从头到尾来回跑,这样肯定不劣,并且这个区间的长度一定是递减的,这是显然的吧。

那么状态就可以设\(f[l][r][0/1]\)表示区间l~r全拿完,此时在左(右)端点时的最小花费,这个状态和下面的圣诞树很类似。转移时,直接取从左边或右边来的区间来更新我,具体可看下方代码,最后统计答案实际上是\(f[i][i][0/1]\)取min之后再加上出口距此距离的最小值,表示我的终点是哪个点。

具体地:

	memset(f,0x3f,sizeof f);
	f[1][C][0]=max(o[1].x,o[1].t);
	f[1][C][1]=max(o[C].x,o[C].t);
	for(int i=1;i<=C;i++){
		for(int j=C;j>=1;j--){
			if(i==1&&j==C) continue;
			f[i][j][0]=min(max(f[i-1][j][0]+o[i].x-o[i-1].x,o[i].t),max(f[i][j+1][1]+o[j+1].x-o[i].x,o[i].t));
			f[i][j][1]=min(max(f[i-1][j][0]+o[j].x-o[i-1].x,o[j].t),max(f[i][j+1][1]+o[j+1].x-o[j].x,o[j].t));
		}
	}
	int ans=INT_MAX;
	for(int i=1;i<=C;i++) ans=min(ans,min(f[i][i][1],f[i][i][0])+abs(B-o[i].x));
	printf("%d",ans);

当然,这是已经排序好的了。

P4766 [CERC2014] Outer space invaders

题意是说:每个点有一个出现的区间a~b,在这个时间你必须把这个点消灭,每个点距离自己的距离是d,每次可以选择任意一个长度R,并把当前已经出现的,在以自己为圆心,以R为半径的范围内的点全部消灭,同时花费R的代价,问把所有点都消灭的最小花费。

由于n很小,a、b很大,并且如果一段时间内没有出现新的节点,那么这段区间就是没用的,所以考虑离散化。我们注意到,对于一段区间,我只要消灭那个离我最远的点这段区间里的所有点就都会消失,那么我们就设\(f[l][r]\)表示存活时间全在离散化后的区间l~r里的点全被消灭的最小花费,再加上上面那个性质,我们只需要枚举当前区间内距离我最远的合法的点,并枚举其存活时间,我可以在其活着的任意时刻k将其消灭,那么会做出d的花费并且在[l,k-1]里以及[k+1,r]里的点也要已经被消灭了,因为根据我们定义的状态,这些点必须在其所在区间内已经被消灭了。如果发现该区间没有点,那花费自然是0了。最后输出\(f[1][m]\)(m为离散化后的总长)即可。

具体转移如下:

for(int len=1;len<=m;len++){
		for(int l=1,r=len;r<=m;l++,r++){
			f[l][r]=INT_MAX;
			int pos=0;
			for(int k=1;k<=n;k++) if(o[k].l>=l&&o[k].r<=r&&o[k].d>o[pos].d) pos=k;
			if(pos) for(int k=o[pos].l;k<=o[pos].r;k++) f[l][r]=min(f[l][r],f[l][k-1]+f[k+1][r]+o[pos].d);
			else f[l][r]=0;
		}
	}

P3607 [USACO17JAN] Subsequence Reversal P

也是步入紫题了。
题意是说:给你一个序列,你可以对其任意子序列进行1次翻转操作,使得最终得到的序列内的最长递增子序列最长,求这个最长长度。

对于这种n很小,只有50的题我们可以考虑多开几维状态,这道题就作为一道启发性的题来讲吧,我也不说是怎么推过来的了。
我们考虑设\(f[l][r][L][R]\)表示当前区间是[l,r],值域是[L,R]的最长不下降子序列,每次转移是好做的,只需要分别枚举值域和区间的左右端点,4重循环,每次当前区间的值域可以从先前的往外扩1,同时对于不使用翻转的情况,直接看左右端点是否和值域相等就可以了,对于翻转的情况,则是将左右端点对应的值域互换一下即左端点对应右值域,右端点对应左值域,这样就完成了翻转操作。为什么这样翻转是对的?考虑一对翻转值在整体的翻转子序列中一定是对称的,这是显然的吧,另外,由于这个巧妙的状态设计,那些不优的翻转在值域这关就没转移过来,所以我的答案就是最优的。这样最后输出\(f[1][n][1][50]\)即可。

还是给出转移吧,虽然上面已经说得很清晰了

    for(int i=1;i<=n;i++)
        for(int l=1;l<=a[i];l++)
            for(int r=a[i];r<=50;r++)
                f[i][i][l][r]=1;//初始化
    for(int len=2;len<=n;len++){
            for(int l=1,r=len;r<=n;l++,r++){
                for(int len2=2;len2<=50;len2++){
                    for(int L=1,R=len2;R<=50;L++,R++){
                        f[l][r][L][R]=max(f[l][r][L+1][R],f[l][r][L][R-1]);
                        f[l][r][L][R]=max(f[l][r][L][R],f[l+1][r][L][R]+(a[l]==L));
                        f[l][r][L][R]=max(f[l][r][L][R],f[l][r-1][L][R]+(a[r]==R));
                        f[l][r][L][R]=max(f[l][r][L][R],f[l+1][r-1][L][R]+(a[l]==R)+(a[r]==L));
                    }
                }
            }
        }

P9119 [春季测试 2023] 圣诞树

题意是说:给你一个凸多边形,并且保证按照给你的点的顺序连起来一定是凸多边形,要求从最高点出发,访问全部点恰好一次的花费最小,其中任意两点之间的花费为两点间的欧几里得距离,最后依次输出访问的点的编号。

$n \le 1000;|x|,|y|\le 1e7 $

首先,这个题目保证按顺序连就是一个凸多边形,可以减轻不少麻烦,当时没看见想这个想了半天

那么我们就可以把这个顺序换一下,把最高点换成的编号换成n,其他跟着变一下,这样可以方便我们后边的计算,变换是简单的,最高点前一个点和最高点后一个点的方向一定是上下颠倒的,也就是说一个上来一个下去,并且他们的前一个数也一定和它的趋势是一样的,根据这一性质就转换完了。

当然这道题还有一个很好的性质,也是凸多边形的性质,就是最优的连边一定不会有交叉,这点用三角形三边关系就可以证明,所以我们转移时只需要枚举两端点往外扩1就好了,这样是不会有交叉的。(及时修锅)

这时考虑区间DP,由于n<=1000我们不能向其他区间DP一样从中间点转移过来,那么我们就根据上面说到的好性质,避免掉那些不优的连边,只枚举两个端点的,否则一定会有交叉,自己手完一下就能看出来,这样复杂度也变成\(O(n^2)\)的了,设\(f[l][r][0/1]\)表示对于转换过后的区间[l,r],我当前在区间的左端点(右端点)时的最小花费,这时我[l,r]的点都选过了,状态是好转移的,每次只要从我的前一个点转过来或者从我的另一个端点转过来,取min即可。至于最后要求的方案我每次取min成功后就记录一下我从哪过来的,最后递归输出就好了。那么这道题就结束了。当然洛谷上卡你精度,必须开根而且要用long double 和 sqrtl。

具体的转移是这样的(Calc就是两点间距离公式)

    f[l][r][0]=f[l][r][1]=1e18;
    if(f[l][r][0]>f[l+1][r][0]+Calc(l,l+1))
      f[l][r][0]=f[l+1][r][0]+Calc(l,l+1),las[l][r][0]=0;
    if(f[l][r][0]>f[l+1][r][1]+Calc(l,r))
	  f[l][r][0]=f[l+1][r][1]+Calc(l,r),las[l][r][0]=1;
    if(f[l][r][1]>f[l][r-1][0]+Calc(l,r))
  	  f[l][r][1]=f[l][r-1][0]+Calc(l,r),las[l][r][1]=0;
    if(f[l][r][1]>f[l][r-1][1]+Calc(r,r-1))
	  f[l][r][1]=f[l][r-1][1]+Calc(r,r-1),las[l][r][1]=1;

最后的输出

void solve(int l,int r,int op){
	if(l==r) return printf("%d ",o[l].id),void();
	if(op) printf("%d ",o[r].id),solve(l,r-1,las[l][r][op]);
	else printf("%d ",o[l].id),solve(l+1,r,las[l][r][op]);
}
......
int main(){
    .......
    printf("%d ",o[n].id);
    solve(1,n-1,(f[1][n-1][0]+Calc(1,n)>f[1][n-1][1]+Calc(n-1,n)));
    return 0;
}

P10600 BZOJ4350 括号序列再战猪猪侠

题意:多测,T=5,给你一个n和m,表示有n组左右括号,其中有m条限制,每次输入一个a和b,表示与第a个左括号匹配的右括号的位置要小于第b个左括号的,问最多有多少种合法的括号序列。n<=300,答案对998244353取模。

区间DP专场,那就直接考虑区间DP。

先考虑合法方案的状态可能会有几种,这里我们定义空字符串也是一个合法的,那么就会想到可能会出现(A)和AB两种情况,其中A,B均表示合法的括号序列,那么这时候会出现一个问题,就是对于()()()这种情况会计算多次,A为()()或()或空时都会记录,这是很不妙的,怎么办呢,我们可以把上面的两种情况归为一种:(A)B,这样就不会重复计算了,而且当B为空时对应的就是第一种情况,对于第二种情况也没有影响。

再考虑它给的m条限制,我们可以对于每个限制连一条有向边,然后跑一边拓扑排序,用bitset存一下哪些点必须在我之前出现。

转移时,我们对于一个区间[l,r],每次去找长度最大的A,也就是说将(A)变得最长,记其的最终点为pt,然后枚举[l,pt]间的所有断点,表示前一半(l到k)是(A),后一半(k+1到r)是B,每次左右方案乘一下,最后\(\sum\)起来就是区间[l,r]的答案,最后的答案自然是\(f[1][n]\)了。不过还要注意那m条限制,可以预处理一下从i到j有几条限制,然后每次找到断点后判断我前半部分合不合法,不合法我就不转移了,细节还是很多的。

具体实现

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=310,mod=998244353;
int read(){
	int x;scanf("%d",&x);return x;
}
struct{
	int y,nxt;
}e[114514];//不给我m范围? 
int len,Link[N];
void add(int x,int y){
	e[++len]={y,Link[x]};
	Link[x]=len;
}
int n,m,d[N]; 
ll f[N][N];
int pre[N][N][N];
bitset<N>F[N],G[N][N];
void work(){
	n=read(),m=read();
	len=0;
	memset(Link,0,sizeof Link);
	memset(d,0,sizeof d);
	for(int i=1;i<=n;i++) F[i].reset();
	for(int i=1;i<=m;i++){
		int x=read(),y=read();
		add(x,y);
		d[y]++;
	}
	queue<int>q;
	for(int i=1;i<=n;i++) if(!d[i]) q.push(i);
	int tmp=0;
	while(!q.empty()){
		tmp++;
		int x=q.front();
		q.pop();
		for(int i=Link[x];i;i=e[i].nxt){
			int y=e[i].y; 
			F[y].set(x);
			F[y]|=F[x];//F[y]的第x位为1代表x的右括号必须在y的前面出现 
			if(--d[y]==0) q.push(y);
		}
	}
	if(tmp<n) return printf("0\n"),void();//有环直接走人 
	for(int i=1;i<=n;i++){
		G[i][i]=F[i];
		for(int j=i+1;j<=n;j++)
			G[i][j]=G[i][j-1]|F[j];
		//G[i][j][k]记录区间i~j,第k位为1代表 存在区间内的点的右括号必须比k的靠后
	}
	for(int i=1;i<=n;i++)
		for(int j=i;j<=n;j++)
			for(int k=1;k<=n;k++)
				pre[i][j][k]=pre[i][j][k-1]+G[i][j][k];
				//记录区间i~j里比我小(即右括号必须比我先出现)的前缀和,具体功能看转移就懂了
	for(int i=1;i<=n+1;i++)
		for(int j=1;j<=n+1;j++)
			f[i][j]=(i>j)?1:0;//空区间初始化为1
	for(int len=1;len<=n;len++){
		for(int l=1,r=len;r<=n;l++,r++){
			int pt=l;
			while(pt<r&&!F[pt+1][l]) pt++;//找到最大的断点,当l必须在pt前出现就不能再扩了
			for(int k=l;k<=pt;k++)
				if(pre[l][k][r]==pre[l][k][k])//如果前缀和相同,说明B里的点没有必须小于A里的点的,这时是合法的,可以转移,自己多想想应该可以理解
					f[l][r]=(f[l][r]+f[l+1][k]*f[k+1][r])%mod;
		}
	}
	printf("%lld\n",f[1][n]);
}
int main(){
//	freopen("1.in","r",stdin);
	for(int t=read();t;t--) work();
	return 0;
}

P5336 [THUSC 2016] 成绩单

这道题和上面那个求最长递增子序列的题的状态非常类似。

题意是说:给你n份成绩,每次必须挑一段连续区间发成绩,发完之后剩余区间自动合并
求出$$a \times k + b \times \sum_{i = 1} ^ k (max_i - min_i) ^ 2$$ 的最小值
其中 \(k\) 是分发的批次数,对于第 \(i\) 批分发的成绩单,\(max_i\) 是最高分数,\(min_i\) 是最低分数,\(a\) 和 \(b\) 是给定的评估参数

n的范围也是50,我们用类似上面那个递增子序列的问题的状态设计方式,设\(f[l][r][L][R]\)表示区间[l,r],该区间内最后一批发的成绩的最值为L和R的最小代价,同时用\(ans[l][r]\)来记录区间[l,r]的最小花费,转移时,我们可以从该区间左边已经发完,右边正要发完、右边已经发完,左边正要发完、左右两边都有并且这次要一下发完的最小花费,转移也是好转的,每次转移过后将答案计入ans里,取min,同时,题目中的\(a*k\)不必理会,我们只需要在每次转移的时候对ans+a即可,这样k次转移过后自然加了k次,没必要再去维护进行了几次转移。具体看完代码会理解很多。

for(int i=1;i<=n;i++){
		ans[i][i]=a;
		for(int j=1;j<=w[i];j++){
			for(int k=w[i];k<=m;k++)
				f[i][i][j][k]=0;//初始化,这跟前面那道最长不下降子序列的题很像 
		}
	}
	//甚至状态都是一样的,f[l][r][L][R]表示区间l~r,最后一批要发的分数范围为L~R 
	//再存一个ans数组,用来计算区间l~r全发完的最小花费
	//每次可从三个地方转移 
	for(int len=2;len<=n;len++){
		for(int l=1,r=len;r<=n;l++,r++)
			for(int L=1;L<=m;L++){
				for(int R=L;R<=m;R++){
					for(int k=l;k<r;k++){
						f[l][r][L][R]=min(f[l][r][L][R],ans[l][k]+f[k+1][r][L][R]);//左边发完了,右边正要发 
						f[l][r][L][R]=min(f[l][r][L][R],f[l][k][L][R]+ans[k+1][r]);//右边发完了,左边正要发 
						f[l][r][L][R]=min(f[l][r][L][R],f[l][k][L][R]+f[k+1][r][L][R]);//两边都要发,合并到一起 
						ans[l][r]=min(ans[l][r],f[l][r][L][R]+a+b*(tmp[R]-tmp[L])*(tmp[R]-tmp[L]));//题目中给的式子,这里题目中的k在每次++里自然就加上了,所以每次只要一个a 
					}
				}
			}
	}
	printf("%lld",ans[1][n]);

对于这种题,只要状态想到了就会非常好写,关键就是状态能否想到,对于这种n很小的,启发我们多开几维限制来保证答案的最优决策,感觉还是要多做才会熟练。

P4170 [CQOI2007] 涂色

题意是说:给你一个目标颜色,每次可以对一个区间染成相同颜色,同时覆盖掉之前的颜色,初始无颜色,求最少几次可以涂成目标颜色。

虽说n仍然小于等于50,但是由于没什么复杂的变换,不需要用到上面的四维限制做法。正难则反,我们染成目标颜色是困难的,但是使整个木板染成同一种颜色是好做的,转移也是方便的,每次都是从左右两个区间转移,表示左右两部分都染成相同颜色了,如果左右区间颜色不同还需再进行一步染色,最后的答案就是\(f[1][n]+1\),为什么+1?因为初始没有颜色,我们只是染成了一种颜色,还需要最后把它变成无色的。转移确实十分简单。

	memset(f,0x3f,sizeof f);
	for(int i=1;i<=n;i++) f[i][i]=0;//自己颜色相同,不用涂了 
	for(int len=2;len<=n;len++){
		for(int l=1,r=len;r<=n;l++,r++){
			for(int k=l;k<r;k++){
				f[l][r]=min(f[l][r],f[l][k]+f[k+1][r]+(s[l]!=s[r]));
				//左边涂成一种颜色的操作次数+右边涂成一种颜色的操作次数+两段是不是还要再涂一次变成相同颜色
			}
		}
	}

至于为什么只看\(s[l]!=s[r]\)?

考虑这整段区间是怎么染过来的,如果左右端点颜色相同,我是不是可以在某次操作中将整段区间染成同种颜色,之后不管左右端点就可以了?至于中间的相同颜色怎么办?那是我的子区间的事,可以转移过来,所以这个转移是既正确又是十分简单的。

后面还有几道区间DP的题……先去写完再过来

posted @ 2025-10-26 20:15  追旭  阅读(63)  评论(0)    收藏  举报
//雪花飘落效果