动态规划入门

动态规划核心思想

把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解

而要理解这句话,那就要从递推开始说起:

递推

递推的特点在于,每一项都和他前面的若干项有一定关联,这种关联一般可以通过递推关系式来表示,然后可以通过其前面若干项得出某项的数据。——摘自计蒜客
而对于递推来说,最核心的就是找到递推方程。递推方程可以从第n个元素开始想,亦可从第m个元素作为结尾开始想,思考问题的角度有很多种。我们的目的是寻找 f ( n ) f(n) f(n) f ( n − 1 ) f(n-1) f(n1)以及 f ( n − 2 ) f(n-2) f(n2)乃至前几项之间的关系,写出关于 f ( n − 1 ) , f ( n − 2 ) . . . f(n-1),f(n-2)... f(n1),f(n2)...的递推方程 f ( n ) = F ( f ( n − 1 ) , f ( n − 2 ) , . . . ) ) f(n) = F(f(n-1),f(n-2),...)) f(n)=F(f(n1),f(n2),...))。而函数 F F F的参数个数,代表了你要在递推的开始时所需要求出的最少基础数据 ( f ( 1 ) , f ( 2 ) , . . . ) (f(1),f(2),...) f(1),f(2),...,亦称边界值。
斐波那契的例子就不举了,我们从下面这个问题开始着手:

我写了n封信,对应有n个信封,如果所有的信都装错了信封,那么会有多少种不同的情况?

我们先将信件排列成从 1 − n 1-n 1n的序列,从第n封信件入手。将第n的信封放到前面某一个下标为m的信封里面。
在这里插入图片描述
将第n个信件放入前面n-1个信封中自然就有n-1种情况。那么我们再对第m个信件进行分析,发现有两种情况:

  • 如果将第m个信件放入第n个信封,此时的情况就是n和m的信件分别交叉放入对方的信封中,那么你们想想,对于剩余的信封信件,是不是相当于处理与问题初始相同的情况,只不过数目变成n-2而已?此时我们记这种情况为f(n-2)
  • 但如果此时我们将第m封信件放入除第n封信外的其他信封里,那么第m封信件只能选择n-2种放置方式(因为第m封信既放不到第n个信封中,又因为第n个信件已经占了自己的信封,所以只用n-2中选择),而对于剩下的信件而言,也是除自身外n-2个选择,那么每个信封都对应n-2中选择,这不就是f(n-1)情况中的每个信件的放置数量吗?

那么我们可以知道,两种情况是互斥的,所以需直接相加再乘以每种情况的系数,写出递推方程: f ( n ) = ( n − 1 ) ∗ ( f ( n − 1 ) + f ( n − 2 ) ) f(n) = (n-1) * (f(n-1) + f(n-2)) f(n)=(n1)(f(n1)+f(n2))
而我们此时只要求出f(1) f(2) 即可用循环地推的方式求出答案了。

递推的应用

递推可以大幅度优化一些回溯搜索算法,减少相当的时间复杂度。
举一个题目:

棋盘上A点有一个过河卒,需要走到目标B点。卒行走的规则:可以向下、或者向右。同时在棋盘上C点有一个对方的马,该马所在的点和所有跳跃一步可达的点称为对方马的控制点。因此称之为“马拦过河卒”。
棋盘用坐标表示,A点(0, 0)、B点(n, m)(n, m为不超过20的整数),同样马的位置坐标是需要给出的。
现在要求你计算出卒从A点能够到达B点的路径的条数,假设马的位置是固定不动的,并不是卒走一步马走一步。

解法:对于每个点的路径数目来说,都来自于其上方与左方的路径数目相加,即 F F Fi,j = F F F i−1,j + F F Fi,j−1 然后记得进行一些合理访问上的判断:写成这样吧

f[0][0] = 1;
for (int i = 0;i <= n; ++i) {
    for (int j = 0; j <= m; ++j) {
        if (i != 0) {
            f[i][j] = f[i][j] + f[i-1][j];
        }
        if (j != 0) {
            f[i][j] = f[i][j] + f[i][j-1];
        }
    }
}
// f[n][m]即为点(n,m)的路径数目。

简单的递推题就不推荐给大家做了,这里有道比较考验思维的题目,现贴出来

墙壁涂色

