<算法笔记03>动态规划

介绍

动态规划的核心将原问题 视作 若干个重叠子问题 逐层递进,每个子问题的求解过程都构成一个”阶段"。在完成前一个阶段的以后,下个阶段根据本阶段的答案计算出新的结果。每个阶段都有利用之前阶段的答案,因此有个很好听的名字叫做 状态转移


在这里插入图片描述
上文的介绍看烦了吧,我们举个有趣的例子

老王有三个孩子,王大,王二,王三,他们的年龄分别是a,b,c,老王去世了很久,年龄不再发生变化。在2021的一天,三个孩子说,我的父亲是我们三个年龄之和。那么在这一年,老王的年龄为 a+b+c,我们通过三个孩子的年龄之和计算得到了老王的年龄,三个孩子的年龄可以认为是一种状态,通过三个状态的结果计算出另外一个状态(老王的年龄),这就是状态转移。老王的年龄被我们拆分成三个孩子年龄之和,老王的年龄是我们要求的问题,三个孩子的年龄就是子问题


上文说,每个阶段都利用之前的阶段的答案。比如当我们在进行某一阶段的计算时,需要利用的之前阶段(子问题)的答案来计算,因此需要确保一个条件,确保(之前阶段)子问题的答案不会再发生变化,这个条件也有一个很好听的名字——”无后向性”。

再举个例子:
2022年,三个孩子年龄都+1,我们就无法算出老王的正确年龄了,如果我们想一直通过三个孩子的年龄之和得到老王的年龄,那么必须确保孩子的年龄再也不会发生变化,也就是上文所说的,“无后向性”。


典型例题

据说动态规划至少刷不同类型的100题才算入门~
(本文章将会持续更新题解和题目链接——2021.9.12)
各种入门题目类型:由简单到难~


基础dp问题混合

数字三角形

d p [ i ] [ j ] dp[i][j] dp[i][j]为从 ( i , j ) (i,j) (i,j)开始向下的最大值之和
从下到上递推,对于点 i . j i.j i.j,其状态转移方称为 d p [ i ] [ j ] = a [ i ] [ j ] + m a x ( d p [ i + 1 ] [ j ] , d p [ i + 1 ] [ j + 1 ] ) dp[i][j]=a[i][j]+max(dp[i+1][j],dp[i+1][j+1]) dp[i][j]=a[i][j]+max(dp[i+1][j],dp[i+1][j+1])

#include<bits/stdc++.h>
typedef long long ll;
using namespace std;
const int N=1005;
int a[N][N];
int dp[N][N];//也可以用降维节约空间
int main() {
    int r;
    cin>>r;
    for(int i=1;i<=r;i++){
        for(int j=1;j<=i;j++){
            cin>>a[i][j];
        }
    }
    for(int i=r;i>=0;i--){
        for(int j=1;j<=i;j++){
            dp[i][j]=a[i][j]+max(dp[i+1][j],dp[i+1][j+1]);
        }
    }

    cout<<dp[1][1];
    return 0;
}

挖地雷

本着求什么设什么的小技巧,设 d p [ i ] dp[i] dp[i] i i i出发挖的数字最大之和。
所以每次遍历的时候,找前面和自己连通的 j j j,然后递推即可,但是需要维护最后的答案,因此设置 d p [ N ] [ 2 ] dp[N][2] dp[N][2]
d p [ i ] [ 0 ] dp[i][0] dp[i][0]代表 从 i i i出发挖的数字最大之和, d p [ i ] [ 1 ] dp[i][1] dp[i][1]代表递推上一个的阶段下标。
全程按顺序进行,保证无后向性。

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=25;
int g[N][N];
int a[N],v[N];
int n,mx;
int dp[N][2];
int reidx=0;
int main() {
    cin>>n;
    for(int i=1;i<=n;i++)
        cin>>a[i];
    for(int i=1;i<n;i++){
        for(int j=i+1;j<=n;j++){
            int x;
            cin>>x;
            g[i][j]=x;
        }
    }

    for(int i=1;i<=n;i++){
        dp[i][0]=a[i];
        dp[i][1]=i;
         for(int j=1;j<i;j++){
             if(g[j][i]){
                 if(dp[j][0]+a[i]>dp[i][0]){
                     dp[i][0]=dp[j][0]+a[i];
                     dp[i][1]=j;
                 }
             }
         }
        if(dp[i][0]>mx){
            mx=dp[i][0];
            reidx=i;
        }
        mx=max(mx,dp[i][0]);
    }
    vector<int>res;//倒序取出答案
    while(1){
        res.push_back(reidx);
        if(reidx==dp[reidx][1])break;
        reidx=dp[reidx][1];
    }
    for(int i=(int)res.size()-1;i>=0;i--){
        if(i!=(int)res.size()-1){
            cout<<" ";
        }
        cout<<res[i];
    }
    puts("");
    cout<<mx;
    return 0;
}

