经典例题:数字金字塔(Luogu 1216)
写一个程序来查找从最高点到底部任意处结束的路径,使路径经过数字的和最大。每一步可以走到左下方的点也可以到达右下方的点。
我们现在这里讨论搜索如何实现:
状态:目前在第x行第y列
行动:向左走,向右走
例如:一个底边为4的三角形共有八种状态:
void dfs(int x,int y,int val)
{
val+=a[x][y];//加上权值
if(x==n-1)
{
if(val>ans)ans=val;//更新更大的ans
return;
}
dfs(x+1,y,val);//往左边走
dfs(x+1,y+1,val);//往右边走
}
考虑时空效率,DFS确实很暴力啊,有没有什么优化呢??
我们引入“冗余搜索”这个概念:无用的,不会改变答案的搜索
例子:观察下面两个例子。用两种方式都能到达第 3 行第 2 列,只是路径不同,同时走到这个点两条路权值和不一样,其中一个总和为 8,一个总和 12。
那么可以观察可得,总和为 8 的搜索是冗余的(不会改变答案),即使不继续搜索,答案也不会改变。
因为 12 往下搜索,无论往左往右,都会比 8 对应的路径大。
可见,冗余就是剪枝的“枝”,那么如何利用冗余搜索,来优化程序呢?
我们可以对于每一个位置记录一个值 F,代表搜索到此位置时,最大的路径和是多
少,这样如果搜到某一个位置时候,路径和不大于记录值F,说明这个搜索是冗余搜索,直接退出,如果大于,就需要更新F值并且继续搜索。
我们就把这种搜索叫做记忆化搜索,根据之前的“记忆”来优化搜索;在这道题中,每个位置的“记忆”就是最大的路径和
//T1:数字金字塔(记忆化搜索)
void dfs(int x,int y,int val)
{
val+=a[x][y];
// 记忆化过程
if(val<=f[x][y])return;//发现冗余搜索,退出
f[x][y]=val;//f[x][y]记录这个点当前最大权值
if(x==n-1)//如果搜到了最后一个点,ans更新保存最大值,退出即可
{
if(val>ans)ans=val;
return;
}
dfs(x+1,y,val);//继续搜索
dfs(x+1,y+1,val);
}
1、01背包问题
经典例题:采药(Luogu 1048)
辰辰是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。为此,他想拜附近最有威望的医师为师。医师为了判断他的资质,给他出了一个难题。医师把他带到一个到处都是草药的山洞里对他说:“孩子,这个山洞里有一些不同的草药,采每一株都需要一些时间,每一株也有它自身的价值。我会给你一段时间,在这段时间里,你可以采到一些草药。如果你是一个聪明的孩子,你应该可以让采到的草药的总价值最大。”
草药 1 :时间 71;价值 100
草药 2 :时间 69;价值 1
草药 3 :时间 1 ;价值 2
@最优选择:草药 2+3:总时间 70;总价值 3
这题是经典的背包问题,和金字塔问题不同的地方在于:它是有重量限制的。
我们还是先用记忆化搜索来思考这个问题:
状态:目前已经决定到第x件物品,当前背包中物品总重量为w,总价值为v;
行动:这件物品取还是不取;
约束:物品总重量不超过w(背包总重量);
目标:物品总价值最大;
比较下列两种情况:
状态相同:x1=x2(当前搜索到同一件物品),w1=w2(当前总重量相等);
价值不同:但它们的背包总价值不同,其中v1
则我们可以说状态1为冗余的,因为它肯定比状态2要差。
*记忆化:对于每个状态(x,w),记录对应的v的最大值。
//T5:采药(记忆化搜索)
void dfs(int t,int x,int val)//t为剩余时间,x为当前决定的第几株草药,val为总价值
{
//记忆化
if(val<=f[t][x])return;
f[t][x]=val;
if(x==n)//把草药采摘完了,直接返回
{
if(val>ans)ans=val;//更新最大值ans
return;
}
dfs(t,x+1,val);
if(w[x]<=t)dfs(t-w[x],x+1,val+v[x]);//如果我们还有时间,继续采摘!
}
那好的,说完记忆化搜索我们回到正题:动态规划啦!记忆化搜索是DP的基础。
我们再回到数字金字塔这个问题来,下图的黑色三角形是我们记忆化搜索的路径,我们想想,是不是可以不通过记忆化搜索就能得到这个黑色三角形??
最优性:设走到某一个位置的时候,它达到了路径最大值,那么在这之前,它走的每一步都是最大值。
-考虑这条最优的路径:每一步均达到了最大值
最优性的好处:要达到一个位置的最优值,它的前一步也一定是最优的。
-考虑图中位置,如果它要到达最优值,有两个选择,从左上方或者右上方的最优值得到:
所以从这里,定义动态规划(DP):只记录状态的最优值,并用最优值来推导出其他的最优值。
记录 F[i][j] 为第 i 行第 j 列的路径最大值,有两种方法可以推导:(两个分支两种状态,选取最大)
@顺推:用 F[i][j] 来计算 F[i+1][j],F[i+1][j+1]
@逆推:用 F[i-1][j],F[i-1][j-1] 来计算 F[i][j]
这两种思考方法也是动态规划中最基本的两种方法,解决绝大部分DP我们都可以采用这样的方法。
//T2:数字金字塔-顺推(有点类似于记忆化搜索的思路)
f[0][0]=a[0][0];
for(int i=0;i
for(int j=0;j<=i;++j)//f数组为最优值路径(黑色金字塔,a为源数据数组(紫色金字塔)
{
//分别用最优值来更新左下方和右下方
f[i+1][j]=max(f[i+1][j],f[i][j]+a[i+1][j]);//和当前的f[i+1][j]比较
f[i+1][j+1]=max(f[i+1][j+1],f[i][j]+a[i+1][j+1]);//和当前的f[i+1][j+1]比较
}
//T4:数字金字塔-逆推(自顶向下)
f[0][0]=a[0][0];
for(int i=0;i
{
f[i][0]=f[i-1][0]+a[i][0];//最左的位置没有左上方
f[i][i]=f[i-1][i-1]+a[i][i];//最右的位置没有右上方
for(intj=1;j在左上方和右上方取较大的
f[i][j]=max(f[i-1][j-1],f[i-1][j])+a[i][j];
}
//答案可能是最后一行的任意一列
ans=0;
for(int i=0;i
ans=max(ans,f[n-1][i]);
*转移方程:最优值之间的推导公式。
@顺推:
F[i+1][j]= MAX (F[i][j] + a[i+1][j]);
F[i+1][j+1]= MAX (F[i][j] + a[i+1][j+1]);
@逆推:
F[i][j]= MAX (F[i-1][j], F[i-1][j-1]) + a[i][j]; (注意!逆推时要注意边界情况! )
顺推和逆推本质上是一样的(复杂度一致);顺推和搜索的顺序类似;而逆推则是将顺序反过来;顺推考虑的是“我这个状态的下一步去哪里” ,逆推的考虑的是“从什么状态可以到达我这里” 。同时在转移的过程中我们要时刻注意边界情况。
我们还可以改变搜索顺序:
//T3:数字金字塔-逆推/路径自底向上
//改变顺序:记录从底部向上走的路径最优值
for(int i=0;i
f[n-1][i]=a[n-1][i];//备份底部自己这一行
//逆推过程:可以从左下方或右下方走过来;没有边界情况
for(int i=n-2;i>=0;--i)
for(int j=0;j<=i;++j)
f[i][j]=max(f[i+1][j+1],f[i+1][j])+a[i][j];//当前[i][j]左下方和右下方取较大加上当前的
//答案则是顶端
ans=f[0][0];
//和之前的逆推区别:这样较自顶向下不需要判断边界,更加简单
*转移顺序:最优值之间的推导顺序
一个小问题:在数字金字塔中,为什么能够使用动态规划呢??答:因为有明确的顺序:自上而下,也就是说,能划分成不同的阶段,这个阶段是逐步进行的,这和搜索顺序也是类似的,所以,只要划分好阶段,从前往后推,与从后往前推都是可以的
接下来我们进入重点,还是回到刚才的采药问题,我们回忆刚才这题的记忆化搜索。
状态设计:记录 F[i][j] 为,已经决定前 i 件物品的情况,在总重量为 j 的情况下,物品总价值的最大值。同样也是有两种方法可以推导:
@顺推: “我这个状态的下一步去哪里”
@逆推: “从什么状态可以到达我这里”
当前状态: F[i][j] 为,已经决定前 i 件物品的情况,在总重量为 j的情况下,物品总价值的最大值。
@顺推: “我这个状态的下一步去哪里” :我现在要决定下一件物品取还是不取。
>如果不取的话,可以达到状态 F[i+1][j];
>如果取的话,可以达到状态 F[i+1][j+w[i+1]](需要满足重量约束);
@逆推: “从什么状态可以到达我这里” :考虑我这件物品取不取。
>如果是不取的,那可以从 F[i-1][j] 推导而来;
>如果是取的,可以从 F[i-1][j-w[i]] 推导而来的(同样需要满足重量约束)
//T6:采药(DP/顺推)
for(int i=0;i
for(int j=0;j<=t;++j)
{
//不取
f[i+1][j]=max(f[i+1][j],f[i][j]);
//取
if(j+w[i]<=t)//满足重量限制(类比背包问题)
f[i+1][j+w[i]]=max(f[i+1][j+w[i]],f[i][j]+v[i]);
}
//答案
ans=0;
for(int i=0;i<=t;++i) ans=max(ans,f[n][i]);
学到这里,我们大概摸清了动态规划的轮廓是什么,使用动态规划较DFS解决了时间上的问题,那么我们可不可考虑解决一下空间上的问题呢?由于动态规划满足”无后效性原则“,当前状态F[i]之和上一个状态F[i-1]有关,和上个状态之前的都没有关系,所以我们可以考虑使用滚动数组来保存这两个状态,一上一下,互为前后状态,节省空间啊!!
这就是——数组压缩!
所以一个直观的做法是,记录两个数组,分别记录 F[i-1] 与 F[i] 的值。
*但更进一步,我们可以甚至不记录两行,只记录一行的状态。
-我们倒着枚举,在 F[i-1] 数组中逐步更新,让它逐步变为 F[i]。
因为是倒着枚举的,先枚举的位置都已经无用了,可以直接用 F[i] 的元素来替换。
//T10:采药(DP/逆推/数组压缩)
//用一个一维数组来代替二维数组
for(int i=1;i<=n;++i)
for(int j=t;j>=0;--j)//重量:倒着枚举
{
//不取:对数组没有影响
//f[i][j]=f[i-1][j];
//取
//if(j>=w[i])f[i][j]=max(f[i][j],f[i-1][j-w[i]]+v[i]);
if(j>=w[i])f[j]=max(f[j],f[j-w[i]]+v[i]);//如果还有采药时间,执行
}
//在枚举过程中,大于j的位置等于f[i][j],小于j的位置等于f[i-1][j]
上题的采药,就是一类经典的背包问题——01背包。
2、完全背包问题
下面我们来说说第二类经典的背包问题——完全背包问题:
经典例题:Piggy-Bank(POJ 1384)
现在有 n 种硬币, 每种硬币有特定的重量 cost[i] 和它对应的价值val[i]. 每种硬币可以无限使用。已知现在一个储蓄罐中所有硬币的总重量正好为 m 克, 问你这个储蓄罐中最少有多少价值的硬币? 如果不可能存在 m克的情况, 那么就输出“This is impossible.”
我们也把这类问题归入到背包问题当中:有物品,重量限制,价值最大。
*但与采药问题不同的是,每件物品可以无限使用。
推导状态转移方程:
当前状态: F[i][j] 为,已经决定前 i 件物品的情况,在总重量为 j的情况下,物品总价值的最小值。
@顺推: “我这个状态的下一步去哪里” :考虑我这件物品取多少件。
>如果是不取的,那可以推导到 F[i+1][j];
>如果是取一件,那可以推导到 F[i+1][j+w[i]];
>如果是取k 件,那可以推导到 F[i+1][j+w[i]*k]
//T8:Piggy-Bank(DP/顺推)
//初值处理:由于问题求的是最小值,所以先把所有状态赋值为最大值
for(int i=1;i<=n+1;++i)
for(int j=0;j<=m;++j) f[i][j]=INF;
//第一件还没取,重量为0
f[1][0]=0;
for(int i=1;i<=n;++i)//i:已经决定的物品
for(int j=0;j<=m;++j)//j:总重量
for(int k=0;j+w[i]*k<=m;++k)//k:这件物品取多少件
f[i+1][j+w[i]*k]=min(f[i+1][j+w[i]*k],f[i][j]+p[i]*k);
//w重量;p[]价值
当前状态: F[i][j] 为,已经决定前 i 件物品的情况,在总重量为 j
的情况下,物品总价值的最大值。
@逆推: “从什么状态可以到达我这里” :考虑我这件物品取多少件。
>如果是不取的,那可以从 F[i-1][j] 处推导得到;
>如果是取一件,那可以从 F[i-1][j-w[i]] 处推导得到;
>如果是取k 件,那可以从 F[i-1][j-w[i]*k] 处推导得到;
//T9/work1:Piggy-Bank(DP/逆推)
for(int i=0;i<=n;++i)
for(int j=0;j<=m;++j) g[i][j]=INF;
g[0][0]=0;
for(int i=1;i<=n;++i)
for(int j=0;j<=m;++j)
for(int k=0;j>=w[i]*k;++k)
g[i][j]=min(g[i][j],g[i-1][j-w[i]*k]+p[i]*k);//三重循环开销太大啦!!我需要一个优化QAQ
你的三重循环让人觉得你很慢,我们对逆推进行优化:
逆推:观察和逆推相关的状态。
假设 w[i]=3,则 F[i][6] 与 F[i-1][0,3,6] 相关; F[i][7] 与F[i-1][1,4,7] 相关;与此同时, F[i][3] 与 F[i-1][0,3] 相关;F[i][4] 与 F[i-1][1,4] 相关。
则可以得到,实际上与 F[i][j] 相关的状态只比 F[i][j-w[i]] 多一个。
则所以我们可以这样推导:
>如果是不取的,那可以从 F[i-1][j] 处推导得到;
>如果是取一件或更多,那可以从 F[i][j-w[i]] 处推导得到; (因为是可以取任意件,所以从 F[i] 中取最优而不是从 F[i-1] 中取)
而从这种逆推也可以方便的写出数组压缩。
//T9/work2:Piggy-Bank(DP/逆推优化)
for(int i=0;i<=n;++i)
for(int j=0;j<=m;++j) g[i][j]=INF;
g[0][0]=0;
for(int i=1;i<=n;++i)
for(int j=0;j<=m;++j)
{
g[i][j]=g[i-1][j];//不取
if(j>=w[i])g[i][j]=min(g[i][j],g[i][j-w[i]]+p[i]);
}
//T9/work3:Piggy-Bank(DP/逆推优化/数组压缩)
for(int j=0;j<=m;++j)f[j]=INF;
f[0]=0;
for(int i=1;i<=n;++i)
for(int j=0;j<=m;++j)//顺着枚举——完全背包(逆着枚举——01背包)
if(j>=w[i])f[j]=min(f[j],f[j-w[i]]+p[i]);
同样地,我们还可以用位运算对一维进行优化,嗯。。由于我比较懒,我就直接引了P2O5大佬的代码:https://zybuluo.com/P2Oileen/note/816892#动态规划
//原来写01背包的时候,循环是这么写的:
for(int i=1;i<=n;i++)
{
for(j=m;j>=a[i];j--) if(f[j-a[i]]) f[j]=1;//这个物品被取用了,标记为1
}
//可以改成位运算版本的,减少一重循环更快~
for(int i=1;i<=n;i++)
{
f=f|f<或f左移a[i]位(×2^a[i])
}
3、背包计数问题
经典例题:集合(Luogu 1466)
对于从 1 到 N (1 <= N <= 39) 的连续整数集合,能划分成两个子集合,且保证每个集合的数字和是相等的。举个例子,如果 N=3,对于[1, 2, 3] 能划分成两个子集合,每个子集合的所有数字和是相等的:- [3] 和[1,2]
这是唯一一种分法(交换集合位置被认为是同一种划分方案,因此不会增加划分方案总数)如果 N=7,有四种方法能划分集合 [1, 2, 3, 4, 5,6, 7],每一种分法的子集合各数字和是相等的:
-[1,6,7] 和 [2,3,4,5] (注: 1+6+7=2+3+4+5)
-[2,5,7] 和 [1,3,4,6]
-[3,4,7] 和 [1,2,5,6]
-[1,2,4,7] 和 [3,5,6]
给出 N,你的程序应该输出划分方案总数,如果不存在这样的划分方案,则输出 0。程序不能预存结果直接输出(不能打表)。
这里引入背包模型:
*物品:可以把所有的数字看作物品。对于数字 i,其对应的重量为 i,则我们需要求出装满载重为 M 的背包的方案数(其中 M 为所有数总和的一半)。
*状态:(仿照之前的方法)设 F[i][j] 为已经考虑完数字 1-i 了,当前数字总和为 j 的总方案数。
*状态转移方程- 顺推:考虑有没有取数字 i。
没取: F[i+1][j] += F[i][j]
取了: F[i+1][j+i] += F[i][j] (j+i<=M)
*状态转移方程- 逆推:考虑有没有取数字 i。
F[i][j]= F[i-1][j] + F[i-1][j-i] (j>=i)
//T14/work1:集合(DP/顺推)
//初值:(什么都不取)和=0,有一种方案
f[1][0]=1;
for(int i=1;i<=n;++i)
for(int j=0;j<=m;++j)
{
f[i+1][j]+=f[i][j];
if(i+j<=m)f[i+1][i+j]+=f[i][j];
}
//T14/work2:集合(DP/逆推)
f[0][0]=1;
for(int i=1;i<=n;++i)
for(int j=0;j<=m;++j)
{
f[i][j]=f[i-1][j];
if(j>=i)f[i][j]+=f[i-1][j-i];
}
//T14/work3:集合(DP/逆推/数组压缩)
g[0]=1;
for(int i=1;i<=n;++i)
for(int j=m;j>=i;--j)//注意要倒着枚举
g[j]+=g[j-i];
4、完全背包计数问题
经典例题:货币系统(Luogu 1474)
母牛们不但创建了它们自己的政府而且选择了建立了自己的货币系统。由于它们特殊的思考方式,它们对货币的数值感到好奇。
传统地,一个货币系统是由 1,5,10,20 或 25,50, 和 100 的单位面值组成的。
母牛想知道有多少种不同的方法来用货币系统中的货币来构造一个确定的数值。
举例来说, 使用一个货币系统 [1,2,5,10,...] 产生 18 单位面值的一些可能的方法是:18x1, 9x2, 8x2+2x1,3x5+2+1, 等等其它。写一个程序来计算有多少种方法用给定的货币系统来构造一定数量的面值。
*物品:可以把所有的货币看作物品。对于每种货币,其对应的重量为它的面值(注意到与前一道题目相比,每个物品是可以取任意件的)。
*状态:(仿照之前的方法)设 F[i][j] 为已经考虑完前 i 种货币了,当前钱的总和为 j 的总方案数。
*状态转移方程- 逆推:考虑货币 i 取了多少件。
F[i][j]= ∑ F[i-1][j - w[i]*k] (加法原理噢~)
//T15/work1:货币系统(DP/逆推)
f[0][0]=1;
for(int i=1;i<=v;++i)
for(int j=0;j<=n;++j)
for(int k=0;k<=j/a[i];++k)
f[i][j]+=f[i-1][j-a[i]*k];
//T15/work2:货币系统(DP/逆推/数组压缩)
g[0]=1;
for(int i=1;i<=v;++i)
for(int j=a[i];j<=n;++j)
g[j]+=g[j-a[i]];
总之,我们在使用动态规划解决问题的时候,要时刻注意一下:
1.划分清楚状态,状态转移方程明了清晰
2.注意顺序
3.尽量使用数据压缩,剪枝之类,比如列出边界条件,位运算优化,多维变滚动甚至一维等。
5、路径行走问题
经典例题:方格取数(Luogu 1004)
设有 N*N 的方格图 (N<=9),我们将其中的某些方格中填入正整数,而其他的方格中则放入数字 0。
*某人从图的左上角的 A 点出发,可以向下行走,也可以向右走,直到到达右下角的 B 点。在走过的路上,他可以取走方格中的数(取走后的方格中将变为数字 0)。
*此人从 A点到 B 点共走两次,试找出 2 条这样的路径,使得取得的数之和为最大。
-与数字金字塔很类似?如果只走一次呢?
*只走一次:(仿照数字金字塔)记录 F[i][j] 为走到第 i 行第 j 列的最大值。
-思考:转移的顺序?转移的方程?
*问题:在这道题目当中我们不能直接套用走一次的方法;一个方格只能被取走一次(也就是说每个权值只能被取用一次)。
-考虑两条道路同时进行:状态 F[i][j][k][l] 来记录第一条路径走到(i,j),而第二条路径走到 (k,l) 的最大值。
*转移方程:考虑逆推(我可能是由哪些状态得到的)。
在这里,我们要保证这两个点所走的步数是相同的,那么这个状态才是有意义的,在这里没有这样算,不算也是对的,算了也不影响答案。。
-每个点可以往下走或者往右走;一共走到有 2*2=4 种可能性(时刻注意边界情况)
//T11:方格取数(DP/逆推)
for(int i=1;i<=n;++i)
for(int j=1;j<=n;++j)
for(int k=1;k<=n;++k)
for(int l=1;l<=n;++l)
{
//注意,如果走到了一起,只加一次
int cost=a[i][j]+a[k][l]-a[i][j]*(i==k&&j==l);//如果两个位置是重叠的,就要减去重复的
//四种可能性;考虑:为什么不加边界情况的判断?
f[i][j][k][l]=max(max(f[i-1][j][k-1][l],f[i-1][j][k][l-1]),max(f[i][j-1][k-1][l],f[i][j-1][k][l-1]))+cost;
}
6、最长不下降子序列问题
经典例题:导弹拦截(Luogu 1020)
某国为了防御敌国的导弹袭击,发展出一种导弹拦截系统。但是这种导弹拦截系统有一个缺陷:虽然它的第一发炮弹能够到达任意的高度,但是以后每一发炮弹都不能高于前一发的高度。某天,雷达捕捉到敌国的导弹来袭。由于该系统还在试用阶段,所以只有一套系统,因此有可能不能拦截所有的导弹。
*输入导弹依次飞来的高度(雷达给出的高度数据是不大于 30000 的正整数),计算这套系统最多能拦截多少导弹,如果要拦截所有导弹最少要配备多少套这种导弹拦截系统。
-389 207 155 300 299 170 158 65
-最多能够拦截: 6;最少要配备: 2
*LIS:一个序列当中一段不下降的子序列。
*这道题目中第一问要求我们找到一段最长的单调下降的子序列。 (无论是上升还是下降,可以使用类似的算法解决)
*状态:我们用F[i] 代表,以i 位置为结尾的一段,最长的下降子序列的长度。
!最优性:如果某段 [q1q2q3 ...qn] 是以qn结尾的最长下降子序列;那么去掉最后一个的序列 [q1q2q3...qn-1],依然是以qn-1结尾的最长下降子序列。(一个这是一个全局最优,每一步的最优解构成全局最优,所以拆成部分还是最优解,满足无后效性原则)
*逆推:假设我们需要求以 X 结尾的最长下降子序列 F[X];
*由最优性可得,我们除去最后一个位置(也就是 X),还是一段最长下降子序列。
*那我们可以枚举这个子序列的结尾 Y,最优值就是 F[Y]。
!但需要注意的是,必须保证A[X] < A[Y], X 比 Y 要低,才满足下降的要求。
*我们从所有枚举的结果中找到一个最大的即可
例如:我们看到这坨绿油油的图,我们讨论F[8]作为谁的结尾,由图中我们可以得到:
F[8]=F[4]+1(F[8]可以作为F[4]的结尾);
F[8]=F[6]+1(F[8]同样可以作为F[6]的结尾);
F[8]=F[7]+1(F[8]可以作为F[7]的结尾)
但是最长的还是F[8]作为F[4]结尾,这时候最长,取max
*注意到题目还需要计算‘如果要拦截所有导弹最少要配备多少套这种导弹拦截系统。’
*可以直接观察得到,所求的答案至少为原题的最长不下降子序列。
-因为它们当中,任意两个都不可能被同一个导弹打中。
-事实可以证明,这就是答案。—— 啊喂?不给个证明嘛→_→?那肯定给啊!证明如下!
证明:因为假设一个导弹a被打中了,那么下次有比它高的导弹b,就没办法用打中a的导弹系统来打b,必须增加一个导弹系统,所以为最长不下降子序列长度。
同样地,我们还可以运用极限思维来考虑,有n颗导弹,它们的高度都是单调递增的,那么这时就必须要n个系统来拦截,所以为最长不下降子序列长度=n;
//T12:导弹拦截(DP/LIS/逆推)
int ansf=0,ansg=0;//记录所有的f(g)中的最优值
//f计算下降子序列,g计算不下降子序列
for(int i=1;i<=n;++i)
{////枚举倒数第二个,寻找最长下降放到f中,最长不下降放到g中
for(intj=1;j
if(a[j]>a[i])f[i]=max(f[i],f[j]);
elseg[i]=max(g[i],g[j]);
++f[i],++g[i];//加上自己的一个
ansf=max(ansf,f[i]);
ansg=max(ansg,g[i]);
}
cout<
问题变形:
经典例题:合唱队形(Luogu 1091)
*N位同学站成一排,音乐老师要请其中的(N-K)位同学出列,使得剩下的 K 位同学排成合唱队形。
*合唱队形是指这样的一种队形:设 K 位同学从左到右依次编号为1,2,…,K,他们的身高分别为T1,T2,...,TK,则他们的身高满足T1<...
*你的任务是,已知所有 N 位同学的身高,计算最少需要几位同学出列,可以使得剩下的同学排成合唱队形。
-186 186 150 200 160 130 197 220
-最少需要4 位同学出列
所谓合唱队形,就是要一个队列高度构成一个山峰的形状,有顶尖,两边单调递减。
我们可以采用枚举每个人为顶尖,从那个定点开始,向左、右寻找单调递减序列的最大和,这样。。。真的很麻烦。
*问题转化:最少的同学出列 -> 尽量多的同学留在队列
*与 LIS的联系:如果确定了中间的“顶尖” ,两侧就是“单调上升” 和“单调下降” 的。
* 状态设计: F[i] 与 G[i](预先处理)
-F[i]:以 i 为端点,左侧的最长的上升子序列长度。
-G[i]:以 i 为端点,右侧的最长的下降子序列长度。
这个思路就是要我们找到每个以第i个点为末端的最长上升子序列和最长下降子序列,最终我们枚举每一个点i访问f[i]、g[i],找出两者相加最大值即可。
同样,我们还有另一种思路,找到了最长上升子序列和最长下降子序列,两个序列合并,去掉中间重复的元素(出现顶尖),即为答案。(这里我就不粘代码了,详见我之前写这个题的博客吧→_→)
//T13:合唱队形(DP/LIS/逆推)
for(int i=1;i<=n;++i) cin>>a[i],f[i]=g[i]=1;
for(int i=1;i<=n;++i)
for(int j=1;j
if(a[i]>a[j]) f[i]=max(f[i],f[j]+1);
for(int i=n;i;--i)//g的计算从反方向进行枚举
for(intj=n;j>i;--j)
if(a[i]>a[j])g[i]=max(g[i],g[j]+1);
int ans=0;
/*把最长上升和最长下降的一部分部分拼在一起求总长度最大*/
for(int i=1;i<=n;++i)//枚举每一个顶点为顶尖
{
ans=max(ans,f[i]+g[i]-1);//"-1"表示减掉顶尖这个重复计算的点
}
7、最长公共子序列问题
经典例题:排列LCS问题(Luogu 1439)
*给出1-n 的两个排列 P1 和 P2,求它们的最长公共子序列。
*公共子序列:既是 P1 的子序列,也是 P2 的子序列。
-3 2 1 4 5
-1 2 3 4 5
-最长公共子序列(LCS): 3([1 4 5])
*LCS:两个序列的最长公共子序列。
*状态:我们用F[i][j] 代表,前一个序列以 i 位置为结尾,后一个序列以 j 位置为结尾,它们的最长公共子序列。
!最优性:如果某段 [q1q2q3...qn] 是分别以i,j结尾的最长公共子序列;那么去掉最后一个的序列[q1q2q3...qn-1],依然是以 i - 1; j - 1结尾的最长公共子序列。
* 逆推:假设我们需要求两个序列分别以 i,j 结尾的最长公共子序列F[i][j],接下来我们可以分几种情况讨论:
-A[i] 不在公共子序列中,那么长度则等于 F[i-1][j];
-B[j] 不在公共子序列中,那么长度则等于 F[i][j-1];
-A[i] 与 B[j] 都在子序列中,并且两者匹配,那么长度等于F[i-1][j-1]+1;
*我们从所有枚举的结果中找到一个最大的即可。
*逆推:假设我们需要求两个序列分别以 i,j 结尾的最长公共子序列F[i][j],可能的三种情况:
//T16:排列LCS问题(DP/LCS/50分数据规模限制)
for(int i=1;i<=n;++i)
for(intj=1;j<=n;++j)
{
//分三种情况进行讨论
f[i][j]=max(f[i-1][j],f[i][j-1]);//如果两个相同,娶一个最大值
if(p[i]==q[j])f[i][j]=max(f[i][j],f[i-1][j-1]+1);
}
int ans=f[n][n];
仅仅在这个问题中,LCS是可以转化为LIS的:
*假定某一个序列为 [1 2 3 ... N],那么答案则是另一个序列的 LIS;(因为满足严格的单调性质)
-3 2 1 4 5
-1 2 3 4 5
*但如果两个序列都不是 [1 2 3 ... N] 呢?通过转化使一个序列变成它(过程为:我们把第一个序列的第一个数5变成1,第二个序列的1变成5;把第一个序列的第二个数3变为2,第二个序列的2变为3,以此类推...),而答案不变。
-5 3 4 1 2 -> 1 2 3 4 5
-3 5 1 2 4 -> 2 1 4 5 3
这种转换只能用在所给序列有不重复元素,长度相同,如果不满足,就会出现重复,会出错。
问题变形:
经典例题:字串距离(Luogu 1279)
*设有字符串X,我们称在 X 的头尾及中间插入任意多个空格后构成的新字符串为 X 的扩展串,如字符串X 为” abcbcd”,则字符串“abcb□cd”,“□a□bcbcd□”和“abcb□cd□”都是 X 的扩展串,这里“□”代表空格字符。
*如果 A1是字符串A 的扩展串, B1 是字符串 B 的扩展串, A1 与 B1具有相同的长度,那么我扪定义字符串 A1 与 B1 的距离为相应位置上的字符的距离总和,而两个非空格字符的距离定义为它们的 ASCII 码的的字符的距离总和,而两个非空格字符的距离定义为它们的 ASCII 码的差的绝对值,而空格字符与其他任意字符之间的距离为已知的定值 K,空格字符与空格字符的距离为 0。在字符串 A、 B 的所有扩展串中,必定存在两个等长的扩展串 A1、 B1,使得 A1 与 B1 之间的距离达到最小,我们将这一距离定义为字符串A、 B 的距离。
*请你写一个程序,求出字符串 A、 B 的距离。
-cmc
-snmn
-2
-距离为:10
*状态设计:仿照最长公共子序列,我们设计状态 F[i][j] 为前一个序列以 i 结尾,后一个序列以 j 结尾的最小距离;同样也有以下三种情况
-A[i] 与空格匹配:距离为 F[i-1][j] + K; (K 为到空格的距离)
-B[j] 与空格匹配:距离为 F[i][j-1] + K;
-A[i] 与 B[j] 匹配:距离为 F[i-1][j-1] + |A[i] - B[j]|;(ASCII 码的差的绝对值)
//T17:字串距离(DP/LCS)
//初值:在遇到最小值的问题,一定要小心初值的处理
f[0][0]=0;
for(int i=1;i<=n;++i) f[i][0]=i*k;
for(int j=1;j<=m;++j) f[0][j]=j*k;
for(int i=1;i<=n;++i)//n为第一个串的长度
for(int j=1;j<=m;++j)//m为第二个串的长度
{
//三种情况
f[i][j]=min(f[i-1][j],f[i][j-1])+k;
f[i][j]=min(f[i][j],f[i-1][j-1]+abs(p[i-1]-q[j-1]));
}
int ans=f[n][m];
8、二维平面问题
经典例题:家的范围(Luogu 2733)
农民约翰在一片边长是 N (2 <= N <= 250) 英里的正方形牧场上放牧他的奶牛。 (因为一些原因,他的奶牛只在正方形的牧场上吃草。 ) 遗憾的是, 他的奶牛已经毁坏一些土地。 ( 一些 1 平方英里的正方形)
农民约翰需要统计那些可以放牧奶牛的正方形牧场 (至少是 2x2 的, 在这些较大的正方形中没有一个点是被破坏的,也就是说,所有的点都是“ 1” )。
你的工作要在被供应的数据组里面统计所有不同的正方形放牧区域(>=2x2) 的个数。当然,放牧区域可能是重叠。
如图所示:以2、3、4为边长的正方形共有10、4、1个
不妨转换一下思路:我们只考虑以(x,y)这个点能构成的最大正方形是到多大?这样把问题化简就行了。
*状态设计:设状态 F[i][j] 为以 (i,j) 位置为右下角,最大的一个正方形区域。
考虑位置F[i-1][j] 与 F[i][j-1];
-则有F[i][j] <= F[i-1][j] + 1; F[i][j] <= F[i][j-1] + 1;
上图中,*号位置(i,j)的大小由上方和左方决定,*号位置的F[i][j]=4而≠5,因为*号的值不能超过4也不能超过5,所以只能取4,要取F[i-1][j] + 1、F[i][j-1] + 1两者中较小的一个。
很明显, F[i][j-1] 与 F[i-1][j] 中较小值限制了最大区域的边长。
*但在某些情况下,仅考虑 F[i][j-1] 与 F[i-1][j] 显然不全面,还需要考虑 F[i-1][j-1]。
*所以我们可以得到最终的表达式: F[i][j] = MIN(F[i-1][j],F[i][j-1], F[i-1][j-1]) + 1 (当 (i,j) 不为障碍时)
//T18:家的范围(DP)
//边界初值
for(int i=0;i
for(intj=0;j
for(inti=1;i
for(intj=1;j
if(a[i][j]=='1')//如果是非障碍
{//计算
f[i][j]=min(min(f[i-1][j],f[i][j-1]),f[i-1][j-1])+1;
t[f[i][j]]++;//当前这个规格为i的正方形的个数++
}
for(int i=n;i;i--)/*统计所有方形的数目——(t[i]已经算出来了以(i,j)这个点为右下角能够成的最大正方形总数,但是题目中要求总和,所以这个for就是求以(i,j)为右下角的正方形总数),例如:以(i,j)为右下角的4*4正方形可以提供一个以(i,j)为右下角的3*3正方形,一个以(i,j)为右下角的2*2正方形…以此类推,倒着累加即可*/
t[i-1]+=t[i];
for(int i=2;i<=n;++i)
if(t[i])//输出结果
cout<
9、区间动态规划
经典例题:游戏 A Game(Luogu 2734)
有如下一个双人游戏:N(2 <= N <= 100) 个正整数的序列放在一个游戏平台上,游戏由玩家 1 开始,两人轮流从序列的任意一端取一个数,取数后该数字被去掉并累加到本玩家的得分中,当数取尽时,游戏结束。以最终得分多者为胜。
编一个执行最优策略的程序,最优策略就是使玩家在与最好的对手对弈时,能得到的在当前情况下最大的可能的总分的策略。你的程序要始终为第二位玩家执行最优策略。
*状态设计方法为: F[i][j] 表示假若只用第 i 个数与第 j 个数之间的数进行游戏,先手能获得的最高的分为多少。
*我们可以根据先手取左边还是取右边来决定哪种情况得分高。
!当先手取完一件后,先后手发生交换,而问题是类似的,如下图:
在左图中,我们已经计算出了7、2、9、5、2中先手能获得的最大值为14,后手为11,当先手取第一个数4时,先手=后手+4,后手=先手(发生了交换)。
同样地,在右图中,我们已经计算出了4、7、2、9、5中先手能获得的最大值为11,后手为16,当先手取最后一个数2时,先手=后手+2,后手=先手(同样发生了当前max值的交换)。
-考虑:如何决定状态转移顺序?先算哪些,后算哪些?如何书写状态转移方程?
在例子中,我们要计算长度为6的区间内的最优值,我们必须要算出左边长度为5的、右边长度为5的两个区间的最优值,所以可以先算长度为1的区间,再算长度为2的区间,再算长度为3的区间…就可以得到最大区间最优值。
//T19:游戏
cin>>n;
for(int i=1;i<=n;++i)
cin>>a[i],
s[i]=s[i-1]+a[i];//s代表前缀和
for(int i=1;i<=n;++i) f[i][i]=a[i];//初值(先手最后只剩下一个数,就是我自己)
for(int k=2;k<=n;++k)//按照序列的长度进行枚举(k为区间长度)
for(inti=1,j;i+k-1<=n;++i)
{
j=i+k-1;
f[i][j]=max(s[j]-s[i]-f[i+1][j]+a[i],s[j-1]-s[i-1]-f[i][j-1]+a[j]);//两者取较大值=max(先取最左边先手最大得分,先取最右边先手最大得分)
} //左边总和-先手玩家得分+自己当前取得的分数=总得分
cout<
经典例题:加分二叉树(Luogu 1040)
设一个 n 个节点的二叉树 tree 的中序遍历为( 1,2,3,…,n),其中数字 1,2,3,…,n 为节点编号。每个节点都有一个分数(均为正整数),记第 i 个节点的分数为 di, tree 及它的每个子树都有一个加分,任一棵子树subtree(也包含 tree 本身)的加分计算方法如下:
subtree 的左子树的加分 × subtree 的右子树的加分+ subtree 的根的分数。
若某个子树为空,规定其加分为 1,叶子的加分就是叶节点本身的分数,不考虑它的空子树。
试求一棵符合中序遍历为( 1,2,3,…,n)且加分最高的二叉树 tree。要求输出;
1.tree的最高加分:
2.tree的前序遍历:
-5 7 1 2 10
-答案 1: 145
-答案 2: 3 1 2 4 5
设 F[i][j] 为只用第 i 个数到第 j 个数构成的加分树的最大权值。下图为样例解释:
牢记一个二叉树的性质:中序遍历时候,左右子树一定在根节点左右两边
*枚举根节点,这样就化成了左子树和右子树的问题,求最优解即可。
*F[i][j] = MAX ( F[i][k-1] * F[k+1][j] + A[k] )(左×右+根k自己本身权值)
//T25:加分二叉树
for(int i=1;i<=n;++i) f[i][i]=a[i];//赋初值(只有一个叶子节点,根就是自己)
for(int i=0;i<=n;++i) f[i+1][i]=1;
for(int k=1;k
for(inti=1;i+k<=n;++i)
{
int j=i+k;
for(intl=i;l<=j;++l) f[i][j]=max(f[i][j],f[i][l-1]*f[l+1][j]+a[l]);//枚举根节点
}
int ans=f[1][n];
*问题:如何求出树的前序遍历(树的形态)?
我们另外记录一个辅助数组 G[i][j],代表 F[i][j] 取最大值的时候,根节点是什么,这样就可以通过递归来求出树的前序遍历。
for(int i=1;i<=n;++i) f[i][i]=a[i],g[i][i]=i;
//边界值(只有一个叶子节点,根就是自己)
for(int i=0;i<=n;++i) f[i+1][i]=1;
//预处理空节点,保证不出错,一个根节点没有左子树,把左子树标记为1
for(int k=1;k
for(int i=1;i+k<=n;++i)
{
int j=i+k;//j:末尾节点
for(intl=i;l<=j;++l)
{
long longt=f[i][l-1]*f[l+1][j]+a[l];
if(t>f[i][j])//记录最优的根
{
f[i][j]=t;
g[i][j]=l;
}
}
}
//T25:加分二叉树
//递归输出x到y这个树的前缀遍历
void dfs(int x,int y)
{
if(x>y) return;
int l=g[x][y];//l为根
cout<
/*=====================*///再输出子树的值
dfs(x,l-1);//左
dfs(l+1,y);//右
/*=====================*/
}
...
//输出答案——整棵树
dfs(1,n);
10、过程型状态划分
经典例题:传球游戏(Luogu 1057)
上体育课的时候,小蛮的老师经常带着同学们一起做游戏。这次,老师带着同学们一起做传球游戏。
游戏规则是这样的: n 个同学站成一个圆圈,其中的一个同学手里拿着一个球,当老师吹哨子时开始传球,每个同学可以把球传给自己左右的两个同学中的一个(左右任意),当老师在此吹哨子时,传球停止,此时,拿着球没有传出去的那个同学就是败者,要给大家表演一个节目。
聪明的小蛮提出一个有趣的问题:有多少种不同的传球方法可以使得从小蛮手里开始传的球,传了 m 次以后,又回到小蛮手里。两种传球方法被视作不同的方法,当且仅当这两种方法中,接到球的同学按接球顺序组成的序列是不同的。比如有三个同学 1 号、 2 号、 3 号,并假设小蛮为 1号,球传了 3 次回到小蛮手里的方式有 1->2->3->1 和 1->3->2->1,共 2 种。
*分析:这道题目十分容易用搜索解决。为什么?
*因为题目已经明确给定了过程:传球的次数。
*因为这个过程是一定按照顺序进行的,所以可以直接写出状态:
*设状态F[i][j] 为传到第 i 次,现在在第 j 个人手上的方案数。
*很显然F[i] 只和 F[i-1] 有关;因为题目已经规定好了传递顺序。
!这一类的过程型问题只需要找出事情发展的顺序,就可以很简单的写出状态与转移方程。
//T26:传球游戏
f[0][1]=1;//赋初值
for(int i=1;i<=m;++i)
{
f[i][1]=f[i-1][2]+f[i-1][n];//头与尾需要特殊处理(第1号从2号和n号传过来)
f[i][n]=f[i-1][n-1]+f[i-1][1];
for(intj=2;j
f[i][j]=f[i-1][j-1]+f[i-1][j+1];//可以在j-1、j+1手里(加法原理)
}
int ans=f[m][1];
经典例题:乌龟棋(Luogu 1541)
乌龟棋的棋盘是一行 N 个格子,每个格子上一个分数(非负整数)。棋盘第 1 格是唯一的起点,第 N 格是终点,游戏要求玩家控制一个乌龟棋子从起点出发走到终点。
乌龟棋中 M 张爬行卡片,分成 4 种不同的类型( M 张卡片中不一定包含所有 4 种类型的卡片,见样例),每种类型的卡片上分别标有 1、 2、 3、4 四个数字之一,表示使用这种卡片后,乌龟棋子将向前爬行相应的格子数。游戏中,玩家每次需要从所有的爬行卡片中选择一张之前没有使用过的爬行卡片,控制乌龟棋子前进相应的格子数,每张卡片只能使用一次。
游戏中,乌龟棋子自动获得起点格子的分数,并且在后续的爬行中每到达一个格子,就得到该格子相应的分数。玩家最终游戏得分就是乌龟棋子从起点到终点过程中到过的所有格子的分数总和。
-棋盘: 6 10 14 2 8 8 18 5 17
-卡片: 13 1 2 1
-答案:73 = 6+10+14+8+18+17
很明显,用不同的爬行卡片使用顺序会使得最终游戏的得分不同,小明想要找到一种卡片使用顺序使得最终游戏得分最多。现在,告诉你棋盘上每个格子的分数和所有的爬行卡片,你能告诉小明,他最多能得到多少分吗?
*思考:如何进行搜索?状态该如何设计?
*DFS(x,c1,c2,c3,c4) 为当前在第 x 个格子上, ci 代表标有数字i 的卡片有多少张。
*于是可以直接写出状态 F[i][a][b][c][d],与状态是一一对应的,表示该状态下,最大的权值是多少(当前第i个格子,四种牌分别用了i,j,k,l张的情况下,能达到的最优值)。
*于是,可以和F[i-1],F[i-2]…进行联系
//T27:乌龟棋(我把i省略了,其实没什么用,加上只是用来判断是否越界而已)
for(int i=0;i<=a;i++)
for(intj=0;j<=b;j++)
for(intk=0;k<=c;k++)
for(intl=0;l<=d;l++)
{
if(i!=0)f[i][j][k][l]=max(f[i][j][k][l],f[i-1][j][k][l]);
if(j!=0)f[i][j][k][l]=max(f[i][j][k][l],f[i][j-1][k][l]);
if(k!=0)f[i][j][k][l]=max(f[i][j][k][l],f[i][j][k-1][l]);
if(l!=0)f[i][j][k][l]=max(f[i][j][k][l],f[i][j][k][l-1]);
f[i][j][k][l]+=s[i+j*2+k*3+l*4];
}
11、序列型状态划分
经典例题:乘积最大(Luogu 1018)
*设有一个长度为N 的数字串,要求选手使用 K 个乘号将它分成 K+1 个部分,找出一种分法,使得这 K+1个部分的乘积能够为最大。
*例如,有一个数字串: 312,当 N=3, K=1 时会有以下两种分法:
1 3×12=36
2 31×2=62
*符合题目要求的结果是: 31×2=62
*现在,请你帮助你的好朋友 XZ 设计一个程序,求得正确的答案。
*题目中要求把整个序列有序地切分成 K+1 个部分。
*状态设计:设我们把前 F[i][j] 为前 i 个数字切成 j 部分所能得到的最大乘积。
*很显然这时候我们只需要通过枚举最后一部分的数字有多大,就能得到结果乘积了。(满足最优性)
//T20:乘积最大(DP)
++k;//K+1
f[0][0]=1;//初值
for(int i=1;i<=n;++i)//枚举长度
for(intj=1;j<=k;++j)//枚举切割部分
for(intl=0;l枚举前一块的最后位置(可以从0开始)
f[i][j]=max(f[i][j],f[l][j-1]*val(l+1,i));//val(a,b)表示a~b之间形成的数
cout<
为什么不设计状态为 F[i][j][k],表示把 i 到 j 的数字切成 k 块?
因为不需要求出中间一块要被一个*号截开的情况,没必要求出中间一块[i,j]的最大乘积。
经典例题:数字游戏(Luogu 1043)
丁丁最近沉迷于一个数字游戏之中。这个游戏看似简单,但丁丁在研究了许多天之后却发觉原来在简单的规则下想要赢得这个游戏并不那么容易。游戏是这样的,在你面前有一圈整数(一共 n 个),你要按顺序将其分为m 个部分,各部分内的数字相加,相加所得的 m 个结果对 10 取模后再相乘,最终得到一个数 k。游戏的要求是使你所得的 k 最大或者最小。
例如,对于下面这圈数字( n=4, m=2):
要求最小值时, ((2-1) mod 10)×((4+3) mod 10)=1×7=7,要求最大值时,为 ((2+4+3) mod 10)×(-1 mod10)=9×9=81。特别值得注意的是,无论是负数还是正数,对 10 取模的结果均为非负值。
*题目中要求把整个环形序列切分成 m 个部分;先不考虑环形的问题,假若是对于一个线形序列,如何做?
*状态设计:参考前一道题目的做法,设我们把前 F[i][j] 为前 i 个数字切成 j 部分所能得到的最大乘积。
*很显然这时候我们只需要通过枚举最后一部分的数字有多大,就能得到结果乘积了。 (满足最优性)
*环形序列:第一个位置不一定是开头,有可能位于序列的中间。
*解决方法:枚举每一个位置,把它当作开头算一遍,得到的结果取最大值即为答案。
当出现循环节时候,我们就可以用“复制一遍序列”的方法来做,这样就可以实现头尾相接,如下图:
对于每一条链,像上一题那样做就OK!
//T21:数字游戏(DP)
void work(int *a)//预处理过程
{
s[0]=0;//预处理前缀和
for(inti=1;i<=n;++i) s[i]=s[i-1]+a[i];
for(inti=0;i<=n;++i)
for(intj=0;j<=m;++j)
f[i][j]=0,g[i][j]=INF;//初值
f[0][0]=g[0][0]=1;//初值
...//DP过程
}
//T21:数字游戏(DP)
void work(int *a)
{
...//初值,预处理
for(inti=1;i<=n;++i)
for(intj=1;j<=m;++j)
for(int k=0;k分别计算最大值和最小值
{
//问题:为什么f不用判断而g需要判断,最大值的话也是不合法的,但是对答案没啥影响,因为0肯定不是最大值
f[i][j]=max(f[i][j],f[k][j-1]*(((s[i]-s[k])%10+10)%10));
if(g[k][j-1]!=INF)
//g[k][j-1]=INF代表前k个不能切成j-1个部分,当i到k这一段为0的时候,前面一段的最小值=inf会有问题
g[i][j]=min(g[i][j],g[k][j-1]*(((s[i]-s[k])%10+10)%10));
}
amax=max(amax,f[n][m]);//全局最大值
amin=min(amin,g[n][m]);//全局最小值
}
//T21:数字游戏(DP)
//主程序
cin>>n>>m;
for(int i=1;i<=n;++i) cin>>a[i],a[n+i]=a[i];//复制一遍
amax=0;
amin=INF;//初值
for(int i=1;i<=n;++i)//以每个位置开始计算(以a[i-1]为开头的数组(指针)保证枚举每一条链)
{
work(a+i-1);
}
cout<
经典例题:能量项链(Luogu 1063)
在 Mars 星球上,每个 Mars 人都随身佩带着一串能量项链。在项链上有 N 颗能量珠。能量珠是一颗有头标记与尾标记的珠子,这些标记对应着某个正整数。并且,对于相邻的两颗珠子,前一颗珠子的尾标记一定等于后一颗珠子的头标记。因为只有这样,通过吸盘(吸盘是 Mars 人吸收能量的一种器官)的作用,这两颗珠子才能聚合成一颗珠子,同时释放出可以被吸盘吸收的能量。如果前一颗能量珠的头标记为 m,尾标记为r,后一颗能量珠的头标记为 r,尾标记为 n,则聚合后释放的能量为m*r*n( Mars 单位),新产生的珠子的头标记为 m,尾标记为 n。
需要时, Mars 人就用吸盘夹住相邻的两颗珠子,通过聚合得到能量,直到项链上只剩下一颗珠子为止。显然,不同的聚合顺序得到的总能量是不同的,请你设计一个聚合顺序,使一串项链释放出的总能量最大。
例如:设 N=4, 4 颗珠子的头标记与尾标记依次为 (2, 3) (3, 5) (5,10) (10, 2)。我们用记号⊕表示两颗珠子的聚合操作, (j⊕k)表示第 j, k 两颗珠子聚合后所释放的能量。则第 4、 1 两颗珠子聚合后释放的能量为:
-(4⊕1)=10*2*3=60
*这一串项链可以得到最优值的一个聚合顺序所释放的总能量为:
-((4⊕1)⊕2)⊕3) =10*2*3+10*3*5+10*5*10=710
*环形序列:枚举每一个位置,把它当作开头算一遍,得到的结果取最大值即为答案。
*那么我们只需要考虑线性序列的问题了。
如上题做法,我们还是把这个序列复制一遍,最后剩下的珠子为最后一个数
*状态设计:设F[i][j] 为把 i 到 j 之间的珠子合并起来,所能释放的最大能量。
*问题:为什么不能只设 F[i] 代表前 i 个珠子合并的最大能量。
-因为和合并的方式有关。
*状态转移:枚举最后一颗和 i, j 一同合并的珠子 k。
//T22:能量项链(DP)
int work(int *a)//对一段线性序列进行DP(枚举i~j合并起来,再枚举中间的k来合并)
{
for(inti=0;i<=n;++i)
for(intj=0;j<=n;++j) f[i][j]=0;
for(ints=2;s<=n;++s)//动态规划(从2开始——最少三个进行合并)
for(inti=0;i+s<=n;++i)
{
int j=i+s;
for(int k=i+1;k
f[i][j]=max(f[i][j],f[i][k]+f[k][j]+a[i]*a[k]*a[j]);
}
return f[0][n];//答案
}
经典例题:摆花(Luogu 1077)
小明的花店新开张,为了吸引顾客,他想在花店的门口摆上一排花,共 m盆。通过调查顾客的喜好,小明列出了顾客最喜欢的 n 种花,从 1 到n 标号。为了在门口展出更多种花,规定第 i 种花不能超过 ai 盆,摆花时同一种花放在一起,且不同种类的花需按标号的从小到大的顺序依次摆列。
试编程计算,一共有多少种不同的摆花方案。
-2 种花,要摆 4 盆
-第一种花不超过3 盆,第二种花不超过 2 盆
-答案: 2
*状态设计:设F[i][j] 为摆到第 i 种花,共摆了 j 盆,的总方案数(枚举最后一种花摆了多少盆,往前推)
//T24:摆花(DP)时间复杂度:O(nm^2) 用前缀和可以优化为O(m)
f[0][0]=1;
for(int i=1;i<=n;++i)
for(int j=0;j<=m;++j)
for(intk=0;k<=j&&k<=a[i];++k)
f[i][j]=(f[i][j]+f[i-1][j-k])%MOD;
int ans=f[n][m];
经典例题:书本整理(Luogu 1103)
Frank 是一个非常喜爱整洁的人。他有一大堆书和一个书架,想要把书放在书架上。书架可以放下所有的书,所以 Frank 首先将书按高度顺序排列在书架上。但是 Frank 发现,由于很多书的宽度不同,所以书看起来还是非常不整齐。于是他决定从中拿掉 k 本书,使得书架可以看起来整齐一点。
书架的不整齐度是这样定义的:每两本书宽度的差的绝对值的和。例如有4 本书:
-1x2 5x3 2x4 3x1 那么 Frank 将其排列整齐后是:
-1x2 2x4 3x1 5x3 不整齐度就是 2+3+2=7
-已知每本书的高度都不一样,请你求出去掉 k 本书后的最小的不整齐度。
*第一步:先把书本按照高度排序!
*状态设计:从N 本书选出 N-k 本书;设 F[i][j] 为从前 i 本书选出j 本书的最小的不整齐度。
*状态转移:讨论第 i 本书选不选?
*上一种方法行不通!
-为什么?我们需要计算选出相邻两本书之间的宽度的差的绝对值,而我们讨论第i本书选不选是算不出相邻两本书的不整齐度,因为你不知道上一本你选了哪本。
*状态设计:从N 本书选出 N-k 本书;设 F[i][j] 为从前 i 本书选出j 本书的最小的不整齐度,并且第 i 本书必须要选。
*状态转移:上一本书选的是什么?
//T23:书本整理(DP)
int m=n-k;//共选出m本书
for(int i=1;i<=n;++i)
for(int j=1;j<=m;++j)
f[i][j]=INF;//初值
for(int i=1;i<=n;++i) f[i][1]=0;//初值,第一本书没有前一本,为0
for(int i=2;i<=n;++i)
for(intj=2;j<=m;++j)
for(intl=1;l为上一本书
f[i][j]=min(f[i][j],f[l][j-1]+abs(a[i]-a[l]));
int ans=INF;
for(int i=1;i<=n;++i) ans=min(ans,f[i][m]);//答案:最后一本书可以是任意一本
12、树形DP
经典例题:奶牛家谱(Luogu 1472)
农民约翰准备购买一群新奶牛。在这个新的奶牛群中, 每一个母亲奶牛都生两个小奶牛。这些奶牛间的关系可以用二叉树来表示。这些二叉树总共有 N 个节点 (3 <= N < 200)。这些二叉树有如下性质:
每一个节点的度是 0 或 2。度是这个节点的孩子的数目。
树的高度等于 K(1 < K < 100)。高度是从根到最远的那个叶子所需要经过的结点数; 叶子是指没有孩子的节点。
有多少不同的家谱结构? 如果一个家谱的树结构不同于另一个的, 那么这两个家谱就是不同的。输出可能的家谱树的个数除以 9901 的余数。
-5 个节点,高度为 3
-答案:可能的家谱树个数为 2
*状态设计:看题说话(十分简单),设 F[i][j] 为 i 个节点高度为 j 的树,一共有多少种方案。
-问题:如何进行状态转移?
-分成左右两棵子树。
*状态转移:枚举子树的状态
-限制:注意要满足深度的限制
*状态转移:F[i][j] = ∑ (k 为左子树大小, l 为右子树深度)+ F[k][j-1] * F[i-k-1][l] * 2 (l< j-1) (左右子树只有一棵深度为 j-1,直接翻倍)+ F[k][j-1] *F[i-k-1][j-1] (左右子树深度均为 j-1,不重复计算)
注意:在状态转移方程中:当我们左右子树的深度相同的话f[i][j] += f[k][j-1] * f[i-k-1][j-1] ,当左右子树深度不同的话我们可以截取到深度相同的部分,把下面所有往深度小的移(如上图右所示,把紫色树红线下方的子树全部移到右边去,变成红树),所以ans×2。
//T28:奶牛家谱(DP)时间复杂度:O(n^4)
f[1][1]=1;//初值,深度为1的子树只有一种情况
for(int i=3;i<=n;++i)
for(intj=2;j<=m;++j)
for(intk=1;k
{
for(intl=1;l
f[i][j]=(f[i][j]+f[k][j-1]*f[i-k-1][l]*2%MOD)%MOD;//左子树方案数*右子树方案数(乘法原理)
f[i][j]=(f[i][j]+f[k][j-1]*f[i-k-1][j-1]%MOD)%MOD;//左右子树深度相同,均为j-1
}
int ans=f[n][m];//答案以是任意一本
//T28:奶牛家谱(DP/前缀和优化)时间复杂度:O(n^3)
f[1][1]=g[1][1]=1;
for(int j=2;j<=m;++j) g[1][j]=1;//前缀和初始化
for(int i=3;i<=n;++i)
for(intj=2;j<=m;++j)
{
for(intk=1;k注意到把深度小于j-1的方案全部加起来利用前缀和可以略去枚举过程
{
f[i][j]=(f[i][j]+f[k][j-1]*g[i-k-1][j-2]*2%MOD)%MOD;
f[i][j]=(f[i][j]+f[k][j-1]*f[i-k-1][j-1]%MOD)%MOD;
}
g[i][j]=(g[i][j-1]+f[i][j])%MOD;//前缀和计算
}
int ans=f[n][m];
经典例题:最大子树和(Luogu 1122)
一株奇怪的花卉,上面共连有 N 朵花,共有 N-1 条枝干将花儿连在一起,并且未修剪时每朵花都不是孤立的。每朵花都有一个“美丽指数”,该数越大说明这朵花越漂亮,也有“美丽指数”为负数的,说明这朵花看着都让人恶心。所谓“修剪”,意为:去掉其中的一条枝条,这样一株花就成了两株,扔掉其中一株。经过一系列“修剪“之后,还剩下最后一株花(也可能是一朵)。老师的任务就是:通过一系列“修剪”(也可以什么“修剪”都不进行),使剩下的那株(那朵)花卉上所有花朵的“美丽指数”之和最大。
*样例如下图(红色树即为答案):
*问题转化:在这棵树中取出若干个连通的节点,使得权值之和最大。
*观察:如下图,根节点为 0。假如我们必须要取根节点 0;同时它有三个儿子,权值分别为 2, 0, -3;则我们能取得的最大的权值是多少?
-贪心地,我们只取不小于 0 的节点。(思路类似于求最大子段和)
*算法思想:贪心的只取不小于 0 的儿子。
*状态设计:设F[i] 为只考虑以 i 为根的这棵子树,并且必定要取 i 这个点,可能达到的最大权值是多少。
*状态转移:把儿子中 F[x] 大于 0 的加起来即可。
//T29:最大子树和(DP)
void dfs(int x,int y=0)//y代表父亲节点(当传入参数是一个时,y默认为0,当传入两个参数,缺省参数y才会被修改)
{
f[x]=a[x];//x节点必取
for(unsignedi=0;i
{
int u=e[x][i];
if(u==y)continue;//当u不为父亲节点
dfs(u,x);//递归求解儿子节点的f值
if(f[u]>=0)f[x]+=f[u];//当儿子权值大于0,则加上
}
}
//T29:最大子树和(DP)
//主程序
cin>>n;
for(int i=1;i<=n;++i) cin>>a[i];
for(int i=1,x,y;i
{
cin>>x>>y;//树边
e[x].push_back(y);//增加树边
e[y].push_back(x);//增加树边
}
dfs(1);//递归求解f值
int ans=a[1];
for(int i=1;i<=n;++i) ans=max(ans,f[i]);//答案取最大的一个
cout<
经典例题:选课(Luogu 2014)
在大学里每个学生,为了达到一定的学分,必须从很多课程里选择一些课程来学习,在课程里有些课程必须在某些课程之前学习,如高等数学总是在其它课程之前学习。现在有 N 门功课,每门课有个学分,每门课有一门或没有直接先修课(若课程 a 是课程 b 的先修课即只有学完了课程a,才能学习课程 b)。一个学生要从这些课程里选择 M 门课程学习,问他能获得的最大学分是多少?
*下图所示:每个节点代表一节课;父亲代表他的先修课;红颜色代表选上的课。左边是一种合法的选课方式;而右边则是一种不合法的选课方式,2 的先修课 1 没有选上。
*问题观察:我们可以观察得到,根节点的课一定是要选的;并且选的节点是和根节点联通的。 (否则不符合选课规则)
*状态设计:设F[i][j] 为,对于每节点 i,只考虑自己的子树,一共选了 j 节课,所能得到的最大权值是多少。假设i为根,并且包括i(根节点一定要选)
*状态转移:假设i 只有两棵子树,那么我们可以枚举在其中一棵子树中,我们一共选了几门课:
-F[i][j] = MAX (F[u][k] + F[v][j-k-1]) + A[i](意思是:我一定要选第i节课并且选了j节课的最优值A[i]+MAX(只考虑u这个节点并且u肯定是要选的,选了k节课的最优值,只考虑v这个节点并且v肯定是要选的,选了j-k-1节课的最优值))
*状态转移:假设i 有超过两棵子树,我们可以使用逐次合并的方法:先合并前两棵子树,然后将其视作一棵,再与余下的子树继续合并。
//T30:选课(DP)时间复杂度:nm^2
void dfs(int x)//处理根节点为x的子树
{
int *dp=f[x];//小技巧,用dp来代替f[x]数组
for(unsignedi=0;i
{
int u=e[x][i];
dfs(u);//处理儿子节点的子树
//合并操作
//将已经合并的子树信息存放到dp数组当中
for(intj=0;j<=m;++j) tp[j]=0;//tp:临时数组
for(intj=0;j<=m;++j)//从已经合并的选j门
for(intk=0;j+k<=m;++k)//从新加入的u子树中选k门
tp[j+k]=max(tp[j+k],dp[j]+f[u][k]);//一共选了j+k节课选一个最大的->已经选了j门,要在新的子树中选k门
for(intj=0;j<=m;++j) dp[j]=tp[j];//复制过来(这样遍历完就是从所有子树中选j门课一共可以得到的学分最多是多少)
}
...
}
//T30:选课(DP)
void dfs(int x)//处理根节点为x的子树
{
...
//必须要选根节点这一门
for(int j=m;j;--j)dp[j]=dp[j-1]+a[x];//新的第j门=在众多子树中选了j-1门+当前
dp[0]=0;
}
//T30:选课(DP)
//主程序
cin>>n>>m,++m;
for(int i=1,fa;i<=n;++i)
cin>>fa>>a[i],e[fa].push_back(i);
//我们设所有没有先修课的父亲为0号
//这样结果是一样的,而且必须要选0号课程
dfs(0);//根节点出发,递归
cout<
13、状态压缩DP
*状态压缩DP:利用位运算来记录状态,并实现动态规划。
*问题特点:数据规模较小;不能使用简单的算法解决。
*给定两个布尔变量a,b,他们之间可进行布尔运算:
*与运算and:当 a,b 均为真的时候, a 与 b为真;
*或运算or:当 a,b 均为假的时候, a 或 b为假;
*非运算not:当 a 均为真的时候,非 a为假;
*异或运算xor:当 a,b 不是同时真,或者同时假的时候, a 异或 b为真。
*我们把 0视作布尔的假,1视作布尔的真,则整数亦存在二进制的运算,而运算的结果则是二进制里对应的位作相应的布尔操作。(按位运算)
*(23)10 = (10111)2,(10)10 = (1010)2;
*与运算and: 23 and 10 = (10)2 = 2
*或运算or: 23 or 10 = (11111)2 = 31
*异或运算xor: 23 xor 10 = (11101)2 = 29
*(23)10 = (10111)2,(10)10 = (1010)2;
*左移 «:将整个二进制向左移动若干位,并用 0 补充;
-10 << 1 = (10100)2 = 20(左移n位=乘以2n)
*右移 »:将整个二进制向右移动若干位(实际最右边的几位就消失了);
-23 >> 2 = (101)2 = 5(右移n位=除以2n)
位运算比加减乘除都快!
经典例题:玉米田(Luogu 1879)
农场主 John 新买了一块长方形的新牧场,这块牧场被划分成 M 行 N列 (1 ≤ M ≤ 12; 1 ≤ N ≤ 12),每一格都是一块正方形的土地。 John打算在牧场上的某几格里种上美味的草,供他的奶牛们享用。
遗憾的是,有些土地相当贫瘠,不能用来种草。并且,奶牛们喜欢独占一块草地的感觉,于是 John 不会选择两块相邻的土地,也就是说,没有哪两块草地有公共边。
John 想知道,如果不考虑草地的总块数,那么,一共有多少种种植方案可供他选择?(当然,把新牧场完全荒废也是一种方案)
-1 1 1
-0 1 0 (1 代表这块土地适合种草)
-总方案数:9
*问题限制:没有哪两块草地有公共边
*考虑动态规划:如何划分状态?
-从上到下,从左到右?
-记录F[i][j] 为 (i,j) 选择在这个位置种草的方案数?
-问题:如何转移?我们只能限制左边的位置不能种草;不能限制上面的位置不能种草,所以这样不可行!
*划分状态:考虑用行来划分状态;第 i 行的状态只取决于第 i-1 行。
*状态表示:如何表示一行的种草状态?
*二进制位:将一整行看作是一个大的二进制数,其上为 1 表示种了草, 0表示没有种草,最好不用二维数组,因为行列数目不定,不好开数组。
*这样,我们可以用一个数来表示一行的种草状态;
例如:下图中,在一行的第一个位置和第四个位置种草,可以用18来表示这种状态:
*状态设计:设F[i][j] 为已经处理到第 i 行,且第 i 行的状态是 j,的总方案数。 (其中 j 为一个大的二进制数)
*状态转移:枚举前一行的种草状态 k,如果没有产生冲突,则加上:
F[i][j]= ∑ F[i − 1][k]
*问题:如何判断两个状态是否冲突?如何运用二进制运算?
*解决方案:采取与运算;如果两个状态是冲突的,则肯定有一位上都为1, and 的结果肯定不为 0;否则若结果为 0,则代表状态不冲突。
*下一个问题:如何判断当前状态j是合法的?
1没有占有障碍物:和判断两个状态是否冲突是类似的。
2左右位置判断:与上一行判断冲突,我们只考虑到了上下位置不可能同时种草,但还没有考虑左右的情况。
-解决方案:j & (j«1) = 0 且 j & (j»1) = 0(我只想说:左右两个相互叠加判断,真TM聪明!)
//T31:玉米田(状态压缩DP)
const int N=13;//边长
const int S=(1<
int a[N][N];//棋盘数组
int s[N];//每一行空地的状态
int f[N][S];//动态规划数组
int g[S];//标记一个状态是否合法
//主过程
ts=1<
for(int i=0;i
g[i]=((i&(i<<1))==0)&&((i&(i>>1))==0);//左右位置是否有冲突
//T31:玉米田(状态压缩DP)
f[0][0]=1;//初值!
for(int i=1;i<=n;++i)//枚举行
for(intj=0;j
if(g[j]&&((j&s[i])==j))//状态本身合法且不占障碍物
{
for(intk=0;k
if((k&j)==0)//如果不冲突
f[i][j]=(f[i][j]+f[i-1][k])%MOD;
}
int ans=0;
for(int j=0;j
ans=(ans+f[n][j])%MOD;//答案加起来
//T31:玉米田(状态压缩DP)