(https://www.jisuanke.com/course/736/37741)

思路:思考思考,填第n的元素的时候有哪些互斥情况,每一种情况又有多少数量?

提示:

不满足递推方程的个数是大于等于边界值的,需要注意,有时候要多求一到两个f(m+1)的值。,比如墙壁涂色的题目,前面3项都不符合递推式。

动态规划——最优类问题

递推与动态规划的区别

动态规划有着递归的思想,但递归一般用于处理判定性问题与计数问题(相信大家在上文能够感受到了),而动态规划一般用来解决最优解问题,但在强调一遍,动态规划的思想脱离不了递归。
最重要的思想是,对于任意一个元素,我们要找到能对他处理的决策
对于递归算法,最重要的是写出递归方程,而同样的,对于动态规划,最重要的是写出状态转移方程.

直接上一道题目:
[外链图片转存失败(img-cr37nQHf-1563336677912)(https://res.jisuanke.com/img/upload/20170113/ae2c2eb61390a08f469136dffa05754874681391.png)]
图中的数字代表消耗值,问从出发地到家里,最少消耗的体力值是多少。
分析:到达一个点,无非是从左侧和下侧过来的,那么写出一个点消耗最少的状态转移方程: d p ( i , j ) = m i n ( d p ( i − 1 , j ) , d p ( i , j − 1 ) ) + a i j dp(i,j) = min(dp(i-1,j), dp(i,j-1)) + a ij dp(i,j)=min(dp(i1,j),dp(i,j1))+aij从左下往右上遍历即可

接着贴一个过河问题:

问题描述在漆黑的夜里,N位旅行者来到了一座狭窄而且没有护栏的桥边。如果不借助手电筒的话,大家是无论如何也不敢过桥去的。不幸的是,N个人一共只带了一只手电筒,而桥窄得只够让两个人同时过。如果各自单独过桥的话,N人所需要的时间已知;而如果两人同时过桥,所需要的时间就是走得比较慢的那个人单独行动时所需的时间。问题是,如何设计一个方案,让这N人尽快过桥。
输入
第一行是一个整数T(1<=T<=20)表示测试数据的组数 每组测试数据的第一行是一个整数N(1<=N<=1000)表示共有N个人要过河。每组测试数据的第二行是N个整数Si,表示此人过河所需要花时间。(0<Si<=100)
输出
输出所有人都过河需要用的最少时间

先给每个人按小排序。我们不妨从已经过去了n-2个人和已经过去了n-1个人且手电筒都在已过桥的一侧的情况开始分析。

01背包

——要不就选,要不就不选的背包
对于01背包,先确定这个问题的状态。共有N个物品,背包总承重为V,那么可以根据物品和容量来确定一个状态。前i个物品,放在背包里,总重量不超过j的前提下,所获得的最大价值为dp[i][j]。
是否将第i个物品装入背包中,就是决策。为了使价值最大化,如果第i个物品放入背包后,总重量不超过限制且总价值比之前要大,那么就将第i个物品放入背包。根据这个逻辑写出转移方程:
存在 j < w [ i ] , d p [ i ] [ j ] = d p [ i − 1 ] [ j ] j<w[i],dp[i][j]=dp[i-1][j] j<w[i]dp[i][j]=dp[i1][j]即不选第i个物品
存在 w [ i ] ≤ j ≤ C , d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ i − w [ i ] ] + v [ i ] ) w[i]≤j≤C,dp[i][j]=max(dp[i-1][j],dp[i-1][i-w[i]]+v[i]) w[i]jCdp[i][j]=maxdp[i1][j]dp[i1][iw[i]]+v[i]选上此物品。
01背包有两种写法,一个是未空间优化的,好理解:

for (int i = 1; i <= N; ++i) {
    for (int j = 0; j <= V; ++j) {
        if(j >= w[i]) {
            dp[i][j] = max(dp[i - 1][j - w[i]] + v[i], dp[i - 1][j]);
        }
        else {
            dp[i][j] = dp[i-1][j];
        }
    }
}

另外一个是空间优化过的,鉴于一维是冗余的:

for (int i = 1; i <= n; ++i)
    for (int j = v; j >= w[i]; --j)			///空间优化过的,从背包大小V开始
        dp[j] = max(dp[j - w[i]] + v[i], dp[j]);	

放一题:习题:蒜头君的购物袋 1此题可把所占体积也理解为单位体积价值为1的物品,那么就是求物品价值的最大值,然后减去体积即可。

完全背包

——可以无限选的背包
完全背包相对于01背包而言,其实就是一个物品可以无限被选取,最后实现背包价值的最大化

for (int i = 1; i <= n; ++ i)
    for (int j = c[i]; j <= v; ++ j)
        dp[j] = max(dp[j - c[i]] + w[i], dp[j]);

可以从代码中看出,完全背包就是空间优化版的01背包,在第二重循环修改的循环条件,从c[i]开始,以递增方式递归

多重背包

——选有限个的背包
一个物品存在有限数量,求多重背包的最大价值。
其实可以将物品数量二进制拆分化,例如14,可以写成1+2+4+7,那么可以发现,1~14个数量都可以用1,2,4,7这几个数字组合出来,比如要个12,就是12 = 1+4+7,将对应倍乘的体积与价值存到体积—价值序列中(比如,就把4个物品看成一个新物体,体积和价值都是原物体的四倍,将这个新物体丢进序列就可),那么就能转化成01背包了。
此处没有代码,就是01背包多了几个新物品而已,这个算法很好写,就不介绍了。

LIS(最长上升子序列)

在原序列取任意多项,不改变他们在原来数列的先后次序,得到的序列称为原序列的子序列。最长上升子序列,就是给定序列的一个最长的、数值从低到高排列的子序列,最长子序列不一定是唯一的。例如,序列2,1,5,3,6,4,6,3的最长上升子序列为1,3,4,6和2,3,4,6,长度均为4。

先确定动态规划的状态,这个问题可以用序列某一项作为结尾来作为一个状态。用 d p [ i ] dp[i] dp[i]表示一定以第i项为结尾的最长上升子序列。用 a [ i ] a[i] a[i] 表示第i项的值,如果有 j < i j<i j<i a [ j ] < a [ i ] a[j]<a[i] a[j]<a[i],那么把第i项接在第j项后面构成的子序列长度为:
d p [ i ] = d p [ j ] + 1 dp[i]=dp[j]+1 dp[i]=dp[j]+1
要使 d p [ i ] dp[i] dp[i]为以 i i i结尾的最长上升子序列,需要枚举所有满足条件的 j j j。所以转移方程是:
存在 j < i ( 1 < = i , j ) j<i(1<=i,j) j<i(1<=i,j)&& a [ j ] < a [ i ] , d p [ i ] = m a x ( d p [ i ] , d p [ j ] + 1 ) a[j]<a[i],dp[i]=max(dp[i],dp[j]+1) a[j]<a[i]dp[i]=maxdp[i]dp[j]+1
那么代码是:

int dp[MAX_N], a[MAX_N], n;
int ans = 0;  // 保存最大值

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

cout << ans << endl;  // ans 就是最终结果

让我们再写一个优化版的LIS:

const int MAX_N = 10;

int main()
{
    int ans[MAX_N], a[MAX_N]={0,2,1,5,3,6,4,6,3}, dp[MAX_N], n=8;  // ans 用来保存每个 dp 值对应的最小值,a 是原数组
    int len; // LIS chccc

    ans[1] = a[1];
    len = 1;
    for (int i = 2; i <= n; ++i) {
        if (a[i] > ans[len]) {
            ans[++len] = a[i];
        } else {
            int pos = lower_bound(ans + 1, ans + len + 1, a[i]) - ans;
            /**要知道,这里的ans数组并不是最终的最长上升子序列
            ***而它也能求出结果
            ***因为当二分找到的是ans[len]的值时,那么此时的a[i]与ans[len]有着同样的dp值
            ***那么修改了ans[len]之后,ans[len]的值就变小了,符合我们的预期
            ***如果二分修改的是前面的值,不影响结果,因为序列仍在继续往前走
            **/
            ans[pos] = a[i];
        }
    }
    cout << len << endl;  // len 就是最终结果
    return 0;
}

LCS(最长公共子序列)

给定两个序列S1和S2,求二者公共子序列S3的最长的长度。
有了前面的基础,可以发现这个问题仍然可以按照序列的长度来划分状态,也就是S1的前i个字符和S2的前j个字符的最长公共子序列长度,记为lcs[i][j]。
那么当S1的第i个元素与S2的第j元素相等,就可以写出状态方程1: d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + 1 dp[i][j] = dp[i-1][j-1]+1 dp[i][j]=dp[i1][j1]+1当S1的第i个元素与S2的第j元素不等时,状态方程2: d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] ) dp[i][j] = max(dp[i-1][j],dp[i][j-1]) dp[i][j]=max(dp[i1][j],dp[i][j1]),最终答案就是dp[lena][lenb]

	string a,b;
    memset(dp,0,sizeof(dp));
    cin>>a>>b;
    int lena = a.size();
    int lenb = b.size();
   	///注意,这里从1开始,一直到lena,lenb,最终答案就是dp[lena][lenb]
    for(int i=1;i<=lena;++i){
        for(int j=1;j<=lenb;++j){
            if(a[i-1]==b[j-1]){
             	dp[i][j] = dp[i-1][j-1]+1;   
            }
            else{
                dp[i][j] = max(dp[i-1][j],dp[i][j-1]);
            }
        }
    }
    cout<<dp[lena][lenb]<<endl;