5倍经验日

01背包的变式,不选择获得 x x x,而选择就会获得更多,获得 x 2 x2 x2,所以我们可以将不选择的经验累加到变量里,然后将 x 2 − = x x2-=x x2=x,这样就转化成01背包了

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=1e3+5;
ll w[N],v[N],lo[N],dp[N];
int main() {
    int n,m;
    cin>>n>>m;
    int cnt=0;
    for(int i=1;i<=n;i++){
        cin>>lo[i]>>v[i]>>w[i];
        v[i]-=lo[i];
        cnt+=lo[i];
    }
    for(int i=1;i<=n;i++) {
        for (int j = m; j >= w[i]; j--) {
            dp[j] = max(dp[j - w[i]] + v[i], dp[j]);
        }
    }
    cout<<(dp[m]+cnt)*5;
    return 0;
}

过河卒

将马控制的点位标记一下,对于每个格子其步数只能从上或左转而来,直接按顺序遍历的时候转移一下即可。

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=22;
int book[N][N];
ll dp[N][N]={1};
void f(int x,int y){
    if(x>=0 && y>=0)
        book[x][y]=1;
}
int main() {
    int bx,by,mx,my;
    cin>>bx>>by>>mx>>my;
    f(mx,my);
    f(mx+2,my+1),f(mx+1,my+2),f(mx-1,my+2),f(mx-2,my+1);
    f(mx-2,my-1),f(mx-1,my-2),f(mx+1,my-2),f(mx+2,my-1);
    for(int i=0;i<=bx;i++){
        for(int j=0;j<=by;j++){
            if(book[i][j])continue;
            if(j && !book[i][j-1])dp[i][j]+=dp[i][j-1];//转移 注意控制边界
            if(i && !book[i-1][j])dp[i][j]+=dp[i-1][j];
        }
    }
    cout<<dp[bx][by];
    return 0;
}

最大约数和

用普通的筛法预处理一下,这题就变成了01背包

#include<bits/stdc++.h>
using namespace std;
const int N =1005;
int a[N],b[N];
int dp[N];
int main() {
    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) {
        for (int j = i * 2; j <= n; j += i) {
            a[j] += i;
        }
    }
    for (int i = 1; i <= n; i++) {
        for (int j = n; j >= i; j--) {
            dp[j] = max(dp[j - i] + a[i], dp[j]);
        }
    }
    cout<<dp[n];
}

序列dp问题

