DP(动态规划)入门基础详解

DP总结(写得这么辛苦点个赞呗!)

DP基本概要:

动态规划算法把原问题视作若干个重叠子问题的逐层递进,每一个子问题的求解过程都构成一个“阶段”。在完成前一个阶段的计算后,动态规划才会执行下一阶段的计算。
无后效性 : 为了保证这些计算能够按顺序,不重复进行,动态规划要求当前阶段不会对前面的阶段产生影响的基本条件。
最优子结构性质:大多数情况下,动态规划是用来求解最优化问题。此时,下一阶段的最优解应该是有是由前面各阶段的子问题的最优解导出。
动态规划算法的三要素:“状态”,“阶段”,“决策”。
状态转移方程:动态规划算法把相同的计算过程作用于各阶段的同类子问题中,就好像把一个固定的公式在格式相同的若干输入数据上运行。因此,一般只需要定义出DP的计算过程,就可以解决编码问题了。这个计算过程就是状态转移方程。简而言之,就是当前阶段如何通过某一个计算过程到达下一个阶段。

Part 1:入门DP(DP的开场)

最基本的DP例题,是DP思想的开始。

例题1:数字三角形

题面
到了这一题,我们可以发现让人有一股递归的冲动,模拟所有的情况求最值,但是简单的递归显然是会超时的,因为我们做了许多重复的工作,大大拖慢了程序的速度。
这时闪亮登场————“记忆化搜索
简单的说,记忆化搜索就是用数组记录当前状态是否已经被搜索过了,那么遇到重复的情况下我们便可以直接调用已经记录的状态,使程序效率大大提高。(前提:记忆化搜索计录的状态不会因为后面的阶段而发生改变,一旦定了便不会被修改)

code(记忆化搜索)
#include
using namespace std;
int a[1000][1000]={};int b[1000][1000];
int n,i,j;
int qwe(int x,int y)//x表示行,y表示列
{
	if(b[x][y]>=0) return b[x][y];//记忆化搜索,调用已经记录的状态
	if(x==n) return a[n][y];//目标状态
	int temp1=qwe(x+1,y);//左下角
	int temp2=qwe(x+1,y+1);//右下角
	b[x][y]=max(temp1,temp2)+a[x][y];
	return b[x][y];
}
int main()
{ freopen("numtri.in","r",stdin);
  freopen("numtri.out","w",stdout);
  scanf("%d",&n);
  memset(b,-1,sizeof(b));//赋初值为-1表示所有状态目前都为空;PS:0状态也是一种可能的情况,我们不能赋初值为0!
  for(int i=1;i<=n;i++)
    for(j=1;j<=i;j++)
      scanf("%d",&a[i][j]);
  printf("%d",qwe(1,1));
  return 0; 	
}

我们也可以使用递推的方法减少重复的计算。
把这个三角形一行一行地分为多个阶段
以下给出两种方法:

法一:逆推法
设f[i][j]表示第i行第j列上的点到最后一行的最大和。
明显 f [ i ] [ j ] = m a x ( f [ i + 1 ] [ j ] , f [ i + 1 ] [ j + 1 ] ) + a [ i ] [ j ] ; f[i][j]=max( f[i+1][j],f[i+1][j+1])+a[i][j]; f[i][j]=max(f[i+1][j],f[i+1][j+1])+a[i][j];
那么此时我们应先求出 f [ i + 1 ] f[i+1] f[i+1]组的,然后以此计算 f [ i ] . . . . f[i].... f[i]....
目标便是 f [ 1 ] [ 1 ] f[1][1] f[1][1]

code

int a[1000][1000],f[1000][1000];
......
for(int i=n;i>=1;i--)
  for(int j=1;j<=i;j++)
    f[i][j]=max(f[i+1][j],f[i+1][j+1])+a[i][j];
cout<<f[1][1];

法二:顺推法
f [ i ] [ j ] f[i][j] f[i][j]表示从第一个点到第i行第j列上的点最大和。
明显 f [ i ] [ j ] f[i][j] f[i][j]是由上面的 f [ i − 1 ] [ j ] f[i-1][j] f[i1][j] f [ i − 1 ] [ j − 1 ] f[i-1][j-1] f[i1][j1]决定的;
所以: f [ i ] [ j ] = m a x f [ i − 1 ] [ j ] , f [ i − 1 ] [ j − 1 ] + a [ i ] [ j ] ; f[i][j]=max{f[i-1][j],f[i-1][j-1]}+a[i][j]; f[i][j]=maxf[i1][j],f[i1][j1]+a[i][j];
那么此时我们应先求出 f [ i ] f[i] f[i]再来以此转移到 f [ i + 1 ] . . . f[i+1]... f[i+1]...
最后比较初始点到达最后一行所有点的最大值便可。

code

int a[1000][1000],f[1000][1000],maxx=-1;
......
for(int i=1;i<=n;i++)
  for(int j=1;j<=i;j++)
    f[i][j]=max(f[i-1][j],f[i-1][j-1])+a[i][j];
for(int i=1;i<=n;i++) maxx=max(maxx,f[n][i]);
cout<<maxx;
例题2:导弹拦截

题面
简而言之就是给你一串数字,要求找出“最长不上升序列”的长度。
为了更好的转移状态,我们需要找一个点作为拦截的导弹的一个终止点。
数组f[i]表示拦截到第i个导弹的时候(即第i个导弹一定拦截),已经拦截了几个导弹,明显在[1,i-1]个导弹中,如果j导弹的高度低于i导弹的高度,那么j导弹便无法被拦截。于是我们便枚举1~i-1这些导弹,选取满足h[j]>=h[i]条件且f值最大的对f[i]进行状态转移。
f [ i ] = m a x ( f [ j ] ) + 1 f[i]=max(f[j])+1 f[i]=max(f[j])+1 1 < = j < i 1<=j1<=j<i && h [ j ] > = h [ i ] ; h[j]>=h[i]; h[j]>=h[i];(不要忘记加上i导弹本身)

code
memset(f,sizeof(f),0);//初始值
f[1]=1;
for(int i=2;i<=n;i++)
{
  for(int j=1;j<i;j++)
    if(h[j]>=h[i]) f[i]=max(f[i],f[j])//取最优值
  f[i]++;//加上本身
}

经过了入门DP,已经初步了解了基本的DP套路,接下来便是挑战更难的DP了

Part 2 :资源分配类问题

此类问题就是把一些资源分给一些人,不同的分配方法有不同的效益求如何分配最优。

例题1:机器分配

题面
f [ i ] [ j ] f[i][j] f[i][j]表示i个公司分配j个机器的最大盈利。(前的含义需要好好体会)
前i个公司得到了j台机器,包含了前i-1,前i-2…
那么我们只需要知道第i个公司分了几台机器,便可以根据前i-1公司来推出。
我们只需要枚举第i个公司分到了几台机器,求一个最值便可。
状态转移方程: f [ i ] [ j ] = m a x ( f [ i − 1 ] [ j − k ] + a [ i ] [ k ] ) 0 < = k < = j f[i][j]=max(f[i-1][j-k]+a[i][k]) 0<=k<=j f[i][j]=max(f[i1][jk]+a[i][k])0<=k<=j

code

#include
using namespace std;
int n,m,a[110][110];
int f[110][110];//表示前i个公司得到j台机器的最优值 
int main()
{   freopen("allot.in","r",stdin);
    freopen("allot.out","w",stdout);
	scanf("%d%d",&n,&m);
	for(int i=1;i<=m;i++)
	{
	  for(int j=1;j<=n;j++)
	    scanf("%d",&a[i][j]);
	  f[i][0]=0;//边界 
    }
    for(int i=1;i<=m;i++)
      for(int j=0;j<=n;j++)
        {
		    for(int k=0;k<=j;k++)
			  {
			  	f[i][j]=max(f[i][j],f[i-1][k]+a[i][j-k]);
			  } 
        }
    printf("%d",f[m][n]);
    return 0;
} 

例题2 :复制书稿

