Icebound

icebound-area

【寒假训练】简单dp总结

正文之前的一段话:假期马上就结束了,要回去上学了!
话说,开学之后,学校里估计得被挤爆了,那么多人,以前的惬意生活估计一去不复返了。(据说食堂吃饭都要抢饭,真的醉了)
不过也好,和其它学院的兄弟姐妹们在一起,也可以互相学习吧?
本来假期我是有个自己的计划的,但是学长们搞了小训练赛,每天5到10个题,做完就啥也不想干了。。。。而且导致脑子一团混乱。
在这里整理一下我现在对DP新的理解吧。

①有关DP的本质
dp,就是动态规划。在我现在的理解里,这种类型的算法必须基于以下条件:
1.最终结果可以由某几个变量对应,中间过程也可以被对应,叫做状态。
2.某个状态可以通过一些变换得到其他状态。
3.各个状态唯一,且互相不受影响。(无后效性)
(不太会说。。大概有了以上三点,就可以着手设计状态转移方程了)
有了状态转移方程,我们就可以把一个状态变为另一个状态,这个方程必须是普适的,形式是多样的。
除了转移方程以外,初始态和结束态也是十分重要的!
下面举一个例子吧。。
CodeForces – 698A Vacations
Vasya有n天的假期,这n天里,有某些特定的日子体育馆开门,有某些特定的日子cf有比赛(也有可能某天都没有,也有可能都有)。Vasya不能在一天里同时去体育馆和打比赛,也不能连续两天去体育馆,或者连续两天打比赛。如果他不干这两件事,他就只能休息。给出哪些天体育馆开门,哪些天有比赛,求Vasya的最少休息天数。
第一步,判断是不是DP。
第一反应,我们用天数对应答案。但是这一天干了什么对后一天的答案有影响。
所以,我们用【天数】和【这一天干了什么】对应答案,这样就是满足以上三点的。知道天数,知道前一天干了什么,就能推出后一天的答案。
第二步,设计状态转移方程。
把刚刚的思路抽象化,设f[i][j]为前i天,第i天干的事情为j情况下的最少休息天数。
j=0 gym,j=1 contest,j=2 rest
那么:
若j!=2 f[i][j]=min(f[i-1][k]) k!=j或k==2 并且j活动必须符合第i天的条件 否则不能转移
若j=2 f[i][j]=min(f[i-1][k])+1
这样看这个转移方程恶心的不行,但是实际写起来还是很好的,有一些过于抽象的转移方程,为了节省时间,可以不写那么严谨,或者写成记忆化搜索。
第三步,明确初始状态和最终状态
这个题比较显然 f[0][2]=0 其他都是inf
最终答案是min(f[n][0],f[n][1],f[n][2])
至此,观察一下时间复杂度,O(N),可以做,整道题的思路就通了。
如果发现时间复杂度不行。。那么考虑优化或者重新划分状态。
然后就可以写代码啦:

#include<iostream>
#include<cstring>
#include<algorithm>
#include<cstdio>
using namespace std;
const int N=105;
int f[N][4];//第几天 做gym contest rest
int s[N],n;
int main()
{
	scanf("%d",&n);
	for(int i=1;i< =n;i++)scanf("%d",&s[i]); 
	memset(f,0x3f,sizeof f);f[0][2]=0;
	for(int i=1;i<=n;i++)
	{
		f[i][2]=min(f[i-1][2],min(f[i-1][0],f[i-1][1]))+1;//rest 
		if(s[i]==1)f[i][1]=min(f[i-1][0],f[i-1][2]);//contest
		if(s[i]==2)f[i][0]=min(f[i-1][1],f[i-1][2]);//gym
		if(s[i]==3)
		{
			f[i][1]=min(f[i-1][0],f[i-1][2]);//contest
			f[i][0]=min(f[i-1][1],f[i-1][2]);//gym
		}
	}
	printf("%d\n",min(f[n][0],min(f[n][1],f[n][2])));
}