导弹拦截(最长(不)上升子序列

有两问:

  1. 最长不上升子子序列长度
  2. 最少需要多少套系统才能拦截完

第一问就是实打实的求解最长不上升子序列
传统的O(n^2)解法就不多赘述了,我们用二分去优化此dp的过程,使时间复杂度到达O(nlogn)

我们可以维护好一个不上升序列,当我们遇到新数字的时候,就根据二分查找替换其在序列里的位置。
比如数据:4 2 2 3 1
维护过程 []

  1. [4]
  2. [4,2]
  3. [4,2,2]
  4. [4,3,2]
  5. [4,3,2,1]

我们看第四步,3把2的位置替换了,为了构造出更紧密的序列,使得后面的机会更大。实际上对于3而言,它的序列是[4,3],后面的数据和它无关。当后面有比3更小的数据,就可以认为是跟在3后面,而不是跟在2后面。

代码很短,但是细节要细细体会。

#include<bits/stdc++.h>
using namespace std;
typedef long long int ll;
const int N=1e5+5;
int a[N],n;
int dp1[N],le;//用来存储序列
int main(){
    while(cin>>a[n++]){};
    n--;
    dp1[0]=a[0],le++;
    for(int i=1;i<n;i++){
        int j=upper_bound(dp1,dp1+le,a[i],greater<int>())-dp1;
        dp1[j]=a[i];
        le=max(le,j+1);
    }
    cout<<le;//最长不上升子序列长度
}

第二题就是求解最长上升子序列,思路和上面一样~ 完整代码如下:

#include<bits/stdc++.h>
using namespace std;
typedef long long int ll;
const int N=1e5+5;
int a[N],n;
int dp1[N],le;
int sys[N],idx;
int main(){
    while(cin>>a[n++]){};
    n--;
    //1.最大不上升子序列 找到小于目标值 然后替换;
    dp1[0]=a[0],le++;
    sys[idx++]=a[0];
    for(int i=1;i<n;i++){
        int j=upper_bound(dp1,dp1+le,a[i],greater<int>())-dp1;
        int j2=lower_bound(sys,sys+idx,a[i])-sys;
        dp1[j]=a[i];
        sys[j2]=a[i];
        le=max(le,j+1);
        idx=max(idx,j2+1);
    }
    cout<<le<<endl<<idx;
}

滑雪

二维最长不递减子序列

每个点位可以从上下左右4个方向比自己大的数字递推而来,但是为了保证无后向性,需要从最大的数字开始遍历。

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<int,int> pii;
const int N=1005;
int a[N][N];
int dp[N][N];
int main() {
    int r,c;
    int res=0;
    cin>>r>>c;
    vector<pair<int,pii>>v;
    for(int i=1;i<=r;i++){
        for(int j=1;j<=c;j++){
           scanf("%d",&a[i][j]);
           v.push_back({a[i][j],{i,j}});
        }
    }
    sort(v.begin(),v.end(),greater<pair<int,pii>>());
    for(auto i:v){
        int value=i.first,x=i.second.first,y=i.second.second;
        int mx=0;
        if(a[x-1][y]>a[x][y])mx=max(mx,dp[x-1][y]);
        if(a[x][y-1]>a[x][y])mx=max(mx,dp[x][y-1]);
        if(a[x+1][y]>a[x][y])mx=max(mx,dp[x+1][y]);
        if(a[x][y+1]>a[x][y])mx=max(mx,dp[x][y+1]);
        dp[x][y]=mx+1;
        res=max(res,dp[x][y]);
    }
    cout<<res<<endl;
    return 0;
}

最大子段和

d p [ i ] dp[i] dp[i]为:考虑 [ 0 , i ] [0,i] [0,i],并且必须包括元素 i i i的最大子段之和。
元素数组 r [ i ] r[i] r[i]
那么有如下的状态转移
d p [ i ] = r [ i ] ( d p [ i − 1 ] < 0 ) dp[i]=r[i](dp[i-1]<0) dp[i]=r[i](dp[i1]<0)
d p [ i ] = d p [ i − 1 ] + r [ i ] ( d p [ i − 1 ] > = 0 ) dp[i]=dp[i-1]+r[i](dp[i-1]>=0) dp[i]=dp[i1]+r[i](dp[i1]>=0)

代码如下:

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=2e5+5;
int r[N];
int dp[N];
int main() {
    int n;
    cin>>n;
    int res=INT_MIN;
    for(int i=1;i<=n;i++){
        cin>>r[i];
    }
    for(int i=1;i<=n;i++){
        if(dp[i-1]>0)dp[i]=r[i]+dp[i-1];
        else dp[i]=r[i];
        res=max(res,dp[i]);
    }
    cout<<res;
    return 0;
}

最长回文子串

在力扣有清晰的题解,在这我就简单讲解一下,我们如何设计 d p dp dp数组从而推演。

抓住回文特点:回文去掉两头还是回文。

长度为1的回文串可推出长度为3的回文串是否为回文串(判断头和尾),长度为2的同理也能推出长度为4的回文串是否为回文串。

因此回文串具有递推性。因此我们可以通过长度去递推不同长度的回文子串。

设计1:
本着求什么设什么的心态 我们设 d p [ i ] [ j ] dp[i][j] dp[i][j]为包括字符 s [ i ] , s [ j ] s[i],s[j] s[i],s[j]最长回文子串的长度。

d p dp dp的三大核心:边界,转移方程,无后效性。

边界:
从长度为1和长度为2的回文串开始

无后效性:
字符串长度为 x x x,从长度 x − 2 x-2 x2的串递推而来,因此枚举长度,故无后效性。(人话:按长度枚举,枚举过的了状态就不会变了)

转移方程: l e = j − i + 1 le=j-i+1 le=ji+1
d p [ i ] [ j ] = 1 ( l e = 1 ) dp[i][j]=1 (le=1) dp[i][j]=1(le=1)
d p [ i ] [ j ] = d p [ i + 1 ] [ j − 1 ] + 1 ( s [ i ] = s [ j ] ) dp[i][j]=dp[i+1][j-1]+1 (s[i]=s[j]) dp[i][j]=dp[i+1][j1]+1(s[i]=s[j])
d p [ i ] [ j ] = 2 ( l e = 2   & &   s [ i ] = s [ j ] ) dp[i][j]=2 (le=2 \ \&\& \ s[i]=s[j]) dp[i][j]=2(le=2 && s[i]=s[j])

代码1:

class Solution {
public:
    string longestPalindrome(string s) {
        int mx=0,le=s.length();
        int dp[1005][1005]={};
        string ans;
        for(int i=1;i<=le;i++){
            for(int j=0;j+i-1<le;j++){
                int k=j+i-1;
                if(j==k)dp[j][k]=1;
                else if(i==2 && s[j]==s[k]){
                    dp[j][k]=2;
                }
                else{
                    if(s[j]==s[k] && dp[j+1][k-1]){
                        dp[j][k]=dp[j+1][k-1]+2;
                    }
                }
                if(dp[j][k]>mx ){
                    mx=dp[j][k];
                    ans=s.substr(j,i);
                }
            }
        }
        return ans;
    }
};

最长公共子序列

设:
两个序列 a [ i ] , b [ i ] a[i],b[i] a[i],b[i]
d p [ i ] [ j ] dp[i][j] dp[i][j],考虑序列 a a a的前 i i i个,并且以 a [ i ] a[i] a[i]为结尾,考虑序列 b b b的前 j j j个,并且以 b [ j ] b[j] b[j]为结尾的最大长度。
故有如下的状态转移:
d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + 1    ( a [ i ] = b [ i ] ] ) dp[i][j]=dp[i-1][j-1]+1\ \ (a[i]=b[i]]) dp[i][j]=dp[i1][j1]+1  (a[i]=b[i]])
d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] )    ( a [ i ] ≠ b [ i ] ] ) dp[i][j]=max(dp[i-1][j],dp[i][j-1]) \ \ (a[i]\ne b[i]]) dp[i][j]=max(dp[i1][j],dp[i][j1])  (a[i]=b[i]])