题面
题目关键信息:不能跳着复制
最长时间为抄写页数最多的人花费的时间,所以要先求最多的抄写页数,从中选择最小值
f [ i ] [ j ] f[i][j] f[i][j]表示前i个人抄写前j本书所用的最长时间的最小值。PS:这里的最长时间是某一个人抄书的时间。
那我们便可以类似于机器分配一样,令第i个人抄第k+1本书到j本书的总页数,那么便可以由前i-1个人抄1–k本书来进行转移。
状态转移方程: f [ i ] [ j ] = m i n ( m a x ( f [ i − 1 ] [ k ] , w [ k + 1 , j ] ) f[i][j]=min(max(f[i-1][k],w[k+1,j]) f[i][j]=min(max(f[i1][k],w[k+1,j](因为要保证每个人至少抄一本书,所以 i − 1 < = k < j i-1<=ki1<=k<j),其中 w [ k + 1 , j ] w[k+1,j] w[k+1,j]表示第k+1本书到j本书的总页数(时间),我们可以使用前缀和小技巧来求w[]。
求具体方案:
在用动态规划求得最优值后,保证前面的人少抄,然后用贪心的思想,让后面的人尽可能的多抄,将最后一本书按逆序将书分配给k个人抄写,从第k个人开始,如果他还能写,就给他,界限就是不大于 f [ n ] [ m ] f[n][m] f[n][m],直到分配完毕。

code

#include
using namespace std;
int m,n,sum[510],a[510];//sum表示第一本书到第i本书的总页数(前缀和) 
int f[510][510];//表示前i个人抄写前j本书的最长时间的最小值 
void print(int i,int j)//输出具体方案,i本书,j个人 
{
	int t,x;
	if(j==0) return;//递归终止条件 
	if(j==1)
	{
		cout<<1<<" "<<i<<endl;
		return;
	}
	else
	{
	t=i;x=a[i];
	while(x+a[t-1]<=f[n][m]&&t>1)//从当前最后一本书开始分配,直到不能分配为止 
	{
		x+=a[--t];
	}
	//while循环以后,第j个人抄的最开始的书便是t
	print(t-1,j-1);//到下一个人,前t-1本书继续来分
	cout<<t<<" "<<i<<endl;
   }
}//interesting
int main()
{   //freopen("input.in","r",stdin);
    //freopen("output.out","w",stdout); 
    sum[0]=0;
  	cin>>m>>n;
  	memset(f,10,sizeof(f));
  	for(int i=1;i<=m;i++)
  	{
  		scanf("%d",&a[i]); 
  		sum[i]=sum[i-1]+a[i];
  		f[1][i]=sum[i];
  	}
  	for(int i=2;i<=n;i++)
  	  for(int j=i;j<=m;j++)//前i位抄写员至少抄写i本,因此令j=i开始循环
  	    for(int k=i-1;k<j;k++)
  	      f[i][j]=min(f[i][j],max(f[i-1][k],sum[j]-sum[k]));//状态转移
	print(m,n);
} 

Part 3 :背包问题

背包问题可以认为是资源分配的分支,单独拿出是因为很有名的《背包九讲》!

一.0/1背包

【问题】有n件物品和一个容量为C的背包。第i件物品的重量为w[i],价值为v[i]。
求解将那些物品装入背包可使价值总和最大。
1)二维数组表示
1.定义状态: f [ i ] [ c ] f[i][c] f[i][c]表示前i件物品恰好放入一个容量为c的背包里可以获得的最大价值。
2.状态转移方程:
f [ i ] [ c ] = m a x { f [ i − 1 ] [ c ] 不选 f [ i − 1 ] [ c − w [ i ] ] + v [ i ] 选 f[i][c]=max \left\{\begin{aligned}f[i-1][c] 不选 \\ f[i-1][c-w[i]]+v[i]选 \end{aligned}\right. f[i][c]=max{f[i1][c]不选f[i1][cw[i]]+v[i]

核心代码

for(int i=1;i<=n;i++)
{
    for(int c=0;c<=C;c++)
    {
        f[i][c]=f[i-1][c];
        if(c>=w[i])
        {
            f[i][c]=max(f[i][c],f[i-1][c-w[i]]+v[i]);
        }
    }
}

时间复杂度,空间复杂度都为O(NC)

2)优化:一维数组表示(滚动数组)
对于 f [ i ] [ c ] f[i][c] f[i][c]只与 f [ i − 1 ] [ c ] f[i-1][c] f[i1][c] f [ i − 1 ] [ c − w [ i ] f[i-1][c-w[i] f[i1][cw[i]有关
所以可将 i i i这个维数优化掉
进一步发现 f [ i ] [ c ] f[i][c] f[i][c]只与 f [ i − 1 ] [ c ] f[i-1][c] f[i1][c] f [ i − 1 ] [ c − w [ i ] f[i-1][c-w[i] f[i1][cw[i]有关
由此,第 i i i层的 f [ c ] f[c] f[c]只与第 i − 1 i-1 i1层的 f [ c ] f[c] f[c] f [ c − w [ i ] ] f[c-w[i]] f[cw[i]]有关
所以在求 f [ c ] f[c] f[c]的时候,必须保证 f [ c − w [ i ] ] f[c-w[i]] f[cw[i]]是第 i − 1 i-1 i1阶段的最优值, c c c C C C开始倒着推
保证 f [ c ] f[c] f[c]左边的状态没有被第 i i i个物品更新过,还是第 i − 1 i-1 i1层的状态。

code

for(int i=1;i<=n;i++)
{
    for(int c=C;c>=0;c--)//注意倒序
    {
        if(c>=w[i])
        {
            f[c]=max(f[c],[c-w[i]]+v[i]);
        }
    }
}

这样就大大节省了空间复杂度。
具体举例为何是倒着推:
DP(动态规划)入门基础详解_第1张图片
不要介意水印嘻嘻
3)常数优化【没必要令下限为0】
code

int bound,sumw=0;
for(int i=1;i<=n;i++)
{
    sumw+=w[i];
    bound=max(c-sumw,w[i]);//剩余空间和当前物品所需要的空间比较
    for(int c=C;c>=bound;c--)
    {
        if(c>=w[i])
        {
            f[c]=max(f[c],[c-w[i]]+v[i]);
        }
    }
}

4 ) 初始化小细节
要求恰好装满,则初始化时令 f [ 0 ] = 0 f[0]=0 f[0]=0,其余都是-INF,那么如果一个f[]值>=0则肯定是由 f [ 0 ] f[0] f[0]拓展出来的,这样就可以知道是否有解了。
如果不是恰好,则 f [ ] f[] f[]可都设为0。

二.完全背包

有n件物品和一个容量为C的背包。第i种物品的重量是w[i],价值是v[i],数量无限。求解将哪些物品装入背包可使价格总和最大。

1)这个问题非常类似于01 背包问题,所不同的是每种物品有无限件。也就是从每种物品的角度考虑,与它相关的策略已并非取或不取两种,而是有取0 件、取1 件、取2
件……直至取⌊V /Ci⌋ 件等许多种.
那么状态转移方程容易得出:
f [ i ] [ c ] = m a x ( f [ i − 1 ] [ c − k ∗ w [ i ] ] + k ∗ v [ i ] ) ( 0 < = k ∗ w [ i ] < = c ) f[i][c]=max(f[i-1][c-k * w[i]]+k * v[i]) (0<=k*w[i]<=c) f[i][c]=max(f[i1][ckw[i]]+kv[i])(0<=kw[i]<=c)

2)更优的算法
code

for(int i=1;i<=n;i++)
{
    for(int c=0;c<=C;c++)
    {
        if(c>=w[i])
        {
            f[c]=max(f[c],f[c-w[i]]+v[i]);
        }
    }
}

你会发现,这个伪代码与01 背包问题的伪代码只有c的循环次序不同而已。
为什么这个算法就可行呢?首先想想为什么01 背包中要按照v 递减的次序来循环。让v 递减是为了保证第i 次循环中的状态 f [ i ] [ c ] f[i][c] f[i][c]是由状态 f [ i − 1 ] [ c − w [ i ] ] f[i - 1][c - w[i]] f[i1][cw[i]] 递推而来。换句话说,这正是为了保证每件物品只选一次,保证在考虑“选入第i 件物品”这件策略时,依据的是一个绝无已经选入第i 件物品的子结果 f [ i − 1 ] [ c − w [ i ] ] f[i -1][c- w[i]] f[i1][cw[i]]。而现在完全背包的特点恰是每种物品可选无限件,所以在考虑“加选一件第i 种物品”这种策略时,却正需要一个可能已选入第i 种物品的子结果 f [ i ] [ c − w [ i ] ] f[i ][c- w[i]] f[i][cw[i]],所以就可以并且必须采用c递增的顺序循环。这就是这个简单的程序为何成立的道理。
值得一提的是,上面的伪代码中两层for 循环的次序可以颠倒。这个结论有可能会带来算法时间常数上的优化。

三.多重背包

【问题】有n种物品和一个容量作为C的背包,第i种物品的重量为w[i],价格为v[i],数量为a[i],求解将哪些物品装入背包可使价值总和最大。
隆重推出!!!!!!
————二进制分割法
举个例子:物品i有13个,就可以把它分成系数为1,2,4,6,即13=20 +21 +22+6。或者说1——13的数字都可以由1,2,4,6组合而成,那么1,2,4,6四个系数便可以表示取多少个i物品的所有情况。

code

#include
using namespace std;
int n,C,w[5100],a[5100],v[5100],f[5100]; 
int main()
{ //freopen("cx.in","r",stdin);
  //freopen("cx.out","w",stdout);
  scanf("%d%d",&n,&C);
  for(int i=1;i<=n;i++)
    scanf("%d%d%d",&a[i],&w[i],&v[i]);
   for(int i=1;i<=n;i++)
     if(a[i]*w[i]>C)
	   {//当完全背包处理 
	   	for(int c=w[i];c<=C;c++)
	   	  f[c]=max(f[c],f[c-w[i]]+v[i]);
	   }
	 else
	 {
	 	int k=1,ans=a[i];
	 	while(k<ans)
	 	{   //是否取一个重量为k*w[i],价值为k*v[i]的物品呢   01背包 
	 		for(int c=C;c>=k*w[i];c--)
	 		  f[c]=max(f[c],f[c-k*w[i]]+k*v[i]);
	 		ans-=k;
	 		k+=k;
	 	}
	 	//剩下的物品当成一个物品
		 for(int c=C;c>=ans;c--)
		   f[c]=max(f[c],f[c-ans*w[i]]+ans*v[i]); 
	 }
	printf("%d",f[C]);	
}

四.混合背包

【问题】仍然是背包问题。有的物品只能取一次,有的物品却能取无限次,有的物品能取有限次。
那么我们直接按照物品属于什么背包划分阶段便可。

code

for(iny i=1;i<N;i++)
{
    if(物品i属于0/1背包)
    {
        按照0/1背包做法取物品i
    }
    if(物品i属于完全背包)
    {
        按照完全背包做法取物品i
    }
    if(物品i属于多重背包)
    {
        按照多重背包做法取物品i
    }
}

五.二维费用背包

【问题】有 n n n件物品和一个容量为 C C C、容积为 U U U的背包。第 i i i件物品的重量是 w [ i ] w[i] w[i],体积是 u [ i ] u[i] u[i],价值是 v [ i ] v[i] v[i]
求解将哪些物品装入背包可使价值总和最大

(1). 0 / 1 0/1 0/1背包的表示方法
费用加了一维,只需把状态也加一维。

1.状态表示:设 f [ i ] [ c ] [ u ] f[i][c][u] f[i][c][u]为前i件物品付出两种代价分别为 c c c u u u时可以获得的最大价值。

2.状态转移方程:

f [ i ] [ c ] [ u ] = m a x { f [ i − 1 ] [ c ] [ u ] f [ i − 1 ] [ c − w [ i ] ] [ u − u [ i ] ] + v [ i ] f[i][c][u]=max \left\{\begin{aligned}f[i-1][c][u] \\ f[i-1][c-w[i]][u-u[i]]+v[i] \end{aligned}\right. f[i][c][u]=max{f[i1][c][u]f[i1][cw[i]][uu[i]]+v[i]

当然,为了节省空间,可以把 i i i去掉,利用滚动数组。

3.启示:当发现由熟悉的动态规划题目变形而来的题目时,在原来的状态中加一维以满足新的限制,这是一种比较通用的方法。

(2).限制物品总个数的0/1背包

n n n件物品和一个容量为 C C C的背包。第 i i i件物品的重量是 w [ i ] w[i] w[i],价值是 v [ i ] v[i] v[i]

现在要求转入背包的物品个数不超过 M M M。求解将哪些物品装入背包可使价值总和最大。

其实,把最大个数看做一种容积就行了。

(3).二维费用的完全背包和多重背包问题

循环时仍然按照完全背包(顺序循环)和多重背包(分割)的方法操作,只不过比完全背包和多重背包多了一维。【有理】

六.分组背包

【问题】有n物品和一个容量为C的背包。第i件物品的重量是w[i],价值是v[i]。
这些物品被划分为K组,每组中的物品互相冲突,最多选一件。
求解将哪些物品装入背包可使价值总和最大。
1.状态设置:f[k][c]表示前k组物品花费c代价时可获得的最大价值。
2.状态方程转移:

f [ k ] [ c ] = m a x { f [ k − 1 ] [ c ] 不选这一组的物品 f [ k − 1 ] [ c − w [ i ] ] + v [ i ] 物品 i 属于第 k 组 f[k][c]=max \left\{\begin{aligned}f[k-1][c]不选这一组的物品 \\ f[k-1][c-w[i]]+v[i]物品i属于第k组 \end{aligned}\right. f[k][c]=max{f[k1][c]不选这一组的物品f[k1][cw[i]]+v[i]物品i属于第k
这其实就是一个0/1背包对吧,只不过每一个物品所选的集合不同,那只要每一个集合枚举里面的物品做0/1背包就好了。
【当然我们可以根据仿照上面的滚动数组优化掉第一维】
code

for(int k=1;K<=K;K++)//每一个组
  for(int c=C;c>=0;c--)
    for(所有属于第k组的物品)
      if(c>=w[i]) f[c]=max(f[c],f[c-w[i]+v[i]);

值得注意的是,除了c的循环要倒序,还要格外留意枚举每一组的物品的循环i要放在c循环的里面。从背包角度看,这是因为每组内至多选择一个物品,若把i循环放在c循环外部,就会类似于完全背包,每组物品转移时就会产生累积,选择的物品可能不止一件。从动态规划的角度来看,k循环是“阶段”,c与i共同构成“状态”,而i是“决策”——在第k组里使用哪一个物品,三者的顺序绝对不能混淆。

七.有依赖的背包问题

例题:金明的预算方案

题面
【解析】题目中的附件不多可以使用朴素的算法:1个主件看成一个物品,1个主件+1个附件a看成1个物品,1个主件+1个附件b…,但是这样每个主件的搭配是2n种的,附件多了就会不好搞了。
考虑当用主件i的时候用一个临时的数组a,用来表示一定选主件的时候的最优值,对所依赖的附件再做一次0/1背包,最后再和原本没有考虑选主件i的数组f进行比较,变成考虑选主件i的最优值。(即对主件做一次0/1背包)

code

#include
using namespace std;
int n,m,f[33000]={ };
int q[70],w[70],v[70];
int a[33000];
int main()
{   //freopen("budget.in","r",stdin);
    //freopen("budget.out","w",stdout);
	scanf("%d%d",&n,&m);
	for(int i=1;i<=m;i++)
	{
	  scanf("%d%d%d",&v[i],&w[i],&q[i]);
	  w[i]=w[i]*v[i];
	}
	for(int i=1;i<=m;i++)
	{
	  if(q[i]==0) //是主件(对附件做一次01背包)
	  {  for(int j=1;j<v[i];j++) a[j]=0;
		 for(int j=v[i];j<=n;j++) a[j]=f[j-v[i]]+w[i];
		 for(int j=1;j<=m;j++)
		   if(q[j]==i)//是附件
		   {
		   	 for(int k=n;k>=v[i]+v[j];k--)//选了主件i又考虑选附件j,那么剩余空间就满足k>=v[i]+v[j]
		   	   a[k]=max(a[k],a[k-v[j]]+w[j]);
		   }
		for(int j=v[i];j<=n;j++)//对主件做01背包,要还是不要
		   f[j]=max(a[j],f[j]); 
	  }	
	} 
	printf("%d",f[n]);
	return 0; 
}

背包问题是DP很重要的一块内容,还有一些背包的特殊要求我还要去细细琢磨,以后再来更新。

Part 4 :双进程类DP

顾名思义,双进程便是两个阶段同时进行,并且彼此会互相影响。

例题1:最长公共子序列

题面
这是一道经典例题
【分析】对于此问题,如果使用单纯的f[]表示两个序列前i个的最长公共子序列就不可以,因为无法表示出所有的情况。
不妨设 f [ i ] [ j ] f[i][j] f[i][j]表示序列X前i个,序列Y前j个的最大值,那么状态转移就好弄了。
对于每一个f[i][j]都有如下的决策:
1.对于序列X,可以不选第i个, f [ i ] [ j ] = f [ i − 1 ] [ j ] ; f[i][j]=f[i-1][j]; f[i][j]=f[i1][j];
2.对于序列Y,可以不选第j个, f [ i ] [ j ] = f [ i ] [ j − 1 ] ; f[i][j]=f[i][j-1]; f[i][j]=f[i][j1];
3.对于两个序列来说,如果序列X的第i个和序列Y的第j个相等, f [ i ] [ j ] = f [ i − 1 ] [ j − 1 ] + 1 ; f[i][j]=f[i-1][j-1]+1; f[i][j]=f[i1][j1]+1; ( a [ i ] = = b [ j ] ) (a[i]==b[j]) (a[i]==b[j])
对于以上三种决策取一个最大值便可。

code

#include
using namespace std;
char a[5100],b[5100];
int f[5100][5100];
int main()
{   freopen("lcs.in","r",stdin);
    freopen("lcs.out","w",stdout);
	char c=getchar();
	int i1=0;
	while(c!='.')
	{ 
	  a[++i1]=c;
	  c=getchar();	
	}
	c=getchar();
	char d=getchar();
	int i2=0;
	while(d!='.')
	{
	  b[++i2]=d;
	  d=getchar();	
	}
	for(int i=1;i<=i1;i++)
	  for(int j=1;j<=i2;j++)
	  {
	  	f[i][j]=max(f[i-1][j],f[i][j-1]);
	  	if(a[i]==b[j]) f[i][j]=max(f[i][j],f[i-1][j-1]+1);
	  }
	  printf("%d",f[i1][i2]);
} 

例题2:配置魔药

题面
【分析】----------------------二维不够三维来凑%%%
按每个草药的结束时间排序,剩下的模型就很类似于背包,只不过有2个背包而已。
因为每一个干锅都是相对独立的,套用一般双进程模板f[i][j](前i个草药,前j分钟的最大价值)是不行的,第一个用时t1但是第二个却不一定,无法覆盖所有的情况。

不妨用一个三维数组 f [ i ] [ t 1 ] [ t 2 ] f[i][t1][t2] f[i][t1][t2]表示前i个草药,第一个干锅用时t1,第二个干锅用时t2的最大价值。

决策有三种:
1.如果干锅1当前的结束时间j大于配置i魔药的结束时间,类似于01背包的考虑选择, f [ i ] [ j ] [ k ] = f [ i − 1 ] [ t 1 [ i ] − 1 ] [ k ] + w [ i ] ; f[i][j][k]=f[i-1][t1[i]-1][k]+w[i]; f[i][j][k]=f[i1][t1[i]1][k]+w[i]; 条件:当 j > = t 2 [ i ] j>=t2[i] j>=t2[i]时;

2.类似如果干锅2当前结束时间k大于配置i魔药的时间 f [ i ] [ j ] [ k ] = f [ i − 1 ] [ j ] [ t 1 [ i ] − 1 ] + w [ i ] f[i][j][k]=f[i-1][j][t1[i]-1]+w[i] f[i][j][k]=f[i1][j][t1[i]1]+w[i],条件: k > = t 2 [ i ] ; k>=t2[i]; k>=t2[i];

3.其余情况: f [ i ] [ j ] [ k ] = f [ i − 1 ] [ j ] [ k ] f[i][j][k]=f[i-1][j][k] f[i][j][k]=f[i1][j][k],没有条件;

优化:我们发现第i阶段只和第i-1阶段有关,那么我们就可以类似0/1背包的滚动数组一样节省掉第一维。(但是要同时注意倒序循环)

code

#include
using namespace std;
int t,n;
int f[520][520];//类似于背包的空间优化 
struct fuc
{
	int x,y,w;
}a[110];
bool mycmp(fuc a,fuc b)
{
	return a.y<b.y;//按结束时间排序 
}
int main()
{ freopen("medic.in","r",stdin);
  freopen("medic.out","w",stdout); 
  scanf("%d%d",&t,&n);
  for(int i=1;i<=n;i++)
   scanf("%d%d%d",&a[i].x,&a[i].y,&a[i].w);	 
   sort(a+1,a+n+1,mycmp);
   for(int i=1;i<=n;i++)
     for(int j=t;j>=0;j--)//01背包 
       for(int z=t;z>=0;z--) 
       { 
       	if(j>=a[i].y) f[j][z]=max(f[j][z],f[a[i].x-1][z]+a[i].w);//如果干锅1当前的时间允许配置i魔药,这种情况就类似于01背包,上一阶段的时间最大价值加上魔药的价值;或者选择不配置; 
       	if(z>=a[i].y) f[j][z]=max(f[j][z],f[j][a[i].x-1]+a[i].w);//同理 
	   }
    printf("%d",f[t][t]);
}

例题3:【NOIP2015提高组】子串

题面
这是一道非常考验深层思考(毒瘤 )的DP题
【分析】:
1.常规思维算法:
f [ i ] [ j ] [ k ] f[i][j][k] f[i][j][k]表示序列a已经扫描到了第i位,序列b已经扫描到了第j位,匹配的字串已有k个的方案数。

对于a串的第i个位置,有两个选择:

1)不选第i位,方案数为 f [ i − 1 ] [ j ] [ k ] f[i-1][j][k] f[i1][j][k];

2)选择了第i个位置,但是要枚举最后一个字串的长度x。
故需要加上 f [ i − x ] [ j − x ] [ k − 1 ] f[i-x][j-x][k-1] f[ix][jx][k1] (前提:a[i-x+1 ~ i]与b[j-x+1~j]匹配)
复杂度较高

2.正解 -------- “一个数组无法完美的表达那就用两个。

g [ i ] [ j ] [ k ] g[i][j][k] g[i][j][k]表示a到第i位,b到第j位,已经用了k个子串,且必须要选上a[i]的方案数

f [ i ] [ j ] [ k ] f[i][j][k] f[i][j][k]表示a到第i位,b到第j位,已经用了k个子串,当前a[i]可以选也可以不选(或者说是可能会选)的方案数

然后我们看一下如何转移

对于 g [ i ] [ j ] [ k ] g[i][j][k] g[i][j][k],a[i]必须选上。

那么决策就有两个:

大前提:要使第i个字符被选上a[i]一定要与b[j]匹配相等,否则此情况不存在,直接为0;
1)单独成为一个串,此时就无需考虑前一位是否选了,所以是 f [ i − 1 ] [ j − 1 ] [ k − 1 ] f[i-1][j-1][k-1] f[i1][j1][k1]

2)与前面的串连在一起,那么前一位必须选,此时方案数就是 g [ i − 1 ] [ j − 1 ] [ k ] ; g[i-1][j-1][k]; g[i1][j1][k]

所以, g [ i ] [ j ] [ k ] = f [ i − 1 ] [ j − 1 ] [ k − 1 ] + g [ i − 1 ] [ j − 1 ] [ k ] ; g[i][j][k]=f [i-1][j-1][k-1]+g[i-1][j-1][k]; g[i][j][k]=f[i1][j1][k1]+g[i1][j1][k]

对于 f [ i ] [ j ] [ k ] f[i][j][k] f[i][j][k], a [ i ] a[i] a[i]可选可不选。

对于选它,那么对应的就是 g [ i ] [ j ] [ k ] g[i][j][k] g[i][j][k]的方案数。

对于不选它,那么就是 f [ i − 1 ] [ j ] [ k ] f[i-1][j][k] f[i1][j][k]。(还没有匹配到第i个,那么肯定不选)

所以(根据加法原理:可能选的方案数=一定选的方案数+不选的方案数)得到 f [ i ] [ j ] [ k ] = f [ i − 1 ] [ j ] [ k ] + g [ i ] [ j ] [ k ] ; f[i][j][k]=f[i-1][j][k]+g[i][j][k]; f[i][j][k]=f[i1][j][k]+g[i][j][k];

当你交完代码后,你会惊喜 发现你MLE了…

优化:使用滚动数组,发现第i层只和第i-1层有关,进行压缩,数组第一维%2便可。(好评%%%)

code

#include
using namespace std;
int f[3][210][210],g[3][210][210];/*g[i][j][k]表示a到第i位,b到第j位,已经用了k个子串,且必须要选上a[i]的方案数
f[i][j][k]表示a到第i位,b到第j位,已经用了k个子串,当前a[i]可以选也可以不选的方案数*/ 
 int n,m,k;
char a[1100],b[210];
int main()
{   freopen("substring.in","r",stdin);
    freopen("substring.out","w",stdout);
	scanf("%d%d%d",&n,&m,&k);
	for(int i=1;i<=n;i++)
	  cin>>a[i];
	for(int i=1;i<=m;i++)
	  cin>>b[i];
	memset(f,0,sizeof(f));
	memset(g,0,sizeof(g));
   	f[0][0][0]=f[1][0][0]=g[0][0][0]=g[1][0][0]=1;//初始化
	for(int i=1;i<=n;i++)
	  for(int j=1;j<=m;j++)
	  	for(int l=1;l<=k;l++)
		  {
		  	if(a[i]==b[j]) g[i%2][j][l]=(f[(i-1)%2][j-1][l-1]+g[(i-1)%2][j-1][l])%1000000007;//发现第i层只和第i-1层有关,用滚动数组 
		  	else g[i%2][j][l]=0;//不匹配方案直接为0 
		  	f[i%2][j][l]=(f[(i-1)%2][j][l]+g[i%2][j][l])%1000000007;//肯定不选a[i]的方案+一定选a[i]的方案数 
		  } 
    printf("%d",f[n%2][m][k]);	  
}

Part 5 :区间DP

区间DP是动态规划很重要的分支,顾名思义是在区间上DP,它的主要思想就是先在小区间进行DP得到最优解,然后再利用小区间的最优解合并求大区间的最优解。

例题1:石子合并

题面
【分析】dp(当然可以dfs记忆化),需要我们决策的是合并石子的顺序。将n堆石子合并可以分解为先将 [ 1 , k ] [1,k] [1,k] 的石子合并,再将 [ k + 1 , n ] [k+1, n] [k+1,n]的石子合并。所以将区间 [ i , j ] [i, j] [i,j]的石子合并的子问题是将 [ i , k ] [ k + 1 , j ] [i,k] [k+1,j] [i,k][k+1,j] 的石子分别合并,最后再将这两堆石子合并起来。

对于区间 [ i , j ] [i, j] [i,j],不知道哪个k是最优的,枚举即可;状态设置为 f [ i ] [ j ] f [i] [j] f[i][j]表示将 区间 [ i , j ] [i,j] [i,j] 的石子合并的最小代价。

状态转移方程 f [ i ] [ j ] = m i n ( f [ i ] [ k ] + f [ k + 1 ] [ j ] ) + w [ i ] [ j ] f [i] [ j ] = min ( f [i] [ k ] +f [ k+1 ] [ j ] ) + w[ i] [ j ] f[i][j]=min(f[i][k]+f[k+1][j])+w[i][j] w [ i ] [ j ] w [i] [j] w[i][j] 为区间 [i, j] 的石子数之和(这里我们可以用前缀和技巧), i < = k < = j ; i<=k<=j; i<=k<=j;

code(DP)

#include
using namespace std;
int f[305][305];//第i堆石头到第j堆石头合并的最小力气 
int duang,n,sum[310];
int main()
{
   freopen("Stone.in","r",stdin);
   freopen("Stone.out","w",stdout);	
   memset(f,10,sizeof(f));
   scanf("%d",&n);
   sum[0]=0;
   for(int i=1;i<=n;i++)
     {
     	scanf("%d",&duang);
     	sum[i]=sum[i-1]+duang;
     	f[i][i]=0;
     }
   for(int l=2;l<=n;l++)
     {
     	for(int i=1;i<=n;i++)
     	{
     		int j=i+l-1;
     		if(j>n) break;
     		for(int k=i;k<=j;k++)
     		  f[i][j]=min(f[i][j],f[i][k]+f[k+1][j]+sum[j]-sum[i-1]); 
     	}
     }
     printf("%d",f[1][n]);
}

发现我还写了一个dfs记忆化的,其实大同小异

code

#include
using namespace std;
int f[305][305];//第i堆石头到第j堆石头合并的最小力气 
int duang,n,sum[310];
int dfs(int i,int j)
{
	if(f[i][j]>=0) return f[i][j];
	int ans=100000000;
	for(int k=i;k<j;k++)
	  ans=min(dfs(i,k)+dfs(k+1,j)+sum[j]-sum[i-1],ans);
	f[i][j]=ans;
	return f[i][j];
}
int main()
{  freopen("Stone.in","r",stdin);
   freopen("Stone.out","w",stdout);
   memset(f,-1,sizeof(f));
   cin>>n;
   sum[0]=0;
   for(int i=1;i<=n;i++)
     {
     	cin>>duang;
     	sum[i]=sum[i-1]+duang;
     	f[i][i]=0;
     }
   int minn=dfs(1,n);
   cout<<minn;	
   return 0;
}

例题2:能量项链

题面
【分析】就是说,给一定一串珠子(我们可以假想为一些数字),他们之间的算法题目给出,我们要做的就是套上一些括号使得最后的答案最大,不同套括号的方法得到的解是不一样的。
那么这就明摆给出了区间DP的思想,如果我们一个一个枚举括号的方案,复杂度高且计算复杂。但是我们可以类似合并石子一样,一个区间[i,j],枚举中间的断点的k,那么原本的大区间便可以分为两个小区间的最优值进行转移,同理小区间又可以分…以此类推。这边是区间DP的基本思想,好好理解。

突然 发现 小问题,本题是有环的,如何解决???
我们将原本区间复制一遍,放在最后不就OK了。(老刘太强了%%%)

code

#include
using namespace std;
struct fuc
{
	int x,y;//x头标记,y尾标记 
}a[220];
int n;
long long f[220][220];//注意,区间变成两倍,数组相应开大
long long maxx=-100;
int main()
{   freopen("energy.in","r",stdin);
    freopen("energy.out","w",stdout); 
	scanf("%d",&n);
	for(int i=1;i<=n;i++)//猥琐的环 
	{
	 scanf("%d",&a[i].x);
	 if(i>=2) a[i-1].y=a[i].x;
	 if(i==n) a[n].y=a[1].x;	
	}
	for(int i=n+1;i<=2*n;i++)//复制
	  a[i].x=a[i-n].x,a[i].y=a[i-n].y;
	for(int st=1;st<=n;st++)//头(因为是环,我们枚举一个长度为n的新区间,在这里面做dp,最后取所有这样区间的最大值)
	{
	  int end=st+n-1;
	  memset(f,0,sizeof(f));//清空
	  for(int l=2;l<=n;l++)//区间长度
	    for(int i=st;i<=end-l+1;i++)
		{
		   int j=i+l-1;
		   f[i][j]=f[i+1][j]+a[i].x*a[i].y*a[j].y;//初值,可以当成k=i的时候情况 
		   for(int k=i+1;k<=j-1;k++)
		     f[i][j]=max(f[i][j],f[i][k]+f[k+1][j]+a[i].x*a[k].y*a[j].y);	
		}
		maxx=max(f[st][end],maxx);	
	}
	printf("%lld",maxx); 
}

例题3:关灯

题面
【分析】区间DP深入题。
显然我们发现对于人走过的地方,灯都肯定已经关闭。所以我们发现已关闭的灯的位置都属于数轴上的一个区间。且人当前的位置必定是区间的端点。所以设 f [ i ] [ j ] [ 0 / 1 ] f[i] [j][0/1] f[i][j][0/1]表示当前已经关闭的灯的区间为 [ i , j ] [i,j] [i,j],现在人在这个区间的左/右端。

然后我们要如何表示能量的消耗?
能量消耗其实就等于时间*没关的灯的功率和(可以用前缀和去做),没关的灯包括区间的左边和右边的灯。

状态转移方程怎么弄?
1.在区间 [ i , j ] [i,j] [ij]的左端,那么他要么是从 [ i + 1 , j ] [i+1,j] [i+1,j]的左端走一步就到了,或者是从右端走 j − i j-i ji步(步数其实就是时间)。
2.同理,在区间 [ i , j ] [i,j] [ij]的右端,那么他要么是从 [ i , j − 1 ] [i,j-1] [i,j1]的右端走一步就到了,或者是从左端走 j − i j-i ji步。
那么每一个端点最优值便是各自两种情况的最小值。

code

#include
using namespace std;
int f[1100][1100][2];//表示[i,j]区间(灯的编号不是坐标)上在左1(右0)端点上关掉所有灯消耗的最小值 
int n,now,a[1100],b[1100];//b为前缀和 
int fuc(int l,int r,int x,int y)
{
	return (a[r]-a[l])*(b[n]-b[y-1]+b[x]);//能量消耗公式:a[r]-a[l]表示时间(步数),b[n]-b[y-1]表示右边没关路灯消耗的能量,b[x]为左边
}
int main()
{   freopen("power.in","r",stdin);
    freopen("power.out","w",stdout); 
  	cin>>n;
  	cin>>now;
  	b[0]=0;
  	for(int i=1;i<=n;i++)
  	{
  		cin>>a[i]>>b[i];
  		b[i]+=b[i-1];
  	}
  	int minn=888888888;
  	memset(f,10,sizeof(f));
  	f[now][now][0]=f[now][now][1]=0;//当前位置不消耗能量
	for(int l=2;l<=n;l++)//区间长度
	  for(int i=1;i<=n-l+1;i++) 
	  {
	    int j=i+l-1;
	  	f[i][j][0]=min(f[i][j-1][0]+fuc(j-1,j,i-1,j),f[i][j-1][1]+fuc(i,j,i-1,j));
	  	f[i][j][1]=min(f[i+1][j][1]+fuc(i,i+1,i,j+1),f[i+1][j][0]+fuc(i,j,i,j+1));
	  }
	minn=min(f[1][n][0],f[1][n][1]);
	cout<<minn; 
} 

我们发现区间一般可以套的模板是这个样子的:

for(枚举区间长度)
  for(枚举起点)
    {
       得出终点
       for(枚举小区间)//一些不需要,如关灯
         状态转移
    }

Part 6:树形DP

树形DP也是动态规划的重要分支,简单说就是在一棵上搞DP。

树形DP?你需要了解以下:
1.树的有关概念;2.树上的基本操作,树的遍历,存储(邻接矩阵,边链表);
这里便不再多说。

一.树的深度和子树大小

根节点的深度为0

我们一开始从根节点开始遍历

那么假设我们现在到了一个点x,明显从x出发所有遍历到的儿子深度都为x的深度+1;

并且以x为根节点的子树大小为所有以他的儿子为根节点的子树大小之和。

code

#include
using namespace std;
int first[100010],top=0,n,dep[100010],size[100010];
struct fuc//你不需要了解这个名字的含义...
{
  int x;
  int next;	
}a[10001000];
void add(int to,int x)
{
  top++;
  a[top].x=to;
  a[top].next=first[x];
  first[x]=top;	
}
void dfs(int fa,int x,int depth)
{
	dep[x]=depth;
	size[x]=1;//子树包括自己
	for(int i=first[x];i;i=a[i].next)
	{
		if(a[i].x==fa) continue;
		dfs(x,a[i].x,depth+1);
	    size[x]+=size[a[i].x];
	}
}
int main()
{ freopen("tree.in","r",stdin);
  freopen("tree.out","w",stdout); 
  scanf("%d",&n);
  for(int i=1;i<=n-1;i++)
   { int xx,yy;
   	 scanf("%d%d",&xx,&yy);
   	 add(xx,yy);
   	 add(yy,xx);
   }
   dfs(0,1,0);
   for(int i=1;i<=n;i++)
   {
     printf("#%d deep:%d count:%d\n",i,dep[i],size[i]);   	
   }	
} 

二.树的子树点权和以及最大点权

子树的点权和和求树子树大小我觉得是类似的,只是将子树大小可以看成每一个点的点权为1而已。

求最大点权的话,假定以x为根节点的子树中的最大点权便是所有以它儿子为根节点的子树中的最大点权的最大值。

code

#include
using namespace std;
long long v[110000],fp[110000],maxx=-10000,q[110000],top=0,n;//fp表示以i为根节点的子树点权和,q表示以i为根节点的最大点权
int first[110000];
struct fuc
{
	int x;
	int next;
}a[11000000];
void add(int to,int x)
{
  top++;
  a[top].x=to;
  a[top].next=first[x];
  first[x]=top;	
}
void dfs(int fa,int x)
{
	fp[x]=v[x];
	q[x]=v[x];
	for(int i=first[x];i;i=a[i].next)
	{
		if(a[i].x==fa) continue;
		dfs(x,a[i].x);
		q[x]=max(q[x],q[a[i].x]);
		fp[x]+=fp[a[i].x];
	}
}
int main()
{   freopen("t20.in","r",stdin);
    freopen("t20.out","w",stdout);
	scanf("%d",&n);
	for(int i=1;i<=n;i++)
	{
		scanf("%lld",&v[i]);
	}
	for(int i=1;i<=n-1;i++)
	{
		int xx,yy;
		scanf("%d%d",&xx,&yy);
		add(xx,yy);
		add(yy,xx);
	}
    dfs(0,1);
    for(int i=1;i<n;i++)
    {
    	printf("%lld ",fp[i]);
	}
	printf("%lld \n",fp[n]);
	for(int i=1;i<n;i++)
    {
    	printf("%lld ",q[i]);
	}
	printf("%lld \n",q[n]);
}

三.树的重心

首先要了解树的重心是个啥子玩意儿。

定义:树的重心是一个结点,其所有的子树中最大的子树节点数最少,那么这个点就是这棵树的重心(换句话说,删除这个点后最大连通块(一定是树)的结点数最小。)

那怎么求重心?

明显我们需要遍历所有点i,然后得出以i的所有儿子为根节点的子树的最大值。
但是在我们进行树的遍历时,从父亲遍历到儿子,那么我们得出以i的儿子为根节点的子树最大值还要去和不包括以i为根节点的子树的剩余部分去比较(即遍历过程中是无法遍历以i的父亲节点为根节点的子树大小,但其实他的父节点也是他的儿子,我们只是防止dfs遍历死循环而已,要明白这个遍历的父亲和儿子是相对的)这样才能得出真正的最大值。

DP(动态规划)入门基础详解_第2张图片
如图,若4号为重心,重心定义为其所有儿子的子树和,但是我们按dfs遍历下来,7是4的相对父亲,但是他的子树和也应该包括与此,我们要特判一下。

code

#include
using namespace std;
int first[100010],top=0,n,size[100010],minn=100000000,pos=0;
struct fuc
{
  int x;
  int next;	
}a[10001000];
void add(int to,int x)
{
  top++;
  a[top].x=to;
  a[top].next=first[x];
  first[x]=top;	
}
void dfs(int fa,int x)
{
	size[x]=1;
	int rfy_maxx=-100;
	for(int i=first[x];i;i=a[i].next)
	{
		if(a[i].x==fa) continue;
		dfs(x,a[i].x);
	    size[x]+=size[a[i].x];
	    rfy_maxx=max(rfy_maxx,size[a[i].x]);
	}
	rfy_maxx=max(rfy_maxx,n-size[x]);//最大子树
	if(rfy_maxx<minn)//可以更新
	{
	  minn=rfy_maxx;
	  pos=x;	
	}
}
int main()
{ freopen("T3.in","r",stdin);
  freopen("T3.out","w",stdout); 
  scanf("%d",&n);
  for(int i=1;i<=n-1;i++)
   { int xx,yy;
   	 scanf("%d%d",&xx,&yy);
   	 add(xx,yy);
   	 add(yy,xx);
   }
    dfs(0,1);
   	printf("%d",pos);
} 

四.求一个点到其他所有点的距离和

g [ i ] g[i] g[i]表示点i到其他点的距离和, s i z e [ i ] size[i] size[i]表示以i为根节点的子树大小, f [ i ] f[i] f[i]表示i到以i为根节点的子树中所有的点距离和,明显 f [ 1 ] = g [ 1 ] f[1]=g[1] f[1]=g[1]

先求出根节点1到其他点的距离和 g [ 1 ] ( f [ 1 ] ) g[1] (f[1]) g[1](f[1]) ,那么它的子节点i到其他点的距离就为 ( n − s i z e [ i ] − s i z e [ i ] ) ∗ e d g e ( i , 1 ) + g [ 1 ] ; (n-size[i]-size[i])*edge(i,1)+g[1]; (nsize[i]size[i])edge(i,1)+g[1]
( n − s i z e [ i ] ) (n-size[i]) nsize[i]是边 e d g e ( i , 1 ) edge(i,1) edge(i,1)多算的次数, ( s i z e [ i ] ) (size[i]) (size[i])是边 e d g e ( i , 1 ) edge(i,1) edge(i,1)少算的次数,二者减一减就是边 e d g e ( i , 1 ) edge(i,1) edge(i,1)被多算或少算的次数,其余的边被算的次数不变。

那么同理,我们就可以只根据父亲点的 f [ ] f[] f[]值便可以求儿子的 g [ ] g[] g[]值了。不必一个个去遍历,那样复杂度高。

code

#include
using namespace std;
long long top=0,first[110000],n,size[110000],f[110000],g[110000];
struct fuc
{
	long long x;
	long long v;
	int next;
}a[10000000];
void add(long long x,long long y,long long v)
{
	top++;
	a[top].x=y;
	a[top].v=v;
	a[top].next=first[x];
	first[x]=top;
}
void dfs(int fa,int x)
{
	size[x]=1;
	for(int i=first[x];i;i=a[i].next)
	{
		if(a[i].x==fa) continue;
		dfs(x,a[i].x);
		size[x]+=size[a[i].x];
		f[x]+=f[a[i].x]+a[i].v*size[a[i].x];
 	}
}
void dfsrfy(int fa,int x,int poo)
{
  if(x!=1) g[x]=g[fa]+(n-size[x]-size[x])*a[poo].v;
  for(int i=first[x];i;i=a[i].next)
  {
     if(a[i].x==fa) continue;
	 dfsrfy(x,a[i].x,i);	
  }     	
}
int main()
{ freopen("t5.in","r",stdin);
  freopen("t5.out","w",stdout); 
  scanf("%d",&n);
  for(int i=1;i<=n-1;i++)
    {
      long long xx,yy,cc;	
      scanf("%lld%lld%lld",&xx,&yy,&cc);
	  add(xx,yy,cc);
	  add(yy,xx,cc);	
	}
	dfs(0,1);
	g[1]=f[1];
	dfsrfy(0,1,0);
	for(int i=1;i<=n;i++)
	  printf("%lld\n",g[i]);	
} 

五.树的最长链(直径)和次长链

我们以 m a x x [ i ] maxx[i] maxx[i]表示以i为根节点的最长链, c m a x x [ i ] cmaxx[i] cmaxx[i]以i为根节点的次长链。

当发现i的儿子j的最长链+边 e d g e ( i , j ) edge(i,j) edgeij比i的最长链还要长,那就更新最长链,同时将原先的最长链复制给i的次长链;

如果无法更新最长链,那就尝试去更新次长链即可。
(注意:此处的最长链和次长链彼此没有重边)

code

#include
using namespace std;
long long top=0,first[100010],n,maxx[100010],cmaxx[100010],T;
struct fuc
{
	long long x;
	long long v;
	int next;
}a[10000000];
void add(long long x,long long y,long long v)
{
	top++;
	a[top].x=y;
	a[top].v=v;
	a[top].next=first[x];
	first[x]=top;
}
void dfs(int fa,int x)
{
	for(int i=first[x];i;i=a[i].next)
	  { 
	  	if(a[i].x!=fa) 
	  	{
		  dfs(x,a[i].x);
	  	  if(maxx[a[i].x]+a[i].v>maxx[x]) cmaxx[x]=maxx[x],maxx[x]=maxx[a[i].x]+a[i].v;
	  	  else cmaxx[x]=max(cmaxx[x],maxx[a[i].x]+a[i].v);
	    }
	  }
}
int main()
{   freopen("T4.in","r",stdin);
    freopen("T4.out","w",stdout);
    int s,n; 
	scanf("%d%d",&n,&s);//s为起点
	for(int i=1;i<n;i++)
	    {
	    	int xx,yy,zz;
	    	scanf("%d%d%d",&xx,&yy,&zz);
	    	add(xx,yy,zz);
	    	add(yy,xx,zz);
		}
		dfs(0,s);
		for(int i=1;i<n;i++) printf("%lld ",maxx[i]);
		printf("%lld\n",maxx[n]);
		for(int i=1;i<n;i++) printf("%lld ",cmaxx[i]);
		printf("%lld\n",cmaxx[n]);
	}
}

六.求树的直径

直径其实便是整棵树中的最长链。

我们一般有以下两种解法:

1)dfs/bfs大法

我们先求得根节点能达到的最远点P,再用此最远点P求得它的最远点Q,这个两个点之间的距离就是直径。

证明如下:

①若P已经在直径上,根据树的直径的定义可知Q也在直径上且为直径的一个端点

②若P不在直径上,我们用反证法,假设此时PQ不是直径,AB是直径

—>若AB与PQ有交点C,由于P到Q最远,那么PC+CQ>PC+CA,所以CQ>CA,易得CQ+CB>CA+CB,即CQ+CB>AB,与AB是直径矛盾,不成立,如下图(其中AB,PQ不一定是直线,画成直线是为了方便):

DP(动态规划)入门基础详解_第3张图片

—>若AB与PQ没有交点,M为AB上任意一点,N为PQ上任意一点。首先还是NP+NQ>NQ+MN+MB,同时减掉NQ,得NP>MN+MB,易知NP+MN>MB,所以NP+MN+MA>MB+MA,即NP+MN+MA>AB,与AB是直径矛盾,所以这种情况也不成立,如下图:

DP(动态规划)入门基础详解_第4张图片
Orz TQL%%%


code(dfs)

void dfs(int x,int f)
{   if(maxx<dis[x])
	{
		maxx=dis[x];
		p=x;
	}
	fa[x]=f;
	for(int i=first[x];i;i=a[i].next)
	{
		if(a[i].x==f) continue;
		dis[a[i].x]=dis[x]+a[i].v;
		dfs(a[i].x,x);
	}
}

那么我们第一遍做完后p就是最远点,把***数组清0后,在做一遍以p为根节点的最远点就OK了。

2 )树形DP

设d[x]表示从节点x出发走向以x为根的子树,能够到达的最远节点的距离。设x的子节点为 y 1 , y 2 , y 3 , . . . , y t , y1,y2, y3, ..., yt, y1,y2,y3,...,yt e d g e ( x , y ) edge(x, y) edge(x,y)表示边权,显然有 d [ x ] = m a x ( d [ y i ] + e d g e ( x , y i ) ) ( 1 < = i < = t ) d[x] = max(d[yi] + edge(x, yi))(1 <= i <= t) d[x]=max(d[yi]+edge(x,yi))(1<=i<=t)

接下来,我们可以考虑对每个节点x求出"经过节点x的最长链的长度“f[x],整棵树的直径就是 m a x ( f [ x ] ) ( 1 < = x < = n ) max(f[x])(1 <= x <= n) max(f[x])(1<=x<=n)
对于x的任意两个节点yi和yj,"经过节点x的最长链长度"可以通过四个部分构成:从yi到yi子树中的最远距离,边 ( x , y i ) (x, yi) (x,yi),边 ( x , y j ) (x, yj) (x,yj),从yj到yj子树中的最远距离。设j < i,因此:
f [ x ] = m a x ( d [ y i ] + d [ y j ] + e d g e ( x , y i ) + e d g e ( x , y j ) ) ( 1 < = j < i < = t ) f[x] = max(d[yi] + d[yj] + edge(x, yi) + edge(x, yj))(1 <= j < i <= t) f[x]=max(d[yi]+d[yj]+edge(x,yi)+edge(x,yj))(1<=j<i<=t)

但是我们没有必要使用两层循环来枚举i, j。在计算 d [ x ] d[x] d[x]的过程,子节点的循环将要枚举到i时 d [ x ] d[x] d[x]恰好就保存了从节点x出发走向“以yj(j < i)为根的子树”,能够到达的最远节点的距离,这个距离就是 m a x ( d [ y i ] + e d g e ( x , y i ) ) ( 1 < = j < i ) max(d[yi] +edge(x, yi))(1 <= j < i) max(d[yi]+edge(x,yi))(1<=j<i)。所以每次我们先用 d [ x ] + d [ y i ] + e d g e ( x , y i ) d[x] + d[yi] + edge(x, yi) d[x]+d[yi]+edge(x,yi)更新f[x],再用 d [ y i ] + e d g e ( x , y i ) d[yi] + edge(x, yi) d[yi]+edge(x,yi)更新d[x]即可

code

void dp(int x,int fa) 
{
   for(int i = first[x]; i; i=a[i].next) 
       {
         int y = a[i].x;
         if(y==fa) continue;
         dp(y,x);
         ans = max(ans, d[x] + d[y] + edge[i]);
         d[x] = max(d[x], d[y] + edge[i]);
     }
 }

好好理解树形DP求解的过程。

以上都是模板题,现在上树形DP真题。

例题1:最大利润

题面

【分析】相邻的点不能同时建商店,每一个点有价值,求最大的价值;

f [ i ] f[i] f[i]表示以i号点为根节点的子树中且i号点一定选的最大值, g [ i ] g[i] g[i]表示以i号点为根节点的子树中且i号点不建商店的最大价值,假定1为树的根节点;

明显可以得到状态转移方程为: f [ i ] = ∑ g [ p ] , ( p 是 i 的儿子 ) , g [ i ] = ∑ m a x ( g [ p ] , f [ p ] ) , f[i]=∑g[p],(p是i的儿子),g[i]=∑max(g[p],f[p]), f[i]=g[p],(pi的儿子),g[i]=max(g[p],f[p]),因为此时儿子可建商店也可以不建,最终答案就是 m a x ( g [ 1 ] , f [ 1 ] ) max(g[1],f[1]) max(g[1],f[1])

code

#include
using namespace std;
long long first[1100000],n,g[110000],f[110000],top=0;
long long sum=0;
struct fuc
{
	int x;
	int v;
	int next;
}a[11000000];
void add(int x,int y)
{
	top++;
	a[top].x=y;
	a[top].next=first[x];
	first[x]=top;
}
void dfs(int x,int fa)
{
	for(int i=first[x];i;i=a[i].next)
	{
		if(a[i].x==fa) continue;
		dfs(a[i].x,x);
		f[x]+=g[a[i].x];
		g[x]+=max(f[a[i].x],g[a[i].x]);
	}
}
int main()
{   freopen("profit.in","r",stdin);
    freopen("profit.out","w",stdout);
	scanf("%d",&n);
	for(int i=1;i<=n;i++)
	{
		scanf("%d",&f[i]);
	}
	for(int i=1;i<n;i++)
	{
		int aa,bb;
		scanf("%d%d",&aa,&bb);
		add(aa,bb);
		add(bb,aa);
	}
	dfs(1,0);
	printf("%lld",max(f[1],g[1]));
}

例题2:背包类树形DP—苹果二叉树

这种题型又被称为有树形依赖的背包问题,实际上就是背包和树形dp的结合。
除了以节点编号作为dp的阶段,还会把当前背包体积作为第二维状态,要处理的实际上就是一个分组背包的问题。
这类题目还可以按照一个“左儿子右兄弟”的方法,把多叉树转化为二叉树来做,个人觉得较复杂,且容易把父子关系和兄弟关系混淆。

题面

关键信息:当某条边被保留下来时,从根节点到这条边的路径上的所有边也都必须保留下来。

【分析】设 f [ u ] [ i ] f[u][i] f[u][i]表示u的子树上保留i条边,至多保留的苹果数目.

那么显然,设v为u的一个儿子,我们枚举以v为根节点的树上保留j条边,子树大小为size[]数组,t为要保留的边数

f [ u ] [ i ] f[u][i] f[u][i]=max{ f [ u ] [ i ] f[u][i] f[u][i] , f [ v ] [ j ] f[v][j] f[v][j]+ f [ u ] [ i − j − 1 ] f[u][i-j-1] f[u][ij1]+ e d g e ( u , v ) edge(u,v) edge(u,v)};

其中:
0 < = j < m i n ( s i z e [ v ] , i − 1 ) 0<=j0<=j<min(size[v],i1)
1 < = i < = m i n ( s i z e [ u ] , t ) 1<=i<=min(size[u],t) 1<=i<=min(size[u],t)

其中edge(u,v)也属于一条边,故为 f [ u ] [ i − j − 1 ] f[u][i-j-1] f[u][ij1]而非 f [ u ] [ i − j ] f[u][i-j] f[u][ij],同理j不能取到i,最多是i-1;

使用的背包思想:
相当于以u为根节点的子树中(不包括v子树)和以v为根节点的子树中各取一个物品,物品体积和为i-1(还有一个 e d g e ( u , v ) edge(u,v) edgeuv)使得价值最大。
为了防止有重复累积,我们对于i循环要使用倒序去做,而j循环是一个决策循环,可以随意。

code

#include
using namespace std;
int first[1100000],f[1100][1100],size[110000],top=0;
int sum=0,n,m;
struct fuc
{
	int x;
	int v;
	int next;
}a[11000000];
void add(int x,int y,int v)
{
	top++;
	a[top].x=y;
	a[top].v=v;
	a[top].next=first[x];
	first[x]=top;
}
void dfs(int fa,int x)
{
   size[x]=1;
   for(int i=first[x];i;i=a[i].next)
   {
   	 int y=a[i].x;
   	 if(y==fa) continue;
   	 dfs(x,y);
   	 size[x]+=size[y];
   }	
}
void dp(int fa,int x)
{
	for(int i=first[x];i;i=a[i].next)
	{
		int y=a[i].x;
		if(y==fa) continue;
		dp(x,y);
		for(int j=min(size[x],m);j>=1;j--)//倒序
		  for(int k=0;k<=min(size[y],j-1);k++)//也可以倒序
		    f[x][j]=max(f[x][j],f[y][k]+f[x][j-k-1]+a[i].v);
	}
}
int main()
{
	scanf("%d",&n);
	scanf("%d",&m);
	for(int i=1;i<n;i++)
	{
		int x,y,z;
		scanf("%d%d%d",&x,&y,&z);
		add(x,y,z);
		add(y,x,z);
	}
	dfs(0,1);
	dp(0,1);
	printf("%d",f[1][m]);
}

Update 20191013

例题3:经典树形DP–求树上一点到其他点的距离最大值

题目:Computer

一个很容易想到的东西:若我们以1为根节点,那么对于一个节点x来说,产生最大值的可能为:1.向其子树内延伸的最长链 2.向其父亲方向延伸的最长链。

1情况还是很容易处理的,就是上面树形DP例题求出树的最长链和次长链。此时更新是由儿子向父亲进行转移。
2情况我们由父亲向儿子转移,如果当前儿子方向是父亲向其子树方向延伸的最长链方向,我们可以利用父亲向其子树的次长链父亲向其父亲方向延伸的最长链来更新,否则就直接可以用父亲向子树的最长链父亲向其父亲方向延伸的最长链来更新。(这样就保证一定是由儿子延伸出的距离),此时是父亲向儿子转移。
很经典的模型。
code

#include
#define ll long long
#define rint register int
using namespace std;
const int N=1e5+7;
int n,tot,first[N],dp[N][3];//(以1为根节点)0--表示i号节点其子树的最长链,1表示次长链,2向其父亲方向延伸的的最长链 
struct fuk
{
	int x,v,next;
}a[N<<1];
void add(int x,int to,int v)
{
	tot++;
	a[tot].x=to; a[tot].next=first[x]; first[x]=tot; a[tot].v=v;
}
void dfs1(int x,int fa)
{
	for(int i=first[x];i;i=a[i].next)
	{
		int y=a[i].x;
		if(y==fa) continue;
		dfs1(y,x);
		int tmp=dp[y][0]+a[i].v;
		if(tmp>=dp[x][0])
		{
		  dp[x][1]=dp[x][0];
		  dp[x][0]=tmp; 
		}
		else if(tmp>dp[x][1])
		{
			dp[x][1]=tmp;
		}
	}
}
void dfs2(int x,int fa)
{
	for(int i=first[x];i;i=a[i].next)
	{
		int y=a[i].x;
		if(y==fa) continue;
		dp[y][2]=max(dp[y][2],max(dp[x][2],dp[y][0]+a[i].v==dp[x][0]?dp[x][1]:dp[x][0])+a[i].v);//判断y是否在x向其子树的最长链上 
		dfs2(y,x); 
	}
}
int main()
{
  scanf("%d",&n);
  for(int i=2;i<=n;i++)
  {
  	int x,y;
  	scanf("%d%d",&x,&y);
  	add(x,i,y); add(i,x,y);
  }
  dfs1(1,0);
  dfs2(1,0);
  for(int i=1;i<=n;i++)
    printf("%d\n",max(dp[i][0],dp[i][2])); 
  return 0;
}

(当然本题有一个显然的结论,距离某一个点最远距离的点一定在树直径的两端,然后三遍dfs也可以求出)
拓展:利用该思想也可以求出经过某一个点的最长路径。


最后

动态规划是信息学的一个非常非常的重要的分支,掌握DP的主要思想以及阶段划分和状态转移又是尤为重要。因此我们在学习时,除了要对基本概念和方法正确理解外,必须具体问题具体分析处理,以丰富的想象力去建立模型,用创造性的技巧去求解。我们也可以通过对若干有代表性的问题的动态规划算法进行分析、讨论,逐渐学会并掌握这一设计方法。

本篇总结可能存在不易理解或错误,希望读者能指出!!!
(我还有许多类型的DP尚未写到,等以后有时间再来更新)

你可能感兴趣的:(专题总结,DP)