学习笔记——动态规划
大坑系列
动态规划 = 推式子 + 初始化 + 各种优化
普通dp
1.背包dp
背包大致有几种:
<1> 0-1背包
P1048 采药
首先我们设第 \(i\) 个“物品”的重量 \(w(i)\),价值 \(v(i)\),背包的容纳量为 \(W\),很容易发现这个状态转移就是“拿与不拿,买与不买”的问题,设置 \(dp_{i,j}\) 表示前 \(i\) 个物品中代价为 \(j\) 时所能得到最大的价值,既然由买与不买转换而来就能够得到:
\[dp_{i,j}=\max(dp_{i-1,j},dp_{i-1,j-w(i)}+v(i)) \]代码实现为:
int main(){
m=read(),n=read();
for(int i=1;i<=n;i++){
w[i]=read(),v[i]=read();
}
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
if(w[i]>j){
dp[i][j]=dp[i-1][j];
}
else{
dp[i][j]=max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]);
}
}
}
printf("%d\n",dp[n][m]);
return 0;
}
要注意到如果当前代价不够取第 \(i\) 个物品也就是没有选择时,也需要更新 \(dp_{i,j}\),可以发现空间复杂度是不够优秀的,可以把第一维滚掉。就变成了:
\[dp_j=\max(dp_j,dp_{j-w(i)}+v(i)) \]注意这里的第二维应当使用倒序,考虑这一数组实际是在第 \(i\) 次循环是把第 \(i-1\) 次的结果对应赋值,也就是只需要对 \([w(i),W]\) 部分进行判断,那么为了防止先更新 \(dp_{j-w(i)}\) 使 \(dp_j\) 的结果错误,选择从大到小遍历,也就是倒序。
int main(){
m=read(),n=read();
for(int i=1;i<=n;i++){
w[i]=read(),v[i]=read();
}
for(int i=1;i<=n;i++){
for(int j=m;j>=w[i];j--){
dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
}
}
printf("%d\n",dp[m]);
return 0;
}
<2> 完全背包
P1616 疯狂的采药
即每种物品个数无限的背包,自然可以在0-1背包的基础上加一层枚举:
\[dp_{i,j}=\max_{k=0}^{+\infty}\{dp_{i-1,j-w(i)\times k}+v(i)\} \]时间复杂度 \(O(nmk)\) ,上文0-1背包中提到的覆盖问题,其实也可以理解成会出现多次选一种物品的情况,恰恰符合完全背包的要求,于是就有:
\[dp_j=\max(dp_j,dp_{j-w(i)}+v(i)) \]只需要把倒序改为正序。
int main(){
m=read(),n=read();
for(int i=1;i<=n;i++){
w[i]=read(),v[i]=read();
}
for(int i=1;i<=n;i++){
for(int j=w[i];j<=m;j++){
dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
}
}
printf("%d\n",dp[m]);
return 0;
}
<3> 多重背包
P1776 宝物筛选
对于每个物品 \(i\) 增加一个数量限制 \(k(i)\)。
根据完全背包的枚举式子就能得到:
\[dp_{i,j}=\max_{k=0}^{k(i)}\{dp_{i-1,j-w(i)\times k}+v(i)\} \]当然这个式子的复杂度中含有一个 \(\sum k(i)\),如何优化呢?
二进制优化
原理上可以理解成如果把一个数 \(p\) 按照二进制也就是 \(1,2,4,8,16\) 的方式拆分成 \(q\) 个,一定能通过这 \(q\) 个数相加得到 \([0,p]\) 中任意一个数,既然如此不如把这个物品看作是二进制拆分后的 \(q\) 个物品,按照0-1背包处理。
int main(){
n=read(),m=read();
for(int i=1;i<=n;i++){
int tmpv=read(),tmpw=read(),k=read();
for(int j=1;j<=k;j<<=1){
v[++cnt]=j*tmpv,w[cnt]=j*tmpw;
k-=j;
}
if(k) v[++cnt]=k*tmpv,w[cnt]=k*tmpw;
}
for(int i=1;i<=cnt;i++){
for(int j=m;j>=w[i];j--){
dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
}
}
printf("%d\n",dp[m]);
return 0;
}
<4> 混合背包
P1833 樱花
将以上三种dp混合在一起
int main(){
scanf("%d:%d%d:%d%d",&sh,&sm,&eh,&em,&n);
m=eh*60+em-sh*60-sm;
//printf("%d %d %d %d\n",sh,sm,eh,em);
for(int i=1;i<=n;i++){
int a=read(),b=read(),c=read();
if(!c) c=1e6;
for(int j=1;j<=c;j<<=1){
w[++cnt]=a*j,v[cnt]=b*j;
c-=j;
}
if(c) w[++cnt]=a*c,v[cnt]=b*c;
}
for(int i=1;i<=cnt;i++){
for(int j=m;j>=w[i];j--){
dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
}
}
printf("%d\n",dp[m]);
return 0;
}
<5> 二维费用背包
P1855 榨取kkksc03
有双重代价限制的背包dp,处理方式自然是加一层循环。
int main(){
n=read(),w1[0]=read(),w2[0]=read();
for(int i=1;i<=n;i++){
w1[i]=read(),w2[i]=read();
}
for(int i=1;i<=n;i++){
for(int j=w1[0];j>=w1[i];j--){
for(int k=w2[0];k>=w2[i];k--){
dp[j][k]=max(dp[j][k],dp[j-w1[i]][k-w2[i]]+1);
}
}
}
printf("%d\n",dp[w1[0]][w2[0]]);
return 0;
}
<6> 分组背包
P1757 通天之分组背包
有冲突限制就用 \(vector\) 存一下分组,接着正常0-1背包。
int main(){
m=read(),n=read();
for(int i=1;i<=n;i++){
w[i]=read(),v[i]=read();
int k=read();
cnt=max(cnt,k);
g[k].push_back(i);
}
for(int i=1;i<=cnt;i++){
for(int j=m;j>=0;j--){
for(int k=0;k=w[now]){
dp[j]=max(dp[j],dp[j-w[now]]+v[now]);
}
}
}
}
printf("%d\n",dp[m]);
return 0;
}
例题
1.新年趣事之打牌
首先我们的目标答案应该为总重量 \(sum\) 与当前重量 \(W\) 之差,用 \(dp(i)\) 表示方案数,递推式同0-1背包,在状态转移的同时用 \(addmark(i)\) 表示在原有牌的基础上加入第几张牌可以得到数量为 \(i\),最后判断方案数并递归输出需要的牌即可。
inline void print(int k){
if(k){
print(k-w[addmark[k]]);
printf("%d",addmark[k]);
if(k!=m) printf(" ");
}
return;
}
int main(){
m=read(),n=read();
for(int i=1;i<=n;i++){
w[i]=read();
sum+=w[i];
}
m=sum-m;
dp[0]=1;
for(int i=1;i<=n;i++){
for(int j=m;j>=w[i];j--){
dp[j]+=dp[j-w[i]];
if(dp[j]>5){
dp[j]=2;
}
if(!addmark[j]&&dp[j-w[i]]){
addmark[j]=i;
}
}
}
if(!dp[m]) printf("0\n");
else if(dp[m]>1) printf("-1\n");
else print(m);
return 0;
}
2.线性dp
只能说是非常不花里胡哨的dp。
例题
1.求最长上升子序列
设 \(dp_i\) 表示以 \(a_i\) 为结尾的最大上升子序列长,很明显转移方程是:
\[dp_i=\max_{j=1}^{i-1}(dp_j+1) [a_i>a_j] \]输出子序列一样是存储倒数第一个下标然后回溯即可。
inline void print(int pos){
if(pos==-1) return;
print(path[pos]);
printf("%d ",a[pos]);
}
string s;
int main(){
getline(cin,s);
for(int i=0;ia[j]&&dp[j]+1>dp[i]){
dp[i]=dp[j]+1;
path[i]=j;
}
}
if(dp[i]>ans){
ans=max(ans,dp[i]);
last=i;
}
}
printf("max=%d\n",ans);
print(last);
return 0;
}
2.P1091 NOIP2004 提高组 合唱队形
观察条件数列的形式:
\[t_i实质是由 \(t_i\) 左侧的一个最长上升序列,右侧的一个最长下降序列组合的,那么分别求以 \(t_i\) 为首和尾的上升和下降序列长,最后求和比较大小,答案为:
\[ans=n-\max_{i=1}^n(dp1(i)+dp2(i)-1) \]int main(){
n=read();
for(int i=1;i<=n;i++){
a[i]=read();
}
for(int i=1;i<=n;i++){
dp1[i]=1;
for(int j=1;ja[j]){
dp1[i]=max(dp1[i],dp1[j]+1);
}
}
}
for(int i=n;i>=1;i--){
dp2[i]=1;
for(int j=n;j>i;j--){
if(a[i]>a[j]){
dp2[i]=max(dp2[i],dp2[j]+1);
}
}
}
for(int i=1;i<=n;i++){
ans=max(ans,dp1[i]+dp2[i]-1);
}
printf("%d\n",n-ans);
return 0;
}
3.P1020 NOIP1999 普及组 导弹拦截
第一问求最长不上升子序列,十分好做。
第二问需要用到Dilworth定理,大体意思就是需要的不上升子序列数等于最长上升子序列长度,同理可做。
4.P2196 NOIP1996 提高组 挖地雷
邻接矩阵记录路径(实际是有向图),设置 \(dp_i\) 表示以 \(i\) 结束的最大值,按照回溯方式输出路径。
5.P2782 友好城市
将每对友好城市按北岸升序排序,再求南岸的最长不下将子序列长,需要更优的 \(O(nlogn)\) 算法
6.P1481 魔族密码
求最长前缀链,本质和最长上升子序列是一样的,至于判断是不是前缀,可以用 \(hash\) 也可以简单粗暴地使用 \(s.substr(pos,len)\),表示字符串 \(s[pos:pos+len-1]\) 的部分。
int main(){
n=read();
for(int i=1;i<=n;i++){
cin>>s[i];
}
for(int i=1;i<=n;i++){
dp[i]=1;
for(int j=1;j
7.求最长公共子序列
对于数组 \(s1(i)\) 与 \(s2(j)\),设置 \(dp(i,j)\) 表示截止 \(s1(i)\) 和 \(s2(j)\) 的最长公共子序列长,容易发现 \(dp(i,j)\) 只能由 \(dp(i-1,j-1)\),\(dp(i-1,j)\) 或 \(dp(i,j-1)\) 转移得来,所以就有:
\[dp(i,j)=\max(dp(i-1,j),dp(i,j-1)) \]\[dp(i,j)=\max(dp(i,j),dp(i-1,j-1))[s1(i)=s2(j)] \]而如果求最长连续公共子序列,\(dp(i,j)\) 就表示以 \(s1(i)\) 和 \(s2(j)\) 结尾的最长连续公共子序列长,也就只有当 \(s1(i)=s2(j)\) 的时候才会转移。
//非连续
int main(){
scanf("%s",s1+1);
scanf("%s",s2+1);
len1=strlen(s1+1),len2=strlen(s2+1);
for(int i=1;i<=len1;i++){
for(int j=1;j<=len2;j++){
dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
if(s1[i]==s2[j]){
dp[i][j]=dp[i-1][j-1]+1;
}
}
}
printf("%d\n",dp[len1][len2]);
return 0;
}
//连续
int main(){
scanf("%s",s1+1);
scanf("%s",s2+1);
len1=strlen(s1+1),len2=strlen(s2+1);
for(int i=1;i<=len1;i++){
for(int j=1;j<=len2;j++){
if(s1[i]==s2[j]){
dp[i][j]=dp[i-1][j-1]+1;
}
ans=max(ans,dp[i][j]);
}
}
printf("%d\n",ans);
return 0;
}
8.P2904 River Crossing S
很明显需要用到前缀和优化,\(sum(i)=sum(i-1)+a(i)\),我们给他再加上本来需要的运载费用(注意返程是空船所以只需要 \(m\) 的代价),也就是 \(sum(i)=sum(i)+m\times 2\),然后用 \(dp(i)\) 表示运前 \(i\) 头牛的最小花费,双层循环转移即可。
int main(){
n=read(),m=read();
for(int i=1;i<=n;i++){
dp[i]=maxxn;
a[i]=read();
sum[i]=sum[i-1]+a[i];
}
for(int i=1;i<=n;i++){
sum[i]+=2*m;
}
for(int i=1;i<=n;i++){
for(int j=i;j<=n;j++){
dp[j]=min(dp[j],dp[j-i]+sum[i]);
}
}
printf("%d\n",dp[n]-m);
return 0;
}
9.P2285 HNOI2004 打鼹鼠
看似三维,实则一维。
可以发现如果想要打第 \(i\) 只和第 \(j\) 只鼹鼠就需要两者之间的曼哈顿距离(平行于 \(x\) 轴和 \(y\) 轴的距离和)小于等于出现的时间差,用 \(dis(i,j)\) 表示这个距离,\(t(i,j)\) 表示这个时间差,根据判断方式用 \(dp(i)\) 表示打到第 \(i\) 只鼹鼠时最多能打几只鼹鼠。易得:
\[dp(i)=\max_{j=1}^{i-1}x\{dp(j)+1\}[dis(i,j)<=t(i,j)] \]int main(){
n=read(),m=read();
for(int i=1;i<=m;i++){
a[i].t=read(),a[i].x=read(),a[i].y=read();
}
for(int i=1;i<=m;i++){
dp[i]=1;
for(int j=1;j=abs(a[i].x-a[j].x)+abs(a[i].y-a[j].y)){
dp[i]=max(dp[i],dp[j]+1);
}
}
ans=max(ans,dp[i]);
}
printf("%d\n",ans);
return 0;
}
所以就是一个距离比较特殊的图而已。
3.坐标dp
比较有区间dp的味儿了,基本上是考虑转移方向或者考虑建图。
例题
1.矩阵取数(简单版)
在数列中从队首或队尾取元素,第 \(i\) 次取第 \(k\) 个元素对答案的贡献为 \(a(k)\times 2^i\),求答案最大值。
我们用 \(dp(i,j)\) 表示取剩下区间 \([i,j]\) 时已经取过的答案最大值,不难发现 \(dp(i,i)=a(i)\times 2^n\),那么区间 \([i,j]\) 一定是由 \([i+1,j]\) 或 \([i,j-1]\) 取 \(i\) 或 \(j\) 得来,如果 \(l=j-i\),就有转移方程:
\[dp(i,j)=\max(dp(i+1,j)+a(i)\times 2^{n-l},dp(i,j-1)+a(j)\times 2^{n-l}) \]inline ll q_pow(ll x,ll p){
ll ans=1;
while(p){
if(p&1){
ans=ans*x;
}
p>>=1;
x=x*x;
}
return ans;
}
int main(){
n=read();
for(int i=1;i<=n;i++){
a[i]=read();
dp[i][i]=a[i]*q_pow(2,n);
}
for(int l=1;l
然而进阶版需要写高精度……
//高精度
int n;
struct node{
int num[505],len;
inline void clear(){
memset(num,0,sizeof(num));
len=0;
}
inline void print(){
for(int i=len;i>=1;i--){
printf("%d",num[i]);
}
printf("\n");
}
node operator + (const node &tmp){
node res;
res.clear();
res.len=max(len,tmp.len)+1;
for(int i=1;i<=res.len;i++){
res.num[i]+=num[i]+tmp.num[i];
if(res.num[i]>9){
res.num[i+1]+=res.num[i]/10;
res.num[i]%=10;
}
}
while(res.num[res.len]) res.len++;
while(!res.num[res.len]&&res.len>1) res.len--;
return res;
}
node operator * (const node &tmp){
node res;
res.clear();
res.len=len+tmp.len;
for(int i=1;i<=len;i++){
for(int j=1;j<=tmp.len;j++){
res.num[i+j-1]+=num[i]*tmp.num[j];
if(res.num[i+j-1]>9){
res.num[i+j]+=res.num[i+j-1]/10;
res.num[i+j-1]%=10;
}
}
}
while(res.num[res.len]) res.len++;
while(!res.num[res.len]&&res.len>1) res.len--;
return res;
}
}a[105],dp[105][105],power[105],base;
inline node Max(node p,node q){
if(p.len>q.len) return p;
else if(p.len=1;i--){
if(p.num[i]>q.num[i]) return p;
else if(p.num[i]
2.P1005 矩阵取数(恶心版)
因为每一行的答案实际是独立的,那么在原题的基础上套一层枚举每一行的循环,求和。
int n,m;
struct node{
int num[505],len;
inline void clear(){
memset(num,0,sizeof(num));
len=0;
}
inline void print(){
for(int i=len;i>=1;i--){
printf("%d",num[i]);
}
printf("\n");
}
node operator + (const node &tmp){
node res;
res.clear();
res.len=max(len,tmp.len)+1;
for(int i=1;i<=res.len;i++){
res.num[i]+=num[i]+tmp.num[i];
if(res.num[i]>9){
res.num[i+1]+=res.num[i]/10;
res.num[i]%=10;
}
}
while(res.num[res.len]) res.len++;
while(!res.num[res.len]&&res.len>1) res.len--;
return res;
}
node operator * (const node &tmp){
node res;
res.clear();
res.len=len+tmp.len;
for(int i=1;i<=len;i++){
for(int j=1;j<=tmp.len;j++){
res.num[i+j-1]+=num[i]*tmp.num[j];
if(res.num[i+j-1]>9){
res.num[i+j]+=res.num[i+j-1]/10;
res.num[i+j-1]%=10;
}
}
}
while(res.num[res.len]) res.len++;
while(!res.num[res.len]&&res.len>1) res.len--;
return res;
}
}a[105][105],dp[105][105],power[105],base,ans;
inline node Max(node p,node q){
if(p.len>q.len) return p;
else if(p.len=1;i--){
if(p.num[i]>q.num[i]) return p;
else if(p.num[i]
3.P1006 NOIP2008 提高组 传纸条
很离谱的四维dp。
首先从 \((1,1)\) 到 \((n,m)\) 再从 \((n,m)\) 到 \((1,1)\) 不能重复的最大值和从 \((1,1)\) 到 \((n,m)\) 走两次不重复的最大值是等价的,这样考虑更加简单。
设置 \(dp(i,j,k,l)\) 表示第一次走到 \((i,j)\),第二次走到 \((k,l)\),因为只能向下或向右,肯定由四种情况转移而来,就有:
\[dp(i,j,k,l)=\max(dp(i-1,j,k-1,l)dp(i-1,j,k,l-1),dp(i,j-1,k-1,l),dp(i,j-1,k,l-1))+a(i,j)+a(k,l) \]注意这里因为不能路径重复所以要判断 \((i,j)\) 与 \((k,l)\) 是否是同一个点。
玄学的讲 \(dp(n,m,n,m)\) 为最终答案。
inline int Max(int a1,int a2,int a3,int a4){
return max(max(a1,a2),max(a3,a4));
}
int main(){
n=read(),m=read();
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
a[i][j]=read();
}
}
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
for(int k=n;k>=1;k--){
for(int l=m;l>=1;l--){
dp[i][j][k][l]=Max(dp[i-1][j][k-1][l],dp[i-1][j][k][l-1],dp[i][j-1][k-1][l],dp[i][j-1][k][l-1])+a[i][j];
if(i!=k||j!=l) dp[i][j][k][l]+=a[k][l];
}
}
}
}
printf("%d\n",dp[n][m-1][n-1][m]);
return 0;
}
4.P1004 NOIP2000 提高组 方格取数
和传纸条是一样的……
5.P1854 花店橱窗问题
题意就是在 \(n\times m\) 的方格中每行取一个数,并且取出数的横纵编号都是单调递增的,求最大值。
不难想到设 \(dp(i,j)\) 表示第 \(i\) 行选在 \((i,j)\) 取,那么转移方程为:
\[dp(i,j)=\max_{k=i-1}^{j-1}\{dp(i-1,k)+a(i,j)\} \]至于 \(k\) 的范围为什么是 \([i-1,j-1]\),原因是每一行都要选择一个位置,因此第 \(i-1\) 行起码要选在 \((i-1,i-1)\) 的位置。至于输出路径依然是在修改的时候用 \(nxt(i,j)\) 记录上一位置,回溯输出。
注意每层循环的范围,可以减少无实际意义的运算,例如 \(i\in [1,n],j\in [i,m-n+i],k\in [i-1,j-1]\)。
inline void print(int ord,int pos){
if(ord==1){
printf("%d ",pos);
return;
}
print(ord-1,nxt[ord][pos]);
printf("%d ",pos);
}
int main(){
n=read(),m=read();
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
a[i][j]=read();
dp[i][j]=minxn;
}
}
for(int i=1;i<=n;i++){
dp[0][i]=0;
}
for(int i=1;i<=n;i++){
for(int j=i;j<=m-n+i;j++){
for(int k=i-1;kdp[i][j]){
dp[i][j]=dp[i-1][k]+a[i][j];
nxt[i][j]=k;
}
}
}
}
for(int i=n;i<=m;i++){
if(dp[n][i]>ans){
ans=dp[n][i];
last=i;
}
}
printf("%d\n",ans);
print(n,last);
return 0;
}
6.P2690 Apple Catching G
其实和在 \(n\times 2\) 的0-1方格中取数没有什么区别,是否取到就看移动次数模 \(2\) 的结果。
int main(){
t=read(),w=read();
for(int i=1;i<=t;i++){
a[i]=read();
}
for(int i=1;i<=t;i++){
for(int j=0;j<=min(t,w);j++){
if(!j){
dp[i][j]=dp[i-1][j];
}
else{
dp[i][j]=max(dp[i-1][j],dp[i-1][j-1]);
}
if(a[i]==j%2+1){
dp[i][j]++;
}
}
}
for(int i=0;i<=w;i++){
ans=max(ans,dp[t][i]);
}
printf("%d\n",ans);
return 0;
}
4.区间dp
相比于前面三种dp的状态转移是由前到后也就是考虑前 \(i\) 个的贡献,区间dp更像是从小到大的转移,多数的状态设置为 \(dp(i,j)\) 表示区间 \([i,j]\) ,用 \(k\) 将区间分成两段,即 \([i,k]\) 与 \([k+1,j]\) 进行转移。
例题
1.石子合并 简单版 普通版 强化版
首先最基本的转移是
\[dp(i,j)=\max_{k=i}^{j-1}\{dp(i,k)+dp(k+1,j)\}+sum(i,j) \]维护一个前缀和即可(求最小值同理),如果是一个环的话,自然是把 \([1,n]\) 复制一遍讨论。
简单版
int n;
int a[305],sum[305];
int dp1[305][305],dp2[305][305];
int main(){
n=read();
for(int i=1;i<=n;i++){
a[i]=read();
sum[i]=sum[i-1]+a[i];
}
for(int l=1;l
普通版
int main(){
n=read();
for(int i=1;i<=n;i++){
a[i]=read();
a[i+n]=a[i];
}
for(int i=1;i<=2*n;i++){
sum[i]=sum[i-1]+a[i];
}
for(int l=1;l
强化版是基于 \(\text{GrasiaWachs}\) 算法的,一个感性的理解是 \(a_{i-1},a_i,a_{i+1}\) 三堆石子,第二次合并的代价一定为 \(a_{i-1}+a_i+a_{i+1}\),而第一次合并一定与 \(a_i\) 有关,所以只需要比较 \(a_{i-1}\) 与 \(a_{i+1}\) 的大小关系就可以得到合并顺序。
3.P1018 NOIP2000 提高组 乘积最大
维护一个 \(sum(i,j)\) 表示 \(s[i:j]\) 部分的数,用 \(dp(i,j)\) 表示 \(s[1:i]\) 分成 \(j\) 各部分的最大乘积,不难得到:
\[dp(i,j)=\max_{k=1}^{i}\{dp(k,j-1)\times sum(k+1,j)\} \]如果输出路径就和线性差别不大了。
4.P1063 NOIP2006 提高组 能量项链
首先看到形成一个环,自然要断 \(n\) 的环成 \(n\times 2\) 的链,然后按照经典区间dp的三层循环——区间长度->区间起点->区间断点,最后答案为长度为 \(n\) 区间的最大值。
int main(){
n=read();
for(int i=1;i<=n;i++){
a[i]=read();
a[n+i]=a[i];
}
for(int l=1;l<=n;l++){
for(int i=1,j=i+l;i<=2*n&&j<=2*n;i++,j++){
for(int k=i+1;k<=j-1;k++){
dp[i][j]=max(dp[i][j],dp[i][k]+dp[k][j]+a[i]*a[j]*a[k]);
}
}
}
ll ans=0;
for(int i=1;i<=n;i++){
ans=max(ans,dp[i][i+n]);
}
printf("%lld",ans);
}
5.P4342 IOI1998 Polygon
依旧是断环为链,状态设置也如出一辙,用 \(f\) 表示最大值,加法运算自然是 \(f(i,j)=\max_{k=i}^{j-1}\{f(i,k)+f(k+1,j)\}\),可问题出在乘法上,如果出现两个绝对值较大的负数,会存在运算的实际最大值不等于两个子区间乘积,因此还要维护最小值 \(g\)。
无脑的转移是模拟四种搭配:
\[f(i,j)=\max_{k=i}^{j-1}\{max(f(i,k)\times(f(k+1,j),f(i,k)\times g(k+1,j),g(i,k)\times f(k+1,j),g(i,k)\times g(k+1,j))\} \]\[g(i,j)=\min_{k=i}^{j-1}\{min(f(i,k)\times(f(k+1,j),f(i,k)\times g(k+1,j),g(i,k)\times f(k+1,j),g(i,k)\times g(k+1,j))\} \]第二问输出断点只需要遍历答案符合的 \([i,i+n-1]\)。
6.P2890 Cheapest Palindrome G
可以发现把一个字符串修改成回文串,删除多余的字符与增加缺失的字符其实是等价的,且对后续的答案没有影响,那么实际上修改的代价就是增加和修改的最小值。
map a;
int main(){
n=read(),m=read();
scanf("%s",s+1);
for(int i=1;i<=n;i++){
char ch;
cin>>ch;
int num1=read(),num2=read();
a[ch]=min(num1,num2);
//printf("%d\n",a[ch]);
}
memset(dp,0x3f,sizeof(dp));
for(int i=1;i<=m;i++){
dp[i][i]=0;
}
for(int l=1;l
5.树形dp
现在作为背景的结构转移到了树上,区别在于,之前的线性dp从前到后或从后到前遍历,区间dp从小区间到大区间遍历,而树形dp是从叶子结点到根结点遍历,形式类似于深搜。
例题
1.P1040 加分二叉树
其实是区间dp,因为已知中序遍历所以 \([l,r]\) 的根一定在该范围内,记录每一段的根结点按照先序遍历输出即可。
2.P2015 二叉苹果树
把以 \(u\) 根的子树分成两部分,然后遍历在当前判断的子树中保留几根树枝,取最大值即可。
3.P2014 选课
同上。
inline void add_edge(int u,int v){
e[++cnt].to=v;
e[cnt].nxt=head[u];
head[u]=cnt;
}
inline void f(int u,int fa){
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
f(v,u);
siz[u]+=siz[v]+1;
for(int j=min(m,siz[u]);j>=1;j--){
for(int k=min(j-1,siz[v]);k>=0;k--){
dp[u][j]=max(dp[u][j],dp[u][j-k-1]+dp[v][k]+w[v]);
}
}
}
}
int main(){
n=read(),m=read();
for(int i=1;i<=n;i++){
int u=read();
w[i]=read();
add_edge(u,i);
add_edge(i,u);
}
f(0,0);
printf("%d\n",dp[0][m]);
return 0;
}
4.P1352 没有上司的舞会
状态的设置关乎参加不参加,也就是 \(dp(u,0/1)\) 表示以 \(u\) 为根的子树中根结点对答案是否产生贡献是答案的最大值,设集合 \(G\) 表示节点 \(u\) 的子节点集,由题意可以得到:
\[dp(u,0)=\max_{v\in G}\{dp(v,0),dp(v,1)\} \]\[dp(u,1)=\max_{v\in G}\{dp(v,0)\}+w(u) \]输出 \(\max(dp(rt,0),dp(rt,1))\) 即可。
inline void add_edge(int u,int v){
e[++cnt].to=v;
e[cnt].nxt=head[u];
head[u]=cnt;
}
inline void f(int u,int fa){
dp[u][0]=0,dp[u][1]=w[u];
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
f(v,u);
dp[u][0]+=max(dp[v][0],dp[v][1]);
dp[u][1]+=dp[v][0];
}
}
int main(){
n=read();
for(int i=1;i<=n;i++){
w[i]=read();
}
for(int i=1;i
5.小胖守皇宫
同上一题,本题的状态设置分为三种 \(dp(u,0/1/2)\) 分别表示如下三种情况
- 状态 \(0\) 表示该节点被自己看守。
- 状态 \(1\) 表示该节点被子节点看守。
- 状态 \(2\) 表示该节点被父节点看守,也就是遍历到该子树时没有被看守。
状态 \(0\) 和状态 \(2\) 的转移是相对好转移的,因为当节点被自己看守时,子节点的状态没有影响,因此有:
\[dp(u,0)=\min_{v\in G}\{dp(v,0),dp(v,1),dp(v,2)\} \]而当节点被父节点看守时,若保证子节点被看守,则子节点的状态只能是已经被看守,因此又有:
\[dp(u,2)=\min_{v\in G}\{dp(v,0),dp(v,1)\} \]剩下的状态 \(1\) 是相对复杂的,因为要保证所有的子节点中至少存在一个是由状态 \(0\) 转移来的,除该条件外,实质上与状态 \(2\) 是没有区别的,因此转移可以建立在状态 \(2\) 答案的基础上进行。
容易发现,当且仅当对于任意一个 \(v\in G\) 都满足 \(dp(v,0)>dp(v,1)\) 时才会与条件矛盾,因此只需要遍历对每一个 \(v\) 都进行处理,找出在 \(dp(v,0)\) 并不优于 \(dp(v,1)\) 时二者差的最小,与 \(dp(u,2)\) 相加,即:
\[dp(u,1)=\min_{v\in G}\{dp(u,2)-min(dp(v,0),dp(v,1))+dp(v,0)\} \]如果取 \(dp(v,0)\) 时自然是对答案没有影响的,因此状态转移正确。
inline void add_edge(int u,int v){
e[++cnt].to=v;
e[cnt].nxt=head[u];
head[u]=cnt;
}
int dp[maxn][3];//0自己,1儿子,2父亲
int rt;
inline void f(int u,int fa){
dp[u][0]=w[u],dp[u][1]=maxxn,dp[u][2]=0;
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
f(v,u);
dp[u][0]+=min(dp[v][0],min(dp[v][1],dp[v][2]));
dp[u][2]+=min(dp[v][0],dp[v][1]);
}
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
dp[u][1]=min(dp[u][1],dp[u][2]-min(dp[v][0],dp[v][1])+dp[v][0]);
}
}
int main(){
n=read();
for(int i=1;i<=n;i++){
int u=read();
w[u]=read();
int siz=read();
for(int j=1;j<=siz;j++){
int v=read();
add_edge(u,v);
add_edge(v,u);
vis[v]=1;
}
}
for(int i=1;i<=n;i++){
if(!vis[i]){
rt=i;
break;
}
}
f(rt,0);
printf("%d\n",min(dp[rt][0],dp[rt][1]));
return 0;
}
6.P2585 ZJOI2006 三色二叉树
只需要按照先序遍历建树,再自上而下更新即可,用 \(0/1/2\) 表示三种颜色,其中一种的状态有另外两种转移而来。
二次扫描和换根dp
根不确定的树形dp问题。
首先一个比较直接的解法是 \(O(n^2)\) 的,把每个节点当作根结点都跑一次dp,然而复杂度极其不优秀。
经典思路:对根结点为 \(1\) 时的做一次dp,然后二次扫描考虑改变根节点后对答案的影响,并更新每一个答案。
7.P3478 POI2008 STA-Station/P2986 USACO10MAR Great Cow Gathering G
类似的两道板子题。
首先以 \(1\) 为根节点的遍历出 \(dep(u),siz(u),dp(1)\) 非常好做,这里不加赘述,主要说说二次扫描。

(为了美观选择以 \(5\) 为初始的根节点)
考虑已知 \(dp(5)\) 转移 \(dp(4)\),以 \(4\) 为根的子树深度会集体 \(-1\),而除此以外的子树深度会集体 \(+1\),因此就有:
\[dp(4)=dp(5)-siz(4)+(n-siz(4))=dp(5)+n-siz(4)\times \]推广到一般就是:
\[dp(v)=dp(u)+n-siz(v)\times 2 \]注意二次扫描时递归与更新的顺序
inline void dfs(int u,int fa){
dep[u]=dep[fa]+1,siz[u]=1;
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
dfs(v,u);
siz[u]+=siz[v];
}
}
ll dp[maxn];
inline void secdfs(int u,int fa){
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
dp[v]=dp[u]+n-2*siz[v];
secdfs(v,u);
}
}
ll ans=-1,pos;
int main(){
n=read();
for(int i=1;ians){
ans=dp[i];
pos=i;
}
}
printf("%lld\n",pos);
return 0;
}
而对于第二题,唯一的区别是 \(siz(u)\) 表示的是奶牛个数,还需要维护 \(wsiz(u)\) 表示代价,而二次扫描时的转移方程有一点点变化:
\[dp(v)=dp(u)+(siz(1)-siz(v)\times 2)\times w \]此时奶牛总数为 \(siz(1)\),而对答案贡献与权值有关。
开longlong
8.P3047 Nearby Cows G
用 \(dp(u,i)\) 表示距离节点 \(u\) 的距离为 \(j\) 的节点权值和,那么第一次得出的答案就是 \(\sum_{i=0}^{m}dp(1,i)\),接下来考虑二次扫描,可以发现第一次更新时是子节点 \(v\) 对父节点 \(u\) 贡献,而二次扫描是父亲节点 \(u\) 对子节点 \(v\) 贡献,因此要先删去原先 \(v\) 的贡献再修改 \(v\),注意回溯以及顺序。
换根dp越来越像莫队了。
inline void dfs(int u,int fa){
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
dfs(v,u);
for(int j=1;j<=m;j++){
dp[u][j]+=dp[v][j-1];
}
}
}
inline void secdfs(int u,int fa){
for(int i=0;i<=m;i++){
ans[u]+=dp[u][i];
}
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
for(int j=1;j<=m;j++){
dp[u][j]-=dp[v][j-1];
}
for(int j=1;j<=m;j++){
dp[v][j]+=dp[u][j-1];
}
secdfs(v,u);
for(int j=1;j<=m;j++){
dp[v][j]-=dp[u][j-1];
}
for(int j=1;j<=m;j++){
dp[u][j]+=dp[v][j-1];
}
}
}
int main(){
n=read(),m=read();
for(int i=1;i
9.CF708C Centroids
题解
6.数位dp
待更
7.状压dp
核心是在 \(n\) 并不多时,把每一种状态 \(0-1\) 转移成十进制的数。
例题
1.P1896 SCOI2005 互不侵犯
预处理出对于单行而言合法的情况,再在转移时判断是否有相邻。
int n,m;
int sit[2005],sitcnt[2005],cnt;
inline void dfs(int sitk,int cntk,int pos){
if(pos>=n){
sit[++cnt]=sitk,sitcnt[cnt]=cntk;
return;
}
dfs(sitk,cntk,pos+1);
dfs(sitk|(1<>1)) continue;
for(int l=m;l>=sitcnt[j];l--){
dp[i][j][l]+=dp[i-1][k][l-sitcnt[j]];
}
}
}
}
for(int i=1;i<=cnt;i++){
ans+=dp[n][i][m];
}
printf("%lld\n",ans);
return 0;
}
2.P2704 NOI2001 炮兵阵地
思路同上,需要将地形也压成状态
3.P2831 愤怒的小鸟
考虑把目前打掉的猪压成一个状态,把过任意两点的抛物线能打掉的猪压成一个状态。
inline void get_ab(db &A,db &B,db a1,db a2,db b1,db b2,db c1,db c2){
B=(a1*c2-a2*c1)/(a1*b2-a2*b1);
A=(c1-b1*B)/a1;
}
int main(){
t=read();
for(int i=0;i<(1<<18);i++){
int j;
for(j=1;j<=18 && i&(1<<(j-1));j++);
unbit[i]=j;
}
while(t--){
memset(dp,0x3f,sizeof(dp));
memset(para,0,sizeof(para));
n=read(),m=read();
for(int i=1;i<=n;i++){
scanf("%lf%lf",&x[i],&y[i]);
}
dp[0]=0;
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
if(fabs(x[i]-x[j])-eps) continue;
for(int k=1;k<=n;k++){
if(fabs(a*x[k]*x[k]+b*x[k]-y[k])
4.P3622 APIO2007 动物园
维护出每个区间是否保留动物状态的贡献,之后枚举状态取最大值即可。
int main(){
n=read(),m=read();
for(int i=1;i<=m;i++){
int e=read(),f=read(),l=read();
int sitf=0,sitl=0;
for(int j=1;j<=f;j++){
int x=read();
x=(x-e+n)%n;
sitf|=(1<
优化dp
1.单调队列优化dp
单调队列优化=提出与内层循环无关的数据,把剩余有关最大值最小值的问题用单调队列维护,要求转移范围是滑动的。
例题
1.CF372C Watching Fireworks is Fun
设 \(dp(i,j)\) 表示放第 \(i\) 个烟花时,在位置 \(j\) 所得到的开心值的最大值。
我们设 \(dx=d\times (t_i-t_{i-1})\),也就代表可以移动到 \(j\) 的最大距离,不难得出转移方程:
\[dp(i,j)=\max_{k=j-dx}^{j+dx} \{dp(i-1,k)+b_i-|a_i-j|\} \]因为 \(b_i\) 累加的结果不变,所以可以在转移方程中删去,在结果中加上 \(sum_b\),也就化简有:
\[dp(i,j)=\max_{k=j-dx}^{j=dx} \{dp(i-1,k)\}-|a_i-j| \]这里 \(-|a_i-j|\) 的结果与 \(k\) 是无关的,故可以提出。而这个值实际为负,可以维护其绝对值的最小值。不难发现,我们每个值,都是从 \([j-dx,j+dx]\) 之间的最值得到的,考虑单调队列优化。
只需要做如下两种操作:
- 队首元素是当前最优且最前,如果超出范围需要弹出
- 队尾元素是可能最优且最后,如果没有新入队的优需要弹出
但是我们的单调队列只能支持到 \(j\) 的范围,所以把这个区间拆分开,正反各跑一遍。
int main(){
n=read(),m=read(),d=read();
memset(dp,0x3f,sizeof(dp));
for(int i=1;i<=m;i++){
a[i]=read(),b[i]=read(),t[i]=read();
sum+=b[i];
}
for(int i=1;i<=n;i++){
dp[1][i]=Abs(a[1]-i);
}
for(int i=2;i<=m;i++){
ll dx=(t[i]-t[i-1])*d;
memset(dp[i&1],0x3f,sizeof(dp[i&1]));
head=1,tail=0;
for(int j=1;j<=n;j++){
while(head<=tail&&q[head]dp[i&1^1][j]) tail--;
q[++tail]=j;
dp[i&1][j]=Min(dp[i&1][j],dp[i&1^1][q[head]]+Abs(a[i]-j));
}
head=1,tail=0;
for(int j=n;j>=1;j--){
while(head<=tail&&q[head]>j+dx) head++;
while(head<=tail&&dp[i&1^1][q[tail]]>dp[i&1^1][j]) tail--;
q[++tail]=j;
dp[i&1][j]=Min(dp[i&1][j],dp[i&1^1][q[head]]+Abs(a[i]-j));
}
}
ans=0x3f3f3f3f3f3f3f3f;
for(int i=1;i<=n;i++){
ans=Min(ans,dp[m&1][i]);
}
printf("%lld\n",sum-ans);
return 0;
}
2.P2569 SCOI2010 股票交易
自然是 \(dp(i,j)\) 表示第 \(i\) 天持有 \(j\) 只股票的最高收益,不难发现有,不操作、买与卖三种状态。
不操作的转移十分朴素,\(dp(i,j)=\max(dp(i,j),dp(i-1,j))\)
其实买卖的操作无需枚举 \(i-w-1\) 及以前的天数,因为不操作的情况已经转移到了后一天,因此从 \(dp(i-w-1,k)\) 转移即可,就能得到两个转移方程。
买:
\[dp(i,j)=\max_{k=j-as_i}^{j-1}\{dp(i-w-1,k)-ap_i\times (j-k)\} \]提出与 \(k\) 的项无关得到:
\[dp(i,j)=\max_{k=j-as_i}^{j-1}\{dp(i-w-1,k)+ap_i\times k\}-ap_i\times j \]卖:
\[dp(i,j)=\max_{j+1}^{j+bs_i}\{dp(i-w-1,k)+bp_i\times(k-j)\} \]同理化简,可以得到:
\[dp(i,j)=\max_{j+1}^{j+bs_i}\{dp(i-w-1,k)+bp_i\times k\}-bp_i\times j \]明显括号里面的是可以用单调队列优化的。
int main(){
n=read(),m=read(),w=read();
for(int i=1;i<=n;i++){
ap[i]=read(),bp[i]=read(),as[i]=read(),bs[i]=read();
}
memset(dp,~0x3f,sizeof(dp));
for(int i=0;i<=n;i++){
dp[i][0]=0;
}
for(int i=1;i<=n;i++){
for(int j=0;j<=as[i];j++){
dp[i][j]=-1*j*ap[i];
}
for(int j=0;j<=m;j++){
dp[i][j]=max(dp[i][j],dp[i-1][j]);
}
if(i>=w+1){
head=1,tail=0;
for(int j=0;j<=m;j++){
while(head<=tail&&q[head]=dp[i-w-1][q[tail]]+ap[i]*q[tail]) tail--;
q[++tail]=j;
if(head<=tail) dp[i][j]=max(dp[i][j],dp[i-w-1][q[head]]-ap[i]*(j-q[head]));
}
head=1,tail=0;
for(int j=m;j>=0;j--){
while(head<=tail&&q[head]>j+bs[i]) head++;
while(head<=tail&&dp[i-w-1][j]+bp[i]*j>=dp[i-w-1][q[tail]]+bp[i]*q[tail]) tail--;
q[++tail]=j;
if(head<=tail) dp[i][j]=max(dp[i][j],dp[i-w-1][q[head]]+bp[i]*(q[head]-j));
}
}
}
int ans=-1;
for(int i=0;i<=m;i++){
ans=max(ans,dp[n][i]);
}
printf("%d\n",ans);
return 0;
}
3.P2254 NOI2005 瑰丽华尔兹
忽略起始位置等等,每个区间都在整个地图中每行或每列用单调队列d优化p,滚动数组减少空间。
4.P2627 Mowing the Lawn G
\[dp_i=\max_{k=i-k}^{i}\{dp_{j-1}+sum_i-sum_j\} \]\[dp_i=\max_{k=i-k}^{i}\{dp_{j-1}-sum_j\}+sum_i \]常规单调队列优化即可。
2.斜率优化dp
讲真很难的优化方式。
简单说一下思想:当递推式中出现了形如 \(a[i]\times b[j]+c[i]+d[j]\) 的式子时,显然单调队列优化是不可行的,因此我们把其化作一次函数 \(b=y-kx\) 的形式,就能得到一个凸包,用单调队列去维护凸包。
例题
1.P3195 HNOI2008 玩具装箱
先求前缀和 \(pre\),直接可以得到的递推式:\(dp_i=\min_{j=1}^{i-1}\{dp_j+(pre_i-pre_j+i-j-1-l)^2\}\)
设 \(sum_i=pre_i+1,l'=l+1\),为方便观察去掉 \(\min\),就能得到 \(dp_i=dp_j+(sum_i-sum_l-l')^2\),也就有:\(dp_i=dp_j+sum_i^2+(sum_j+l')^2-2sum_isum_j-2sum_il'\),之后移项成上述式子就有 \(dp_i-sum_i^2+2sum_il'=dp_j+(sum_j+l')^2-2sum_i\times sum_j\),其中 \(b=dp_i-sum_i^2+2sum_il',y=dp_j+(sum_j+l')^2,k=2sum_i,x=sum_j\)。
之后我们得到了一个下凸包,让斜率在保证大于 \(k\) 的情况下尽量小,单调队列维护这个凸包。
ll n,l;
ll sum[maxn],dp[maxn];
ll q[maxn],head,tail;
inline ll X(ll i){
return sum[i];
}
inline ll Y(ll i){
return dp[i]+(sum[i]+l)*(sum[i]+l);
}
inline ldb slope(ll i,ll j){
return (ldb)(Y(j)-Y(i))/(X(j)-X(i));
}
int main(){
n=read(),l=read()+1;
for(int i=1;i<=n;i++){
sum[i]=read()+sum[i-1]+1;
}
head=1,tail=1;
q[1]=0;
for(int i=1;i<=n;i++){
while(head=slope(q[tail-1],i)) tail--;
q[++tail]=i;
}
printf("%lld\n",dp[n]);
return 0;
}
2.P3628 APIO2010 特别行动队
借本题谈一谈斜率优化的常规做法。
首先还是维护前缀和 \(sum\),正常的递推式(省略 \(\min\))是 \(dp_i=dp_j+a(sum_i-sum_j)+b(sum_i-sum_j)+c\),我们把式子打开,只 \(i\) 相关的移到等式左边,就能得到 \(dp_i=dp_j+asum_i^2-2asum_isum_j+asum_j^2+bsum_i-bsum_j+c\),\(dp_i-asum_i^2-bsum_i=-2asum_isum_j+asum_j^2-bsum_j+c\),这样就得到了 \(b=y-kx\) 形式的递推式,可以发现我们的 \(b\) 是关于 \(i\) 的,\(y\) 是关于 \(j\) 的 \(-kx\) 是与 \(i,j\) 都相关的一项,并且把前半部分当做 \(-k\),后半部分当作 \(x\)。
之后只需要带入维护凸包即可。
int n;
int a,b,c;
ll sum[maxn],dp[maxn];
int q[maxn],head,tail;
inline ll X(int i){
return sum[i];
}
inline ll Y(int i){
return dp[i]+a*sum[i]*sum[i]-b*sum[i];
}
inline ll K(int i){
return 2*a*sum[i];
}
inline ll B(int i){
return dp[i]-a*sum[i]*sum[i]-b*sum[i]-c;
}
inline db slope(int i,int j){
return (db)(Y(i)-Y(j))/(X(i)-X(j));
}
int main(){
n=read();
a=read(),b=read(),c=read();
for(int i=1;i<=n;i++){
sum[i]=sum[i-1]+read();
}
int head=1,tail=1;
q[tail]=0;
for(int i=1;i<=n;i++){
while(headK(i)) head++;
dp[i]=Y(q[head])-K(i)*X(q[head])+a*sum[i]*sum[i]+b*sum[i]+c;
while(head
3.P2120 ZJOI2007 仓库建设
同样是几步走——前缀和——推式子——移项整理——维护凸壳。
3.四边形不等式优化dp
待更