[专题七] 动态规划

痴心易碎 提交于 2020-02-01 12:48:34
01背包 完全背包 多重背包 分组背包 混合背包
对于物品而言只能选择1个或者0个两种情况 对于物品而言可以无限制选取,也可以不选 对于物品而言最多能够选择从s[i]个,同样也可不选 一些物品捆绑在一起,每一组物品中只能选择其中的一个物品 有些物品可以选择1,有些物品可以选择无数个,有些物品只能选择是s[i]个.即:01背包+完全背包+多重背包.
滚动数组 滚动数组 滚动数组 滚动数组 滚动数组
二进制优化或者单调队列优化 其中多重背包部分参考多重背包优化
0 - 1 背包问题

NN 件物品和一个容量是 VV 的背包。每件物品只能使用一次。

ii 件物品的体积是 vivi,价值是 wiwi。

求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。

/*
    特点:每个物品仅能使用一次
  f[i][j]:前i个物品,背包容量为j的情况下的最优解
  1)当前背包容量不够,为前i-1个物品最优解:j<w[i] f[i][j] = f[i-1][j]
    2)当前背包容量够,判断选与不选第i个物品
    选:f[i][j] = f[i-1][j-w[i]] + v[i]
    不选:f[i][j] = f[i-1][j]
*/
// 二维表示法
int n, m;
int v[N], w[N]; //v为体积 w为价值
int f[N][N];
int main() {
    cin >> n >> m; //n个物品 背包体积为m
    for(int i = 1; i <= n; i++) cin >> v[i] >> w[i];
    for(int i = 1; i <= n; ++i) 
      for(int j = 1; j<=m; ++j)
      {
        if(j<w[i])  //  当前重量装不进,价值等于前i-1个物品
          f[i][j] = f[i-1][j];
        else    // 能装,需判断 
          f[i][j] = max(f[i-1][j], f[i-1][j-w[i]]+v[i]);
      }
    cout << f[n][m] << endl;
 return 0;    
}
//一维优化
int f[N]; //二维变成一维 f[i]表示的是体积为i的情况下最大价值是多少
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--) // j的体积至少大于v[i]
            f[j] = max(f[j], f[j-v[i]]+w[i]); //从后往前算 防止被更新
    cout << f[m] << endl;
  // 由于先更新下标比较大的dp数组元素,此时通过状态转移方程求最大值的时候还未更新下标较小的dp数组元素,即下标较小的dp数组元素还是上一层的值,因此倒序的方法可以使用!
 return 0;    
}
完全背包问题

首先dp数组初始化全为0:给定物品种类有4种,包最大体积为5,数据来源于题目的输入
v[1] = 1, w[1] = 2
v[2] = 2, w[2] = 4
v[3] = 3, w[3] = 4
v[4] = 4, w[4] = 5

i = 1 时: j从v[1]到5
dp[1] = max(dp[1],dp[0]+w[1]) = w[1] = 2 (用了一件物品1)
dp[2] = max(dp[2],dp[1]+w[1]) = w[1] + w[1] = 4(用了两件物品1)
dp[3] = max(dp[3],dp[2]+w[1]) = w[1] + w[1] + w[1] = 6(用了三件物品1)
dp[4] = max(dp[4],dp[3]+w[1]) = w[1] + w[1] + w[1] + w[1] = 8(用了四件物品1)
dp[5] = max(dp[3],dp[2]+w[1]) = w[1] + w[1] + w[1] + w[1] + w[1] = 10(用了五件物品)

i = 2 时:j从v[2]到5
dp[2] = max(dp[2],dp[0]+w[2]) = w[1] + w[1] = w[2] = 4(用了两件物品1或者一件物品2)
dp[3] = max(dp[3],dp[1]+w[2]) = 3 * w[1] = w[1] + w[2] = 6(用了三件物品1,或者一件物品1和一件物品2)
dp[4] = max(dp[4],dp[2]+w[2]) = 4 * w[1] = dp[2] + w[2] = 8(用了四件物品1或者,两件物品1和一件物品2或两件物品2)
dp[5] = max(dp[5],dp[3]+w[2]) = 5 * w[1] = dp[3] + w[2] = 10(用了五件物品1或者,三件物品1和一件物品2或一件物品1和两件物品2)

