动态规划基础

本节动态规划(Dynamic Programming,DP),分析使用的是闫氏DP分析法

DP分析法:从集合角度分析

\[DP\left\{ \begin{matrix} 状态表示:维数从小到大考虑,若两维f(i,j) \left\{ \begin{matrix} 集合 \left\{ \begin{matrix} 什么的集合?\\(如:所有可能选项的集合)\\ 集合所要满足的条件:i、j实际意义的体现 \end{matrix} \right. \\ 集合属性 \left\{ \begin{matrix} 最大值 \\ 最小值 \\ 数量 \end{matrix} \right. \end{matrix} \right. \\ 状态计算:如何一步一步算出状态、如何划分子集 \end{matrix} \right. \]

DP问题优化

对方程进行等价变换,先思考朴素方法再优化。

以01背包为例,具体模型下一节,

状态表示:化零为整

\[状态表示:f(i,j)\left\{ \begin{matrix} 集合 \left\{ \begin{matrix} 所有选法 \\ 条件 \left\{ \begin{matrix} 只从前i个物品里面选 \\ 总体积\leqslant j \end{matrix} \right. \end{matrix} \right. \\ 属性:最大值 \end{matrix} \right. \]

f(i,j)存的是集合,的属性值,f(i,j)表示的含义是集合--->满足条件的所有选法的集合,的属性--->集合中只从前i个物品里面选且总体积小于等于j时,的最大值

答案:假设物品总量是N,背包容量V,答案就是f(N,V)

状态计算:化整为零

集合划分,把当前集合f(i,j)划分成若干个子集,同时每个子集都能算出来

(划分原则:不重不漏,一定需要不漏,但有时可以重复,如求最大值)

--->(以01背包为例)

分成两个集合:包含i与不包含i

不包含i:即从1~i-1中选,且总体积不超过j,的集合,即f(i-1,j)

包含i:即从1~i中选,包含i,且总体积不超过j,的集合,

即选i,占用一个v[i],(由于前提是包含i,这部分已经固定);再从1~i-1中选,且这些总体积不超过j-v[i]的集合,

即f(i-1,j-v[i])+w[i]

--->故

\[f(i,j)=max(f(i-1,j),f(i-1,j-v[i])+w[i]) \]

时间复杂度:状态数量*转移数量

输出具体一步一步怎么过来的:单独开一个数组记录当前坐标从哪一个状态坐标转移过来,若没有上一个状态/现在状态是起点,就设置为-1或者一个比较特殊的数,这个数字同时可以作为回溯的终点,最后再输出

背包问题

01背包

模型:在一个背包容积固定的情况下,能装下的最大价值是多少(不要求装满背包)?假设有若干物品(每件物品只有一个),每种物品体积以及价值不同。

按照DP分析法的思路

朴素的二维写法

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int n,m;
int v[N],w[N];
int f[N][N];

int main()
{
    cin>>n>>m;
    
    for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
    //从1开始,下标具有实际含义
    
    //初始化f[0][0~m]全是0,全局变量默认0,略去
    
    for(int i=1;i<=n;i++)
        for(int j=0;j<=m;j++)
        {
            f[i][j]=f[i-1][j];
            if(j>=v[i]) f[i][j]=max(f[i][j],f[i-1][j-v[i]]+w[i]);
            //只有j>=v[i]的前提下,子集情况“必包含i”才有意义
        }
        
    cout<<f[n][m]<<endl;
    
    return 0;
}

优化成一维

/*
算f[i][j]只用到了f[i-1]这一层的[j]与[j-v[i]],即0~i-2都没有用,同时j与j-v[i]都在j的一侧,都小于等于j,所以可以用滚动数组,改成一维数组计算,不断用老一层(i-1)数据来计算新一层(i)数据再覆盖掉老一层数据

f[i][j]=f[i-1][j]删去第一维度之后是f[j]=f[j],直接略去;

由于if(j>=v[i]),循环只有在j=v[i]~m之间才有效:改循环上下限

在循环是for(int j=v[i];j<=m;j++)情况下
f[i][j]=max(f[i][j],f[i-1][j-v[i]]+w[i]);直接删去第一维变成
f[j]=max(f[j],f[j-v[i]]+w[i]),然而这时f[j-v[i]]等价的是f[i][j-v[i]]而不是f[i-1][j-v[i]],
故让j从小到大更新显然不行,
此时解决方法是让j倒着算for(int j=m;j>=v[i];j--)
这样根据j每次更新f[j]的时候,f[j-v[i]] (后更新数据) 相对于f[j] (先更新) 用的就是老一层(i-1)的数据
*/
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int n,m;
int v[N],w[N];
int f[N];

int main()
{
    cin>>n>>m;
    
    for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
    
    //相对二维改动部分:
    for(int i=1;i<=n;i++)
        for(int j=m;j>=v[i];j--)
            f[j]=max(f[j],f[j-v[i]]+w[i]);
        
    cout<<f[m]<<endl;
    
    return 0;
}

完全背包

模型:相对于01背包而言,每件物品有无限个

分析:

\[状态表示:f(i,j)\left\{ \begin{matrix} 集合 \left\{ \begin{matrix} 所有选法 \\ 条件 \left\{ \begin{matrix} 只从前i个物品里面选 \\ 总体积\leqslant j \end{matrix} \right. \end{matrix} \right. \\ 属性:最大值 \end{matrix} \right. \]

状态计算:子集为第i个物品选几次,从不含到含1个、含2个...含k个,k的范围是k*v[i]<=j,即不超过容量j

朴素代码

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;
int f[N][N],w[N],v[N];
int n,m;

int main()
{
    cin>>n>>m;
    for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
    
    for(int i=1;i<=n;i++)
        for(int j=0;j<=m;j++)
            for(int k=0;v[i]*k<=j;k++)
                f[i][j]=max(f[i][j],f[i-1][j-k*v[i]]+k*w[i]);
    
    cout<<f[n][m];
    
    return 0;
}

优化

\[f[i][j]=max(f[i-1][j],f[i-1][j-v[i]]+w[i],...,f[i-1][j-k_1*v[i]]+k*w[i])\\k_1*v[i]<=j\\ f[i][j-v[i]]=max(f[i-1][j-v],...,f[i-1][j-k_2v]+(k_2-1)*w[i])\\k_2*v[i]<=j\\k_1=k_2\\ f[i][j]=max(f[i-1][j],f[i][j-v[i]]+w[i]) \]

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int n,m;
int v[N],w[N];
int f[N][N];

int main()
{
    cin>>n>>m;
    
    for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
    
    for(int i=1;i<=n;i++)
        for(int j=0;j<=m;j++)
        {
            f[i][j]=f[i-1][j];
            if(j>=v[i]) f[i][j]=max(f[i][j],f[i][j-v[i]]+w[i]);
        }//注意这里二维优化为一维的思想,用[j-v]与[j]来更新[j],在更新操作时,由于j++,j是递增的,[j-v]已经更新,是第i层的,而[j]正要更新,所以仍然是第i-1层的数据,更新之后j是第i层的,即f[i][j]=max(f[i-1][j],f[i][j-v[i]]+w[i])可以直接化为f[j]=max(f[j],f[j-v[i]]+w[i]),j++.(注意代码里的f[i][j]在if之前实际是f[i-1][j],先被赋值的)
        
    cout<<f[n][m]<<endl;
    
    return 0;
}

进一步优化为一维

//优化原理见上一个代码块注释

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int n,m;
int v[N],w[N];
int f[N];

int main()
{
    cin>>n>>m;
    
    for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
    
    for(int i=1;i<=n;i++)
        for(int j=v[i];j<=m;j++)
        {
            f[j]=max(f[j],f[j-v[i]]+w[i]);
        }
        
    cout<<f[m]<<endl;
    
    return 0;
}

多重背包

模型:相对于01背包而言,每件物品有S[i]个

分析:

\[状态表示:f(i,j)\left\{ \begin{matrix} 集合 \left\{ \begin{matrix} 所有选法 \\ 条件 \left\{ \begin{matrix} 只从前i个物品里面选 \\ 总体积\leqslant j \end{matrix} \right. \end{matrix} \right. \\ 属性:最大值 \end{matrix} \right. \]

状态计算:子集为第i个物品选几个,从0个到k个,k要同时小于,第i个物品最大数量,&&k*v[i]<=j

朴素写法:也就比完全背包朴素写法第三层循环多一个条件k<=s[i]

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 110;
int f[N][N],w[N],v[N],s[N];
int n,m;

int main()
{
    cin>>n>>m;
    for(int i=1;i<=n;i++) cin>>v[i]>>w[i]>>s[i];
    
    for(int i=1;i<=n;i++)
        for(int j=0;j<=m;j++)
            for(int k=0;v[i]*k<=j&&k<=s[i];k++)
                f[i][j]=max(f[i][j],f[i-1][j-k*v[i]]+k*w[i]);
    
    cout<<f[n][m];
    
    return 0;
}

优化

按照完全背包的思路不可行

\[f[i][j]=max(f[i-1][j],f[i-1][j-v[i]]+w[i],...,f[i-1][j-k*v[i]]+k*w[i])\\直接把j-v代入j\\ f[i][j-v[i]]=max(f[i-1][j-v],...,f[i-1][j-(k+1)v]+k*w[i]) \]

多重背包的二进制优化方法

假设说s[i]=1023,将物品打包,每个打包只有一份/只能选一次,分别将1,2,4,...,512个物品打包,每包视为01背包中的一项,这些数字一定能表示出0~1023中任意一个数字,因为他们的二进制码分别是1,10,100,...1000000000.

进一步,假设s[i]=200,那么只要1,2,4,8,16,32,64,73即可,验证:1可表示01,所以1,2可表示03任一个数,以此类推1,2,4,8,16,32,64可表示0127任一个数,加上73边可表示0200任一个数。

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 12010,M = 2010;//N=1000*log2(2000),每个物品假设有k个,最多能拆成log2(k)个

int n,m;
int v[N],w[N];
int f[N];

int main()
{
    cin>>n>>m;
    
    int cnt=0;
    for(int i=1;i<=n;i++)//根据物品件数拆分每个物品
    {
        int a,b,s;
        cin>>a>>b>>s;
        int k=1;
        while(k<=s)
        {
            cnt++;
            v[cnt]=a*k;
            w[cnt]=b*k;
            s-=k;
            k*=2;
        }
        if(s>0)
        {
            cnt++;
            v[cnt]=a*s;
            w[cnt]=b*s;
        }
    }
    
    n=cnt;
    //做一遍01背包
    for(int i=1;i<=n;i++)
        for(int j=m;j>=v[i];j--)
            f[j]=max(f[j],f[j-v[i]]+w[i]);
        
    cout<<f[m]<<endl;
    
    return 0;
}

分组背包

模型:相对于01背包而言,物品会被分类,每类物品只能拿一个,同类物品不能拿第二个。

分析:

\[状态表示:f(i,j)\left\{ \begin{matrix} 集合 \left\{ \begin{matrix} 所有选法 \\ 条件 \left\{ \begin{matrix} 只从前i组物品里面选 \\ 总体积\leqslant j \end{matrix} \right. \end{matrix} \right. \\ 属性:最大值 \end{matrix} \right. \]

状态计算:不选第i组即f[i-1,j],或选第i组的第几个物品f[i-1,j-v[i,k]]+w[i,k]

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 110;

int n,m;
int f[N],s[N],v[N][N],w[N][N];

int main()
{
    cin>>n>>m;
    for(int i=1;i<=n;i++)
    {
        cin>>s[i];
        for (int j=0;j<s[i];j++)
            cin>>v[i][j]>>w[i][j];
    }
    
    for(int i=1;i<=n;i++)
        for(int j=m;j>=0;j--)
            //若写二维f[i][j]=f[i-1][j]在这,不要放到第三层循环里了
            for(int k=0;k<s[i];k++)
                if(v[i][k]<=j)
                    f[j]=max(f[j],f[j-v[i][k]]+w[i][k]);
                
    cout<<f[m];
    
    return 0;
}

线性DP

例.AcWing898 数字三角形

分析

状态表示需要两维坐标,(i,j)即表示第i行第j个点

\[状态表示:f(i,j)\left\{ \begin{matrix} 集合:从起点到(i,j)的所有路径\\ 属性:最大值 \end{matrix} \right. \]

状态计算:f(i,j)分为两类,来自左上方f(i-1,j-1),来自右上方f(i-1,j),取max最后统一加上(i,j)的值

题解

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 510,INF = 1e9;

int a[N][N],f[N][N];

int main()
{
    int n;
    cin>>n;
    
    for(int i=1;i<=n;i++)
        for(int j=1;j<=i;j++)
            cin>>a[i][j];
            
    for(int i=0;i<=n;i++)
        for(int j=0;j<=n+1;j++)
            f[i][j]=-INF;
            
    f[1][1]=a[1][1];
    for(int i=2;i<=n;i++)
        for(int j=1;j<=i;j++)
            f[i][j]=max(f[i-1][j-1],f[i-1][j])+a[i][j];
            
    int ans=-INF;
    for(int i=1;i<=n;i++) ans=max(ans ,f[n][i]);
    
    cout<<ans;
    
    return 0;
}

例.AcWing895、896 最长上升子序列

注意:子序列并不要求连续

分析

状态表示需要一维坐标,i

\[状态表示:f(i)\left\{ \begin{matrix} 集合:所有以第i个数结尾的上升子序列的集合 \\ 属性:长度最大值 \end{matrix} \right. \]

状态计算:

对于f(i),一定选i,所以考虑i-1选的情况,首先不选前一项,从f[i]=1开始,其次i-1选从以第1、2...i-1位为结尾(不一定每一位都满足条件),f[i]=max+1

示例( O(n2) ):

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int f[N],a[N];

int main()
{
    int n;
    cin>>n;
    for(int i=1;i<=n;i++) cin>>a[i];
    
    for(int i=1;i<=n;i++)
    {
        f[i]=1;
        for(int j=1;j<i;j++)
            if(a[i]>a[j]) f[i]=max(f[i],f[j]+1);
    }
    
    int res=-1;
    for(int i=1;i<=n;i++) res=max(res,f[i]);
    
    cout<<res;
    
    return 0;
}

要输出具体是哪一串子序列:

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int f[N], a[N];
int g[N];

void show(int k)
{
    if (k == 0) return;
    show(g[k]);
    cout << a[k] << ' ';
}

int main()
{
    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];

    for (int i = 1; i <= n; i++)
    {
        f[i] = 1;
        g[i] = 0;
        for (int j = 1; j < i; j++)
            if (a[i] > a[j])
                if (f[i] < f[j] + 1)
                {
                    f[i] = f[j] + 1;
                    g[i] = j;//记录每个状态是从哪个状态转移过来的
                }
    }

    int k = 1;//记录最优解下标
    for (int i = 1; i <= n; i++)
        if (f[i] > f[k])
            k = i;

    cout << f[k] << endl;

    show(k);

    return 0;
}

优化( O(nlogn) ):当数据量很大时

假如现在在求f[i],在i之前,长度相同的子序列,只要留下结尾最小的就行了,因为比它大的一定不如小的好。

性质:不同长度子序列结尾值依长度递增,反证易得。

思路:在算i时,用二分找到最大长度满足结尾值小于第i位的数字,然后再看此时的i是否比最大长度+1的子序列末尾旧值小(其实一定是小于或者等于,因为满足条件情况下找最大,也就是从小到大找第一个不满足条件的前一个),若小,就更新

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 100010;

int n;
int a[N],q[N];//q存(在i之前)不同长度子序列最小末尾值

int main()
{
    cin>>n;
    for(int i=0;i<n;i++) cin>>a[i];
    
    int len=0;//目前最长子序列长度
    for(int i=0;i<n;i++)//遍历a[i],q数组的下标就是子序列长度
    {
        int l=0,r=len;//这个l,r是用于二分查找q数组的,q数组有l与r的讲法
        while(l<r)
        {
            int mid=l+r+1>>1;
            if(q[mid]<a[i]) l=mid;//check()描述为q[mid]<a[i],找到最大的满足小于a[i]的长度数组下标
            //由于是在长为r后接a[i]使其变为r+1,为保证严格单调递增,必须<而不能<=
            else r=mid-1;
        }
        len = max(len,r+1);//取max,若r+1小于len,因为q数组下标表示r表示现在i能接上的最长的子序列,r+1也就是0~i的最长子序列。
        q[r+1]=a[i];//q[r]是q中最后一个小于a[i]的,q[r+1]必然大于等于a[i]
    }
    
    cout<<len;
    
    return 0;
}

例.AcWing897 最长公共子序列

分析

状态表示需要两维坐标,(i,j)

\[状态表示:f(i,j)\left\{ \begin{matrix} 集合:所有在第一个序列的前i各种字母出现,且在第二个序列的前j个字母中出现的子序列的集合 \\ 属性:长度最大值 \end{matrix} \right. \]

状态计算:四个子集(这四个子集的“表达”有重叠部分,他们的表达并不完全一一对应子集,只要不漏就行):

不选a[i]不选b[j],即f(i-1,j-1);

不选a[i]选b[j],不是f(i-1,j),因为这个集合长度最大的选择不一定以j结尾,但我一定要选b[j],但又由于f(i-1,j)这个集合肯定包含 选a[i]不选b[j] 这种情况,只是属性最大值并不一定来自于它,而f(i,j)肯定包含f(i-1,j),也就是说f(i-1,j)包含的集合与其他三种情况有重叠,同时又一定包含现在的选法,并且由于我是在求“最大值”,所以可以用f(i-1,j)来代表这一个选项;

选a[i]不选b[j],f(i,j-1),解释与第二种情况同理;

选a[i]选b[j],即f(i-1,j-1)+1

示例

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

char a[N],b[N];
int f[N][N];

int main()
{
    int n,m;
    cin>>n>>m>>a+1>>b+1;

    for(int i=1;i<=n;i++)
        for(int j=1;j<=m;j++)
        {
            f[i][j]=max(f[i-1][j],f[i][j-1]);//这里略去f[i-1][j-1],因为肯定都被f[i-1][j]与f[i][j-1]包含了
            if(a[i]==b[j]) f[i][j]=max(f[i][j],f[i-1][j-1]+1);
        }
    
    cout<<f[n][m];
    
    return 0;
}

例.AcWing902 最短编辑距离

分析

状态表示需要两维坐标,[i,j]第i堆石子到第j堆石子区间

\[状态表示:f(i,j)\left\{ \begin{matrix} 集合:所有将a[1到i]变成b[1到j]的操作方式 \\ 属性:所有操作方式操作次数最小值 \end{matrix} \right. \]

状态计算 ( O(n2) ):

三个子集,对于a[i]:增/删/改

若删 说明a[i]和b[j]不同,但和a[i-1]相同,则f(i-1,j)+1

若增加 添在a[i]后添加b[j],即a和b的前j-1匹配,即f(i,j-1)+1

若改 说明要做的操作是先让a[i-1]与b[j-1]以及之前全都相同,即f(i-1,j-1)步,如果a[i]和b[j]不相等要改,则+1,如果本身就相同,则+0不需要改。

题解

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int n,m;
char a[N],b[N];
int f[N][N];

int main()
{
    cin>>n>>a+1;
    cin>>m>>b+1;
    
    //初始化
    for(int i=0;i<=m;i++) f[0][i]=i;//第一个长0,那第二个有多长,增多长
    for(int i=0;i<=n;i++) f[i][0]=i;//第二个长0,那第一个有多长,减多长
    
    for(int i=1;i<=n;i++)
        for(int j=1;j<=m;j++)
        {
            f[i][j]=min(f[i-1][j]+1,f[i][j-1]+1);
            if(a[i]==b[j]) f[i][j]=min(f[i][j],f[i-1][j-1]);
            else f[i][j]=min(f[i][j],f[i-1][j-1]+1);
        }
    
    cout<<f[n][m];
    
    return 0;
}

例.AcWing899 编辑距离

也就是上道例题的改编,计算时间复杂度后发现可以用上面的方法。

题解

#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 15,M = 1010;

int n,m;
int f[N][N];
char str[M][N];

int edit_distance(char a[],char b[])
{
    int alen=strlen(a+1),blen=strlen(b+1);
    //int alen=strlen(a)-1,blen=strlen(b)-1;是错的,在C++中char在全局变量中初始化是0,就是休止符号,根据strlen的原理是找休止符号位置,所以strlen(a)一上来就等于0
    
    for(int i=0;i<=blen;i++) f[0][i]=i;
    for(int i=0;i<=alen;i++) f[i][0]=i;
    
    for(int i=1;i<=alen;i++)
        for(int j=1;j<=blen;j++)
        {
            f[i][j]=min(f[i-1][j]+1,f[i][j-1]+1);
            if(a[i]==b[j]) f[i][j]=min(f[i][j],f[i-1][j-1]);
            else f[i][j]=min(f[i][j],f[i-1][j-1]+1);
        }
    
    return f[alen][blen];
}

int main()
{
    scanf("%d%d",&n,&m);
    
    for(int i=0;i<n;i++) scanf("%s",str[i]+1);
    
    while(m--)
    {
        char s[N];
        int limit;
        scanf("%s%d",s+1,&limit);
        
        int res=0;
        for(int i=0;i<n;i++)
        {
            int len=edit_distance(str[i],s);
            if(len<=limit) res++;
        }
        
        printf("%d\n",res);
    }
    
    return 0;
}

区间DP

例.AcWing282 石子合并

分析

状态表示需要两维坐标,[i,j]第i堆石子到第j堆石子区间

\[状态表示:f(i,j)\left\{ \begin{matrix} 集合:所有将第i堆石子到第j堆石子合并成一堆石子的合并方式的集合 \\ 属性:合并代价最小值 \end{matrix} \right. \]

状态计算 ( O(n3) ):

以合并的倒数第二步还剩两堆时的分界线为划分子集依据:即一开始左边1、2、3...j-i个,下表为i~j-1,代价即 f(i,k)+f(k+1,j)+最后一步代价 的最小值 最后一步代价最小值不用从i加到j,利用前缀和快速计算s[j]-s[i-1]

为了保证每个子集都已经计算过,按照区间长度从小到大的顺序来枚举

题解

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 310;

int n;
int s[N];
int f[N][N];

int main()
{
    scanf("%d",&n);
    for(int i=1;i<=n;i++) scanf("%d",&s[i]);
    
    for(int i=1;i<=n;i++) s[i]=s[i-1]+s[i];//前缀和
    
    for(int len=2;len<=n;len++)//枚举长度
        for(int i=1;i+len-1<=n;i++)//枚举起点
        {
            int l=i,r=l+len-1;
            f[l][r]=0x3f3f3f3f;
            for(int k=l;k<r;k++)//把k作为分界点,其中k属于左侧,f[k+1]要合法,所以k+1<=r,即k<r
            {
                f[l][r]=min(f[l][r],f[l][k]+f[k+1][r]+s[r]-s[l-1]);
            }
        }
        
    printf("%d",f[1][n]);
    
    return 0;
}

计数类DP

例.AcWing900 整数划分

方案一:当作被背包问题,思路,将背包容量视为n,然后分别有1,2,3...n种体积与价值的物品,每种物品无限个,看作完全背包,找出恰好填满背包的方案数。

分析:

\[状态表示:f(i,j)\left\{ \begin{matrix} 集合 \left\{ \begin{matrix} 所有选法 \\ 条件 \left\{ \begin{matrix} 只从前i个物品里面选 \\ 总体积=j \end{matrix} \right. \end{matrix} \right. \\ 属性:数量 \end{matrix} \right. \]

状态计算:子集为第i个物品选几次,从不含到含1个、含2个...含k个,k的范围是k*v[i]<=j,即不超过容量j。这些所有满足条件:总体积=j的选法之和。

根据完全背包优化

\[f[i][j]=f[i-1][j]+f[i][j-v]\\从小到大推进i可化简为\\f[j]=f[j]+f[j-v] \]

题解

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010,mod = 1e9 + 7;

int n;
int f[N];

int main()
{
    cin>>n;
    f[0]=1;//这里很特殊,当n=0时当作1,而其他情况初始值是0,没有方案也作为一种方案,在有可行方案的时候没有方案不作为方案数。
    
    for(int i=1;i<=n;i++)
        for(int j=i;j<=n;j++)
            f[j]=(f[j] + f[j-i])%mod;
        
    cout<<f[n];
    
    return 0;
}

方案二

分析

\[状态表示:f(i,j)\left\{ \begin{matrix} 集合:所有总和是i,并且恰好表示成j个数的方案\\ 属性:数量 \end{matrix} \right. \]

状态计算:f[i,j]所有可行方案分为:方案拆分成的数字最小值为1和最小值大于1两种,

若是最小值是1,即去掉这个1,总和变为i-1,需要j-1个数字,表示为f[i-1,j-1];

若最小值大于1,即对于每一个单独的方案,拆出来的每一个数减1,即总和减j,数字总量j保持不变,可表示为f[i-j,j],逆向思考,对于f[i,j]表示的所有方案每个组成的数字全部加1,就构成了总和为i,总数为j且最小值大于1的方案总数。

综上所述f[i,j]=f[i-1,j-1]+f[i-j,j] answer=f[n,1]+f[n,2]+...+f[n,n]

示例
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010,mod = 1e9 + 7;

int n;
int f[N][N];

int main()
{
    cin>>n;
    
    f[0][0]=1;
    for(int i=1;i<=n;i++)
        for(int j=1;j<=i;j++)
            f[i][j]=(f[i-1][j-1]+f[i-j][j])%mod;
            
    int res=0;
    for(int i=1;i<=n;i++) res = (res + f[n][i])%mod;
    
    cout<<res;
    
    return 0;
}

数位统计DP

例.AcWing338 计数问题

分析

设一函数f(n,x)可以算出1~n里数字x出现的次数。

以n=abcdefg,x=1为例:

以求1在第四位上出现的次数为例:

1<=xxx1yyy<=abcdefg

(1)xxx=000abc-1,yyy=000999,1出现的总数为abc*1000

(2)xxx=abc,从三种情况取其一

​ 2.1) d<1,abc1yyy>abc0efd,无需继续考虑,总数为0

​ 2.2) d=1,yyy=000~efg-1,总数为efg+1

​ 2.3) d>1,yyy=000~999,总情况数为1000

故总情况数为(1)+(2)

边界情况:

1)x=1为例,1出现在最高位时无需考虑(1)

2)x=0时,对于(1),xxx显然不能取000,应从001开始取,也就是(abc-1)*1000

题解

#include <iostream>
#include <algorithm>
#include <vector>

using namespace std;

int get(vector<int> num,int l,int r)//得到第一种情况所需的“abc”是多少,第二种情况的“efg”是多少
{
    int res=0;
    for(int i=l;i>=r;i--)
        res=res*10+num[i];
    return res;
}

int power10(int x)//求10的i次方
{
    int res=1;
    while(x--) res*=10;
    return res;
}

int count(int n,int x)
{
    if(!n) return 0;
    
    vector<int> num;
    while(n)//将n的每一位拿出来,只不过是倒着的
    {
        num.push_back(n%10);
        n/=10;
    }
    
    n=num.size();
    
    int res=0;
    for(int i=n-1-!x;i>=0;i--)//当x=0时最高位由于不可能是0所以要从第二位开始枚举,注意num是倒着的原数字
    {
        if(i<n-1)
        {
            res+=get(num,n-1,i+1)*power10(i);
            if(!x) res-=power10(i);//x等于0特殊情况
        }
        
        if(num[i]==x) res+=get(num,i-1,0)+1;
        else if(num[i]>x) res+=power10(i);
    }
    
    return res;
}

int main()
{
    int a,b;
    while(cin>>a>>b,a||b)
    {
        if(a>b) swap(a,b);
        
        for(int i=0;i<10;i++)
            cout<<count(b,i)-count(a-1,i)<<' ';
        cout<<endl;
    }
    
    return 0;
}

状态压缩DP

特点:由于计算量大,所以数据范围不是很大。一般N<=20。

例.AcWing291 蒙德里安的梦想

核心

先放横着的,再放竖着的,横着的摆好后,竖着的只要依次填充,没有操作空间。

总方案数 = 等于只放横着的小方块的合法方案数

=>如何判断,当前方案是否合法?合法:竖着的小方块能恰好塞满剩余空间

=>由于所有的小方块都是竖着的,所以一列一列看,只要每列所有连续且空闲的方块连着数量为偶数即可

分析

\[状态表示:f(i,j)\left\{ \begin{matrix} 集合:已将前i-1列摆好,且从第i-1列,伸出到第i列的且状态是j的所有方案\\ (j依次枚举每一种伸出的情况,用二进制状态压缩)\\ \end{matrix} \right. \]

状态计算:

分割f[i,j]枚举的是第i-1列伸出的情况,每行都有伸或不伸两种情况,用二进制压缩的k表示伸的情况以表示一个f[i,j]的子集 f[i-1,k] ,但由于第i-2列已经固定,所以i-1列不是每行都能放横着的块,故最坏的情况是每行都可以选择放或者不放横着的块,最坏情况数2^n。

但同时需要考虑,k与j要构成合法方案才能进行状态转移

k与j的合法方案要满足的条件:

(1) k&j == 0,即i-1这一列的格子不能同时被k与j设为1同时占据

(2) 一列中所有空着的位置的长度必须是偶数,保证竖着的小方块恰好能填满空闲

答案即为f[m,0] (从0开始表示第一列),按照之前的理解是整个第m+1列都是0,即没有伸出来到m+1列的,且m+1列之前的m列全部都是排满且合法的

题解

#include <cstring>
#include <algorithm>
#include <iostream>
#include <vector>

using namespace std;

typedef long long LL;

const int N = 12,M = 1 << N;

int n,m;
LL f[N][M];
vector<int> state[M];//预处理所有状态state[i](也就是j)能够从哪些状态(也就是k)转移过来
bool st[N];//判断某个状态是否合法,即当前这一列的状态i(状压)是否满足每个连续空位为偶数

int main()
{
    while(cin>>n>>m,n||m)
    {
        for(int i=0;i<1<<n;i++)//预处理某个状态是否合法,cnt就是计数连续方格数量
        {
            int cnt=0;
            bool is_valid = true;
            for(int j=0;j<n;j++)
            {
                if(i>>j&1)//判断第j位是不是1
                {
                    if(cnt&1)//二进制数第一位是0是1说明了奇偶性
                    {
                        is_valid = false;
                        break;
                    }
                    cnt=0;
                }
                else cnt++;
            }
            if(cnt&1) is_valid = false;//特判最后一段空白是不是奇数个格子
            st[i]=is_valid;
        }
        
        for(int i=0;i<1<<n;i++)//预处理状态i对应的所有可以进行转移的状态
        {
            state[i].clear();
            for(int j=0;j<1<<n;j++)
            {
                if((i&j)==0 && st[i|j])//检查合法方案的两个条件
                    state[i].push_back(j);
            }
        }
        
        memset(f,0,sizeof f);
        f[0][0]=1;//通常起始状态没有方案为1
        for(int i=1;i<=m;i++)
            for(int j=0;j<1<<n;j++)//对于所有状态j
                for(auto k:state[j])//遍历所有合法上一个状态k
                    f[i][j]+=f[i-1][k];
                    
        cout<<f[m][0]<<endl;
    }
    
    return 0;
}

例.AcWing91 最短Hamilton路径

分析

\[状态表示:f(i,j)\left\{ \begin{matrix} 集合:所有从0走到j,走过的所有点是i(状态压缩)的所有路径\\ 属性:最小值 \end{matrix} \right. \]

状态计算:

分割子集依据:倒数第二个点是什么,分别是0、1...n-1;

假设倒数第二个点其中一个是k,0->k->j,即集合i除去j这个点的f[i-{j},k],再加上k到j的举例a[k,j],枚举这样的k,对刚刚的表达式取min

题解

#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 20,M = 1<<N;

int n;
int w[N][N];
int f[M][N];

int main()
{
    cin>>n;
    for(int i=0;i<n;i++)
        for(int j=0;j<n;j++)
            cin>>w[i][j];
    
    memset(f,0x3f,sizeof f);
    f[1][0]=0;
    for(int i=0;i<1<<n;i++)
        for(int j=0;j<n;j++)
            if(i>>j&1)//i集合中要含有j这个点
                for(int k=0;k<n;k++)//枚举所有j点的前一个点
                    if((i-(1<<j))>>k&1)//满足条件的k才是合法的,要求k属于i且不是j
                        f[i][j]=min(f[i][j],f[i-(1<<j)][k]+w[k][j]);
                        
    cout<<f[(1<<n)-1][n-1]<<endl;
    
    return 0;
}

树形DP

例.AcWing285 没有上司的舞会

分析

\[状态表示:f(u,0)/f(u,1)\left\{ \begin{matrix} 集合:以u为根节点且不包含u/包含u的所有选法的集合\\ 属性:最大值 \end{matrix} \right. \]

状态计算:

f(u,0),因为没有选点u,所以其子节点Sn可选可不选,所以选最大值,即f(u,0)=...+max( f(Sn,0),f(Sn,1) )+...

f(u,1),因为选了点u,所以首先先加上点u的权,然后所有的子节点Sn都不能选,故f(u,1)=w[u]+...

+f(Sn,0)+...

题解

#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 6010;

int n;
int happy[N];
int h[N],e[N],ne[N],idx;
int f[N][2];
bool has_father[N];

int add(int a,int b)
{
    e[idx]=b;
    ne[idx]=h[a];
    h[a]=idx;
    idx++;
}

void dfs(int u)//从根节点遍历每个子节点,广度优先
{
    f[u][1]=happy[u];//f[u][1]因为选了u点自身,上来要先加上自己
    
    for(int i=h[u];i!=-1;i=ne[i])
    {
        int j = e[i];
        dfs(j);
        
        f[u][0]+=max(f[j][0],f[j][1]);
        f[u][1]+=f[j][0];
    }
}

int main()
{
    scanf("%d",&n);
    for(int i=1;i<=n;i++) scanf("%d",&happy[i]);
    
    memset(h,-1,sizeof h);
    for(int i=0;i<n-1;i++)
    {
        int a,b;//b是a的父节点
        scanf("%d%d",&a,&b);
        has_father[a]=true;
        add(b,a);
    }
    
    int root=1;
    while(has_father[root]) root++;
    
    dfs(root);
    
    printf("%d\n",max(f[root][0],f[root][1]));
    
    return 0;
}

记忆化搜索

本质上是对DP的一种更简单的理解
代码的形式有所变化,但核心思想与DP一样

例.AcWing901 滑雪

分析

\[状态表示:f(i,j)\left\{ \begin{matrix} 集合:所有从(i,j)开始滑的路径\\ 属性:最大值 \end{matrix} \right. \]

状态计算:

按往哪滑分成四类,但不是一定可行,可行的条件是下一个格子的高度低于现在的高度

不同于一般DP,这题提供一种递归写法作为启发示例

记忆化搜索写起来要比循环更好写更好理解,但可能会溢出。

题解

#include <cstring>
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 310;

int n,m;
int f[N][N];
int h[N][N];

int dx[4]={-1,0,1,0},dy[4]={0,1,0,-1};

int dp(int x,int y)
{
    int &v=f[x][y];//引用,简化代码
    if(v!=-1) return v;
    
    v = 1;//这个v=1最为重要,一步一步递归深入,找到最开始的起点,将其设为1
    
    for(int i=0;i<4;i++)
    {
        int a=x+dx[i],b=y+dy[i];
        if(a>=1 && a<=n && b>=1 && b<=m && h[a][b]<h[x][y])
            v=max(v,dp(a,b)+1);
    }
    
    return v;
}

int main()
{
    scanf("%d%d",&n,&m);
    
    for(int i=1;i<=n;i++)   
        for(int j=1;j<=m;j++)
            scanf("%d",&h[i][j]);
            
    memset(f,-1,sizeof f);//初始化,表明没有被算过
    
    int res=0;
    for(int i=1;i<=n;i++)
        for(int j=1;j<=m;j++)   
            res=max(res,dp(i,j));
            
    printf("%d\n",res);

    return 0;
}
posted @ 2022-01-02 21:57  泝涉江潭  阅读(131)  评论(0)    收藏  举报