零、前言
作者是 xxs,DP学的不多,文章有错误还请指出。
一、基础
DP 这个算法的大致思路是将一个比较大的问题转化为一个个较小的子问题。
它其实可以看作是记忆化搜索的递推形式。
DP 在应用时有三个必要条件:无后效性 、最优子结构性 、子问题重叠性。
无后效性指的是"未来的结果与过去的过程无关";最优子结构性指"最优解所包含的子问题也是最优的";而子问题重叠性指"每个子问题都是重复的"。
DP 有以下几个基础的种类,都还不是很难。
1.递推
DP 中最简单的一种,一般只需要求一下递推式然后循环即可,一般形式比较单一。
例题 Luogu:P2807 三角形计数
要求三角形个数,分类讨论。
首先求朝上的。
边长为一的很明显:\(\frac{n(n+1)}{2}\)。
边长为二的求法则类似,我们可以将其看成求边长为 \((n-1)\) 的大三角形中的边长为一的小三角形的个数,即 \(\frac{(n-1)n}{2}\)。
边长为 \(i\) 的同理,为 \(\frac{(n-i+1)(n-i+2)}{2}\)。
所以所有朝上的三角形个数为 \(\sum_{n}^{i=1}{\frac{(n-i+1)(n-i+2)}{2}}=\frac{n(n+1)(n+2)}{6}\)。
接着看朝下的。
边长为一:\(\frac{(n-1)n}{2}\)。
边长为 \(i\) 的我们和上面类似,读者们可以自行求证:\(\frac{(n-2i+1)(n-2i+2)}{2}\)
最后所有朝下的个数为
那么最终的答案就是:
(图片来源于:https://www.luogu.com.cn/article/cz1beb6j)
看完例题你们应该就会发现,递推的确非常非常非常简单,几乎不用动脑子(嗯对)。
习题
习题吗,在各大网站上随便找就行了,反正很简单。
2.背包
同样是一种比较基础的DP,有很多类型。
1)01 背包
\(01\) 背包的问题模型如下:
有 \(n\) 个物品,第 \(i\) 个物品体积为 \(V_i\),价值为 \(W_i\),有一个容积为 \(M\) 的背包,要求在所选物品总体积不超过 \(M\) 的前提下,物品总价值最大。
容易想到,这个 DP 的状态 \(dp_{i,j}\),表示从前 \(i\) 个物品中选了总体积为 \(j\) 的物品放进背包。
那么它的状态转移方程就会是:
\(dp_{i,j} = max\left\{\begin{matrix}dp_{i-1,j} \ 不选第 i 个\\dp_{i-1,j-V_i}+W_i \ 选第 i 个\end{matrix}\right.\)
但是这里直接采用二维数组的话,会出现 MLE,于是我们考虑将其转化为一维数组。
于是 DP 的状态就可以简化为:\(dp_j\) 表示背包里面放进体积和为 \(j\) 的物品的最大价值。
那么状态转移方程就是:\(dp_j=max(dp_j,dp_{j-V_i}+W_i)\)。
例题代码
cpp
#include<bits/stdc++.h>
using namespace std;
int T,m;
int v[110];
int w[110];
int f[1010];
int main(){
cin>>T>>m;
for(int i=1;i<=m;i++)cin>>v[i]>>w[i];
for(int i=1;i<=m;i++)
for(int j=T;j>=v[i];j--)
f[j]=max(f[j],f[j-v[i]]+w[i]);
cout<<f[T];
return 0;
}
2) 多重背包
多重背包是 \(01\) 背包的一个变式,与 \(01\) 背包的区别只在于每种物品有 \(m_i\) 个。
考虑和 \(01\) 背包类似的解法:
因为每种物品有 \(m_i\) 个,于是就可以看做一共有 \(\sum_{i=1}^{n}{m_i}\) 个物品的 \(01\) 背包,此时时间复杂度为 \(O(M \sum_{i=1}^{n}{m_i})\)。
这种时间复杂度并不是那么优秀,于是我们需要一些优化:二进制优化 。
对于 \(m_i\) 个同一种物品,我们将其分为若干个 \(2\) 的连续次幂个物品相加,剩下一组余数。这样就可以将时间复杂度降到 \(O(M \sum_{i=1}^{n}{\log_{2}{m_i}})\)。
例题二进制优化代码:
cpp
for(int i=1;i<=n;i++){
int num=min(a[i],m/w[i]);
for(int k=1;num>0;k<<=1){
if(k>num)k=num;
num-=k;
for(int v=m;v>=k*w[i];v--)f[v]=max(f[v],f[v-k*w[i]]+k*c[i]);
}
}
3)分组背包
这个问题就是从"在所有物品中选择一件"变成了"从当前组中选择一件",于是就对每一组进行一次 \(01\) 背包就可以了。
例题代码:
cpp
for(int i=1;i<=n;i++){
cin>>v[i]>>w[i]>>c;
if(cnt[c]==0)num++;
cnt[c]++;
t[c][cnt[c]]=i;
}
for(int k=1;k<=num;k++)
for(int i=m;i>=0;i--)
for(int j=1;j<=cnt[k];j++)
if(i>=v[t[k][j]])
f[i]=max(f[i],f[i-v[t[k][j]]]+w[t[k][j]]);
例题
- 01 背包:Luogu:P1048 NOIP 2005 普及组 采药
- 多重背包:Luogu:P1776 宝物筛选
- 分组背包:Luogu:P1757 通天之分组背包
3.LIS(最长上升子序列)
\(dp_i\) 表示以 \(i\) 结尾的 LIS 长度。
那么转移方程就是:\(dp_i=max(dp_i,dp_j+1),if \ a_j<a_i,j<i\),推导很显然。
但是很显然,这个时间复杂度不咋滴(\(O(n^2)\)),于是考虑优化:树状数组 。
其实不一定要树状数组,只要是支持单点修改和区间最值的 DS 都可以。
先按值排序,排序之后再查询序号前最大的 \(dp_i\) 进行转移。由于 LIS 是严格上升的,所以我们遇到重复的值时应放在后面一次性处理。
这样就可以将时间复杂度降低至 \(O(n \log_{2}{n})\)。
4.LCS
\(dp_{i,j}\) 表示序列 x 的前 \(i\) 个和 y 的前 \(j\) 个的 LCS。
状态转移方程即为:
\(dp_{i,j}=\left\{\begin{matrix} 0 & i=0 or j=0 \\ dp_{i=1,j-1}+1 & i,j>0,x_i=y_j \\ max(dp_{i,j-1},dp_{i-1,j}) & i,j>0,x_i \ne y_j\end{matrix}\right.\)
推导同样很简单,分类讨论即可。
二、区间 DP
区间 DP 顾名思义就是在一段区间上的动态规划,它不仅需要满足 DP 的几个基本条件,而且还需要符合在区间上操作的特点。
我们用例题来引入。
例题:石子合并
题意:
有 \(n\) 堆石子排成一排,每堆石子有一定的数量。现要将 \(n\) 堆石子并成为一堆。合并的过程只能每次将相邻的两堆石子堆成一堆,并将新的一堆石子数记为该次的得分。求可能的最大/最小得分。
对于这道题,我们考虑一个状态:\(dp_{i,j}\) 表示从 \(i\) 到 \(j\) 合并所得的最大得分,\(sun_{i,j}=a_i+a_{i+1}+...+a_j\)。
很显然,对于区间 \(i,j\),我们可以将其分为 \(i,i+a\cup i+a+1,j\) 两个区间分开计算,有 \(j-i+1\) 种情况,取 max/min 即可。
但是要注意,还需要加上 \(sum_{i,j}\),比较显然。
故,转移方程为:\(dp_{i,j}=min(dp_{i,i+a}+dp_{i+a+1,j}+sum_{i,j})\),最大同理。时间复杂度 \(O(n^3)\)。
破环成链
但是,对于某些区间 DP 题目,它会是一个环,要难一点点,但是还是比较好处理。
将环上的问题转换为链上的问题,可以将环断开,复制,然后出现一条新的链。接下来的处理完全一样。
注意区间长度最大仍取到 \(n\),但右端点最大能取到 \(2n\),于是答案就会变成:max 或 min \(dp_{i,i+n-1}\)。
例题 代码:
cpp
for(int len=2;len<=n;len++)
for(int i=1;i<=2*n-len+1;i++){
int j=i+len-1;
dp1[i][j]=1e9;
for(int k=i;k<j;k++){
dp1[i][j]=min(dp1[i][j],dp1[i][k]+dp1[k+1][j]+s[j]-s[i-1]);
dp2[i][j]=max(dp2[i][j],dp2[i][k]+dp2[k+1][j]+s[j]-s[i-1]);
}
}
for(int i=1;i<n;i++){
ansmin=min(ansmin,dp1[i][i+n-1]);
ansmax=max(ansmax,dp2[i][i+n-1]);
}
习题
- P4170 CQOI2007 涂色
- Luogu:P1063 NOIP 2006 提高组 能量项链
- Luogu:P1005 NOIP 2007 提高组 矩阵取数游戏
- Luogu:P4767 IOI 2000 邮局 加强版
三、树形 DP
树形 DP 顾名思义就是在树上进行的 DP,它并没有很多比较固定的形式,读者可以在例题中领悟树形 DP 的基本思想。
1.基础树形 DP
对于树形dp,最常用的是DFS来实现遍历。
例题:没有上司的舞会
一道极其经典的树形 DP 入门题目,但是它的确非常非常简单,所以作者就直接给出式子了,请读者自己推一下:
设 \(f_{x,0}\) 表示以 \(x\) 为根的子树,且 \(x\) 不参加舞会的最大快乐值。
\(f_{x,1}\) 表示 \(x\) 参加了舞会的最大快乐值。
则 \(f_{x,0}=\sum {max(f_{y,0},f_{y,1})},(y \in son_x)\),\(f_{x,1}=\sum {f_{y,0}}+r_i,(y \in son_x)\)
通常,树形 DP 的状态一般都为当前节点的解.先 DFS 子树的所有解,然后传递给父节点进行转移。
习题
- Luogu:P2015 二叉苹果树
- Luogu:B4016 树的直径
- Luogu:P1395 会议
- HDU:2196 Computer
- Luogu:P3574 POI 2014 FAR-FarmCraft
2.树上背包
树上背包就是背包问题与树形 DP 的结合。
还是先看例题:
例题:选课
这是一个典型的树上背包问题。
我们新增一门 \(0\) 学分的课程,作为所有课程的先修课,这样我们就将森林变成了一棵以 \(0\) 课程为根的树。
这样我们就可以进行 DP。
我们考虑和 \(01\) 背包一样的思路,令 \(dp_{u,i,j}\) 表示节点 \(u\) 的前 \(i\) 个子节点,限重为 \(j\) 能得到的最大价值。
我们发现可以将 \(i\) 直接去除,于是就有:
dp[u][j]表示节点 u ,限重 j 的最大价值。
那么,仿照背包 DP,就有:\(dp_{x,j}=max(dp_{x,j},dp_{x,j-k}+dp_{v,k})\)。
显然,这种方法复杂度为 \(O(mn)\)。
参考代码:
cpp
void dfs(int x){
for(int i=h[x];i;i=e[i].nxt){
int v=e[i].to;
dfs(v);
for(int j=m+1;j>=1;j--)
for(int k=0;k<j;k++)
dp[x][j]=max(dp[x][j],dp[x][j-k]+dp[v][k]);
}
}
习题
3.换根 DP
换根 \(dp\) 又被称为二次扫描,是一种在无根树上所进行的 DP。
比如对于这道题:Accumulation Degree。
比较朴素的想法就是 \(n\) 次的循环,让每一个点都当一次根,基础树形 DP 就可以解决。但是复杂度高达 \(O(n^2)\)。
于是,换根 DP 就应运而生了。
我们先以一个确定节点跑出来最优解,通过一个转移方程,即可得出与他有关的其他节点的答案,这就是换根 DP 的基本思想。
我们用上面的例题来实战一下:
例题:Accumulation Degree
首先,我们可以先处理出正常以 \(1\) 为根的情况,再考虑转移。
cpp
void dfs1(int x,int f){
for(int i=head[x];i;i=nxt[i]){
int y=to[i];
if(y==f)continue;
dfs1(y,x);
if(degree[y]>1)dp1[x]+=min(val[i],dp2[y]);
else dp1[x]+=val[i];
}
}
现在考虑转移:
分类讨论。
-
degree[x]==1,也就是 \(x\) 有可能作为叶子,比较好想:\(dp_y=dp1_y+w_i\),加上边权即可。
-
- \(x\) 不可能作为叶子,难一点,
dp[y]=min(dp[x]-val[i],val[i])+dp1[y]。
- \(x\) 不可能作为叶子,难一点,
代码:
cpp
void dfs2(int x,int f){
for(int i=head[x];i;i=nxt[i]){
int y=to[i];
if(y==f)continue;
if(degree[x]==1)dp[y]=dp1[y]+val[i];
else dp[y]=dp1[y]+min(dp[x]-val[i],val[i]);
dfs2(y,x);
}
}