i = 3时:j从v[3]到5
dp[3] = max(dp[3],dp[0]+w[3]) = dp[3] = 6 # 保持第二轮的状态
dp[4] = max(dp[4],dp[1]+w[3]) = dp[4] = 8 # 保持第二轮的状态
dp[5] = max(dp[5],dp[2]+w[3]) = dp[4] = 10 # 保持第二轮的状态

i = 4时:j从v[4]到5
dp[4] = max(dp[4],dp[0]+w[4]) = dp[4] = 10 # 保持第三轮的状态
dp[5] = max(dp[5],dp[1]+w[4]) = dp[5] = 10 # 保持第三轮的状态

上面模拟了完全背包的全部过程,也可以看出,最后一轮的dp[m]即为最终的返回结果。

// 无优化
for(int i = 0; i < n; i++) {
  for(int j = m; j >= v[i]; j--) {
    for(int k = 0; k * v[i] < j; k++) {
      f[j] = max(f[j], f[j-v[i]*k] + k*w[i]);
    }
  }
}

//优化解
int n,m, arr[N],v[N],w[N];
int main() {
    cin >> n >> m; //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++){
            arr[j] = max(arr[j] , arr[j-v[i]]+w[i]);
        }
    }
    cout << arr[m];
    return 0;   
}
分组背包问题

有 N 组物品和一个容量是 V的背包。每组物品有若干个,同一组内的物品最多只能选一个。每件物品的体积是 vijvij,价值是 wijwij,其中 ii 是组号,jj 是组内编号。求解将哪些物品装入背包,可使物品总体积不超过背包容量,且总价值最大。输出最大价值。

// 无优化版本类似于 0 - 1 背包问题
int n, m, v[N][N], w[N][N], s[N], f[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 -- )
            for (int k = 0; k < s[i]; k ++ ) // 不选, 选第一个, 选第二个 ...  
                if (v[i][k] <= j) // 要求大于0
                    f[j] = max(f[j], f[j - v[i][k]] + w[i][k]);
    cout << f[m] << endl;
    return 0;
}
多重背包问题

有 N 种物品和一个容量是 V的背包。第 i 种物品最多有 s 件,每件体积是 vivi,价值是 wiwi。求解将哪些物品装入背包,可使物品体积总和不超过背包容量,且价值总和最大。输出最大价值。

for (int i = 1; i <= n; i ++ )
        for (int j = 0; j <= m; j ++ )
            for (int k = 0; k <= s[i] && k * v[i] <= j; k ++ )
                f[i][j] = max(f[i][j], f[i - 1][j - v[i] * k] + w[i] * k);
记忆化搜索

给定一个R行C列的矩阵,表示一个矩形网格滑雪场。矩阵中第 i 行第 j 列的点表示滑雪场的第 i 行第 j 列区域的高度。一个人从滑雪场中的某个区域内出发,每次可以向上下左右任意一个方向滑动一个单位距离。当然,一个人能够滑动到某相邻区域的前提是该区域的高度低于自己目前所在区域的高度。下面给出一个矩阵作为例子:

在给定矩阵中,一条可行的滑行轨迹为24-17-2-1。在给定矩阵中,最长的滑行轨迹为25-24-23-…-3-2-1,沿途共经过25个区域。现在给定你一个二维矩阵表示滑雪场各区域的高度,请你找出在该滑雪场中能够完成的最长滑雪轨迹,并输出其长度(可经过最大区域数)。

const int N = 310;
int n, m;
int g[N][N];
int f[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;
    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 && g[x][y] > g[a][b]) // 界限内部且高度低于自身
            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", &g[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)); //选择最大的路径返回
    // 福大的题特别喜欢考找回路径的打印 那就从res取得的那个结点出发,依次递减往回找路,同时用栈存储,最后打印
    // typedef pair<int int> PII;
    printf("%d\n", res);
    return 0;
}
树形DP: 没有上司的舞会
const int N = 6010;
int n;
int h[N], e[N], ne[N], idx;
int happy[N];
int f[N][2];
bool has_fa[N]; //因为没有告诉我们父结点是谁 因此开一个bool数组 判断该结点是否有父结点

