众所周知,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( ext{子集的max} ))
当属性为 (count) 时,(F(i)=sum_ ( ext{子集的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)) 有关,且 (xleq 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&堆石子的质量。
输出格式:
一个整数,表示代价。
数据范围:(1leq Nleq 100)
样例:
#in:
4
1 3 5 2
#out
22
解析
满足有限集最优化请读者自证
仍然从两个角度考虑:
状态表示
对于(F(i,j))
集合:所有将区间([i,j])合并成一堆的方案集合
属性:题目求的是最小值,所以(min)。
状态计算
老套路,来看这个(F(i,j))表示的集合:
仍然是考虑如何划分这个集合
考虑最后一个不同点
最后一次, 也就是合并到([i,j])时,一定是由两个区间([i,k])和([k,j])合并而来的。显然,(kin [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的有关问题,里面的状态表示设计仍然需要做题经验的积累才能顺利完成。道阻且长,但是经验多了自然水到渠成。