算法(cpp) - 动态规划(dp)

141 阅读15分钟

数字三角形模型

  • 当前的状态由上一层与当前状态相关的两个或多个状态转移过来(上一层的状态已知)例如:杨辉三角形
  • 当路线是双路甚至是多路的情况时,可以用 k=i1+j1,k=i2+j2,中 k 表示状态减少 dp 的纬度,例如:dp[i1][j1][i2][j2]=dp[k][i1][i2]

背包模型

背包问题的初始化

  • 对于最值问题
    • 对于状态合法
      • 如果状态定义为小于等于v 的状态,那0是所有状态的初始化
        • 状态转移时,dp[j]=max/min(dp[j],dp[j-v]+w);
      • 如果状态定义为恰好为v的状态,那么dp[0]=0,其他状态则是不合法的状态
        • 状态转移时,dp[j]=max/min(dp[j],dp[j-v]+w);
      • 如果状态定义为大于等于v的状态,那么dp[0]=0,其他状态则是不合法状态
        • 状态转移时,dp[j]=max/min(dp[j],dp[max(j-v,0)]+w);
    • 对于状态不合法情况,对于max,不合法的初始化为-0x3f3f3f3f,对于min,不合法状态为0x3f3f3f3f
  • 对于方案数问题,只需要初始化dp[0]=1,其他状态保持0即可
    • 状态转移 dp[j]+=dp[j-v]

01背包

  • 每件物品 体积:v 价值:w 数量:1
f[i][j]=Max(f[i-1][j],f[i-1][j-v]+w)

f[j]=Max(f[j],f[j-v]+w)
#include<iostream>
#include <algorithm>

using namespace std;

const int N = 1010;
int dp[N];
int main()
{
    int n,m;
    cin>>n>>m;
   
    for(int i=1;i<=n;i++)
    {
        cin>>v>>w;
        for(int j=m;j>=v[i];j--)
            dp[j]=max(dp[j],dp[j-v]+w);
    }    
  
     
    cout<<dp[m];
    return 0;
    
}
  • 先考虑是滚动数组的优化,像在表达式中,只有 i 层 和 i-1 两层时,可用滚动数组
  • 然后考虑如 j 和 j-w 这样 j-w 严格在 j 的一侧的时候,可以直接优化成一维
    • 由于 i 层是由 i - 1 层来转化的,需要考虑的是怎么样防止 i-1 层的 j- w 要不先被更新
    • 很明显,当前 i 层的值 j 需要左边的 i - 1 层的 j - w 值来更新的时候,需要从右边开始更新,防止 j - w 值先被更新成 i 层
    • 反之,如果 这一层的值需要上一层的右边值来更新的话,那就先从左边开始更新

完全背包

  • 每件物品 体积:v 价值:w 数量:无穷
f[i,j]=Max(f[i-1][j], f[i-1][j-v]+w,f[i-1][j-2v]+2w....f[i-1][j-kv]+kw)
f[i,j-v]=Max(f[i-1][j-v], f[i-1][j-2v]+w,f[i-1][j-3v]+2w....f[i-1][j-kv]+(k-1)w)


f[i][j]=Max(f[i-1][j],f[i][j-v]+w)
f[j]=Max(f[j],f[j-v]+w)
  • 与 01 背包问题不同,完全背包问题的 i 层状态是通过 i 层来更新的,所以要先更新转移前的状态 也就是从左边开始更新
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;
int dp[N];
int main()
{
    int n,m;
    cin>>n>>m;

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

多重背包

  • 每件物品 体积:v 价值:w 数量:s
f[i,j]=Max(f[i-1][j], f[i-1][j-v]+w,f[i-1][j-2v]+2w....f[i-1][j-sv]+sw)
f[i,j-v]=Max(f[i-1][j-v], f[i-1][j-2v]+w,f[i-1][j-3v]+2w....f[i-1][j-sv]+(s-1)w
+f[i-1][j-(s+1)v]+sw)//多出一块:f[i-1][j-(s+1)v]+sw
//无法通过完全背包问题优化                                       

//二进制优化:
1,2,4,8, ... ,512 
//从二进制角度来看:其实每个数都是二进制中的一个位为1的数
//所以加在一起能够表示的是(第n号位*2-1)的数
//此时的总数量是n*logs个
//多重背包二进制优化为01背包问题

//单调队列优化
//dp[i][j] = (dp[i-1][j],···,dp[i-1][r](r为j%v))
//(一共有s个)中最大值dp[i-1][k]+(j-k)/v*w
//枚举r,通过r来得到每一个j值,dp[i][j]由dp[i-1][j···j-s*v](一共s个)中最大值组成
//用单调队列存储从r开始的j值,通过单调队列维持区间j··j-s*v且单调队列对头为区间最大值
//从r开始更新所有以r为模的体积的最大值

二进制优化

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1100;
int v[N],w[N],dp[N];

int main()
{
    int n,m;
    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]=k*a;
            w[cnt]=k*b;
            s-=k;
            k*=2;
        }
        if(s>0)
        {
            cnt++; 
            v[cnt]=s*a;
            w[cnt]=s*b;
        }
    }
    n=cnt;

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

}

