dp是算法竞赛中经常考察的一个知识点。但是到目前为止,在分析dp问题时,选手们往往没有一个比较有效的思路,在状态划分时基本凭借做题经验,而不是一个合理的理论体系。在这里,我将给出一个比较有效的dp分析方法,这个方法可以将dp的分析规范化,使dp设计不再凭借经验和感觉。
下面我将通过下面这道例题分析讲述dp分析方法
dp的状态划分方法
众所周知,在dp时,我们往往使用一个数组来存储每个状态对应的特征值,并用前一个状态的特征值推导下一个状态。在代码中,这个数组一般是一个多维数组:
dp[N1][N2][N3]......[NN]
在这个分析法中,我将这个多维数组拆成不同的维度,那么,在决定dp数组时,我们只需要确定所有的维度即可。
但是,在实际的设计中,dp的维度往往有多种选择,如何决定选择哪个维度是本方法要解决的一个难题。
下面,我将通过上面的例题分析如何选择dp的维度:
-
首先,需要列出所有可能被选择的维度:
len(数组的前len个值)、
the_number_of_changes(已经发生改变的天数)、
the_number_of_sad(不高兴的天数)、
0/1状态机(当前天是否选择)、
the_sum_of_grade(总成绩) -
观察上面的所有维度,由于dp是一种由子问题的解推导全局解的算法,不论如何,len都是一个必选的维度。下面我们继续分析下面的维度,这个问题要求解的答案是the_number_of_sad(不高兴的天数),并且要让答案最小。因此,这个数值必须被维护。
-
对于每次状态转移,都有一个必要的判断条件:那就是当前值是否小于前面所有值的平均值,因此,必须维护the_sum_of_grade。
-
可以得出答案,我们需要维护的维度是:
len
the_number_of_sad
the_sum_of_grade
这三个维度
至于为什么不维护其他的维度,不是不能维护,而是不必须维护。 -
至此,我们已经确定了要维护的三个维度,但是三个维度需要三层循环来维护吗?显然不是,因为dp数组中本身可以存储一个维度,我们只需循环两个维度。
-
由算法复杂度分析,我们需要用len和the_number_of_sad作为下标。
-
至此,我们已经完成了dp的状态划分,这里总结一下划分的主要依据:
我们需要维护:
将问题分割成子问题的维度、
答案要问的维度、
题目中写明的,决定状态如何转移的维度。
有多种情况结合复杂度选择。
注:最后一条中很可能包含状态机,这需要自己讨论,毕竟状态机是否添加不会影响时间复杂度
写出dp状态转移方程
这里不多赘述状态转移方程的写法,具体可以参考闫氏dp分析法。
例题1的ac代码:
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=4010;
const int inf=0x3f3f3f3f;
int dp[N][N];//第一个维度表示长度,第二个维度表示sad数值,
//数组中维护总分(越小越好)
int A[N];
int B[N];
int main(){
int n;
cin>>n;
for( int i=1;i<=n;i++){
scanf("%d%d",&A[i],&B[i]);
}
for( int i=0;i<=n;i++){
for( int j=0;j<=n;j++){
dp[i][j]=inf;
}
}
dp[0][0]=0;
dp[1][0]=min(A[1],B[1]);
for( int i=2;i<=n;i++){
for( int j=0;j<i;j++){
if(j==0){
if(A[i]*(i-1)>=dp[i-1][0]) dp[i][0]=min(dp[i][0],dp[i-1][0]+A[i]);
if(B[i]*(i-1)>=dp[i-1][0]) dp[i][0]=min(dp[i][0],dp[i-1][0]+B[i]);
continue;
}
if(A[i]*(i-1)>=dp[i-1][j-1] ){
//选择A不会增加sad
dp[i][j]=min(dp[i-1][j]+A[i],dp[i][j]);
}
else {
//选择A会增加sad
dp[i][j]=min(dp[i-1][j-1]+A[i],dp[i][j]);
}
if(B[i]*(i-1)>=dp[i-1][j-1]){
dp[i][j]=min(dp[i-1][j]+B[i],dp[i][j]);
}
else {
dp[i][j]=min(dp[i-1][j-1]+B[i],dp[i][j]);
}
if(A[i]*(i-1)>=dp[i-1][j]){
dp[i][j]=min(dp[i-1][j]+A[i],dp[i][j]);
}
if(B[i]*(i-1)>=dp[i-1][j]){
dp[i][j]=min(dp[i-1][j]+B[i],dp[i][j]);
}
}
}
// for( int i=0;i<=n;i++){
// for( int j=0;j<=n;j++){
// printf("%10d ",dp[i][j]);
// }
// cout<<endl;
// }
for( int i=0;i<=n;i++){
if(dp[n][i]!=inf){
cout<<i<<endl;
return 0;
}
}
return 0;
}
一些注意事项:
在设计dp时,我们往往会遇到端点问题,有时也会遇到dp公式少讨论情况。这里给出一种较为简单的解决方法。
对于端点问题,我们只需遵循一个原则,那就是无关项赋值为极值。
但是,即便如此,端点问题还是没有完全解决,这时,我们只需打印出状态转移表格(即dp数组),即可一目了然地找到bug。
另一道例题:
下面再通过分析另一道dp问题证明方法的合理性:
注:此题选自2021 ICPC 银川站(B题 The Great Wall)
传送门
首先,列出所有可能的维度:
1.长度len。
2.状态机0/1(当前值选为最大值还是最小值)。
3.划分成的区间个数。
4.当前区间的最大值,最小值。
5.所有区间的最大值,所有区间的最小值。
6.所有区间的最大值于最小值的差。
- 为了将问题划分为子问题,我们选取长度len作为第一个维度,但是这里存在一个问题,那就是不能确定本题是区间dp还是线性dp,这个问题先不讨论,我们先往下进行。
- 观察到答案是所有区间的最大值减所有区间的最小值,由于时空复杂度,我们无法维护当前区间的最大值与最小值(因为如果推导答案这需要维护每个区间的最大值 和最小值),那么我们只能选择维护5和6,实际上,这两种方案都可行。
- 同时,应题目要求,我们还需要维护区间的个数(题目中写了要求将数组分成k个区间的答案)。
以上三点就是我们需要维护的dp状态,其中最大值和最小值的差要放在dp数组中,同时要加上状态机。
最后分析一下,如果我们采用区间dp的思路,三层循环一定会超时,而且没有优化的余地。
例题2 ac代码:
注意由于空间问题,需要滚动数组优化
#include<bits/stdc++.h>
using namespace std;
const int N=10100,inf=0x3f3f3f3f;
typedef long long ll;
int dp[2][N][4];
//前i个数,分成j段,0代表第j段没有选,1代表选了最大值,2代表选了最小值,3代表既选了最大值,又选了最小值
int w[N];
int main(){
int g=1;
int n;
scanf("%d",&n);
for( int i=1;i<=n;i++){
scanf("%d",&w[i]);
}
for( int i=1;i<=n;i++){
for( int j=1;j<=i;j++){
if(i-1>=j)
dp[g][j][0]=max(dp[g^1][j-1][3],dp[g^1][j][0]);
else if(i==j) dp[g][j][0]=max(dp[g^1][j-1][3],dp[g^1][j-1][0]);
if(i-1>=j)
dp[g][j][1]=max(dp[g^1][j][1],max(dp[g^1][j][0]+w[i],dp[g^1][j-1][3]+w[i]));
else if(i==j) dp[g][j][1]=w[i];
if(i-1>=j)
dp[g][j][2]=max(dp[g^1][j][2],max(dp[g^1][j][0]-w[i],dp[g^1][j-1][3]-w[i]));
else if(i==j) dp[g][j][2]=-w[i];
if(i-1>=j){
dp[g][j][3]=max(dp[g^1][j][3],dp[g^1][j-1][3]);
dp[g][j][3]=max(dp[g][j][3],dp[g^1][j][1]-w[i]);
dp[g][j][3]=max(dp[g][j][3],dp[g^1][j][2]+w[i]);
}
else if(i==j)
dp[g][j][3]=0;
}
g=(g^1);
}
for( int i=1;i<=n;i++){
printf("%d\n",dp[g^1][i][3]);
}
return 0;
}