void add(int a, int b) //添加边 现在应该会了吧大哥!!!!
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ; //结点为e[idx]的值为b,第i个结点的下一条边为h[a]指向。更新表头
}
void dfs(int u)
{
    f[u][1] = happy[u]; //先读存入自己的高兴值
    for (int i = h[u]; ~i; i = ne[i]) // ~i的含义的就是i!=-1
    {
        int j = e[i];
        dfs(j); //自底向上实现过程
        f[u][1] += f[j][0];
        f[u][0] += max(f[j][0], f[j][1]);
    }
}

int main()
{
    scanf("%d", &n);
    for (int i = 1; i <= n; i ++ ) scanf("%d", &happy[i]); //读入高兴值
    memset(h, -1, sizeof h); //表头打-1
    for (int i = 0; i < n - 1; i ++ ) {
        int a, b;
        scanf("%d%d", &a, &b);
        add(b, a);
        has_fa[a] = true;
    }
    int root = 1;
    while (has_fa[root]) root ++ ;
    dfs(root); //找到根结点
    printf("%d\n", max(f[root][0], f[root][1])); //返回值是 含根结点情况及无根结点情况 两者取最大值
    return 0;
}
状态压缩:蒙德里安的梦想

求把 N 乘 M 的棋盘分割成若干个1*2的的长方形,有多少种方案。

例如当N=2,M=4时,共有5种方案。当N=2,M=3时,共有3种方案。

如下图所示:

解题思路: 按列摆放,分为横向10瓷砖、纵向00瓷砖。假设此时已将所有横向摆放的瓷砖排列完全,此时就仅剩下纵向格子的摆放位置,因此,对于某一列来说,本行与上一行同为0的瓷砖,不能为奇数个,不然这样就不能接着放下纵向瓷砖了。比如说现在有3行全0列,剩下一行,什么也放不了。其次,相邻的两列不能均为1,如果出现的话,两个横向摆放瓷砖冲突。最后,由于横向10,纵向00的布局,导致第m列只能是全0,状态存储至f [m] [0]。实际状态是0到m列。

const int N = 1 << 12;
long long f[12][N];
bool st[N];
int n, m;
int main() {
    int n, m; // n行m列 我们按照列进行状态压缩
    while(cin >> n >> m, n || m) {
        memset(f, 0, sizeof f);
        // 枚举行的二进制表达 查看是否存在连续为0且为奇数的某列
        // 如果存在,则st置false
        for(int i = 0; i < 1 << n; i++) {
            int ans = 0;
            st[i] = true;
            for(int k = 0; k < n; k++) {
                if(i>>k & 1) {
                    if(ans&1) st[i] = false;
                }
                else ans++;
            }
            if(ans&1) st[i] = false;
        }
        // 初始状态为f[0][0] = 1 因为第一列全为0也是一种排法,也是第一种排法,总不能没有初始状态吧
        f[0][0] = 1;
        for(int i = 1; i <= m; i++) {
            for(int k = 0; k < N; k++) {
                for(int j = 0; j < N; j++) {
                    if((k&j)==0 && st[k|j]) {
                        f[i][k] += f[i-1][j];
                    }
                }
            }
        }
        cout << f[m][0] << endl;
    }
}
状态压缩:最短Hamilton路径

题目:给定一张 n 个点的带权无向图,点从 0~n-1 标号,求起点 0 到终点 n-1 的最短Hamilton路径。 Hamilton路径的定义是从 0 到 n-1 不重不漏地经过每个点恰好一次。

using namespace std;
const int N = 1 << 20, M = 20;
int f[N][M], weight[M][M];
int n;