关于LIS,按例贴一道题目:习题:删除最少的元素 画图可知,只要在某一个节点(循环取此节点)将后面的数都取负数,然后做一个最长不升子序列就可以了。
(也可以优化,先从前面开始做最长不升子序列,在从后面做最长不升子序列,dp1[i]+dp2[i] - 1就是取a[i]为最低值的最长ans序列,对dp1[i]+dp2[i]跑个循环即可)
关于LCS,也贴一道题目:习题:回文串 把回文串的顺序倒转后,与原串是一样的。
那么我们只要把给定的字符串顺序倒转与原串求最长公共子序列,再用字符串总长度减去最长公共子序列的长度就是相差的字符个数,也就是答案。(虽然我不知道为什么,就是很神奇,算作求回文的一个LCS应用吧)

状态压缩DP

状压DP应该是动态规划入门最难的一个算法,目前笔者遇到的问题有两类:

  • 一类用来处理矩阵中一行的取值方式跟上面行的信息相关联,那么这时候就可以用二进制枚举第一行(如果前两行有关联,多加个第二行),然后滚动dp解决
  • 另一类用来处理从上一层枚举的结果进一步枚举的问题,用子集dp解决(如果一次枚举就枚举完,那么结果就是枚举完的结果,否则就是子集枚举结果的最小之和(一般拆成两个互补的子集))。

