基础动态规划

Dynamic Programming 动态规划

方法论

  计算机的本质是一个状态机,内存里存储的所有数据构成了当前的状态,CPU只能利用当前的状态计算出下一个状态(不要纠结硬盘之类的外部存储,就算考虑他们也只是扩大了状态的存储容量而已,并不能改变下一个状态只能从当前状态计算出来这一条铁律)
  当你企图使用计算机解决一个问题是,其实就是在思考如何将这个问题表达成状态(用哪些变量存储哪些数据)以及如何在状态中转移(怎样根据一些变量计算出另一些变量)。所以所谓的空间复杂度就是为了支持你的计算所必需存储的状态最多有多少,所谓时间复杂度就是从初始状态到达最终状态中间需要多少步!
  例:计算非波那契数,每一个非波那契数就是这个问题的一个状态,每求一个新数字只需要之前的两个状态。所以同一个时刻,最多只需要保存两个状态,空间复杂度就是常数;每计算一个新状态所需要的时间也是常数且状态是线性递增的,所以时间复杂度也是线性的。这种状态计算很直接,只需要依照固定的模式从旧状态计算出新状态就行a[i]=a[i-1]+a[i-2],不需要考虑是不是需要更多的状态,也不需要选择哪些旧状态来计算新状态。对于这样的解法,我们叫递推。

  • 每个阶段的最优状态都是由上一个阶段的最优状态得到的->贪心;
  • 每个阶段的最优状态是由之前所有阶段的状态的组合得到的->搜索;

  • 每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到而不管之前这个状态是如何得到的->动态规划。
    • 每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到这个性质叫做最优子结构。
    • 不管之前这个状态是如何得到的这个性质叫做无后效性。
DP为什么会快?

  无论是DP还是暴力,我们的算法都是在可能解空间内,寻找最优解。暴力做法是枚举所有的可能解,这是最大的可能解空间。DP是枚举有希望成为答案的解(最优子结构)。这个空间比暴力的小得多。也就是说:DP自带剪枝。舍弃了一大堆不可能成为最优解的答案。从而我们可以得到DP的核心思想:尽量缩小可能解空间。在暴力算法中,可能解空间往往是指数级的大小;如果我们采用DP,那么有可能把解空间的大小降到多项式级。一般来说,解空间越小,寻找解就越快。这样就完成了优化。

如何设计DP算法

  首先,把我们面对的局面表示为x。这一步称为设计状态。对于状态x,记我们要求出的答案(e.g. 最小费用)为f(x).我们的目标是求出f(T).找出f(x)与哪些局面有关(记为p),写出一个式子(称为状态转移方程),通过f(p)来推出f(x).

DP步骤

  设计DP算法,往往是三个步骤:

  • 我是谁? ——设计状态,表示局面
  • 我从哪里来?
  • 我要到哪里去? ——后两者设计转移

  设计状态是DP的基础。接下来的设计转移,有两种方式:一种是考虑我从哪里来;另一种是考虑我到哪里去,这常见于求出f(x)之后,更新能从x走到的一些解。这种DP也是不少的,我们以后会遇到。总而言之,“我从哪里来”和“我要到哪里去”只需要考虑清楚其中一个,就能设计出状态转移方程,从而写代码求解问题。前者又称pull型的转移,后者又称push型的转移。

参考资料
数学推导

模板例题

/*
题意:求n个元素的数组的m个连续子段的和的最大值
dp[i][j] = 选择i个连续子段时 前j个数的最大值 
状态转移方程:max[i-1][j-1]=max{ dp[i - 1][k] }(i - 1 <= k <= j - 1)
                dp[i][j] = max{dp[i][j - 1],max[i-1][j-1]} + a[j]
解释:第j个元素可以和前面的连接在一起,构成i段dp[i][j-1]+a[j],
        或者独自成为一段,dp[i-1][k]+a[j] 因为分成i-1段,所以k>=i-1
        前j - 1个数必须组合出i - 1段,选择多种情况里的最大值,
注:最后由于数据较大,所以要压缩空间,使用滚动数组
*/
#include<iostream>
#include<stdio.h>
#include<algorithm>
#include<iomanip>
#include<cmath>
using namespace std;
#define ull unsigned long long
#define ll long long
#define MAX 1000005
const int inf = 0x3f3f3f3f;

int t, n, m, x, y, z;
int a[MAX],mx;
int dp[MAX];
int ma[MAX];

