Loading

基础dp问题总结

背包dp

  • P6433 出题

    题目大意见原题,看上去是一个背包,但是可以对某些物品的价值进行一些增益操作。

    之前背包学得太死板了,所以一般碰到dp题我只想两层以及以内的状态,对于这个题,我最开始就只是定义\(f[i][j]\)为在前i道题里面选,花费j以内的时间,能够得到的最大毒瘤值,但是我们如果只知道\(f[i][j]\)的值的话,是没办法知道之前用过多少次增益操作的,这样是没法dp的。遇到这种需要考虑之前的选择细节的情况,一般是dp状态的定义提供的信息不够,即可能缺状态维度。思考之后,我们发现,可以把增益操作的次数也作为一维,即\(f[i][j][k]\)是从前i个题里面选,花费j以内的时间,并且用了k次增益操作,所能够达到的最大毒瘤值。这显然是一个高维背包问题,这样的话,根据增益操作的定义,转移方程是好写的,这道题目难度就降下来了。如果初始化弄不好,可以写记忆化搜索。

    代码如下:

    #include <cstdio>
    #include <algorithm>
    #define ll long long
    using namespace std;
    typedef struct{
    	ll a,x;
    }Question;
    const ll N=109;
    const ll M=1e3+9;
    ll n,m,k,sumx,newn;
    ll f[N][M][N];
    Question q[N],tmp[N];
    ll dfs(ll i,ll j,ll p);
    void Mergesort(ll start,ll end);
    void Merge(ll start,ll mid,ll end);
    int main(){
    	scanf("%lld %lld %lld",&n,&m,&k);
    	for(int i=1;i<=n;i++){
    		scanf("%lld %lld",&q[i].a,&q[i].x);
    		sumx+=q[i].x; 
    	}
    	Mergesort(1,n);
    	if(sumx<=m){ //只需要把最不毒瘤的删除就可以了 
    		newn=n-1;
    	}
    	else {
    		newn=n;
    	}
    	printf("%lld\n",dfs(newn,m,k));
    	return 0;
    } 
    ll dfs(ll i,ll j,ll p){
    	if(i<1)
    		return 0;
    	if(j<1)
    		return 0; 
    	if(f[i][j][p])
    		return f[i][j][p];
    	if(p<1){
    		if(j-q[i].x>=0){
    			f[i][j][p]=max(dfs(i-1,j,p),dfs(i-1,j-q[i].x,p)+q[i].a);
    		}
    		else {
    			f[i][j][p]=dfs(i-1,j,p);
    		}
    	}
    	else {
    		if(j-q[i].x>=0){
    			f[i][j][p]=max(max(dfs(i-1,j,p),dfs(i-1,j-q[i].x,p)+q[i].a),dfs(i-1,j-q[i].x,p-1)+2*q[i].a);
    		} else {
    			f[i][j][p]=dfs(i-1,j,p);
    		}
    	}
    	return f[i][j][p];
    }
    void Merge(ll start,ll mid,ll end){
    	ll i=start,j=mid+1,k=start;
    	for(;i<=mid && j<=end;k++){
    		if(q[i].a>=q[j].a){
    			tmp[k]=q[i++];
    		} else {
    			tmp[k]=q[j++];
    		}
    	}
    	for(;i<=mid;i++,k++){
    		tmp[k]=q[i];
    	}
    	for(;j<=end;j++,k++){
    		tmp[k]=q[j];
    	} 
    	for(i=start;i<=end;i++){
    		q[i]=tmp[i];
    	}
    	return;
    }
    void Mergesort(ll start,ll end){
    	if(start<end){
    		ll mid=(start+end)/2;
    		Mergesort(start,mid);
    		Mergesort(mid+1,end);
    		Merge(start,mid,end);
    	}
    }
    
  • P1441 砝码称重

    题目大意:n个数中去掉m个数,然后看一下剩下的数最多能组成多少种数。

    本题是用背包求方案数的题目,也是dfs+dp的一道模板题目。本题意在提醒自己能够用01/完全/带容斥的背包去求一些恰好背包的方案数,还有就是如果dfs写得好,那么dfs+dp不会超时。如果超时了,排除死循环情况之后,要看看能否优化dfs。

    代码如下:

    #include <bits/stdc++.h>
    #define ll long long
    using namespace std;
    ll n,m,a[200],ans=0,sign[200],choose[200],f[3000],sum=0;
    void dfs(int cnt,int pos);
    ll get_combine();
    int main(){
    	scanf("%lld %lld",&n,&m);
    	for(int i=1;i<=n;i++){
    		scanf("%lld",&a[i]);
    		sum+=a[i];
    	}
    	dfs(0,1);
    	printf("%lld\n",ans);
    	return 0;
    }
    
    ll get_combine(){ 
    	ll tmpans=0;
    	for(int i=0;i<=sum;i++){
    		f[i]=0;
    	}
    	f[0]=1;
    	for(int i=0;i<n-m;i++){ //求出所能拼成的所有的数字以及次数 
    		for(int j=sum;j>0;j--){
    			if(j-choose[i]>=0){
    				f[j]+=f[j-choose[i]];
    			}
    		}
    	}
    	for(int i=1;i<=sum;i++){ //统计能拼成的数字个数 
    		if(f[i]>0){
    			tmpans++;
    		}
    	}
    	return tmpans;
    } 
    
    void dfs(int cnt,int pos){ //传递参数pos,是利用剪枝策略中的2号优化手段
    	if(pos>n){ //没有可以选择的数了 
    		if(cnt==n-m){ //选够了数 
    			ans=max(ans,get_combine());
    		} else {
    			return;
    		}
    	} else {
    		if(cnt==n-m){ //选够了数 
    			ans=max(ans,get_combine());
    		} else {
    			dfs(cnt,pos+1); //不选
    			choose[cnt]=a[pos];
    			dfs(cnt+1,pos+1);  //选 
    		}
    	}
    }
    //下面是一种会TLE的dfs写法:
    void dfs(int cnt){
    	if(cnt==n-m){
    		ans=max(ans,get_combine());
    	} else {
    		for(int i=1;i<=n;i++){
    			if(sign[i]==0){
    				sign[i]=1;
    				choose[cnt]=a[i];
    				dfs(cnt+1);
    				sign[i]=0;
    			}
    		}
    	} 
    }
    