滚动dp

话不多说,对于第一类直接上题:习题:灌溉机器人

#include <iostream>
#include <cstring>
using namespace std;

const int MAX_N = 100;
const int MAX_M = 10;
int state[MAX_N + 1];//i行状态
//int dp[MAX_N + 1][1 << MAX_M][1 << MAX_M];//i行状态为j i-1行状态为k时包含的最多1的个数
int dp[2][1 << MAX_M][1 << MAX_M];//i行状态为j i-1行状态为k时包含的最多1的个数

bool not_intersect(int now, int prev) {
    return (now & prev) == 0;
}

bool fit(int now, int flag) {
    return (now | flag) == flag;
}
bool ok(int x) {
    // 行内自己不相交,返回true
    return ( (x & (x / 2)) == 0 ) && ( (x & (x / 4)) == 0 );
}

int calc(int now) {
    int s = 0;  // 统计 now 的二进制形式中有多少个 1
    while (now) {
        s += (now & 1);  // 判断 now 二进制的最后一位是否为 1,如果是则累加
        now >>= 1;  // now 右移一位
    }
    return s;
}

int main() {
    int n, m;
    cin >> n >> m;
    // 初始化所有数组
    memset(state, 0, sizeof(state));
    memset(dp, 0, sizeof(dp));

    char c;
    for (int i = 1; i <= n; ++i) {
        for (int j = 0; j < m; ++j) {
            int flag;
            cin >> c;
            if(c == 'P') {
                flag = 1;
            } else {
                flag = 0;
            }
            state[i] |= (1 << j) * flag;  // 将 (i,j) 格子的状态放入 state[i] 中,state[i] 表示第 i 行的可选格子组成的集合
        }
    }

    //处理第1行边界
    for(int j = 0; j < (1 << m); ++j) {
        if (!ok(j) || !fit(j, state[1])) {  // 如果第1行状态不合法则不执行后面的枚举
                continue;
        }

        int cnt = calc(j);
        dp[1%2][j][0] = cnt;

    }

    //处理第2行边界
    for(int j = 0; j < (1 << m); ++j) {
        if (!ok(j) || !fit(j, state[2])) {  // 如果第2行状态不合法则不执行后面的枚举
                continue;
        }

        int cnt = calc(j);
        for (int k = 0; k < (1 << m); ++k) {
            if (ok(k) && fit(k, state[1]) && not_intersect(j, k)) {  // 第1行合法且第2行 第1行不冲突
                dp[2%2][j][k]= dp[1%2][k][0] + cnt;  // 更新当前行、当前状态的最优解
            }
        }
    }

    //正常处理
    for (int i = 3; i <= n; ++i) {
        for (int j = 0; j < (1 << m); ++j) {  // 枚举当前行的状态
            if (!ok(j) || !fit(j, state[i])) {  // 如果当前行状态不合法则不执行后面的枚举
                continue;
            }

            int cnt = calc(j);  // 统计当前行一共选了多少个格子

            for (int k = 0; k < (1 << m); ++k) {//上行状态为k
                if (!ok(k) || !fit(k, state[i-1])) {  // 如果上行状态不合法则不执行后面的枚举
                    continue;
                }

                for(int l = 0; l < (1 << m); ++l) {//上上行状态为l
                    if (!ok(l) || !fit(l, state[i-2])) {  // 如果上上行状态不合法则不执行后面的枚举
                        continue;
                    }

                    if(not_intersect(j, k) && not_intersect(k, l) && not_intersect(j, l)) {
                        dp[i%2][j][k]= max(dp[i%2][j][k], dp[(i - 1)%2][k][l] + cnt);  // 更新当前行、当前状态的最优解
                    }
                }

            }
        }
    }

    int ans = 0;  // 保存最终答案
    for (int i = 0; i < (1 << m); ++i) {
            for(int j = 0; j < (1 << m); ++j)
                ans = max(ans, dp[n%2][i][j]);  // 枚举所有状态,更新最大值
    }
    cout << ans << endl;
    return 0;
}