这里没有用j循环,而是手动写的,可能思路会清晰一些。
②记忆化搜索
做了几个题之后,感觉记忆化搜索和dp本质一样,但是思路是完全不同的。
记忆化搜索是一种搜索,找到最终结果之后,回溯回去。
dp是拿到一个状态,顺着推出最终状态。
记忆化搜索之所以可以“记忆化”,还是因为可以被划分为状态、无后效性(与dp)一样。
一般有两种情况会很顺畅的想出记忆化搜索。
第一种是在爆搜的时候发现可以记忆化:

CodeForces – 39E What Has Dirichlet Got to Do with That?
两个人以最优策略玩游戏,规则如下:
把a个物品放到b个盒子里,盒子可以空。物品和盒子都不同。
一开始有a个物品和b个盒子和一个常数n,轮到某个人开始玩时,他必须增加一个物品或者增加一个盒子(不能什么也不做),使得把a个物品放到b个盒子里的方案数变为k,若k>=n,则当前玩家就输掉了。
所以这两个人轮流以最优策略进行操作,问最后谁赢,或者平局。
首先【把a个物品放到b个盒子里,盒子可以空。物品和盒子都不同】是一个高中数学问题。。答案显然是b^a。
对于完全不知道这种博弈题怎么做的我来说,第一反应是爆搜。。1代表赢 0为输 2平局
我们以a,b为参数开始爆搜,如果b^a>=n 那么直接返回0
先不考虑平局情况
否则就递归下去 dfs(a+1,b) dfs(a,b+1)
如果返回上来有0 能胜了 我们就选取这个最优策略 返回1
如果返回上来没有0 但是有2 就是能平局 就能保证自己不输 返回2
否则返回0
那么啥时候平局呢?b==1的时候
考虑b==1 并且 (b+1)^a>=n 时 我们只能增加a来确保自己不输,但是因为b==1,无论两人怎么增加a,都不会分出胜负,这时就平局了!
这时爆搜就写好了,我们观察这个爆搜函数,dfs(a,b)的答案只和a b有关,不和外部变量有关,这时,就可以设数组f[a][b],进行记忆化搜索了!
我们从这里得出结论,只要是不借助外部变量的搜索,都可以改成记忆化搜索,复杂度为状态总数*每次搜索的复杂度
下面是代码:

#include<iostream>
#include<cstring>
#include<cstdio>
#include<algorithm>
using namespace std;
const int N=100005;
int f[N][55];
long long n;
int a,b;
bool judge(long long a,long long b)
{
	long long ret=1;
	for(int i=1;i< =b;i++)
	{
		ret*=a;
		//cout<<ret<<endl;
		if(ret>=n)return 1;
	}
	return 0;
}
int dfs(int a,int b)
{
	//cout< <a<<" "<<b<<" "<<judge(a,b+1)<<endl;
	if(f[a][b]!=-1)return f[a][b];
	if(judge(a,b))return f[a][b]=1;//当前输掉 
	if(a==1&&judge(a+1,b))return f[a][b]=2;
	int l=dfs(a+1,b),r=dfs(a,b+1);
	if(l==0||r==0)return f[a][b]=1;	
	if(l==2||r==2)return f[a][b]=2;
	return f[a][b]=0;
}
int main()
{
	cin>>a>>b>>n;
	memset(f,-1,sizeof f);
	int ans=dfs(a,b);
	if(ans==2)puts("Missing");
	else puts(ans==1?"Masha":"Stas");
	return 0;
}
//</algorithm></cstdio></cstring></iostream>