时间复杂度: O ( n 2 ) O(n^2) O(n2)

说一下为什么不能如下转移方程不成立:
d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] ) + 1   ( a [ i ] = b [ i ] ) dp[i][j]=max(dp[i-1][j],dp[i][j-1])+1 \ (a[i]=b[i]) dp[i][j]=max(dp[i1][j],dp[i][j1])+1 (a[i]=b[i])

因为 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j] d p [ i ] [ j − 1 ] dp[i][j-1] dp[i][j1]已经将 b [ j ] b[j] b[j] a [ i ] a[i] a[i]考虑进去了,如果转移到 d p [ i ] [ j ] dp[i][j] dp[i][j]就可能造成了重复计算。

#include<bits/stdc++.h>
using namespace std;
const int N =1005;
int a[N],b[N];
int dp[N][N];
int main() {
    int n;
    cin >> n;
    for(int i=1;i<=n;i++)cin>>a[i];
    for(int i=1;i<=n;i++)cin>>b[i];

    for(int i=1;i<=n;i++){
        for(int j=1;j<=n;j++){
            if(a[i]==b[j])
                dp[i][j]=dp[i-1][j-1]+1;
            else
                dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
        }
    }
    cout<<dp[n][n];
}

在我给的链接——洛谷P1439有一个很奇妙的解法
重复一下原题~

