简单dp总结

### 简单dp总结

本文是阅读《挑战程序设计第二版》其中关于dp章节所作总结。将简要描述dp的部分知识。

一、dp是什么?

dp在计算机专业学科中全称是动态规划(dynamic programming),指的是我们可以用前面的子状态来推导出后面的状态的一种方式。根据指出的定义,我们便知道,要能使用动态规划要满足几个条件:1、每个子状态都必须是最优的,才能用来推导后面的最优。2、每个状态都不能直接影响后续状态,只能成为后续状态判断的一种依据。3、子问题的重叠性,动态规划将原来具有指数级时间复杂度的搜索算法改进成了具有多项式时间复杂度的算法。

满足的三个条件中,前两个条件都是容易理解的,但是第三个可能有点不具体。下面我举一个例子来解释一下。

给定n个重量和价值为w[i]和v[i]的物品,选出重量总和不超过W的物品,求所能挑选出的最大值。

  • 题解:看到题目,我们可以采用最容易想到的方式,挑选出所有选择的可能,然后选择最大值就好。
    //i代表现在搜索的是第几个物品,物品顺序受,j代表到目前剩下的能选择的重量。
    //返回是在这个重量下能找到的最大值
    int rec(int i,int j){
        //没有物品了,要结束搜索。
        int res=0;
        if(i==n){
            res=0;
        }
        //放不下这个物品,继续找下一个
        else if(j<a[i){
            res=rec(i+1,j);
        }
        //放得下这个物品,是否要放要考虑一下。
        else{
            res=max(rec(i+1,j),rec(i+1,j-w[i])+v[i]);
        }
        return res;
    }
    
  • 然后我们来看这个搜索的过程,对于每个物品,都一般而言,会延生放不放两种递归,所以复杂度看起来是O(2^n),但再仔细看一看,rec(i+1,j-w[i])这个搜索过程,如果前面存在不同的物品,使得从起始点到终点(即j1-j2)的过程中,存在长度相同但不同的路径(比如j初始化为7,7-3-1与7-2-2),于是就会导致某一个rec(i,j)的被反复执行调用,浪费了大量资源。
  • 出现了反复执行rec(i,j)的过程,这容易让我们联想到,如果我们记录每次rec(i,j)的执行记过,每次递归进入这个函数时,判断这个是否被访问过,如果访问过,就直接返回,就可以节省资源。
    int dp[MAX_N+1][MAX_W+1];
    //初始化dp(i,j)数组为-1,标记为rec(i,j)没有被访问过。
    memset(dp,-1,sizeof(dp));
    int rec(int i,int j){
        if(dp[i][j]>=0) return dp[i][j];
        int res;
        if(i==n){
            res=0;
        }
        else if(j<a[i]){
            res=rec(i+1,j);
        }
        else{
            res=max(rec(i+1,j),rec(i+1,j-w[i])+v[i]);
        }
        //获得结果,写入dp数组
        dp[i][j]=res;
        return res;
    }
  • 上述这种搜索就叫做记忆化搜索。通过这种搜索,可以大幅度减少重复浪费。不知道你有没有注意到,每个函数返回的结果都记录在dp数组中,我们可以访问dp数组直接得到结果。那有没有一种方法,可以绕过递归,直接求得dp数组的结果呢?

二、dp在解题中的使用

在上面的例子中,我们其实可以再探讨一下:1、作为前面搜索的状态的结果,是否有直接影响到后续状态?2、每次搜索得到的结果是不是最优的?3、是否有重复的子结构?(用来判断dp是否合算。)

根据上面的表述,答案是比较明显的。作为前面的搜索状态,实际上只是提供了给后面搜索状态的一种决策信息,并没有直接影响。其次我们每次返回的都是当前的最优结果。最后,通过上面的举例,我们知道有很多重复性的搜索过程。这就意味着我们可以使用dp来解题,而且是比较合算的。

dp解题的模型:
(1)确定问题的决策对象。 (2)对决策过程划分阶段。 (3)对各阶段确定状态变量。 (4)根据状态变量确定费用函数和目标函数。 (5)建立各阶段状态变量的转移过程,确定状态转移方程。

对于这题,要求的是在不超过W重量时的最大价值,所以决策对象就是重量和价值。又因为每一个物体都是独立的,于是可以将每一次挑选一个物品作为一个阶段。接下来,设i表示所在的阶段(对于本题即是在挑选的第几个物品时),j表示当前对应的重量的总值,其f(i,j)代表在挑选第i个物品时,总重量为j的价值的最大值。

于是根据前面描述的,思考每一次挑选时,只有两个状态,这个物品选不选,于是得到状态方程 dp[i][j]=max(dp[i-1][j],dp[i-1][j-w[i]]+v[i])。写代码时要注意初始化

    int dp[max_N+1][max_W+1];
    //初始化
    memset(dp,0,sizeof(dp));
    
    void solve(){
        for(int i=1;i<=n;i++)
            for(int j=0;j<=W;j++){
                //放不下第i个物品
                if(j<w[i]){
                    dp[i][j]=dp[i-1][j];
                }
                else{
                    dp[i][j]=max(dp[i-1][j]+dp[i-1][j-w[i]]+v[i]);
                }
            }
    }
    

这题实际上就是dp中著名的0-1背包问题。如果我们将二维数组展开,可以看到我们的依赖关系是上,上左依赖(dp[i+k][j]依赖于dp[i][j]为上依赖,dp[i][j+k]依赖于dp[i][j]成为左依赖),而且只依赖最近的一行数组,可以对程序再进行优化,想象成包含dp[i][j]的左部投影到dp[i+1][j]中,为了维持依赖性,即只有 dp[i][j]更新完后,才能修改dp[i-1][j]和dp[i-1][j-w[i]],所以可以从右往左更新,这样就能保持依赖的正确性了。

    int dp[max_W+1];
    memset(dp,0,sizeof(dp));
    
    void solve(){
        for(int i=1;i<=n;i++)
            for(int j=W;j>=w[i];j--){
                dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
            }
    }

实际上,这一题可以看到,只使用最近的两行数组,其他的不用,那么我们可以用滚动数组来实现

    dp[i&1][j]=max(dp[(i-1)&1][j]+dp[(i-1)&1][j-w[i]]+v[i]);

三、背包问题

完全背包问题:有n种重量和价值分别为wi和vi的物品,从中挑选总重量不超过W的物品,求出挑选物体价值总和的最大值。在这里,每种物品可以选择任意多件。

这一题,按照dp问题的解题模型,先确定决策对象,是重量和价格之间的对应关系,因为每一种物体之间都是独立的,所以可以将不同阶段对应于每一种挑选的物品,于是可以定义dp[i][j]为选择到第i种物品时,剩余j重量下的最大值。接着可以写出状态转移方程:

dp[i][j]=max(dp[i-1][j-k * w[i]]+k v[i],k=0,1,2...,j/w[i)。

上面的转移方程可以做以下变形:

dp[i][j]=max(dp[i-1][j],dp[i-1][j-k *w[i]]+k
v[i],k=1,2,...,j/w[i])。

dp[i][j]=max(dp[i-1][j],dp[i-1][j-w[i]-(k-1) * w[i]]+(k-1) * v[i]+v[i],k=1,2,...,j/w[i])

令k-1=p,则dp[i][j]=max(dp[i-1][j],max(dp[i-1][j-w[i]-p *w[i]]+p *v[i]+v[i]),p=0,1,2...,(j-w[i])/w[i])

于是可以化简得到:

dp[i][j]=max(dp[i-1][j],dp[i][j-w[i]]+v[i])。


根据01背包的经验,可以看到我们的方程是上依赖和左依赖,所以可以将“上”的投影下来,又因为依赖于左边,所以可以从左到右更新。

    /*
    *写dp时很重要的是要注意初始化。
    */
    memset(dp,0,sizeof(dp));
    int dp[W_max+1];
    for(int i=1;i<=n;i++)
        for(int j=w[i];j<=W;j++){
            dp[i][j]=max(dp[i-1][j],dp[i][j-w[i]]+v[i]);
        }

前面讲了01背包和完全背包,接下来再介绍01背包的变形。设有n个重量和价值分别为wi,vi的物品,在这些物品中挑选总重量不大于W的物品,求挑选方案中的最大值。n<=100,wi<=10^7, v[i]<=100,W<=10^9。

这题与前面01背包所不同的是,约束范围变了,如果继续定义dp[i][j]为挑选第i个物品时剩余重量为j时的最大价值,很容易发现,会超时。

其实,可以把背包问题描述成,在n个有两个属性<cost,value>物体中,找出在总cost花费条件限制内,特定的value值,我们可以把cost理解成背包问题中的重量或者价值。

这就可以引出我们对于这题的解法:决策属性依然是重量和价值,但我们可以把问题转变成下面所描述的:

dp[i][j]表示在挑选第i个物品时,总价值为j时的最小重量。于是dp[i][j]=min(dp[i-1][j],dp[i-1][j-v[i]]+w[i])。

    /*
    *本题在初始化时,显然每一个dp[i]在未放入任何东西时,为INF,即无穷大
    */
    int dp[VALUE_max];
    for(int i=1;i<=n;i++)
        for(int j=sum_value;j>=v[i];j--){
            dp[j]=min(dp[j],dp[j-v[i]]+w[i]);
        }

四、更多dp问题

最长公共子序列问题:给定两个字符串s1s2...sn和t1t2...tm。求出这两个字符串最长的公共子序列的长度。

对于这道题,决策对象为字符串中的字符,以及长度。可以把每一个字符都看成是独立的,所以,可以按字符来划分阶段,设dp[i][j]表示s1-si,t1-ti时的最长公共子序列。

因为dp[i][j]只能从三个状态转移得到,即,dp[i-1][j-1]、dp[i-1][j]、dp[i][j-1],可以清楚的得到转移方程:

如果si=tj dp[i][j]=dp[i-1][j-1]+1 如果si!=tj dp[i][j]=max(dp[i-1][j],dp[i][j-1])

    /*
    *很显然在没有挑选任何一个字符时,dp[][0]=0,dp[0][]=0;
    */
    int dp[N_max+1][M_max+1];
    memset(dp,0,sizeof(dp));
    for(int i=1;i<=n;i++)
        for(int j=1;j<=m;j++){
            if(s[i]==t[j]){
                dp[i][j]=dp[i-1][j-1]+1;
            }
            else{
                dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
            }
        }

最长上升子序列问题:给定数列a1,a2,...,an,求其最长上升子序列。

设dp[i]为长度为i时的子序列的末尾最小值。可以得到转移方程:dp[i]=min(aj,dp[i]);

    /*
    *lower_bound(dp,dp+n,a[i])返回在有序dp数组中,第一个大于等于a[i]的地址。
    */
    int dp[N_max+1];
    memset(dp,0,sizeof(dp));
    for(int i=1;i<=n;i++){
        *lower_bound(dp,dp+n,a[i])=a[i];
    }

多重部分和问题:有n种大小不同的数字ai,每种各mi个。判断是否可以从这些数字中选出若干使它们的和为K。

定义dp[i][j]为在挑选前i种物品达到j时ai的剩余量。于是,如果dp[i-1][j]>=0,证明这个数已经达到了,所以dp[i][j]=mi;如果dp[i][j-ai]<=0或者j< ai,证明不可达到,则dp[i][j]=-1;在其他条件下,dp[i][j]=dp[i][j-ai]-1;
显然也可以看到,状态转移方程是上依赖和左依赖,可以压缩成一维。

    int dp[K_max+1];
    memset(dp,-1,sizeof(dp));
    for(int i=1;i<=n;i++)
        for(int j=0;j<=K;j++){
            if(dp[j]>=0){
                dp[j]=m[i];
            }
            else if(j<a[i]||dp[j-a[i]]<=0){
                dp[j]=-1;
            }
            else{
                dp[j]=dp[j-a[i]]-1;
            }
        }

划分数:有n个无区别的物品,将它们划分成不超过m组,求划分方法数模M的余数。

定义dp[i][j]为前i个物品的j划分的划分数。接下来考察j种划分,如果存在一种划分为0,则为dp[i][j-1],或者每种划分都大于0,如果每种划分都大于0,则将每种划分减1;
于是dp[i][j]=dp[i][j-1]+dp[i-j][j];

    int dp[M_max+1][N_maxn+1];
    dp[0][0]=1;
    for(int i=0;i<=n;i++)
        for(int j=1;j<=n;j++){
            if(i-j>=0){
                dp[i][j]=(dp[i][j-1]+dp[i-j][j])%M;
            }
            else{
                dp[i][j]=dp[i][j-1];
            }
        }

多重集组合数:有n种物品,第i种物品有ai个。不同种类的物品可以互相区分但相同种类无法区分。从这些物体中取出m个的话,有多少种取法?求方案数模M的余数。

设dp[i][j]为前i个物品取出j个的方法数:dp[i][j]=∑dp[i-1][j-k] 其中k=0,1,...,min(ai,j)

可以仿照前面完全背包的推导方式,考虑是j-1还是ai起作用,可以得到:dp[i][j]=dp[i-1][j]+∑dp[i-1][j-1-p]-dp[i-1][j-1-ai] p=0,1,...,min(j-1,ai)。

所以dp[i][j]=dp[i-1][j]+dp[i][j-1]-dp[i-1][j-1-ai];

    int a[MAX_N];
    int dp[MAX_N+1][MAX_M+1];
    for(int i=0;i<=n;i++)
        dp[i][0]=1;
    for(int i=1;i<=n;i++)
        for(int j=1;j<=m;j++){
            if(j-1-a[i]>=0){
                dp[i][j]=(dp[i-1][j]+dp[i][j-1]-dp[i-1][j-1-a[i]])%M;
            }
            else{
                dp[i][j]=(dp[i-1][j]+dp[i][j-1])%M;
            }
        }

#### 五、summary

看完前面的这些基础的题目,可以感受得到,一种好的状态的定义,将能带来好的状态转移方程的书写,以及对复杂度的大量降低。但是还是面临一个问题,如何选择好一个好的状态定义?我记得在以前看过的文章中,写过一句话,其实大部分问题都确实有动态规划的解法,是否能用动态规划解题,取决于你对问题的描述与理解。

最后再说一句,但行好事,莫问前程。

猜你喜欢

转载自www.cnblogs.com/waaaafool/p/10646952.html