int main() {
    cin >> n; //
    for(int i = 0; i < n; i++) {
        for(int j = 0; j < n; j++) {
            cin >> weight[i][j]; //输入各边权重
        }
    }
    memset(f, 0x3f, sizeof f); // 0x3f表示无穷大
    f[1][0] = 0; // 从第一个顶点开始
    
    for(int i = 1; i < 1<<n; i++) {  // 从1开始,到 1<<n
        for(int j = 0; j < n; j++) {  // 第j位合法
            if((i >> j) & 1) { // 右移j位为0
                for(int k = 0; k < n; k++) {
                    if ((i - (1 << j)) >> k & 1) { //刨去第j位,判断第k位,也还是用枚举的方法
                        f[i][j] = min(f[i][j], f[i - (1 << j)][k] + weight[k][j]);
                    }
                }
            }
        }
    }
    
    cout << f[(1 << n) - 1][n - 1] << endl;
    return 0;
}
线性DP:数字三角形

题目:给定一个如下图所示的数字三角形,从顶部出发,在每一结点可以选择移动至其左下方的结点或移动至其右下方的结点,一直走到底层,要求找出一条路径,使路径上的数字的和最大。

using namespace std;
const int N = 500, INF = 1e9;
int path[N][N], w[N][N]; // 第0列全都是0
int n;

int main() {
    scanf("%d", &n);
    for(int i = 1; i <= n; i++) {
        for(int j = 1; j <= i; j++) {
            scanf("%d", &path[i][j]);
        }
    }
    // 每行多初始化一个 每行还要多初始化一列
    for(int i = 0; i <= n; i++) {
        for(int j = 0; j <= i + 1; j++) {
            w[i][j] = -INF; // 初始化成正无穷 使得每一层的边界值 都必须是从上一层转移过来的
        }
    }
    w[1][1] = path[1][1]; // 起始状态
    for(int i = 2; i <= n; i++) {
        for(int j = 1; j <= i; j++) {
            w[i][j] = max(w[i-1][j], w[i-1][j-1]) + path[i][j];
        }
    }
    int ans = -INF;
    for(int i = 1; i <= n; i++) ans = max(ans, w[n][i]);
    cout << ans;
    return 0;
}
线性DP:最长上升公共子序列

题目:给定一个长度为N的数列,求数值严格单调递增的子序列的长度最长是多少。

/*集合划分标准:枚举第i-1个元素,如果a[i-1] < a[i],则在每一个小于a[i]且排在它前面的值的f[j]取一个最大值*/
using namespace std;
const int N = 1000;
int n, res = -0x3f;
int f[N], a[N];
int main() {
    scanf("%d", &n);
    for(int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
    }
    memset(f, 0, sizeof f); // 不能用它将int数组初始化为0和-1之外的其他值 woc刚发现 还是自己试写了
    for(int i = 1; i <= n; i++) {
        for(int j = 1; j < i; j++) {
            if (a[j] < a[i]) f[i] = max(f[j], f[i]);
        }
        f[i] += 1; // 继承第i-1个元素的f后加上1。 其实就是把自己也算上了
    }
    for(int i = 1; i <= n; i++) {
        res = max(res, f[i]); // 遍历数组 取一个max
    }
    cout << res;
    return 0;
}

思路二:我之前好像尝试了使用单调队列做,但是没有明白如果遇到元素冲突应该怎么解决,然后看了一个题解,发现一个很厉害的做法。

枚举数组里的数,如果插入值大于队列,就插在末尾,反之,就按查找到序号往左替换队列的值,时间复杂度是对数级。队列搞个负无穷来初始化也是为了防止数据出现空集的情况。