单调队列优化

#include <bits/stdc++.h>

using namespace std;

const int N = 1010, M = 20010;

int n, m;
int v[N], w[N], s[N];
int dp[M],g[M];
int q[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)
    {
        memcpy(g,dp,sizeof g);//g储存上一层的所有值
        for (int r = 0; r < v[i]; ++ r)//枚举余数j%v
        {
            int hh = 0, tt = -1;//单调队列
            for (int j = r; j <= m; j += v[i])//枚举所有体积
            {
                while (hh <= tt && j - q[hh] > s[i] * v[i]) hh ++ ;//维持队列区间j--j-s*v
                //维护单调队列
                while (hh <= tt && g[q[tt]] + (j - q[tt]) / v[i] * w[i] <= g[j]) -- tt;
                q[ ++ tt] = j;
                //更新
                dp[j] = g[q[hh]] + (j - q[hh]) / v[i] * w[i];
            }
        }
    }
    cout << dp[m] << endl;
    return 0;
}

分组背包

  • 每件物品 体积:v 价值:w 分为 N 组,每组只能选一个物品
f[i][j]=Max(f[i-1][j],f[i-1][j-v[i,k]]+w[i,k])

f[i][j]=Max(f[j],f[j-v[i,k]]+w[i,k])
  • 与 01 背包问题类似的遍历方式,多一层组的循环,j 的循环依旧是从左边开始
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 110;
int dp[N],w[N][N],v[N][N],s[N];
int main()
{
    int n,m;
    cin>>n>>m;
    for(int i=1;i<=n;i++)
    {
        cin>>s[i];
        for(int j=1;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--)
        {
            for(int k=1;k<=s[i];k++)
            {
                if(v[i][k]<=j)
                dp[j]=max(dp[j],dp[j-v[i][k]]+w[i][k]);
            }
        }
    }
    cout<<dp[m];
    return 0;
}

线性 DP(最长上升子序列模型)

  • 当前的状态由前面的状态转移过来(一般遍历前面的所有状态选最优状态)
  • 如果要先上升再下降,就先分别求出顺序最长上升和逆向最长上升,再遍历所有状态的两个上升的序列值相加,得到 max 值
  • 多组上升子序列
    • 对于上升序列,其最大上升序列的每个值都严格大于其他上升序列的值
    • 所以只需要依次从大到小遍历所有序列,得到合适位置插入即可
    • 对于多组下降子序列同理

最长上升子序列

给定一个长度为 N 的数列,求数值严格单调递增的子序列的长度最长是多少。
  • 当前点的状态由前面所有比它小的点的中最长的值转移过来
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1010;
int nums[N];
int dp[N];
int main()
{
    int n;
    cin>>n;
    for(int i=1;i<=n;i++)cin>>nums[i];
    int ans=1;
    
    for(int i=1;i<=n;i++)
    {
        dp[i]=1;
        for(int j=i-1;j>0;j--)
        {
            if(nums[j]<nums[i])
            {
                dp[i]=max(dp[i],dp[j]+1);
                ans=max(ans,dp[i]);
            }
        }
    }
    cout<<ans;
    return 0;
}
//二分优化 :
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 101000;
int q[N];
int a[N];