代码高亮插件出了点小问题。。还在排查。。
还有另一道题,是优化记忆化搜索的。
优化,无非是优化转移复杂度和状态复杂度。
CodeForces – 729F Financiers Game
还是两个人以最优策略轮流玩游戏。
有一个数列,一个人从数列开头取数字,并希望两人取出数字总和的差值最大,一个人从数列结尾取数字,并希望两人取出数字总和的差值最小。
每个人取的数字的数量为k或k+1,其中k为上一个人取出数字的数量,第一个取数字的人可以取出1个或两个数字。
当某个人想要取的数量大于剩余数字数量时,游戏停止。
求两人取出数字的差值为多少。
这题充满了隐含条件。。。
首先考虑搜索,dfs(l,r,p,q) 区间l到r,当前取数的数量为p,当前取数的人为q,比较显然。
发现答案只和l,r,p,q有关,考虑加上记忆化。
我们发现这题数据范围是4000 4000*4000*4000*2 爆了啊!
但是!可达鸭发现事情并不是这么简单!
假设这俩人每次都比上一次取的数多,也就是1+2+3+4+… 但是加到90的时候,就已经超过4000了,也就是说,这俩人每次取的数的数量在[1,90]之间。
所以复杂度变成了4000*4000*90*2
但是!可达鸭又一次发现事情并不是这么简单!
每个人只会从自己的那一边取,而且每次最多只会多取一个,所以每个人只会取自己那半边的!
所以复杂度变成2000*2000*90*2 空间复杂度同样减少 看上去快要可以做了(cf评测机超快,据说1e8都能过)
但是!可达鸭再一次发现事情并不是这么简单!
每个人每次最多只会多取一个,所以这两个人取数的位置相对于起始位置的差值非常接近!
即abs(l-(n-r+1))< =90 我们用一个变量记录l-n+r就行啦!每次转移时维护这个值。 至此,复杂度变为2000*90*90*2 可以过了! 这就是利用记忆化搜索来减少状态表示的复杂度。因为记忆化搜索不用刻意注意转移顺序,所以优化要方便很多。 代码

#include<iostream>
#include<cstring>
#include<algorithm>
#include<cstdio>
#include<bitset>
using namespace std;
//f[l][len][k][q] 起点为l 计数器为Len 上次选了k个 是否为左侧
const int N=2100;
int f[N][105][105][2],n,s[N*2];
int dfs(const int l,const int len,const int k,const int q)
{
	int r=n-len-l+1;
	if(r-l+1<k )return 0;
	if(f[l][len][k][q]!=-1)return f[l][len][k][q];
	//cout<<l<<" "<<r<<" "<<k<<" "<<q<<endl;
	int ret;
	if(q==1)//左选 
	{
		ret=-0x7fffffff;
		if(k<=r-l+1)ret=max(dfs(l+k,len-k,k,0)+s[l+k-1]-s[l-1],ret);
		if(k+1<=r-l+1)ret=max(dfs(l+k+1,len-k-1,k+1,0)+s[l+k]-s[l-1],ret);
	}
	else 
	{
		ret=0x7fffffff;
		if(k<=r-l+1)ret=min(dfs(l,len+k,k,1)-(s[r]-s[r-k]),ret);
		if(k+1<=r-l+1)ret=min(dfs(l,len+k+1,k+1,1)-(s[r]-s[r-k-1]),ret);
	}
	f[l][len][k][q]=ret;
	return ret;
}
int main()
{
	scanf("%d",&n);
	for(int i=1;i<=n;i++)
	{
		int x=0;scanf("%d",&x);
		s[i]=s[i-1]+x;
	}
	memset(f,-1,sizeof f);
	printf("%d\n",dfs(1,0,1,1));
	return 0;
}

下面说一个记忆化搜索方便转移的
poj 3186
给一个数列,每次可以在开头或者结尾取一个数t,第i次取出数获得的收益为i*t,求最大收益。
很显然是区间dp。如果直接写dp的话,我们需要考虑dp的顺序(其实也很简单)
我们直接考虑记忆化搜索,是非常好写的!

#include<iostream>
#include<cstring>
#include<cstdio>
#include<algorithm>
#include<string>
#include<queue>
using namespace std;
const int N=2005;
long long f[N][N];
int n,a[N];
long long dfs(int l,int r)
{
	if(f[l][r]!=-1)return f[l][r];
	if(l==r)return f[l][r]=n*a[l];
	long long now=l-1+n-r+1;
	long long x=now*a[l]+dfs(l+1,r);
	long long y=now*a[r]+dfs(l,r-1);
	return f[l][r]=max(x,y);
}
int main()
{
	scanf("%d",&n);
	memset(f,-1,sizeof f);
	for(int i=1;i< =n;i++)scanf("%d",&a[i]);
	cout<<dfs(1,n)<<endl;
	return 0;
}