int main()
{
    while (scanf("%d%d",&m,&n)!=EOF)
    {
        memset(dp, 0, sizeof(dp));
        memset(ma, 0, sizeof(ma));
        for (int i = 1; i <= n; i++)
            scanf("%d",a+i);
        
        
        for (int i = 1; i <= m; i++)
        {
            mx = -inf;
            for (int j = i; j <= n; j++)
            {
                dp[j] = max(dp[j - 1], ma[j - 1]) + a[j];
                ma[j-1]= mx;                //为下一轮i循环保留max[i-1][j-1]
                mx = max(mx,dp[j]);         //mx为dp[i][i]到dp[i][j]的最大值
            }
        }
        printf("%d\n", mx); //因为循环到n所以mx最终为结果
    }
    return 0;
}
/*
题意:求奇数个元素的数组的出现次数大于一半的数
        此数出现的次数大于其他数出现次数的总和,
状态转移方程:新增数==现有最多次数的数,次数+1,else 次数-1
                次数为0时转移到新增数
解释:每增加一个数做一次决策判断之前的数是否是新增状态中的次数过半的数,
        若判定越过的数当前不可能次数过半,由于一定有次数过半的数,
        因此可以暂定新增数为次数过半的数
注:此题没有卡内存,因此还可以用桶装,输出第一个次数过(n+1)/2的数
    for (int i = 1; i <= n; i++)
    {
        cin >> x;
        a[x]++;
    if (a[x] == (n + 1) / 2)
        y = x;
    }
    cout << y << endl;
*/
#include<iostream>
#include<stdio.h>
#include<algorithm>
#include<iomanip>
#include<cmath>
using namespace std;
#define ull unsigned long long
#define ll long long
#define MAX int(1e6+5)
const int inf = 0x3f3f3f3f;

int t, n, m, cnt, x, y, z;
int a[MAX];
int dp[MAX];
int ma[MAX];

int main()
{
    while (cin >> n)
    {
        cnt = 0;
        
        for (int i = 1; i <= n; i++)
        {
            cin >> x;
            if (cnt == 0)
            {
                m = x;
                cnt = 1;
            }
            else
            {
                if (m == x)
                    cnt++;
                else cnt--;
            }
        }
        cout << m << endl;
    }
    return 0;
}
最长上升子序列(LIS)
/*
**题意:求n个元素的数组中最长的由依次增大的元素组成的子序列
**状态转移方程:if (a[i] > a[j])
**              dp[i] = max(dp[i], dp[j] + 1);
**                  sum = max(dp[i], sum);
**解释:整体最长上升子序列的从头开始的每个部分都是特点末元素的最长上升子序列
**      每增加一个元素,判断以此元素是否比以其他元素大
**      若大则以j元素结尾的最长上升子序列dp[j]+1
**      且得到所有以i元素结尾的子序列,比较得以i元素结尾的最长上升子序列
**      一直到第n个元素,得到以n元素结尾的最长上升序列
**      比较所有元素结尾的最大子序列,得到最长上升子序列
**      注:  if (a[i] > a[j])
**                  dp[i] = max(dp[i], dp[j] + a[i]);
**                  sum = max(dp[i], sum);
**      此方程得到最大上升子序列(所有元素的和最大)
*/
#include<iostream>
#include<stdio.h>
#include<algorithm>
#include<iomanip>
#include<cmath>
using namespace std;
#define ull unsigned long long
#define ll long long
#define MAX int(1e6+5)
const int inf = 0x3f3f3f3f;

int t, n, m, cnt, sum, flag;
int x, y, z;
int a[MAX];
int dp[MAX];

int main()
{
    while (cin >> n)
    {
        memset(dp, 0, sizeof(dp));
        for (int i = 1; i <= n; i++)
            cin >> a[i];
        a[0] = 0;
        sum = 0;
        for(int i=1;i<=n;i++)
            for (int j = 0; j < i; j++)
            {
                if (a[i] > a[j])
                    dp[i] = max(dp[i], dp[j] + 1);

                sum = max(dp[i], sum);
            }
        cout << sum << endl;
    }
    return 0;
}