int main()
{
    int n;
    cin>>n;
    for(int i=1;i<=n;i++)cin>>a[i];
    
    q[0]=-0x3f3f3f3f;
    int len=0;
    for(int i=1;i<=n;i++)
    {
        int l=0,r=len;
        while(r>l)
        {
            int mid=l+r+1>>1;
            if(q[mid]<a[i])l=mid;
            else r=mid-1;
        }
        len=max(len,r+1);
        q[r+1]=a[i];
    }
    cout<<len<<endl;
    return 0;
    
}

区间 DP

  1. 从小到大枚举区间大小 len
  2. 枚举区间左端点 l ,同时根据区间大小 len 和左端点 l 计算出区间右端点 r=l+len
  3. 通过状态转移方程求 dp[l] [r] 的值
有 N 堆石子排成一排,其编号为 1,2,3,…,N1,2,3,…,N。
每堆石子有一定的质量,可以用一个整数来描述,现在要将这 N 堆石子合并成为一堆。
每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,
合并后与这两堆石子相邻的石子将和新堆相邻,合并时由于选择的顺序不同,合并的总代价也不相同。
  • 区间 a 到 b 整体合并,可以划分到更小子区间(a-k,k-b)的合并,k 可以是 a-b 的任何一个点,取最小
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 310;
int dp[N][N];
int s[N];
int main()
{
    int n;
    cin>>n;
    
    for(int i=1;i<=n;i++)
    {
        cin>>s[i];
        s[i]+=s[i-1];
    }
    
    for(int k=1;k<n;k++)
    {
        for(int i=1;i+k<=n;i++)
        {
            int j=i+k;
             dp[i][j]=1e9;
            for(int t=i;t<j;t++)
            {
                dp[i][j]=min(dp[i][j],dp[i][t]+dp[t+1][j]+s[j]-s[i-1]);
            }
        }
    }
    cout<<dp[1][n];
    
    return 0;
}

数位统计 DP

分析

  • 设数N=a(n) a(n-1) a(n-2)...a(1) a(0)
  • 当高位取到0 - a-1时,低位可以任意取
  • 对其分析有: 4E378387-1287-4906-A1C5-F50A9833D6DB.png
  • 最终的答案是 所有树中的0 - a-1的方案加上最后一颗子树的a(0)的方案
void init()//初始化所有情况的方案数

int  dp(int n)//dp(n)表示0-n所有数中符合要求的数的数量,对于区间数量 dp(r)-dp(l-1)
{
    if(!n)return 0;//特判n==0的情况
    
    vector<int>nums;
    while(n)nums.push_back(n%10),n/=10;
    
    int res=0;//总的数量;
    int last=0;//对当前位的前面所有位的情况记录;
    
    for(int i=nums.size()-1;i>=0;i--)
    {
        int x=nums[i];
        //对所有情况做判断,并统计最终结果
        
        if(!i)//对=最后一颗子树的a(0)的方案判断
    }
    return res;
}

题目

  • 分类讨论
//计数问题

给定两个整数 ab,求 ab 之间的所有数字中 09 的出现次数。
例如,a=1024b=1032,则 ab 之间共有 9 个数如下:
1024 1025 1026 1027 1028 1029 1030 1031 1032
其中 0 出现 10 次,1 出现 10 次,2 出现 7 次,3 出现 3 次等等…
输入包含多组测试数据。
每组测试数据占一行,包含两个整数 ab。
当读入一行为 0 0 时,表示输入终止,且该行不作处理。
  1. 实现一个find(x,k)的函数,表示 1~x 中 k 出现的次数,则 a~b 中 i 出现的次数就是 find(b,i)-find(a-1,i)
  2. 对于 0000000 ~ abc d efg,假设求 4 位出现 k 则数为 xxxkyyy:
  3. 当000<=xxx<=abc-1,则这个数严格小于 abcdefg,这样的情况个数有:abc*(10^3)
    1. 当 d==0,这时xxx!=000,此时000<xxx<=abc-1,情况个数:(abc-1)*(10^3)
  4. 当xxx==abc,对 d 进行分类:
    1. 当d<k,此时abckyyy>abcdefg 无解
    2. 当d==k,则只有000<=yyy<=efg 有解 ,情况个数:efg+1
    3. 当d>k, 则abcdefg>abckyyy,情况个数:(10^3)
