一种比较好用的dp分析法

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.所有区间的最大值于最小值的差。

  1. 为了将问题划分为子问题,我们选取长度len作为第一个维度,但是这里存在一个问题,那就是不能确定本题是区间dp还是线性dp,这个问题先不讨论,我们先往下进行。
  2. 观察到答案是所有区间的最大值减所有区间的最小值,由于时空复杂度,我们无法维护当前区间的最大值与最小值(因为如果推导答案这需要维护每个区间的最大值 和最小值),那么我们只能选择维护5和6,实际上,这两种方案都可行。
  3. 同时,应题目要求,我们还需要维护区间的个数(题目中写了要求将数组分成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;
}
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值