闫氏DP分析法
众所周知,OI界有一种神奇(ex)的算法,它几乎可以应用在任何题上,它的分析让许多OIer感到头皮发麻。它就是:DP
闫氏DP分析法
核心思想:从集合的角度考虑
\(\color {red}{“所有的DP问题,本质上都是有限集中的最值问题” — yxc}\)
阶段
动态规划有两个要点:状态与状态转移
那么阶段自然也应该有两个:状态表示和状态计算
状态表示:化零为整
把几个具有相同点的元素合在一起考虑,成为一个状态
对于一个状态 \(F(i)\) ,考虑两个角度:
- 1.集合 :\(F(i)\) 表示什么集合
由于 \(F(i)\) 表示的是一堆东西(这也是DP优于枚举的核心),我们要考虑这一堆东西的共同特征,如:所有满足某个条件的元素集合
这一点请仔细考虑,到底是大于等于,大于,小于,小于等于,等于......这些的不同会导致状态计算方式的不同
- 2.属性:\(F(i)\) 存的数与集合的关系:如 \(max,min,count,sum\) 等
很明显,\(F(i)\) 大多数时候是一个数,代表这个集合的某一个属性,多是最大值、最小值、数量、总和等。题目问什么,属性一般就是什么
状态计算:化整为零
先看 \(F(i)\) 表示的集合:
将其划分为若干个子集,要求不重(针对涉及加和类型的属性)和不漏
划分的依据:找最后一个不同点(这个待会会讲)
划分过后,求 \(F(i)\) 就可根据子集来求
如:当属性为 \(max\) 时,\(F(i)=max( \text{子集的max} )\)
当属性为 \(count\) 时,\(F(i)=\sum_ (\text{子集的count})\)
具体例子:
0-1背包问题
有 \(N\) 件物品以及一个容量为 \(V\) 的背包,每个物品只能使用一次。放入第 \(i\) 件物体的代价是 \(C_i\) ,得到的价值是 \(W_i\)。求在不超过容量的情况下获得的最大价值。
输入格式:
第1行两个正整数,分别表示 \(N\),\(V\)。
接下来N行分别有 \(2\) 个正整数 \(C_i\) ,\(W_i\)。
输出格式:
一行一个正整数,为最大价值总和。
样例
#in:
4 20
8 5
9 6
5 7
2 3
#out:
16
数据范围: \(1≤N≤100,1≤V≤10^6,1≤C_i≤10000,1≤W_i≤10000\) 。
解析:
根据乘法原理,总共的方案为 \(2^n\) 。在所有的方案数中选择一个价值最大的方案,属于有限集的最优问题,可以用试着DP来解。
状态表示
对于\(F(i,j)\):
集合:所有只考虑前\(i\)个物品,且总体积不超过的\(j\)的方案
属性:题目要求我们求最大价值,则其属性就是\(max\)。
状态计算
首先看一下\(F(i,j)\)集合:
我们试着把这个集合分成若干个子集
找最后一个不同点
对于这个题,最后一个不同点就是最后一个物品选或不选
所以对于 \(F(i,j)\) 对应的集合可以如下划分
显然,这种划分方案不重不漏。
现在要分别求出左边和右边的最大值。
-
对于左边的集合,由于它在 \(F(i,j)\) 内,且不包含 \(i\) ,那它其实相当于
\(F(i-1,j)\)。 -
对于右边的集合,我们再次细分,将 \(i\) 单独拿出来,得到\(F(i-1,j-V_i)\)。
也就是说求右边的最大值:\(max(F(i-1,j-V_i)+W_i)\)。
但是,右边这个集合不一定存在,所以要特判:\(j≥V_i\)。
于是我们可以得到状态转移方程:
这就事朴素DP的分析过程了,至于压维等时空优化从状态转移方程出发
\(\color{red}{“DP的所有优化,都是对代码的恒等变形” — yxc}\)
对于01背包,方程中 \(F(i,j)\) 只与 \(F(i-1,x)\) 有关,且 \(x\leq j\)
所以第 \(i\) 维可以使用滚动数组滚掉。
还是放一下代码:
#include<bits/stdc++.h>
using namespace std;
int ci[4000],wi[4000];
int f[138800]//下标要>=c;
int main()
{
int n,c;
cin>>n>>c;
int i,j;
for(i=1;i<=n;i++)
{
cin>>ci[i]>>wi[i];
}
for(i=1;i<=n;i++)
{
for(j=c;j>=ci[i];j--)
{
f[j]=max(f[j],f[j-ci[i]]+wi[i]);//左子集就是f[j]=f[j],省略没写
}
}
cout<<f[c];
return 0;
}
完全背包问题
有 \(N\) 种物品以及一个容量为 \(V\) 的背包,每种物品有无限个可用。放入第 \(i\) 种物体的代价是 \(C_i\) ,得到的价值是 \(W_i\)。求在不超过容量的情况下获得的最大价值。
输入格式:
第1行两个正整数,分别表示 \(N\),\(V\)。
接下来N行分别有 \(2\) 个正整数 \(C_i\) ,\(W_i\)。
输出格式:
一行一个正整数,为最大价值总和。
解析
仍然从两个角度考虑:
设状态 \(F(i,j)\)
状态表示:
对于 \(F(i,j)\):
集合:所有只从前\(i\)个物品中选,总体积不超过\(j\)的所有方案。
属性:\(max\)。
原因和01背包相似,毕竟都是背包问题
状态计算:
对于 \(F(i,j)\) 的集合:
划分子集
这里,最后一个物品可以选若干个,所以要把集合划分成若干个,分别代表不选第 \(i\) 种,选\(1\)个\(i\),选两个\(i\)......
不重不漏性显然。
考虑每个子集怎么求。
第一个子集:不选第\(i\)种物品:显然,就是 \(F(i-1,j)\)
剩下的子集,我们可以试着考虑一般情况:选\(k\)个第\(i\)种物品。
那么每个方案可以再次细分为两个部分:\(k\)个第\(i\)种物品和前面 \(i-1\) 种物品总体积 \(j-kV_i\) 的方案
所以最大值就是:\(F(i,j)=max(\ F(i-1,j-kV_i)+kW_i\)。
于是易得状态转移方程:
但是这个东西项数太多,我们平时写的只有两项啊?
那想想办法把它转换成两项
由上面的状态转移方程我们可以得到:
观察得到,上面的每一项,都是下面的每一项加上一个 \(W_i\) 。类比,上面的最大值就是下面的最大值加上一个 \(W_i\)
于是我们可以得到我们最常用的状态转移方程:
得到了状态转移方程,我们就要来想想能否有时空优化了。
对比一下01背包和完全背包的状态转移方程
01:\(F(i,j)=max(\ F(i-1,j)\ ,\ F(i-1,j-V_i)+W_i\ )\)
完全:\(F(i,j)=max(\ F(i-1,j)\ ,\ F(i,j-V_i)+W_i\ )\)
只有一个\(i-1\)和\(i\)的不同,但就是这一个不同,致使在滚动数组时一个是从大到小枚举一个是从小到大枚举。
放一下参考code:
#include <bits/stdc++.h>
using namespace std;
int ci[10010],wi[10010];
int f[100010];
int main()
{
int t,n;
scanf("%d%d",&t,&n);
for(int i=1;i<=n;i++)
scanf("%d%d",&ci[i],&wi[i]);
for(int i=1;i<=n;i++)
{
for(int j=ci[i];j<=t;j++)
{
f[j]=max(f[j],f[j-ci[i]]+wi[i]);
}
}
cout<<f[t];
return 0;
}
石子合并
设有\(N\)堆石子排成一排,其编号为 \(1,2,3,\dots ,N\)。
每堆石子都有一定的质量,可以用一个整数来描述,现在要把这 \(N\) 堆石子合并成一堆。
每次只能合并相邻的两堆,合并的代价是这两堆石子的质量之和,合并后与这两堆石子相邻的石子将要和新的堆相邻。
显然,合并时的顺序不同会导致合并的总代价不同。
找出一种合理的方法,使得将所有石子合并成一堆的代价最小,输出最小代价。
输入格式:
第一行一个整数\(N\)表示石子的堆数。
接下来的一行\(N\)个整数,第\(i\)个整数表示第&i&堆石子的质量。
输出格式:
一个整数,表示代价。
数据范围:\(1\leq N\leq 100\)
样例:
#in:
4
1 3 5 2
#out
22
解析
满足有限集最优化请读者自证
仍然从两个角度考虑:
状态表示
对于\(F(i,j)\)
集合:所有将区间\([i,j]\)合并成一堆的方案集合
属性:题目求的是最小值,所以\(min\)。
状态计算
老套路,来看这个\(F(i,j)\)表示的集合:
仍然是考虑如何划分这个集合
考虑最后一个不同点
最后一次, 也就是合并到\([i,j]\)时,一定是由两个区间\([i,k]\)和\([k,j]\)合并而来的。显然,\(k\in [i,j]\)
所以我们考虑以这个分界点 \(k\) 为划分依据,分成 \(j-i\) 类。
再来看一下合并的区间:
两个区间互不干扰,所以两边取\(min\),两边恰好是 \(F(i,k)\) 和 \(F(k+1,j)\)。
但是这只是两个子区间的最小代价,求 \(F(i,j)\) 还要加上这部分石子的总质量
于是\(F(i,j) = min(\ F(i,k+1) + F(k+1,j)\ ) + S_j-S_{i-1}\) ,\(S\)是石子重量的前缀和。
放代码:
#include <bits/stdc++.h>
using namespace std;
const int N=310;
int n;
int s[N];
int dp[N][N];
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
{
cin>>s[i];
s[i]+=s[i-1];
}
if(n==1)//n=1的情况特判
{
cout<<0;
return 0;
}
for(int l=2;l<=n;l++)//枚举区间长度
{
for(int i=1;i+l-1<=n;i++)
{
int j=i+l-1;
dp[i][j]=0x3f3f3f3f;
for(int k=i;k<j;k++)
{
dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j]+s[j]-s[i-1]);
}
}
}
cout<<dp[1][n];
return 0;
}
练习
留一道题练练手
如果找不到感觉,可以再看看上面的过程,yxc老师的视频里也有讲这道题。
最长公共子序列
给定两个长度分别为 \(N\) 和 \(M\) 的字符串 \(A\) 和 \(B\),求\(A,B\)最长公共子序列长度。
输入格式
第一行两个整数\(N,M\)。
第二行为一个长度为\(N\)的字符串,表示字符串 \(A\) 。
第三行为一个长度为\(M\)的字符串,表示字符串 \(B\) 。
字符串均由小写字母构成
输出格式
一个整数,表示最大长度。
样例
#in
4 5
acbd
abedc
#out
3
总结
DP是一个经验性的问题,闫氏DP分析法只是给出了一个分析角度,帮助人更好的分析考虑DP的有关问题,里面的状态表示设计仍然需要做题经验的积累才能顺利完成。道阻且长,但是经验多了自然水到渠成。