练习题核心代码

  • nlogn求最长上升子序列及变形

  • 洛谷P1439最长公共子序列模板
    一般解法:我们可以用p[i][j]来表示第一个串的前i位,第二个串的前j位的LCS的长度,那么我们是很容易想到状态转移方程的:如果当前的A1[i]A2[j]相同(即是有新的公共元素) 那么dp[i][j]=max(dp[i][j],dp[i−1][j−1]+1);如果不相同,即无法更新公共元素,考虑继承:dp[i][j]=max(dp[i−1][j],dp[i][j−1]。但对于此题朴素算法是\(n^2\)会因为\(10^5\)规模数据超过时间范围,因此套考虑nlogn算法,因为两个序列都是1~n的全排列,那么两个序列元素互异且相同,也就是说只是位置不同罢了,那么我们通过一个数组将A序列的数字在B序列中的位置表示出来——因为最长公共子序列是按位向后比对的,所以a序列每个元素在b序列中的位置如果递增,就说明b中的这个数在a中的这个数整体位置偏后,可以考虑纳入LCS——那么就可以转变成nlogn求用来记录新的位置的map数组中的LIS。
n = 0;
   cin >> n;
   for (i = 0; i < n; ++i)
       cin >> a1[i];
   for (i = 0; i < n; ++i)
       cin >> a2[i];
   for (i = 0; i < n; ++i)
       dp[a1[i]] = i; //将a1数组中的数的顺序载入
       int len = 0;
   for (i = 1; i <n; ++i)
   {
       if (dp[a2[len]] < dp[a2[i]])
           a2[++len] = a2[i];
       else //二分查找
       {
           int j = 0, k = len,mid;
           while(j<k)
           {
               mid = (j + k) / 2;
               if (dp[a2[mid]] > dp[a2[i]])
                   k = mid;
               else j = mid + 1;
           }
           if(dp[a2[j]]>dp[a2[i]])
               a2[j] = a2[i];
       }
   }
   cout << len+1<< endl;
  • 洛谷P1020导弹拦截
    这道题就是要求一个最长单调不升子序列(最多能拦截多少导弹)和一个最长单调上升子序列(最少需要多少拦截系统)。
int a[MAX],a2[MAX],dp[2][MAX];

    n = 0;
    while ((cin >> a[n]))
        ++n;
    dp[0][0] = a[0];
    dp[1][0] = a[0];
    int len1 = 0, len2 = 0;
    for (i = 1; i <n; ++i)
    {
        if (dp[0][len1] >= a[i])
            dp[0][++len1] = a[i];
        else
        {
            int p= upper_bound(dp[0], dp[0]+ len1, a[i], greater<int>()) - dp[0];
            dp[0][p] = a[i];
        }//最长单调不升序列
        if (dp[1][len2] < a[i])
            dp[1][++len2] = a[i];
        else
        {
            int p= lower_bound(dp[1], dp[1]+ len2, a[i]) - dp[1];
            dp[1][p] = a[i];
        }//最长单调递减序列
    }
    cout << len1 + 1 << '\n' << len2 + 1 << endl;
  • 洛谷P2782 友好城市
    将南岸或北岸排序后,一边已经成单调递增,求另一边最长单增子序列即可
  struct node
{
    int n, s;
}a[2*MAX+10];
bool cmp(struct node x, struct node y)
{
    return x.n < y.n;
}
int main()
{
    cin >> n;
    for (i = 1; i <= n; ++i)
        scanf("%d%d", &a[i].n,&a[i].s);
    int len= 1;
    sort(a + 1, a + n + 1,cmp);//排序
    for (i = 2; i <= n; ++i)//二分求最长单增子序列
    {
        if (a[i].s > a[len].s)
        {
            a[++len].s = a[i].s;
        }
        else
        {
            j = 1, k = len;
            int mid;
            while(j<k)
            {
                mid = (j + k) / 2;
                if (a[mid].s > a[i].s)
                    k = mid;
                else j = mid + 1;
            }
            a[j].s = min(a[j].s, a[i].s);
        }
    }
    printf("%d", len);
    return 0;
}
  • OpenJ_Bailian - 2995
#include <iostream>
#include <cstdio>
using namespace std;
int n,i,j;
int a[1200],f1[1200],f2[1200],maxn;
int main()
{
    scanf("%d",&n);
    for (i=1;i<=n;i++)
        scanf("%d",&a[i]);
    for (i=1;i<=n;i++)
        f1[i]=f2[i]=1;
    for (i=1;i<=n;i++)
        for (j=1;j<i;j++)
            if (a[i]>a[j])    
            f1[i]=max(f1[i],f1[j]+1);//求首元素开始到某一元素结束的最长递增序列长度
    for (i=n;i>=1;i--)
        for (j=n;j>i;j--)
            if (a[i]>a[j])    
            f2[i]=max(f2[i],f2[j]+1);//求某一元素开始到尾元素的最长递减序列长度
    for (i=1;i<=n;i++)
        maxn=max(maxn,f1[i]+f2[i]-1); //遍历求除最大值
    printf("%d\n",maxn);
    return 0;
}
  • 打印dp路径

  • 洛谷P2196 挖地雷
    定义状态f[i]为以第i个节点结束的最大值,则f[i]=max{f[j]}+a[i] (g[j][i]=1),
void print(int x)//打印前驱节点
{
    if (pre[x] == 0)//起始节点无前驱节点
    {
        printf("%d", x);
        return;
    }
    else print(pre[x]);
    printf(" %d", x);
}
int main()
{
    cin >> n;
    for (i = 1; i <= n; ++i)
        cin >> a1[i];
    for(i=1;i<n;++i)
        for (j = i + 1; j <= n; ++j)
            cin >> a[i][j];
    for (i = 1; i <= n; ++i)
    {
        for (j = 1; j <= n; ++j)
            if (a[j][i] == 1&&dp1[j]>dp1[i])
            {
                dp1[i] =dp1[j];
                pre[i] = j;//i点由j点到达
            }
        dp1[i] += a1[i];
        if (dp1[i] > sum)
        {
            sum = dp1[i];
            k = i;//记录结束的节点
        }
    }
    print(k);
    cout << '\n'<< sum << endl;
}
  • 洛谷 P2066 机器分配
    f(i,j)表示将j台机器分给1~i公司的最大盈利,w[i][j]表示第i公司有j台机器的盈利转移方程就是f(i,j)=max(k∈[0,j]){f(i+1,j-k)+w[i][j]}重点在如何优化数组大小(虽然这题数据极小),注意到转移方程里只用到了f(i+1,l)(l∈[0,j]),因此只要从大到小枚举j就可以在dp数组里去掉i这一维
    接下来是输出,用ans数组保存f(i,j)取最大时k的值,然后依次求出f(i,j)是由f(i+1,?)转移而来就可以输出方案了。字典序最小所以k要正序枚举,比较f[j]与f[j-k]+w[i][k]大小时不能取等;为方便正序输出所以i倒序枚举(如果正序枚举i答案就是f(n,m),输出就要从n开始,如果要正序输出就得用数组保存)。
int main()
{
    cin >> n>>m;
    sum = 0;
    for (i = 1; i <= n; ++i)
        for (j = 1; j <= m; ++j)
            scanf("%d", &a[i][j]);
    dp1[0] = 0;
    for (i = n; i > 0; --i)
        for (j = m; j >=0; --j)
            for (k = 1; k <= j; ++k)
                if (dp1[j-k] + a[i][k] > dp1[j])
                {
                    dp1[j] = dp1[j-k] + a[i][k];
                    a1[i][j] = k;
                }
    cout << dp1[m];
    for (i = 1, j = m; i <= n; ++i)
    {
        cout << endl << i << " " << a1[i][j];
        j -= a1[i][j];  
    }
    return 0;
}
  • 背包问题

    背包九讲
  • 01背包
    最基础的背包问题,特点是:每种物品仅有一件,可以选择放或不放。
    用子问题定义状态:即f[i][v]表示前i件物品恰放入一个容量为v的背包可以获得的最大价值。则其状态转移方程便是:f[i][v]=max{f[i-1][v],f[i-1][v-c[i]]+w[i]}“将前i件物品放入容量为v的背包中”这个子问题,若只考虑第i件物品的策略(放或不放),那么就可以转化为一个只牵扯前i-1件物品的问题。如果不放第i件物品,那么问题就转化为“前i-1件物品放入容量为v的背包中”
    以上方法的时间和空间复杂度均为O(N*V),其中时间复杂度基本已经不能再优化了,但空间复杂度却可以优化到O(V)。
    先考虑上面的状态转移方程如何实现,肯定是有一个主循环i=1..N,每次算出来二维数组f[i][0..V]的所有值。若只用一个数组f[0..V],需要保证第i次循环结束后f[v]中表示的就是我们定义的状态f[i][v],因f[i][v]是由f[i-1][v]f[i-1][v-c[i]]两个子问题递推而来,保证在推f[i][v]时(也即在第i次主循环中推f[v]时)能够得到f[i-1][v]f[i-1][v-c[i]]的值即可。事实上,每次主循环中我们以v=V..0的顺序推f[v],这样就能保证推f[v]f[v-c[i]]保存的是状态f[i-1][v-c[i]]的值。
    我们看到的求最优解的背包问题题目中,事实上有两种不太相同的问法。有的题目要求“恰好装满背包”时的最优解,有的题目则并没有要求必须把背包装满。一种区别这两种问法的实现方法是在初始化的时候不同。如果是第一种问法,要求恰好装满背包,那么在初始化时除了f[0]为0其它f[1..V]均设为-∞,这样就可以保证最终得到的f[N]是一种恰好装满背包的最优解。如果并没有要求必须把背包装满,而是只希望价格尽量大,初始化时应该将f[0..V]全部设为0。前者所有其他体积的最大值值都必须从0得到,从而得到装满的背包,而后者任何体积的空背包可能作为起始,因此可以剩余任何体积。这个小技巧完全可以推广到其它类型的背包问题
    洛谷 采药P1048
scanf("%d%d", &m, &n);
        for (i = 1; i <= n; ++i)
            scanf("%d%d", a1 + i, a2+i);

        for (i = 0; i <= m; ++i)
            dp[i] = 0;
        for (i = 1; i <= n; ++i)
            for (j =m ; j >= 0; --j)
                if (a1[i]<=j)
                    dp[j] = max(dp[j], dp[j-a1[i]] + a2[i]);
    
        printf("%d\n",dp[m]);

洛谷 P2871 [USACO07DEC]手链Charm Bracelet

        scanf("%d%d", &n, &m);
        for (i = 1; i <= n; ++i)
            scanf("%d%d", a[0] + i, a[1] + i);
        for (i = 0; i <= n; ++i)
            for (j = m; j >= a[0][i]; --j)
                dp[j] = max(dp[j - a[0][i]] + a[1][i], dp[j]);

        printf("%d\n",dp[m]);

数字组合 OpenJ_Bailian - 4004

    dp1[0] = 1;\\初始化和为0的方式为1
    for (i = 0; i < n; ++i)
        for (j = m; j >= 1; --j)
            if (a1[i] <= j && dp1[j - a1[i]] != 0)
                dp1[j] += dp1[j - a1[i]];\\得到和为j的组合方式
                printf("%d\n", dp1[m]);
  • 完全背包
    完全背包非常类似于01背包问题,所不同的是每种物品可以取无限件。令f[i][v]表示前i种物品恰放入一个容量为v的背包的最大权值。可以按照每种物品不同的策略写出状态转移方程,像这样:f[i][v]=max{f[i-1][v-k*c[i]]+k*w[i]|0<=k*c[i]<=v}
    最优化的方式是改变循环的结构第二场循环从0开始,使得f[i][v]可以由f[i][v-c[i]]+w[i]得到
    洛谷P1616 疯狂的采药
for (i = 1; i <= n; ++i)
            scanf("%d%d", a[0] + i, a[1] + i);
        for (i = 1; i <= n; ++i)
            for (j = a[0][i]; j<= m; ++j)\\从a[0][i]开始
                dp[j] = max(dp[j], dp[j- a[0][i]] + a[1][i]);
  • 其他运用

  • 最大子矩阵 HDU - 1559
int dp[1100][1100];
int MAX;
int main()
{

    int i,j,n,m,T,x,y;
    scanf("%d",&T);
    while(T--)
    {

       scanf("%d %d %d %d",&n,&m,&x,&y);

       MAX=0;

       memset(dp,0,sizeof(dp));

       for(i=1;i<=n;i++)
         for(j=1;j<=m;j++)
         {

              scanf("%d",&dp[i][j]);

              dp[i][j]+=dp[i-1][j]+dp[i][j-1]-dp[i-1][j-1];//状态转移方程 
              if(i>=x&&j>=y) //矩阵大小为x*y
              {
                 MAX=max(MAX,dp[i][j]-dp[i][j-y]-dp[i-x][j]+dp[i-x][j-y]);
              }           

         }

       printf("%d\n",MAX);

    }

    return 0;
}
  • 摘花生 OpenJ_Bailian - 3727
cin >> t;
    while (t--)
    {
        cin >> m >> n;
        sum = 0;
        memset(a, 0, sizeof(a));
        for (j = 1; j <= m; ++j)
            for (i = 1; i <= n; ++i)
            {
                cin >> a[j][i];
                a[j][i] += max(a[j - 1][i], a[j][i - 1]);//只能由左方或上方走到
            }
        cout << a[m][n] << endl;
    }

猜你喜欢

转载自www.cnblogs.com/luanma-2333/p/11257110.html