#include <iostream>
#include <cstring>
#include <algorithm>
#include <vector>

using namespace std;

int to_int(vector<int>num,int l,int r)
{
    int ans=0;
    for(int i=l;i>=r;i--)
    {
        ans=ans*10+num[i];
    }
    return ans;
}
int to_10(int k)
{
    int ans=1;
    while(k--)ans*=10;
    return ans;
}
int find(int x,int k)
{
    if(!x)return 0;
    
    vector<int>num;
    while(x)num.push_back(x%10),x/=10;
    
    int n=num.size();
    int ans=0;
    
    for(int i=n-1-!k;i>=0;i--)
    {
        if(i<n-1)
        {   
            ans+=to_int(num,n-1,i+1)*to_10(i);
            if(!k)ans-=to_10(i);
        }
        if(num[i]==k)ans+=to_int(num,i-1,0)+1;
        else if(num[i]>k)ans+=to_10(i);
    }
    return ans;
}


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<<find(b,i)-find(a-1,i)<<" ";
        cout<<endl;
    }
    return 0;
}

状态压缩 DP

  • 时间复杂度计算:状态数量*状态计算的计算量

棋盘式

蒙德里安的梦想
求把 N×M 的棋盘分割成若干个 1×2 的长方形,有多少种方案。
  • 状态dp[i,j]表示:第 i 列中,存在以 从i-1列伸出的所有组成的j的最大方案数
    • (例如:总共 5 行,第 1,3,5 行有从i-1列的横向 1*2 方块占有第i列,所以dp[i,j]就是dp[i,10101],用j的·二进制表示第i列的状态)
  • 要使得dp[i-1,k]转移到dp[i,j]需要满足
    • j&k==0
      • 表示第 i 行和第 i-1 行的横向方块没有冲撞
    • j|k不存在连续奇数个 0
      • 表示第 i 行的 j 是由第 i - 1 行放的横向和纵向转移来
        • 如果有三个连续的 0,那很明显最后一行或者第一行是可以放一个横向剩下放个竖向,或者直接放三个横向的,那就说明第 i 行的 j 不是由第 i-1 行的 k 转移
        • 如果就一个 0 一样可以是放一个横向
        • 所以只有偶数时,说明那放的是竖向的,此时才说明转移是正确的 每组测试用例占一行,包含两个整数 N 和 M。 当输入用例 N=0,M=0 时,表示输入终止,且该用例无需处理。
#include<iostream>
#include <cstring>

using namespace std;

const int N = 12,M=1<<N;
long long dp[N][M];
bool st[M];
int main()
{
    
    int n,m;
    while(cin>>n>>m,n||m)
    {
        memset(dp, 0, sizeof dp);
        
        for(int j=0;j<1<<n;j++)//列举所有二进制状态
        {
            int cnt=0;//cnt 记录连续0的个数
            st[j]=true;
            for(int i=0;i<n;i++)//j表示的是所有行的转态,所以有n位
            {
                if(j>>i&1)//当前位是1
                {
                    if(cnt&1)st[j]=false;//前面有奇数个0
                    cnt = 0;//更新cnt
                }else cnt++;//当前位为0,cnt++
            }
            if(cnt&1)st[j]=false;//判断最后一次cnt
        }
        
        dp[0][0]=1;//第0列没有从-1列的横向
        for(int i=1;i<=m;i++)//枚举到第m列
            for(int j=0;j<1<<n;j++)
                for(int k=0;k<1<<n;k++)
                    if((j & k)==0&&st[j|k]) dp[i][j]+=dp[i-1][k];
        cout<<dp[m][0]<<endl;//不存在第m列,所以dp[m][0]其实就表示前面的所有方案和
    }
    
    return 0;
}

集合