状压dp

  • P1896 互不侵犯

    状压dp的模板题。在预处理好每一行的合法状态之后,如果按行暴力搜索的话,时间复杂度是\(O(cnt^n)\),其中\(cnt\)指的是一行中的合法的状态。即使说\(n\leq 9\),但是\(cnt\)却是一个比较大的数字,显然只要\(cnt>5\),基本上就TLE了,普通的剪枝甚至是一般的启发式剪枝都没法保证AC,所以必须改进方法。

    由于这道题是求方案数,所以我们很可能没必要去枚举所有可能的状态去暴力搜索。

    考虑dp,但是不到明显的可以用来dp的数据结构(比如序列,树等),这个时候我们一般就是在一个集合里面进行dp了。而集合中子集的表示,就用到了状态压缩。我们考虑设\(f[i][s]\)为第\(i\)行的放法为\(s\)的方案数,那么考虑\(i-1\)行的状态,设为\(u\),如果\(s\)\(u\)不冲突,那么就可以\(f[i][s]+=f[i-1][u]\)。当然,本题还有个个数限制,所以dp还要加一维,变成\(f[i][s][cnt]+=f[i-1][u][cnt-num[u]]\) ,最后计算所有的\(f[n][s][k]\)的和就是答案了。

    状压代码如下:

    #include <bits/stdc++.h>
    #define ll long long
    using namespace std;
    const int N=10;
    const int K=100;
    const int S=(1<<10);
    ll n,k,f[N][S][K],valid[S],cnt,tmp[10],num[S];
    void pre();
    void dfs(ll now,ll bit);
    int main(){
    	ll ans=0;
    	scanf("%lld %lld",&n,&k);
    	pre(); //预处理出每一行中合法的状态,初始化dp数组 
    	for(ll i=2;i<=n;i++){
    		for(ll j=0;j<cnt;j++){ //枚举第i行的状态 
    			ll u=valid[j];
    			for(ll l=0;l<cnt;l++){ //枚举第i-1行的状态 
    				ll v=valid[l];
    				if(u&v) continue; //巧妙的实现 
    				if((u<<1)&v) continue;
    				if(u&(v<<1)) continue;
    				for(ll m=k;m>=num[j];m--){
    					f[i][u][m]+=f[i-1][v][m-num[j]];
    				}
    			}
    		}
    	}
    	for(ll i=0;i<cnt;i++){
    		ans+=f[n][valid[i]][k];
    	}
    	printf("%lld\n",ans);
    	return 0;
    }
    void pre(){
    	dfs(1,0);
    	for(ll i=0;i<cnt;i++){
    		f[1][valid[i]][num[i]]=1;
    	}
    	return;
    }
    void dfs(ll now,ll bit){
    	if(now>n){
    		ll ret=0;
    		for(ll i=1;i<=n;i++){
    			ret=2*ret+tmp[i];
    //			printf("%lld",tmp[i]);
    		}
    //		printf("\n");
    		valid[cnt]=ret; //记录压缩后的合法状态 
    		num[cnt++]=bit; //记录这个状态有多少个1
    		return; 
    	} else {
    		if(tmp[now-1]==0){
    			tmp[now]=0;
    			dfs(now+1,bit);
    			tmp[now]=1;
    			dfs(now+1,bit+1);
    		} else {
    			tmp[now]=0;
    			dfs(now+1,bit);
    		}
    	} 
    }
    

    本题暴力搜索的话,能通过7个测试点。如果采用启发式剪枝,第6个点将会跑得很快,但是还是不可过。

  • P2831 愤怒的小鸟

    题目大意:平面直角坐标系中有n个点,求至少需要多少条形如\(y=ax^2+bx(a<0)\)的抛物线,能够使得这n个点全部在这些抛物线上。

    拿到题之后一看,能够想到的是:我们再取两个点,只要这两个点的横坐标不一样,那么我们就可以确定一条抛物线,使之经过这两个点,然后可能还会有其他的点恰好也落在了这条抛物线上。假设我们把这至多\(C_n^2\) 条不同的抛物线都求出来了,它经过的点也都知道了,那么当我们随便取一个点集\(s\)时,对于某条抛物线\(C\),设其经过的点集为\(s'\),若\(s\bigcap s'\neq\phi\) ,那么点集\(s\)的最少抛物线数可能由\(s-(s'\bigcap s)\)的最少抛物线数转移过来,即若设\(f[s]\)为经过\(s\)点集中的所有点所需要的最少的抛物线数,那么\(f[s]\)可能由\(f[s-(s'\bigcap s)]\)转移过来,具体来讲是\(f[s]=min_{s'}(f[s-(s'\bigcap s)])+1\) 。本题数据范围\(n\leq 18\) ,在dp中,外层循环枚举点集,内层循环枚举抛物线,则复杂度应该为\(O(n^22^n)\) ,应该可以接受。

    考虑具体实现,首先我们得知道每条抛物线到底经过哪些点。要知道每条抛物线到底经过哪些点,首先要把抛物线方程求出来。显然这是二元一次方程组,一个克莱姆法则而已。在求出来之后,遍历其他的点,标记是否经过。这里,我们其实是可以考虑状压的,即每条抛物线用一个整数表示,其二进制表示中第\(i\)位为1代表其经过第\(i\)个点,且显然,对于不同的抛物线,我们可以仅通过这个数来加以区分。预处理完了之后,枚举子集常规操作,判断交集是否为空直接按位与\((s\&s')\)就好了,这样顺便求出了交集,然后我们按位异或\((s\ xor\ (s\&s'))\)就可以求出来\(s-(s'\bigcap s)\) ,相当地方便。

    注意在想用dp做某道题的时候,只要想好转移关系就好了,总是假设规模小的问题的答案可以算出来,不要去具体想小规模的怎么算,因为初状态+转移关系就能确定所有东西了。

    本题卡精度,eps要设到\(10^{-6}\)级别。

    代码如下:

    #include <bits/stdc++.h> 
    #define ll long long
    #define INF 9999999999
    #define eps 0.0000001 //这个题卡精度
    using namespace std;
    const int N=20;
    ll c[N*N],cnt,n,f[1<<N],m,T;
    typedef struct{
    	double x,y;
    }Point;
    Point p[N];
    int main(){
    	scanf("%lld",&T);
    	while(T--){
    		scanf("%lld %lld",&n,&m);
    		for(int i=1;i<=n;i++){
    			scanf("%lf %lf",&p[i].x,&p[i].y);
    		}
    		cnt=0;
    		for(int i=1;i<=n;i++){ //预处理出所有的抛物线 
    			ll st=0;
    			st=st|(1<<(i-1));
    			c[cnt++]=st;
    			for(int j=i+1;j<=n;j++){
    				if(fabs(p[i].x-p[j].x)>eps){ //不能无解 
    					double a,b; //克莱姆法则求系数 
    					a=(p[i].y*p[j].x-p[j].y*p[i].x)/(p[i].x*p[i].x*p[j].x-p[j].x*p[j].x*p[i].x);
    					b=(p[j].y*p[i].x*p[i].x-p[i].y*p[j].x*p[j].x)/(p[i].x*p[i].x*p[j].x-p[j].x*p[j].x*p[i].x);
    					ll pos=0;
    					if(a>0){
    						continue;
    					}
    					for(int k=1;k<=n;k++){
    						if(fabs(a*p[k].x*p[k].x+b*p[k].x-p[k].y)<eps){
    							pos=(pos|(1<<(k-1)));
    						}
    					}
    					c[cnt++]=pos;
    				}
    			}
    		}
    		for(ll i=1;i<(1<<n);i++){
    			f[i]=INF;
    		}
    		f[0]=0;
    		for(ll s=0;s<(1<<n);s++){ //枚举子集 
    			for(ll i=0;i<cnt;i++){
    				ll t=c[i];
    				t=t&s; //看一下有哪些相交的点 
    				f[s]=min(f[s],f[s^t]+1); //s^t是除了相交的点之外的点 
    			}
    		}
    		printf("%lld\n",f[(1<<n)-1]);
    	}
    	return 0;
    }
    
  • P4163 排列

    题目大意:给一串数\(a[]\)和一个模数\(d\),求这串数字的全排列中有多少个排列,能够被\(d\)整除。

    其实,您只要会怎么朴素生成全排列,就能通过不断的思考得到这道题的dp算法。下面我来还原这一思考过程。当然,为了能写出来状压代码,您得会写状压里面的形如判断某个元素选没选之类的基本操作。

    其实作为一个蒟蒻,我连全排列生成的高效算法都不会手写,我只会最朴素的全排列生成算法。想一下那个经典的朴素枚举全排列的算法:每次取一个之前没选的数放在已有排列后面,dfs做下去。全排列的个数实在太多了,而我们最后搜出来的全排列中只有那么一丢丢是能够被\(d\)整除的,所以我们的枚举是有很多冗余的,所以我们先考虑是否可以对dfs进行剪枝,从而有目的性地去进行搜索。

    考虑现在我们已经排列好了\(cnt\)个数,其中\(cnt<n\),然后我们想选第\(cnt+1\)个数。我们怎么知道现在还该不该往下搜了呢?如果我们能用现在的这个小的排列,预测出未来的排列是否能被整除,就可以剪枝。我们尝试一下,先不贪多,只往后走一步吧先,考虑选第\(cnt+1\)个数码为\(x\),那么就是刚才那个\(cnt\)个数的排列后面又跟了一个数码叫做\(x\),那怎么判断这个时候选\(x\)能否保证所有数都选完之后最终的排列能被\(d\)整除呢?想做这件事,我们必须知道选了\(x\)之后的当前排列的余数是多少,有了这个余数,说不定可以通过一些关系判断在当前这部分的排列的基础上还有没有希望去生成一个最终能被\(d\)整除的排列。

    假设我们知道了排列好\(cnt\)个数的时候的余数叫做\(r\),那么,它后面跟了一个\(x\)之后余数是多少呢?如果您会把字符串转化成整数的话,这个一定难不倒您,就是\((10r+x)\%d\) 。那这个新的余数是多少的时候我们还能往下搜,是多少的时候肯定没希望了呢?这个似乎是不得而知的。不过,我们已经知道了对于一个规模小的排列来说,假如我们知道规模小的那个排列的余数,那么它得到的规模更大一点的排列的余数是可以算的,不要泄气!

    根据上面的研究,我们发现单纯地通过搜索+剪枝的方式可能是做不了的,但是排列与排列余数之间的奇妙关系我们已经知道了。我们也知道了枚举所有的排列究竟是什么是一件不现实的事情,所以我们还是想减少枚举。那么我们可不可以不知道每个排列具体是啥呢?似乎是可行的,因为我们刚才只用到了上一个排列的余数是多少,然后推出了在选某个数的时候得到的新排列的余数是什么,至于上一个排列到底是啥,不重要,只要知道它的余数就行了。换句话说,不管某个排列具体是啥样的,只要它的余数是同一个数\(r\),那我们就可以把他们“看成一类”,并且, 这一类排列在往后转移的时候的规律是完全相同的。这下可厉害了,我们不用枚举排列了,我们只需要枚举排列的类别就好了。那么问题来了:排列有多少类别呢?这取决于有多少种可能的余数,显然是\(0,1,2,……d-1\)\(d\)种情况,所以我们只需要知道一个小规模的余数为\(r\)的排列的数量,然后往后加一个数\(x\),就能得到规模比他大一号的且余数为\((10r+x)%d\)的排列的数量了。这看起来是个递推,所以这道题应该可以dp去做。

    但是我们又发现一个难题:不妨考虑已经知道长度为\(cnt<n\)的且余数为\(r\)个排列的个数,想求再在后面加一个数\(x\)之后的余数是多少,那可以枚举的\(x\)是哪些呢?好像不太好弄啊,毕竟长度为\(cnt\)且余数为\(r\)的排列可能由各种可能的数字组合得到,比如考虑集合为\({1,2,3,4,6}\),那么\(123\%3=0,246\%3=0\),但是对于\(123\)来说,\(x\)可能是\(4,6\),对于\(246\)来说,\(x\)可能取\(1,3\),对于不同的数字组合,可以枚举的\(x\)就不一样,这是很麻烦的事情,如果我们能限制生成排列的数字组合是相同的就好了。说干就干,我们就设当前的这个排列个数是\(\{1,2,3\}\)这个数字组合生成的长度为\(3\)且余数是0的排列的个数,那么可选的\(x\)就都是\(\{4,6\}\)了,这样真的太好了。 这样的话,为了做dp,我们要枚举最初的那个数串的子集,枚举子集我会呀,用二进制表示就好了。讲到这里,这个问题的思路已经很清楚了:我们枚举原数字串的子集,在已知其余数分别为\(0,1,2,……d-1\)的全排列有多少个的情况下,试图往子集中再加一个数,利用方程转移一下,得到规模更大一号的余数为某个数的排列的个数。这样下去,最后的结果就是在集合为输入的那个数字串本身的情况下,余数为\(0\)的排列的个数。

    具体来说,我们设\(f[s][r]\)为由\(s\)集合里面的所有的数生成的全排列并且余数为\(r\)的排列的个数,则在后面放一个新的数\(x\)之后,我们就可以转移到\(f[s+\{x\}][(10r+x)\%d]\) ,即:

    \(f[s+\{x\}][(10r+x)\%d]+=f[s][r]\)

    转化成状压的话,假设\(x\)是输入数串中的第\(k\)个数(从0开始计数),那判断数\(x\)选没选就是看\(s\&(1<<k)\)是否为0,把\(x\)加入到集合\(s\)中得到\(s+\{x\}\)就是\(s|(1<<k)\)

    这道题有一个有点坑的地方就是输入的数串可以有重复的数字,这会导致一定的重复统计。怎么做到不重复呢?考虑我们的输入是000这串数(就是样例),我们设最左边的叫第0个0,最右边的叫第2个0。集合\(s\)的二进制表示为\(000\)的情况是平凡的,当\(s=001\)时,我们当前就是枚举到了只选最左边的0为集合\(s\)中的元素,当然我们知道接下来无论是选中间那个0还是最右边那个0都是一样的,因为这两个元素的值相同,都是0。我们枚举可以加入的元素的时候,每当可以加入一个元素\(x\)时,我们都在一个vis数组中标记这个数码的数之前已经被选过了,所以当\(s\)和余数不变的时候,如果再枚举到位置不同但是数码也是\(x\)的元素的时候,就不用计数了,这样就可以解决重复计数的问题啦。

    关于复杂度,我们用题目中给的字母:首先最外层是枚举\(s\)集合,有\(2^{|s|}\)个,然后枚举取模结果,有\(d\)个,然后还要枚举\(s\)的各个位以看看到底还有哪些数没有放在集合\(s\)中,所以又是\(s\),所以总的复杂度是\(O(2^{|s|}d|s|)\)

    代码如下:

    #include <bits/stdc++.h>
    #define ll long long
    using namespace std;
    const ll S=(1<<10)-1;
    const ll M=1e3+9;
    const ll N=12;
    char b[N];
    ll f[S+9][M],a[N],d,n,t;
    bool vis[N];
    int main(){
    	scanf("%lld",&t);
    	while(t--){
    		ll ans;
    		scanf("%s",b);
    		scanf("%lld",&d);
    		n=strlen(b);
    		for(ll i=0;i<(1<<n);i++){
    			for(ll j=0;j<d;j++){
    				f[i][j]=0;
    			}
    		}
            for(ll i=n-1;i>=0;i--){
    			a[i]=b[i]-'0';
    		}
    		f[0][0]=1;
            //a[]数组表示当时读入的那个数字串,长度为n,f[][]数组是dp用的,f[s][j]代表当前选的数组成的可重集是s,并且这个可重集s的对d取模余数为j的排列的个数,d为题目给定的模数
    		for(ll s=0;s<(1<<n);s++){ //状压枚举当前已经选过了哪些数 
    		for(ll i=0;i<10;i++){
    				vis[i]=false; //初始化0-9每个数码都没选过
    			}
    			for(ll k=0;k<n;k++){ //枚举还没选的数 
    				if((s&(1<<k))==0 && !vis[a[k]]){ //如果某个位置的数没选过,且该位置的数的值也没选过,这时候vis[]相当于一个桶
    					vis[a[k]]=true; //标记选过这个数 
    					for(ll j=0;j<d;j++){ //枚举排列的余数 
    						f[s|(1<<k)][(10*j+a[k])%d]+=f[s][j];
    					} 
    				}
    			}
    		} //考虑000这串数,我们设最左边的叫第0个0,最右边的叫第2个0。最开始s=000,f[000][0]=1是初始化好的,然后呢,在枚举还没选的数的时候,我们先是枚举到了最左边的0,然后标记vis[0]=true,然后更新了f[001],接下来在枚举中间的0的时候,由于vis[0]=true了,所以就不能选了,这样就实现了去重。
    		ans=f[(1<<n)-1][0];
    		printf("%lld\n",ans);
    	}
    	return 0;
    }
    
  • P2157 学校食堂

    题目大意:见原题。

    这个题看了之后是没什么思路的。先想搜索:直接枚举全排列,然后看一下排列是否合法,如果合法的话,再算一下和。在判断全排列是否合法时,一个低效的方法是\(O(n^2)\)的判断。值得注意的是,这道题是多组数据\(ans\)每次暴搜完了之后都要刷新成\(INF\) 。然后,我们得到了8分的好成绩。

树形dp

​ 总体规律:设\(f[i][]\)为以\(i\)为根节点的子树的怎样的最优解,用子树去更新父亲,有的是算出来一个子树就更新 父亲,有的是算出来所有子树再更新父亲。

  • P3478

    题目大意:一棵树,问以哪个点为根的时候,树的结点的总共的深度最大?

    题意很简单,暴力想法很简单,就是枚举根,然后暴力dfs。为了加速计算,我们需要换思路。对于树上的问题,可能的思考方向有树剖,LCA,树形dp。像这种求最值的问题,一般就要考虑树形dp了。树形dp,一般来说定义状态时都是以某点为根的子树的一些情况,比如点权和,然后转移的时候,我见过的有两种转移:一是父亲和儿子之间的转移,一般是用所有的儿子去更新父亲,另一种是换根转移,这里考虑了原来的根和新的根之间的关系。对于本题来讲,用的是换根dp的思路。我们先随便选一个点为树根,一次dfs预处理出所有点的深度,子树大小等信息,然后考虑转移:不妨设原来的根是x,现在想把根变成x的直系儿子y,并设x为根时深度值和为f[x],则通过画图可以看出来,在根变化的时候,原来的y的子树里的所有点的深度都-1,剩下的所有点深度都+1,所以转移方程是:\(f[y]=f[x]-size[y]+n-size[y]\)。为了方便写这个转移,我们一般用记忆化搜索实现这个dp。

    代码如下:

    #include <cstdio>
    #include <cstdlib>
    #include <algorithm>
    #define ll long long 
    using namespace std;
    const ll M=2e6+9;
    const ll N=1e6+9;
    typedef struct{
    	ll to,nxt;
    }Edge;
    typedef struct{
    	ll depth;
    	ll size;
    	ll fa;
    	ll sumdepth;
    }Vertex;
    Vertex v[N];
    Edge edge[M];
    ll head[N],cnt,sign[N];
    ll n,f[N],maxx,ans;
    void dfs2(ll now);
    void add(ll u, ll v);
    void dfs1(ll now,ll fa,ll depth);
    int main(){
    	ll u,v;
    	scanf("%lld",&n);
    	for(int i=0;i<=n;i++)
    		head[i]=-1;
    	for(int i=1;i<n;i++){
    		scanf("%lld %lld",&u,&v);
    		add(u,v);
    		add(v,u);
    	}
    	dfs1(1,0,1);
    	dfs2(1);
    	printf("%lld\n",ans);
    	return 0;
    }
    void dfs2(ll now){
    	if(v[now].fa==0){
    		f[now]=v[now].sumdepth;
    		maxx=f[now];
    		ans=1;
    	} else {
    		f[now]=f[v[now].fa]+n-2*v[now].size;
    		if(f[now]>maxx){
    			maxx=f[now];
    			ans=now;
    		}
    	}
    	for(ll i=head[now];i>=0;i=edge[i].nxt){
    		if(edge[i].to!=v[now].fa){
    			dfs2(edge[i].to);
    		}
    	}
    }
    void dfs1(ll now,ll fa,ll depth){
    	v[now].fa=fa;
    	v[now].depth=depth;
    	v[now].size=1;
    	v[now].sumdepth=depth;
    	sign[now]=1;
    	for(ll i=head[now];i>=0;i=edge[i].nxt){
    		if(sign[edge[i].to]==0){
    			dfs1(edge[i].to,now,depth+1);
    			v[now].size+=v[edge[i].to].size;
    			v[now].sumdepth+=v[edge[i].to].sumdepth;
    		}
    	}
    }
    void add(ll u, ll v){
    	edge[cnt].to=v;
    	edge[cnt].nxt=head[u];
    	head[u]=cnt++;
    }
    
  • BZOJ4472(但是没找到)

    题目大意:给定一棵 n 个点的树,有点权,你从 1 号点开始一次旅行,最后 回到 1 号点。每到达一个点,你就能获得等于该点点权的收益。 但每个点都有进入该点的次数限制,且每个点的收益只能获得一 次。求最大收益。

    我们来考虑限制每个点的访问次数是什么意思:即限制能访问该结点的几棵子树。比如某个结点的访问次数限制是3次,那么,从根的方向访问过来消耗一个次数,然后还剩两次,也就是说,它至多还能再回溯到这个点两次,至多再访问两棵子树。显然,我们想访问受益比较大的那几棵树。考虑状态定义:设\(f[i]\)为以\(i\)为根的子树最多可以获得多少受益。那么\(f[i]\)应该由它的一系列儿子的值\(f[j]\)转移过来,即先算出来\(i\)的所有子树的\(f\)值,排一下序,然后选择最大的几个就好了。

    想一下这个题怎么写:dfs的时候,我们应该先一直到叶子节点,如果叶子节点可以被访问小于等于1次,那么受益就是0,否则受益就是叶子的权值。等某个结点的所有子树都处理好之后,用一个优先队列或者sort处理出来前几大,取出来加到\(f[i]\)上就是\(f[i]\)的值了。这样是不会超时的,为什么呢?因为我们每次加入优先队列的都是直系儿子,所以总的来看是维护了\(n\)个点,复杂度正确。

    伪代码如下:

    void dfs(ll i){
        if(isleave(i)){
            if(visitcnt[i]<=1){
                f[i]=0;
            } else {
                f[i]=w[i];
            }
        } else {
            priority_queue<ll> q; //大顶堆
            for(ll j=head[i];j>=0;j=edge[j].nxt){
                dfs(edge[j].to);
                q.push(f[edge[j].to]);
            }
            for(ll j=1;j<visitcnt[i] && !q.empty();j++){
                f[i]+=q.top();
                q.pop();
            }
        }
    }
    
  • P3574 安装软件

    题目大意:有一棵 n 个点的树,有边权且默认都是1。你初始在 1 号节点,你需要走遍整 棵树为 2 ∼ n 号点的居民分发电脑,但你的汽油只够经过每条边 恰好两次。一个居民拿到电脑后会马上开始安装软件,i 号点的 居民安装需要 ti 的时间。分发完成后你会回到 1 号点开始安装 自己的软件。求所有人的软件安装完成所需的最少时间。

    条件中说到每条边至多经过2次,这意味着我们走的时候是遍历整棵树。由于每条边都必然走了2次,所以最终回到1号结点的时间是无关走法的。考虑最后的答案是怎么来的:对于每个点来说,在到达它之前需要走一段时间,到了它之后直到安装完成又需要一段时间,所以,我们最后的答案就是第一次到达某个点花费的时间+该点安装需要的时间的最大值。在上面说答案的组成时,1号点要单独考虑,应该是遍历整棵树的时间+1号点安装软件的时间(题中说了最终走回来才安装1号点)。

    由于我们要让最大值尽可能小,所以直观地想,点权越大的点我们越希望先走,往返消耗时间比较少的子树我们希望先走,为了兼顾这两个方面,我们不能简单地进行贪心。根据经验,影响答案的因素多的时候,可能就需要dp寻找最优解了,所以我们考虑在树上做dp。

    根据上面的分析,为了算走路的时间,首先不难想到设\(f[x]\)为从\(x\)结点开始(每个\(f[x]\)都从0开始计数),遍历完以\(x\)为根的子树所花费的时间。关于\(f[x]\)的计算,我们需要利用它的所有的子树的结果。设\(y\)的父亲是\(x\),则\(f[x]=f[x]+f[y]+2\) ,在这里\(+2\)是因为我们要先从\(x\)走到\(y\),然后再遍历\(y\)子树,最终再从\(y\)返回到\(x\)

    为了再把安装的时间考虑进去,考虑设\(g[x]\)为从\(x\)结点开始(每个\(g[x]\)都从0开始计数),遍历完以\(x\)为根的子树并回到\(x\),并直到以\(x\)为根的这棵树全部安装完毕所花费的时间。关于\(g[x]\)的计算,我们不难发现,对于\(x\)的两个儿子\(a,b\),先走\(a\)子树和先走\(b\)子树,可能会导致\(g[x]\)最终算出来不一样,而我们希望这个值越小越好,这怎么办呢?我们需要确定一个访问顺序,来使得\(g[x]\)计算出来最小,这样就需要进行排序。排序的标准是什么呢?因为写sort的时候我们一般要重载小于号以比较任意两个元素的大小关系,所以我们在这里考虑对于\(x\)的任意两个儿子\(a,b\),先访问谁。假设先访问\(a\),那么\(a\)这棵子树里面所有的点都安装完成时什么时间呢?应该是\(g[a]+\)在访问\(a\)之前在以\(x\)为根的树中游走所消耗的时间。上述式子中用文字描述的这个时间是多少呢?这个时候我们再回忆一下\(f[x]\)是怎么算的:\(f[x]=f[x]+f[y]+2\) ,也就是说我们的\(f[x]\)不是一下子算出来的,而是步步更新的。考虑当想要从\(x\)走到\(a\)但还没有走的时候的中间结果\(f[x]\)的意义,它就是遍历完\(a\)之前的那些子树所消耗的总时间,这个时间再加上从\(x\)走到\(a\)的时间,就是上面文字描述的那个时间,即:在访问\(a\)之前在以\(x\)为根的树中游走所消耗的时间\(=f[x]+1\) ,所以\(a\)这棵子树中的所有点都安装完成的时间是\(g[a]+f[x]+1\) 。那么这个东西对于\(g[x]\)的贡献是什么呢?根据我们的\(g[x]\)的定义,我们的\(g[x]\)应该是\(x\)点的安装时间\(t[x]\)与所有的\(g[child]+f[x]+1\)中的最大值,即\(g[x]=max(t[x],max(g[child]+f[x]+1))\) ,该式中的\(f[x]\)的意义是我们刚才说的遍历时的中间结果。有了这个分析,如果先访问\(a\),紧接着访问\(b\)的话,那\(g[x]=max(t[x],max(g[a]+f[x]+1,f[x]+1+f[a]+1+1+g[b]))\) ,其中里面的\(max\)中的第二项表示的是先访问\(a\)紧接着再访问\(b\)\(b\)的贡献,\(f[x]\)仍为本段中标黑的定义,之所以没合并同类项是因为大家可以从这个结构中看出来这个数是怎么来的。

    下面考虑\(max\)究竟是怎么取出来的:如果\(t[x]\)大到无论怎么访问\(x\)的子树,都是\(x\)最后安装完的话,那当然\(g[x]=t[x]\);如果不是上面那种情况,我们就要考虑内层\(max\)的取值了。在这之前,我们先写一下先访问\(a\)紧接着访问\(b\)以及先访问\(b\)紧接着访问\(a\)时的贡献表达式:

    先访问\(a\)紧接着访问\(b\):\(g[x]=max(t[x],max(g[a]+f[x]+1,f[x]+f[a]+g[b]+3))\)

    先访问\(b\)紧接着访问\(a\):\(g[x]=max(t[x],max(g[b]+f[x]+1,f[x]+f[b]+g[a]+3))\)

    如果我们最终的顺序真的是先访问\(a\)再访问\(b\),那么第一个式子的内层\(max\)应该比第二个式子的内层\(max\)要小。即\(max(g[a]+f[x]+1,f[x]+f[a]+g[b]+3)<max(g[b]+f[x]+1,f[x]+f[b]+g[a]+3)\)

    如果不等号左边取了\(g[a]+f[x]+1\),那么右边的\(f[x]+f[b]+g[a]+3\)显然比左边大,所以不等式一定成立;如果不等号左边取了\(f[x]+f[a]+g[b]+3\),那么显然\(f[x]+f[a]+g[b]+3>g[b]+f[x]+1\),这个时候只需要再满足\(f[x]+f[a]+g[b]+3<f[x]+f[b]+g[a]+3\)就好了,化简一下就是\(g[a]-f[a]>g[b]-f[b]\) ,这就是我们的排序标准,只要满足这个式子,那么\(a\)子树就应该比\(b\)子树先访问。

    讲到这里,关于\(f,g\)如何计算,如何转移,按照如何的顺序进行访问都已经搞清楚了,最终输出\(g[1]\)就是结果了。

    代码如下,很重要,自下而上推的模板:

    #include <bits/stdc++.h>
    #define ll long long
    #pragma GCC optimize(2)
    using namespace std;
    const int N=5e5+9;
    vector<ll> graph[N];
    ll n,c[N],f[N],g[N];
    void dfs(ll now,ll fa);
    bool cmp(ll a,ll b);
    int main(){
    	ll x,y;
    	scanf("%lld",&n);
    	for(int i=1;i<=n;i++){
    		scanf("%lld",&c[i]);
    	} 
    	for(int i=1;i<n;i++){
    		scanf("%lld %lld",&x,&y);
    		graph[x].push_back(y);
    		graph[y].push_back(x);
    	}
    	dfs(1,0);
    	printf("%lld\n",max(g[1],c[1]+f[1])); //特判起点 
    	return 0;
    } 
    void dfs(ll now,ll fa){
    	vector<ll>::iterator i;
    	int leave=1;
    	for(i=graph[now].begin();i!=graph[now].end();i++){ //判断是不是叶子 
    		if(*i!=fa){
    			leave=0;
    			ll tmp=*i;
    			dfs(tmp,now); //注意如果不是叶子就要先一直往下走,走到叶子才能开始从下往上的dp
    		}
    	}
    	if(leave){
    		f[now]=0;
    		g[now]=c[now];
    		return;
    	} else {
    		sort(graph[now].begin(),graph[now].end(),cmp);
    		g[now]=c[now];
    		for(i=graph[now].begin();i!=graph[now].end();i++){
    			if(*i!=fa){
    				ll tmp=*i;
    				g[now]=max(g[now],g[tmp]+f[now]+1); //注意先刷新g再刷新f 
    				f[now]+=f[tmp]+2;
    			}
    		}
    	}
    }
    inline bool cmp(ll a,ll b){
    	return (g[a]-f[a])>(g[b]-f[b]);
    }
    

    注意:自下而上推,在代码实现上,我们都是先用一个dfs一直往下走,什么都不干,走到叶子之后再进行相应操作

  • P5658 括号树

    题目大意:有一棵树,每个节点都有一个括号,可能是左括号,也可能是右括号。记\(s[i]\)为从根节点到\(i\)结点的括号串,求对于所有的\(s[i]\),一共有多少个合法的括号子串。

    我们先考虑序列上的做法。给一个括号序列,求这个括号序列有多少个合法的括号子串。由于括号匹配问题常用栈,所以我们这里先用栈去做一些事情。由于遇到右括号,如果栈不为空就要出栈,所以这个时候我们记录下来到底是原序列中哪个位置的左括号出栈了,即位于\(i\)位置的右括号使得位于\(l_i\)位置的左括号出栈了。如果不匹配,则\(l_i=0\) 。然后就是线性dp了。经过尝试,我们需要设以\(i\)结尾的合法括号串的个数为\(f[i]\)。如果\(i\)为左括号,则显然\(f[i]=0\);如果为右括号,考虑与其匹配的\(l_i\),之所以他们两个能够匹配,是因为他们两个之间是合法的括号串,否则\(l_i\)应该匹配到\(i\)之前的某个右括号或者\(i\)匹配到\(l_i\)之后的某个左括号。对于这个性质,在我们的合法括号串的形式化定义里的第二条中也可以看出来。

    有了这个,考虑转移方程。在\(l_i-1\)以及之前肯定还有很多合法的括号串,那么他们是怎么给\(f[i]\)做贡献的呢?我们一步一步来。为了让前面的有贡献,在\(l_i-1\)位置之前的合法括号串必须与\(l_i\)\(i\)的括号串连起来,否则得到的就不是以\(i\)结尾的合法括号串,也就是说我们只需要也仅能考虑\(f[l_i-1]\)的贡献。对于\(f[l_i-1]\)中的每个合法括号串,我们都让它和\(l_i\)\(i\)的括号串连起来,得到一个新的括号串,这样的话有多少个以\(l_i-1\)结尾的括号串就能贡献多少以\(i\)结尾的括号串。那么\(f[i]\)\(f[l_i-1]\)到底有什么区别呢?由于\(l_i\)\(i\)这部分的串也是一个合法的括号串,所以:

    \(f[i]=f[l_i-1]+1\)

    这个就是序列上做法的状态转移方程了。关于最后的答案,由于需要前\(n\)个括号中的合法括号串,所以应该求\(\sum\limits_{i=1}^{n}f[i]\)

    对于树上做法,是类似的,只是转移的时候不是按照下标顺序转移了而已。对于题中定义的串\(s[i]\),考虑到\(i\)到根只有一条简单路径,所以我们只要考虑求出在这条路径上的\(l_i\)就好了。由于dfs的性质就是朝着一个方向往深处搜,所以在dfs时我们就是走了一条链,所以直接按照序列做法即可。当然,这个在实现上和序列的区别是,在回溯的时候应该把我们进行的那些操作都复原回去,也就是说之前括号匹配的时候我们用右括号弹出了一些左括号,现在我们要把它们再加回去,另一个区别是每次转移时是从\(fa[l_i]\)转移的而不是\(l_i-1\)了。因为我们要求所有的串的合法括号子串,所以我们可能要维护一个\(f\) 的前缀和,这样能简化计算。

    另外值得注意的是,和上面几个题比起来,这个题的特点是从根往叶子进行转移,即从上往下推。

    代码如下:

    #include <bits/stdc++.h>
    #define ll long long
    using namespace std;
    const int N=5e5+9;
    vector<ll> graph[N];
    stack<ll> s;
    char a[N],q[N];
    ll f[N],sum[N],n,l[N],ans=0,father[N];
    void dfs(ll now,ll fa);
    int main(){
    	ll x;
    	scanf("%lld",&n);
    	scanf("%s",a+1);
    	for(ll i=1;i<n;i++){
    		scanf("%lld",&x);
    		graph[i+1].push_back(x);
    		graph[x].push_back(i+1);
    	}
    	dfs(1,0);
    	printf("%lld\n",ans);
    	return 0;
    } 
    void dfs(ll now,ll fa){
    	father[now]=fa;
    	vector<ll>::iterator i;
    	if(a[now]=='('){
    		s.push(now); //左括号入栈 
    	} else {
    		if(!s.empty()){ //栈不为空就可以匹配 
    			l[now]=s.top();
    			s.pop();
    			f[now]=f[father[l[now]]]+1; //注意和序列上的稍有不同 
    		} 
    	}
    	sum[now]=sum[fa]+f[now];
    	ans=ans^(sum[now]*now);
    	for(i=graph[now].begin();i!=graph[now].end();i++){
    		if(*i!=fa){
    			ll tmp=*i;
    			dfs(tmp,now);
    		}
    	}
    	//恢复现场 
    	if(a[now]=='('){ //如果当前是左括号,就需要弹栈一次 
    		s.pop();
    	} else { //如果是右括号,则如果它成功匹配过的话,需要把它弹出的那个左括号进栈 
    		if(l[now]!=0){
    			s.push(l[now]);
    		}
    	}
    }
    
  • 树上背包 && 数据结构优化dp

    题目大意:一棵 n 个点的树,有点权。在树上选择一个大小为 K 的联通块, 使得点权和最大。 n ≤ 2000

    显然每个点有选与不选两种选择,并且由于块是连通的,所以可能有一些约束关系。

    如果这个问题是在序列上做,那么就是最大子段和问题,我们可以通过分治或者dp去做。由于这个还是挺重要的,所以先讲一下序列上的这两种做法。为了方便,序列上的做法中这里我们先不限制大小了。但如果真的是限制只能大小为k的话,直接尺取法就能解决问题。

    分治解法:我们原本是要在\([1,n]\)中选最大子段,而最大子段无非以下三种情况:全部在\([1,\frac{n}{2}]\)、全在\([\frac{n}{2}+1,n]\),以及跨过中点。显然,在递归的时候已经求出了\([1,\frac{n}{2}]\)\([\frac{n}{2}+1,n]\)内部的最大子段,那么我们现在还需要求跨越中间的最大子段。怎么做呢?我们考虑从\(\frac{n}{2}\)往前求和,记录途经的最大值,从\(\frac{n}{2}+1\)开始向后求和,记录途经最大值,这两个加起来就是跨中点的最大的值了。最后,对这三个取最大值,就是\([1,n]\)中的最大子段和。时间复杂度\(O(nlog_2n)\)

    代码如下:

    #include <bits/stdc++.h>
    #define ll long long
    #define INF 999999999
    using namespace std;
    const int N=2e5+9;
    ll a[N],n;
    ll solve(ll left,ll right);
    int main(){
    //	freopen("in.txt","r",stdin);
    	ll ans=0;
    	scanf("%lld",&n);
    	for(int i=1;i<=n;i++){
    		scanf("%lld",&a[i]);
    	}
    	ans=solve(1,n);
    	printf("%lld\n",ans);
    	return 0;
    }
    ll solve(ll left,ll right){
    	if(left>right){
    		return 0;
    	} else if(left==right){
    		return a[left];
    	} else {
    		ll mid=(left+right)/2;
    		ll ansl,ansr,ansm,tmp1=0,tmp2=0,max1=-INF,max2=-INF; //注意初始化
    		ansl=solve(left,mid);
    		ansr=solve(mid+1,right);
    		for(ll i=mid;i>=left;i--){
    			tmp1+=a[i];
    			max1=max(max1,tmp1);
    		}
    		for(ll i=mid+1;i<=right;i++){
    			tmp2+=a[i];
    			max2=max(max2,tmp2);
    		}
    		ansm=max1+max2;
    		return max(max(ansl,ansr),ansm);
    	}
    }
    

    更优的做法是利用数据结构优化dp。考虑到每个子段和都是两个前缀和的差,所以朴素地想,我们枚举这\(O(n^2)\)个前缀和的差,求最大值就好了。显然这是超时的,因为我们的枚举太盲目了。如果用dp的话,我们不妨设\(f[i]\)为以\(i\)结尾的最大字段和的值,那么,我们怎么算\(f[i]\)呢?肯定是\(sum[i]\)减去前面的某个\(sum[j]\),哪个\(j\)呢?显然是\(sum[j]\)最小的那个\(j\)。也就是说:

    \(f[i]=sum[i]-min_{1\leq j< i}(sum[j])\)

    如果遍历找那个最小值,肯定又白费了。下面考虑用数据结构优化。一个思路,用线段树,很容易维护前缀和的最小值,这样的话总的复杂度就是\(O(nlog_2n)\) ;另一个思路,就是用单调队列优化,因为我们的单调队列可以轻易地求出一个区间内的最小值。这样的话预处理\(O(n)\),就可以\(O(1)\)查询了,总复杂度\(O(n)\)

    然后不争气的我不太会写单调队列了,写代码的时候因为这个是前缀中的最小值,所以直接动态更新了,没用到单调队列。

    代码如下:

    #include <bits/stdc++.h>
    #define ll long long
    #define INF 999999999999
    using namespace std;
    const int N=2e5+9;
    ll a[N],sum[N],minn=INF,ans=-INF,n;
    int main(){
    	scanf("%lld",&n);
    	for(int i=1;i<=n;i++){
    		scanf("%lld",&a[i]);
    		sum[i]=sum[i-1]+a[i];
    		minn=min(sum[i-1],minn);
    		ans=max(ans,sum[i]-minn);
    	}
    	printf("%lld\n",ans);
    	return 0;
    } 
    

    至此,我们刚说完序列上的做法,下面讲树上的做法。

    首先是连通性,序列上下标差为1就表示相邻,但是在树上这个指的是儿子和父亲算是相邻。由于我们设状态都是设子树的状态,然后从子树转移到根节点,所以为了让这些子树的结果能够合法地汇总起来,我们必须选择这个时候的根节点(否则两个子树的解无论如何都不能连通)。所以,我们设\(f[now][i]\) 为在以\(now\)为根的子树中选\(i\)个点,并且强制必须选\(now\)点的最大点权和。设\(now\)的某个儿子是\(u\),在访问\(u\)子树之前,可能还访问了其他的子树,得到了一系列\(f[now][i]\)的中间结果,其意义代表在以\(now\)为根的访问时在\(u\)子树之前的子树中选\(i\)个带你,并且强制必须选\(now\)点的最大点权和。这个时候,我们肯定要用一系列的中间值\(f[now][i]\)\(u\)子树的一系列最终版\(f[u][j]\)去综合起来,算出来更新的一轮的\(f[now][i]\)值。因为我们在转移的时候,只能用旧的\(f[now][i]\),所以,我们开临时数组\(g\),来保存可能的新的\(f[now][i]\)的值,等到用完\(u\)子树之后,我们再用\(g\)去更新\(f[now][i]\)。值得注意的是枚举范围:我们动态刷新\(size[now]\),所以\(i\)的取值就是\(1\)\(size[now]\)(因为now必须要选);对于\(j\),其枚举范围显然是\([0,size[u]]\)。我们的\(g[]\)数组每次用之前都要赋值成无穷小,其赋值范围应该是\([1,size[now]+size[u]]\)

    代码如下:

    代码如下:

    void dfs(ll now,ll fa){
    	vector<ll>::iterator it;
    	size[now]=1;
    	for(it=graph[now].begin();it!=graph[now].end();it++){
    		if(*it!=fa){
    			ll u=*it;
    			dfs(u,now); //先一直访问到叶子节点 
    		}
    	}
    	f[now][0]=0; //到达叶子节点或者某个结点的子节点都处理完之后的初始化 
    	f[now][1]=a[now];
    	for(it=graph[now].begin();it!=graph[now].end();it++){
    		if(*it!=fa){
    			ll u=*it;
    			for(ll i=0;i<=size[now]+size[u];i++){
    				g[i]=-INF;
    			}
    			for(ll i=1;i<=size[now];i++){
    				for(ll j=0;j<=size[u];j++){ //这层循环正反都行,因为不影响结果 
    					g[i+j]=max(g[i+j],f[now][i]+f[u][j]); //这里的f[now][i],是在访问u子树之前时的一个中间值 
    				}
    			}
    			size[now]+=size[u];
    			for(ll i=1;i<=size[now];i++){
    				f[now][i]=max(f[now][i],g[i]); //访问完u子树了,用g刷新一波f[now][i] 
    			}
    		}
    	}
    }
    

    这个就是在树上做背包了。它与线性的背包的不同点在于:我们每次用一棵子树去更新根节点的信息,但是在对于一棵子树的转移的过程中,我们不能直接改原值,而是要保持原值不动,把新的值存在一个临时空间里面,等到这棵子树用完了,才能用临时空间里的值去刷新原值,然后再考虑下一棵子树的转移。

  • P2607 骑士

    这道题最开始我看到的是简化题意之后的版本,忽略了建模的过程,后来做的时候已经忘掉了简化题面,看到原题面之后愣了一下,做起来也不是很顺利,所以就从原题面一步一步来分析吧。

    首先,读完题之后,我们发现题中所说的某个人恨另一个人描述的是一种关系,这种关系如何记录呢?一种方式就是用图来记录。注意到如果\(x\)\(y\),那么\(y\)\(x\)不一定成立,所以这个图应该存成有向图。由于一个人不会恨自己,所以不会有自环。分析到这里,感觉这个图还是挺常规的,没发现啥特殊性质。

    然后在建图的时候就发现了一个问题:这个有向边到底是谁指向谁的呢?一开始我是跟着感觉走的,\(x\)\(y\)我应该是\(x\)指向\(y\)的有向边吧,然后我手造了一组数据:

    4
    
    10 2
    
    20 1
    
    30 1
    
    40 1
    

    发现图的形状很差劲,因为有很多入度为0的点。如果要在这样的图里跑题目要求的最大值的话,就会分好多个强连通分量,并且这样的强连通分量之间在选择点的时候是有制约关系的,比如如果我不选1的话,我得想办法告诉另外三个点,然而我们在遍历图的时候,1,2号点在同一个顶层的dfs被访问,然后3号点就是在另一个dfs里面被访问了,难以做到消息通信,很难受。于是考虑当\(x\)\(y\)时由\(y\)指向\(x\)连接一条有向边,这样对于同样的输入,画出来的图明显好多了,虽然还是有很多强连通分量,但是我们观察到这个时候1号节点选与不选的影响传到了其他节点,这是挺好的。这样建边的结果是这个图中的每个强连通分量都变成基环树了。

    然后怎么做呢?由于每个强连通分量都是基环树,所以我们可以考虑拆边变成树,然后用树形dp去做。考虑\(x\)\(y\),那么最终可能会怎么选呢?有可能只选\(x\)不选\(y\),有可能只选\(y\)不选\(x\),有可能二者都不选。那么,我们可以直接考虑强制不选\(x\)的情况和强制不选\(y\)的情况,这两个中取最大值就是这个连通分量中最大的取法的值。那么这时候还需要枚举所有环上的边吗?看来是不需要了,因为我们这个强连通分量上的答案只有上面所说的三种情况。

    实现的代码如下(随机TLE/MLE最后一个点):

    #include <bits/stdc++.h>
    #define ll long long
    using namespace std;
    const int N=1e6+9;
    typedef struct{
    	ll u,v;
    }NB;
    vector<ll> graph1[N],graph2[N];
    vector<NB> loop;
    ll n,a[N],ans=0,f[N][2];
    bool vis1[N],vis2[N],instack[N];
    ll read();
    void findloop();
    void eraseedge();
    void dfs(ll now,ll fa);
    void getloop(ll now,ll fa);
    int main(){
    	ll u;
    	n=read();
    	for(int i=1;i<=n;++i){
    		a[i]=read();
    		u=read();
    		graph1[u].push_back(i);
    		graph2[i].push_back(u);
    		graph2[u].push_back(i); //建两个图,1是有向,2是无向 
    	}
    	findloop(); //在有向图中找到每个连通块中的环的一条边 
    	eraseedge(); //在无向图中把环中那条有向边代表的无向边删除
    	vector<NB>::iterator i;
    	for(i=loop.begin();i!=loop.end();i++){
    		ll ans1,ans2;
    		NB now=*i;
    		dfs(now.u,0); //强制让u不选,以u为根进行dp,求f[u][0] 
    		ans1=f[now.u][0];
    		dfs(now.v,0); //强制让v不选,以v为根进行dp,求f[v][0] 
    		ans2=f[now.v][0];
    		ans+=max(ans1,ans2);
    	} 
    	printf("%lld\n",ans);
    	return 0;
    }
    inline void dfs(ll now,ll fa){
    	vector<ll>::iterator it;
    	for(it=graph2[now].begin();it!=graph2[now].end();it++){
    		if(*it!=fa){
    			ll u=*it;
    			dfs(u,now);
    		}
    	}
    	f[now][0]=0;
    	f[now][1]=a[now];
    	for(it=graph2[now].begin();it!=graph2[now].end();it++){
    		if(*it!=fa){
    			ll u=*it;
    			f[now][0]+=max(f[u][1],f[u][0]);
    			f[now][1]+=f[u][0];
    		}
    	}
    }
    inline void eraseedge(){
    	vector<NB>::iterator i;
    	vector<ll>::iterator j,pos;
    	for(i=loop.begin();i!=loop.end();i++){
    		ll u=i->u,v=i->v;
    		for(j=graph2[u].begin();j!=graph2[u].end();j++){
    			if(*j==v){
    				pos=j; //之前写java的时候发现在遍历容器的时候直接删除一般会有错误,所以这里我先把要删除的地方存下来 
    			}
    		}
    		graph2[u].erase(pos);
    		for(j=graph2[v].begin();j!=graph2[v].end();j++){
    			if(*j==u){
    				pos=j;
    			}
    		}
    		graph2[v].erase(pos);
    	}
    }
    inline void findloop(){
    	for(ll i=1;i<=n;i++){
    		if(!vis1[i]){
    			getloop(i,0);
    		}
    	}
    }
    inline void getloop(ll now,ll fa){
    	vis1[now]=true;
    	vector<ll>::iterator it;
    	instack[now]=true;
    	for(it=graph1[now].begin();it!=graph1[now].end();it++){
    		ll u=*it;
    		if(!vis1[u]){ 
    			getloop(u,now);
    		} else if(vis1[u] && instack[u]){ //如果标记访问过,并且还在栈中,那就是有环 
    			NB tmp;
    			tmp.u=now;
    			tmp.v=u;
    			loop.push_back(tmp); //找到了环上的一条边 ,存起来 
    		}
    	}
    	instack[now]=false;
    }
    inline ll read(){
    	ll ret=0,f=1;
    	char ch=getchar();
    	while(ch<'0' || ch>'9'){
    		if(ch=='-'){
    			f=-1;
    		}
    		ch=getchar();
    	}
    	while(ch>='0' && ch<='9'){
    		ret=10*ret+ch-'0';
    		ch=getchar();
    	}
    	return ret*f;
    }
    

    后来我用前向星重写了这道题,并且没有进行显式的删边操作,折腾了好一会儿之后终于AC了:

    #include <bits/stdc++.h>
    #define ll long long
    using namespace std;
    const int N=1e6+9;
    typedef struct{
    	ll to,nxt;
    }Edge;
    Edge edge[N];
    ll n,cnt,fa[N],w[N],head[N],f[N][2];
    bool vis[N],sign[N];
    void add(ll u,ll v);
    void dfs(ll now);
    int main(){
    	ll ans=0;
    	scanf("%lld",&n);
    	for(int i=0;i<=n;i++){
    		head[i]=-1;
    	}
    	for(ll i=1;i<=n;i++){
    		scanf("%lld %lld",&w[i],&fa[i]);
    		add(fa[i],i);
    	}
    	for(ll i=1;i<=n;i++){
    		if(!vis[i]){ //如果i结点没有被访问过 
    			vis[i]=true;
    			ll now=i;
    			while(!vis[fa[now]]){ //相当于找到这个强连通分量的"根" 
    				now=fa[now];
    				vis[now]=true;
    			} //至此,我们可以知道,now是强连通分量的根,而有向边fa[now]->now是基环树环上的一条边
    			//显然,now和fa[now]不能同时被选,所以我们分别考虑强制不选now和强制不选fa[now]的情况
    			dfs(now);
    			ll ans1=f[now][0];
    			dfs(fa[now]);
    			ll ans2=f[fa[now]][0];
    			ans+=max(ans1,ans2);
    		}
    	}
    	printf("%lld\n",ans);
    	return 0;
    }
    void dfs(ll now){
    	sign[now]=true;
    	vis[now]=true;
    	for(ll i=head[now];i>=0;i=edge[i].nxt){
    		if(sign[edge[i].to]) continue;
    		dfs(edge[i].to); //一直往下走到叶子 
    	}
    	f[now][0]=0; //初始化 
    	f[now][1]=w[now];
    	for(ll i=head[now];i>=0;i=edge[i].nxt){
    		if(sign[edge[i].to]) continue; //防止转移的时候出现环 
     		f[now][0]+=max(f[edge[i].to][0],f[edge[i].to][1]);
    		f[now][1]+=f[edge[i].to][0];
    	}
    	sign[now]=false;
    	return;
    }
    void add(ll u,ll v){
    	edge[cnt].to=v;
    	edge[cnt].nxt=head[u];
    	head[u]=cnt++;
    }
    

    并且我考虑了一下怎么在前向星中删除一条边:想删掉一条边(u,v),考虑遍历u的出边时在(u,v)之前遍历到的那条边e1(如果有的话)和在(u,v)之后紧接着遍历到的那条边e2(如果有的话),然后把e1的next改成e2在edge数组中的下标就好了(存疑)。

  • P2014 选课

区间dp

  • P3205 合唱队

    题目大意:见原题。

    这是一道区间dp问题。为什么是呢?根据观察,这道题目如果想要用dp做,则状态转移发生在左右两个端点,并不好变成只是在同一端转移的问题。并且,这个问题的转移对于左右端是敏感的,上次的插入是在左端还是右端直接影响了这次需要插入到哪里。综上所述,如果要用dp的话,区间dp更适合做这个问题。我们考虑设\(f[i][j][k]\)为组成理想队列的第\(i\)到第\(j\)个位置,并且最后一次插入在\(k\)端的初始队列情况数,其中\(k=0\)代表在左端,\(k=1\)代表在右端。考虑一下\(f[i-1][j][0]\),即往左扩展了一个人,为了转移到这个状态,我们需要考虑\(f[i][j][0]\)\(f[i][j][1]\)。对于\(f[i][j][0]\),要想转移到目标状态,则插入的\(h[i-1]\)应该比\(h[i]\)小,对于\(f[i][j][1]\),需要插入的\(h[i-1]\)\(h[j]\)小,所以,\(f[i-1][j][0]=f[i][j][0]*(h[i-1]<h[i]?1:0)+f[i][j][1]*(h[i-1]<h[j]?1:0)\) 。其余三个转移方程是类似的。这里的经验是:区间dp的题目,一种设计状态的方式是既有区间位置,还有区间端点信息;在转移的时候,有时只需要考虑在端点处转移(有些题目是不能只考虑端点的)。另外,区间dp的题目通常可以用记忆化搜索去写,这样比较方便,也不用考虑转移时枚举顺序是什么样的。这道题写记忆化搜索仍有一些细节要注意,详见代码:

    #include <bits/stdc++.h>
    #define ll long long 
    using namespace std;
    ll f[1003][1003][3],n,h[1003],sign[1003][1003][3];
    ll mod=19650827;
    ll dfs(ll i,ll j,ll flag);
    int main(){
    	scanf("%lld",&n);
    	for(int i=1;i<=n;i++){
    		scanf("%lld",&h[i]);
    	}
    	printf("%lld\n",((dfs(1,n,0)%mod)+(dfs(1,n,1)%mod))%mod);
    	return 0;
    }
    ll dfs(ll i,ll j,ll flag){
    	if(sign[i][j][flag]){
    		return f[i][j][flag];
    	}
    	
    	if(i==j){
    		if(flag==0){
    			f[i][j][flag]=1;
    			sign[i][j][flag]=1;
    			return 1;
    		} else {
    			f[i][j][flag]=0;
    			sign[i][j][flag]=1;
    			return 0;
    		}
    	}
    	
    	ll ans1=0,ans2=0;
    	if(flag==0){
    		ans1=(dfs(i+1,j,0)*(h[i]<h[i+1]?1:0))%mod;
    		ans2=(dfs(i+1,j,1)*(h[i]<h[j]?1:0))%mod;
    	} else {
    		ans1=(dfs(i,j-1,0)*(h[j]>h[i]?1:0))%mod;
    		ans2=(dfs(i,j-1,1)*(h[j]>h[j-1]?1:0))%mod;
    	}
    	sign[i][j][flag]=1;
    	f[i][j][flag]=(ans1+ans2)%mod;
    	return f[i][j][flag];
    }
    
  • P4933 大师

    更好的阅读体验请看这里

dp的优化

  • 最大子段和

    在树形dp中的树上背包中的序列上的做法中已经讲过了,参考那里即可。

posted @ 2020-07-16 00:07  BUAA-Wander  阅读(332)  评论(0编辑  收藏  举报