两个序列,都是n的全排列,求最长公共子序列
如:
a=[3,1,2,5,4]
b=[3,2,5,4,1]

我们可以将第一个序列作为标准序列,数字只是数字而已~
做法如下,制作一个映射:
mp[3]=1,mp[1]=2,mp[2]=3,mp[5]=4,mp[4]=5
接下来 数字都全部丢映射里才算
那么序列变为
a = [ 1 , 2 , 3 , 4 , 5 ] a=[1,2,3,4,5] a=[1,2,3,4,5]
b = [ 1 , 3 , 4 , 5 , 2 ] b=[1,3,4,5,2] b=[1,3,4,5,2]
我们细细观察, a a a序列都是上升序列, b b b序列是上升序列的全排序,如果 b b b的子序列 b x bx bx满足上升条件,可以认为 b x bx bx是a的子序列。

假设我们已经获得了最长公共子序列 b x bx bx
b x bx bx满足什么性质?

  1. 因为在 a a a之中,所以一定是递增的
  2. 也在 b b b中,所以是 b b b的其中一个递增序列
  3. b b b的所有递增序列中的最长的一个,就是最长上升子序列

结论: b b b里的最上升长子序列就是 a , b a,b a,b的最长公共子序列

问题就转化成了求 b b b的最长公共子序列
很常规的二分优化
最后时间复杂度 O ( n 2 ) O(n^2) O(n2)

#include<bits/stdc++.h>
using namespace std;
const int N =1e5+5;
int a[N],b[N];
int mp[N];
int dp[N],le,res;
int main() {
    int n;
    cin>>n;
    for(int i=1;i<=n;i++){
        cin>>a[i];
        mp[a[i]]=i;
    }
    for(int i=1;i<=n;i++)cin>>b[i];
    for(int i=1;i<=n;i++){
        int j=upper_bound(dp,dp+le,mp[b[i]])-dp;
        dp[j]=mp[b[i]];
        le=max(le,j+1);
    }
    cout<<le;
}

最长回文子序列

没有找到模板题,其实都差不多,我们重点理解一下回文子序列的求法。
题意

题目求解的是最少添加多少字符串使得字符串变成回文字符串

简单讲一下思路:先求最长回文子序列,如字符串 a f b e b a afbeba afbeba,其最长回文字符串为 a b e b a abeba abeba,我们发现被“剔除”的字符串为 f f f,我们将“被剔除”的字符串插回字符串里,并且在对应的位置插入对称的字符串。

所以"被剔除"的字符串的长度就是我们最终的答案

求解最长回文子序列的思路:
回文的关键是从前读和从后面读是一样的,所以我们将原字符串 s 1 s1 s1倒置构成新字符串 s 2 s2 s2,求其最长公共子序列即可。

AC代码

#include<bits/stdc++.h>
using namespace std;
const int N =1e3+5;
char s1[N],s2[N];
int dp[N][N];
int main() {
    scanf("%s",s1+1);
    int le=strlen(s1+1);
    for(int i=1;i<=le;i++){
        s2[i]=s1[le-i+1];
    }
    int res=0;
    for(int i=1;i<=le;i++){
        for(int j=1;j<=le;j++){
            if(s1[i]==s2[j]){
                dp[i][j]=dp[i-1][j-1]+1;
            }
            else{
                dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
            }
            res=max(dp[i][j],res);
        }
    }
    cout<<le-res;
}
  • 4
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值