最短Hamilton路径
给定一张 n个点的带权无向图,点从 0∼n−1 标号,求起点 0到终点 n−1 的最短 Hamilton 路径。
Hamilton 路径的定义是从 0 到 n−1不重不漏地经过每个点恰好一次。
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 20,M=1<<N;
int w[N][N];
int dp[M][N];
int main()
{
    int n;
    cin>>n;
    for(int i=0;i<n;i++)
        for(int j=0;j<n;j++)
            cin>>w[i][j];
    
    memset(dp, 0x3f, sizeof dp);
    dp[1][0]=0;
    
    for(int i=0;i<1<<n;i++)
        for(int j=0;j<n;j++)            
            if(i>>j&1)
                for(int k=0;k<n;k++)
                    if((i - (1<<j) ) >>k & 1)dp[i][j]=min(dp[i][j],dp[i - (1<<j)][k]+w[k][j]);
    
    cout<<dp[(1<<n)-1][n-1]<<endl;
    return 0;
}

树形 DP

Ural 大学有 N 名职员,编号为 1∼N。
他们的关系就像一棵以校长为根的树,父节点就是子节点的直接上司。
每个职员有一个快乐指数,用整数 Hi 给出,其中 1i≤N。
现在要召开一场周年庆宴会,不过,没有职员愿意和直接上司一起参会。
在满足这个条件的前提下,主办方希望邀请一部分职员参会,使得所有参会职员的快乐指数总和最大,求这个最大值。

输入格式
第一行一个整数 N。
接下来 N 行,第 i 行表示 i 号职员的快乐指数 Hi。
接下来 N−1 行,每行输入一对整数 L,K,表示 K 是 L 的直接上司。
  • 状态表示
    • dp[u][0]
      • 表示以 u 为根节点且不包含 u 的最大值
      • dp[u][0]+=max(dp[w][0],dp[w][1])w 为所有 u 的子节点
    • dp[u][1]
      • 表示以 u 为根节点且=包含 u 的最大值
      • dp[u][1]+=dp[w][0]w 为所有 u 的子节点
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 6010;
int dp[N][2],happy[N],h[N], e[N], ne[N], idx;
bool fa[N];
void add(int a,int b)
{
    e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}

void dfs(int u)
{
    dp[u][1]=happy[u];
    
    for(int i=h[u];i!=-1;i=ne[i])
    {
        int w=e[i];
        dfs(w);
        
        dp[u][1]+=dp[w][0];
        dp[u][0]+=max(dp[w][0],dp[w][1]);
    }
}


int main()
{
    int n;
    cin>>n;
    for(int i=1;i<=n;i++)cin>>happy[i];
    memset(h, -1, sizeof h);
    for(int i=0;i<n-1;i++)
    {
        int a,b;
        cin>>a>>b;
        add(b, a);
        fa[a]=true;
    }
    int k=1;
    while(fa[k])k++;//找到根节点
    
    dfs(k);
    cout<<max(dp[k][0],dp[k][1])<<endl;
        
}

记忆化搜索

给定一个 R 行 C 列的矩阵,表示一个矩形网格滑雪场。 矩阵中第 i 行第 j 列的点表示滑雪场的第 i 行第 jj列区域的高度。 一个人从滑雪场中的某个区域内出发,每次可以向上下左右任意一个方向滑动一个单位距离

  • dp[i][j] 表示从( i ,j )出发的最长路径
  • dp[i][j] 由 4 个方向中比( i ,j )点小的状态 +1 转移
  • 在每次搜索时,会同时搜索到 其余部分点的状态,对这些点搜索过的直接返回值
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 310;
int dp[N][N];
int nums[N][N];
int dx[4]={0,-1,1,0};
int dy[4]={-1,0,0,1};
int n,m;
int dfs(int x,int y)
{
    int &v=dp[x][y];
    if(v!=-1)return v;
    
    v=1;
    for(int i=0;i<4;i++)
    {
        int nx=dx[i]+x;
        int ny=dy[i]+y;
        if(nx>=0&&ny>=0&&nx<n&&ny<m&&nums[nx][ny]<nums[x][y])
        v=max(v,dfs(nx,ny)+1);
    }
    return v;
    
}
int main()
{
    cin>>n>>m;
    
    for(int i=0;i<n;i++)
    {
        for(int j=0;j<m;j++)
        {
            cin>>nums[i][j];
        }
    }
    memset(dp, -1, sizeof dp);
    int res=0;
    for(int i=0;i<n;i++)
    {
        for(int j=0;j<m;j++)
        {
            res=max(res,dfs(i,j));
        }
    }
    cout<<res<<endl;
}