代码简洁,思路清晰!
③dp中的一些思路
1.辅助数组帮助优化转移
HDU1024
给出一个长度为n的数列,要求从这个数列中找出m个子串,使得子串的和最大。n< =1000000 m很小 很容易(想了半天)想到状态表示f[i][j]为前i个子串,结尾为j时的最大子串和(j必须要选)。 转移就是要么加到原来的串里,要么新开一串。 f[i][j]=max(f[i][j-1],f[i-1][k])+s[j] 但是这样复杂度是(n^2)*m 过不了,要优化。 我们发现,在dp中,当我计算完i-1那一行的时候,我是可以同时算出i-1这一行的f[i-1][k]的最大值。 我们令mx[j]=max(f[i-1][k]) (k< =j) 在计算f[i-1][j]时就预处理出来。 现在发现空间可能会爆,那么直接扔掉i这一维,把数组写成f[j]。 最后的转移方程: f[j]=max(f[j-1],mx[j-1])+s[j] mx[j]=max(f[k]) k<=j 这里辅助数组减少了大量冗余计算,相当于用空间换时间。

/*
f[i][j] 前i组 以j结尾
mx[j]=max(f[i-1][k]) k< =j
f[i][j]=max(f[i][j-1],mx[j-1]);
*/
#include<iostream>
#include<cstring>
#include<algorithm>
#include<cstdio>
using namespace std;
const int N=1000005;
int f[N],s[N];
int mx[N];
int n,m;
int main()
{
    while(scanf("%d%d",&m,&n)!=EOF)
    {
        for(int i=1;i< =n;i++)scanf("%d",&s[i]);
        memset(f,0,sizeof f);
        memset(mx,0,sizeof mx);
        for(int i=1;i<=m;i++)
        {
            f[i]=0;
            for(int j=i;j<=n;j++)
            {    
                f[j]=max((i<=j-1)?f[j-1]:-0x7fffffff,mx[j-1])+s[j];
            }
            mx[i-1]=-0x7fffffff;
            for(int j=i;j<=n;j++)
            mx[j]=max(mx[j-1],f[j]);
        }
        int ans=-0xfffffff;
        for(int i=m;i<=n;i++)ans=max(ans,f[i]);
        printf("%d\n",ans);
    }
    return 0;
}
/*
3 10
-1 2 -4 -6 -1 2 3 4 0 10
*/

2.利用初始状态不同,顺序不同,得到不同结果。
最经典的就是背包问题。
完全背包为保证每种物品可以用很多次,就01背包的把i,j循环反过来。
而要求恰好装满的背包则是把所有位置都付初始值inf,而f[0]=0。这样保证每次转移都从已经装过的位置转移而来,不会留下空隙。
HDU 1114
给出一个存钱罐的重量,以及里面可能的钱的种类。每种钱有自己的重量和面值,求这个存钱罐里的钱的总价值的最小值。
非常显然的完全背包,并且要求恰好装满。

#include<iostream>
#include<cstring>
#include<algorithm>
#include<cstdio>
#include<string>
using namespace std;
const int N=10005,M=505;
int f[N],n,m,s[M],w[M],cnt;
int main()
{
    scanf("%d",&cnt);
    while(cnt--)
    {
        int t1,t2;
        scanf("%d%d",&t1,&t2);
        m=t2-t1;
        scanf("%d",&n);
        for(int i=1;i< =n;i++)scanf("%d%d",&w[i],&s[i]);
        memset(f,0x3f,sizeof f);
        f[0]=0;
        for(int i=1;i<=n;i++)
        for(int j=s[i];j<=m;j++)
        f[j]=min(f[j],f[j-s[i]]+w[i]);
        if(f[m]!=0x3f3f3f3f)
        printf("The minimum amount of money in the piggy-bank is %d.\n",f[m]);
        else puts("This is impossible.");
    }
    return 0;
}

3.巧妙设计状态表示
poj 1661
中文题。。
一开始想用平台来表示状态,但是发现平台有长度,没法用“第i个平台”来表示jimmy的准确位置。
但是我们发现,jimmy从某个平台下落时,不是从平台左侧跳下就是从右侧跳下。
我们用“第i个平台的左侧或右侧”表示jimmy的位置就行啦!
剩下的就是记忆化搜索或者dp了。