滚动dp的特点(对于矩阵状压)就是有多少个关联行的个数,那么就有多少个边界值==初始行。通过枚举第一行以及关联的第m行将初始行确立下来,进而遍历所有行,复杂度为 O ( 3 n + n × 2 n ) O(3^n+n×2^n) O(3n+n×2n), n × 2 n n×2^n n×2n中的 n n n来自于遍历所有行, 2 n 2^n 2n来自枚举, 3 n 3^n 3n来自初始化(上面举的这道题大约复杂度为 2 n ∗ 2 n 2^n * 2^n 2n2n,因为关联行有两行)

tips:这里用了滚动数组,可以节省空间。DP题目是一个自底向上的扩展过程,我们常常需要用到的是连续的解,前面的解往往可以舍去。可以通过取模来实现。

子集dp

第二类伪代码:

for (int t = 1; t < (1 << n); t++) {  // 枚举当前状态
     dp[t] = t满足条件(G) ? ans : inf;  // 判断当前状态是否是回文,如果是回文则步骤数为 1
    for(int i = t; i; i = (i - 1) & t) { // 枚举 t 的所有子集
        dp[t] = min(dp[t], dp[i] + dp[t ^ i]);  // 更新当前状态的解的最小值
    }					///↑这个是两个互补的子集
}
printf("%d\n", dp[(1 << n) - 1]);  // 输出最终答案
///答案是全员枚举的下标,dp[(1<<n) - 1];

上一道题:习题:蒜头君的积木
代码:

#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long LL;
int linked[20][20] = {0};
int n;
int dp[1<<20] = {0};
const int INF = 0x3f3f3f3f;


LL qp(int data,int n)
{
    LL ans = 1;
    while(n){
        if(n&1){
            ans=(ans*data)%(1LL<<32);
        }
        data = (data*data)%(1LL<<32);
        n>>=1;
    }
    return ans;
}

bool ban(int x)
{
    for(int i=0;i<n;i++){
        if(x&(1<<i)){
            for(int k=i+1;k<n;k++){
                if(x&(1<<k)&&linked[i][k]){
                    return false;
                }
            }
        }
    }
    return true;
}

int main()
{
    cin>>n;
    LL ans = 0;
    char op;
    getchar();
    for(int i=0;i<n;i++){
        for(int j=0;j<n;j++){
            op=getchar();
            if(op=='1') linked[i][j] = 1;
        }
        getchar();
    }
    for(int i=0;i<(1<<n);i++){
        if(ban(i)){
            dp[i] = 1;
        }
        else{
            dp[i] = INF;
            for(int k=i;k;k=(k-1)&i){
                dp[i] = min(dp[i],dp[i^k]+dp[k]);
            }
        }
        ans += dp[i]*qp(233,i)%(1LL<<32);
        ans%=(1LL<<32);
    }
    cout<<ans-1<<endl;
    return 0;
}


基本的动态规划到这里就介绍结束了,本文仅作为个人复习向。在此向广大博友推荐计蒜客的动态规划课程,这里是链接:动态规划——计蒜客基本本博内容都来源于此课程,上课程的时候可以结合本博进行参考。如有纰漏,望指正。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值