【测试数组】(在样例上多加了两个数)
[10, 9, 2, 5, 3, 7, 101, 18, 4, 19]
【有效长度/队列/插入值】
1 [-inf, 10] <- 10
1 [-inf, 9] <- 9
1 [-inf, 2] <- 2
2 [-inf, 2, 5] <- 5
2 [-inf, 2, 3] <- 3
3 [-inf, 2, 3, 7] <- 7
4 [-inf, 2, 3, 7, 101] <- 101
4 [-inf, 2, 3, 7, 18] <- 18
4 [-inf, 2, 3, 4, 18] <- 4
5 [-inf, 2, 3, 4, 18, 19] <- 19
线性DP:最长上升公共子序列II
const int N = 1e5; // 1 * 10^5
int f[N], a[N]; //f[i]存储的是, 长度为i的所有队列中,最小的队尾元素
int n;
int main() {
    scanf("%d", &n);
    for(int i = 0; i < n; i++) { // 从0开始!!!!!!!!!!
        scanf("%d", &a[i]);
    }
    for(int i = 0; i < n; i++) {
        f[i] = -0x3f; // 其实只要初始化f[0]即可 
    }
    int maxLen = 0; // 初始化队列长度 最大为0
    for(int i = 0; i < n; i++) {
        // 遍历a中的每一个数,二分查找f数组,快速判断应该插入的位置
        int l = 0,r = maxLen;
        while(l < r) {
            int mid = (l + r + 1) >> 1;
            if(f[mid] < a[i]) l = mid; // 如果找到的元素比a[i]小,说明我需要插入的数组在右侧
            else r = mid - 1;
        }
        // while循环结束时,指向的位置为,f[r], 保存了某个r长度的队伍中的最小的队尾元素
        maxLen = max(r + 1, maxLen); // 更新长度值 插入后,其长度变为r + 1。取一个max值
        f[r + 1] = a[i]; // 因为长度 + 1后,变成右邻
    }
    printf("%d", maxLen);
    return 0;
}
线性DP:最长公共子序列

题目:给定两个长度分别为N和M的字符串A和B,求既是A的子序列又是B的子序列的字符串长度最长是多少。

char a[N], b[N];
int n, m;
int f[N][N];
int main() {
    scanf("%d %d", &n, &m);
    // 判断两个字符串的最后一位 如果相等 则长度+1 并转移两者同时-1
    // 如果不相等 则使max(分别-1)
    scanf("%s %s", a+1, b+1); //因为涉及到f[i-1] 从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]);
            if(a[i] == b[j]) f[i][j] = max(f[i][j], f[i-1][j-1] + 1);
        }
    }
    cout <<  f[n][m];
    return 0;
}
线性DP:编辑最短距离

题目:给定两个字符串A和B,现在要将A经过若干操作变为B,可进行的操作有:现在请你求出,将A变为B至少需要进行多少次操作。

/*1. 删除–将字符串A中的某个字符删除。2. 插入–在字符串A的某个位置插入某个字符。3. 替换–将字符串A中的某个字符替换为另一个字符。*/
const int N = 1010;
char a[N], b[N];
int n, m;
int f[N][N]; //f[i][j]表示的是把前i个字母转化为b的前j个字母
int main() {
    scanf("%d %s", &n, a + 1);
    scanf("%d %s", &m, b + 1);
    // 设置边界值 把a的前i个字母转化为b的前0个字母
    for(int i = 0; i <= n; i++) f[i][0] = i; //只能选择删除
    // 设置边界值 把a的前0个字母转化为b的前i个字母
    for(int i = 0; i <= m; i++) f[0][i] = i; //只能选择添加
    
    for(int i = 1; i <= n; i++) {
        for(int j = 1; j <= m; j ++){
            //分别对应的是删除和增加操作
            // 删除的话 是删除第i个位置使得二者匹配 即 前i-1 与 前j 匹配
            // 增加的话 增加第i+1位后与f的前j位匹配
            f[i][j] = min(f[i-1][j], 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;
}
区间DP:石子合并

题目:每堆石子有一定的质量,可以用一个整数来描述,现在要将这N堆石子合并成为一堆。每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻,合并时由于选择的顺序不同,合并的总代价也不相同。

int s[N], f[N][N];
int 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]; //前缀和
    for(int len = 2; len <= n; len++ ) {//石子堆数为2... n堆 总共n堆
        for(int i = 1; i + len - 1 <= n; i++ ) {
            // 从第一堆开始 长度为len 因此右端点为i+len-1 应该小于n 最后至少为两堆
            int l = i, r = i + len - 1;
            f[l][r] = 1e8;
            for(int k = l; k < r; k++) {
                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:整数划分

题目:现在给定一个正整数n,请你求出n共有多少种不同的划分方法。

/*完全背包解法
状态表示: f[i][j]表示只从1~i中选,且总和等于j的方案数
状态转移方程: f[i][j] = f[i - 1][j] + f[i][j - i];*/
int n;
int f[N];
int main()
{
    cin >> n;
    f[0] = 1;
    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] << endl;
    return 0;
}

知识点和代码均学习于Acwing: https://www.acwing.com/activity/

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!