#include<iostream>
#include<cstdio>
#include<algorithm>
#include<cstring>
using namespace std;
const int N=1005;
int f[N][2];
struct pla
{
	int x1,x2,h;
}s[N];
int n,mx;
bool cmp(pla a,pla b)
{return a.h>b.h;}
int dfs(int t,int p)
{
	if(f[t][p]!=-1)return f[t][p];
	f[t][p]=0x3f3f3f3f;
	bool flag=0;
	int q=0;
	if(p==0)q=s[t].x1;
	else q=s[t].x2;
	for(int i=t+1;i< =n;i++)
	if(s[t].h-s[i].h<=mx)
	if(s[i].x1<=q&&s[i].x2>=q)
	{
		f[t][p]=min(dfs(i,0)+abs(q-s[i].x1),dfs(i,1)+abs(q-s[i].x2))+s[t].h-s[i].h;
		flag=1;break;
	}
	if(!flag&&s[t].h< =mx)f[t][p]=s[t].h;
	return f[t][p];
}
int main()
{
	int cnt=0;
	scanf("%d",&cnt);
	while(cnt--)
	{
		scanf("%d%d%d%d",&n,&s[0].x1,&s[0].h,&mx);s[0].x2=s[0].x1;
		for(int i=1;i<=n;i++)scanf("%d%d%d",&s[i].x1,&s[i].x2,&s[i].h);
		sort(s,s+n+1,cmp);
		memset(f,-1,sizeof f);
		printf("%d\n",dfs(0,0));
	}
	return 0;
}

poj3666
给出一个数列,要求把这个数列a[i]改成非严格递增或者非严格递减的w[i],修改的代价为∑abs(w[i]-a[i]) 求最小代价
朴素的想法,f[i][j]表示前i个数,其中修改后最大的数为j的答案。
但是a[i]太大了,状态存不下。继续观察发现,修改后的数列里的数,可以全部都在原数列中找到。
什么意思呢?就是每次修改,可以理解为将a[i]变为a[j],获得abs(a[j]-a[i])的代价。
这样一来就好办了,状态存下了,考虑转移
f[i][j]=abs(a[j]-a[i])+min(f[i-1][k]) 总体复杂度O(N^3) 不行。
观察到min(f[i-1][k])可以提前被算出,利用一个辅助数组完成转移。
mx[j]=min(f[i-1][k]) f[i][j]=abs(w[j]-a[i])+mx[j] 这里要dp两遍,一遍递增一遍递减,w[j]一遍排序成递增,一遍排序成递减。
这道题体现了”观察”并减少状态表示复杂度,以及利用辅助数组进行优化来减少转移复杂度。

#include<iostream>
#include<cstring>
#include<cstdio>
#include<algorithm>
#include<string>
#include<queue>
using namespace std;
const int N=2005;
int f[N][N];//前i个数字 其中最大/最小值为w[j]
int w[N],mx[N],mn[N],n,a[N];
bool cmp(int a,int b)
{return a>b;}
int up()
{
	// w 从小到大 
	sort(w+1,w+n+1); 
	memset(f,0x7f,sizeof f);
	for(int i=1;i< =n;i++)
	{
		for(int j=1;j<=n;j++)
		{
			f[i][j]=abs(w[j]-a[i])+mx[j];
		}
		memset(mx,0x7f,sizeof mx);
		for(int j=1;j<=n;j++)
		mx[j]=min(mx[j-1],f[i][j]);
	}
	int ans=0x7fffffff;
	for(int i=1;i<=n;i++)
	ans=min(ans,f[n][i]);
	return ans;
}
int down()
{
	sort(w+1,w+n+1,cmp);
	memset(f,0x7f,sizeof f);
	for(int i=1;i<=n;i++)
	{
		for(int j=1;j<=n;j++)
		{
			f[i][j]=abs(w[j]-a[i])+mn[j];
		}
		memset(mn,0x7f,sizeof mn);
		for(int j=1;j<=n;j++)
		mn[j]=min(mn[j-1],f[i][j]);
	}
	int ans=0x7fffffff;
	for(int i=1;i<=n;i++)
	ans=min(ans,f[n][i]);
	return ans;
}
int main()
{
	scanf("%d",&n);
	for(int i=1;i<=n;i++)scanf("%d",&a[i]),w[i]=a[i];
	printf("%d\n",min(up(),down()));
	return 0;
}