动态规划的本质是什么?2万字深度剖析动态规划核心、多种背包问题模型变形拓展以及内在联系,从0建立动态规划思维体系【动态规划系统1】

动态规划


声明

本篇是动态规划系列的第一章,主要内容是从大量实例出发探索动态规划的核心和剖析经典的背包问题。

本系列基于代码源wls的动态规划系列课程,主要内容包括dp概述、背包、区间dp、树形dp、换根树dp、数位dp、状态压缩dp、概率dp等内容,持续更新,欢迎关注。

所有例题均可在Home - Daimayuan Online Judge的动态规划课程题单中提交。

概述

核心在解决一个问题时其所需要的子问题已经全部解决

这句话贯穿整个动态规划

整章逻辑:
在这里插入图片描述

例1:楼梯

题目描述

在这里插入图片描述

dp分析

考虑最后一步的情况,只能从第n-1阶或者第n-2阶走上去

用f表示走到第n级台阶有多少走法,则

f(n)=f(n-1)+f(n-2) (n >= 2)

f(0)=1

f(1)=1

如果用递归效率很低

考虑到“解决一个问题时其所需要的子问题已全部解决”,即在求f(n)时f(n-1)和f(n-2)均已被算出来了。

所以先算根据f(0)和f(1)算f(2),再根据f(1)和f(2)算f(3)……以此类推,f(n)就算出来了。即i从小到大一个一个计算出所有的f[i]。

int n, f[51];

int main() {
    cin >> n;
    f[0] = 1;
    f[1] = 1;
    for (int i = 2; i <= n; ++i) {
        f[i] = f[i - 1] + f[i - 2];
    }
    cout << f[n] << endl;
    return 0;
}

注:把基本信息和记录状态的数组f定义为全局变量是很好的习惯,编译器会自动赋初值,而且使用非常方便。

要求与元素

要求
  1. 最优子结构:大问题的解可以由小问题的解推出。

    ​ 例1中f(n)的解可以由f(n-1)和f(n-2)推出

  2. 无后效性:只关心小问题的解,不关心小问题是怎么解决的

​ 例1中想要得到f(n)只需要知道f(n-1)和f(n-2)的值,而得到f(n-1)和f(n-2)的过程与f(n)没有 任何关系。即小问题的求解不影响大问题的求解

元素
  1. 状态:一个子问题。

    ​ 例1中的f(n)

  2. 转移:由小问题的解推导出大问题的解的过程

    ​ 例1中的递推式

例2:最短路

题目描述

用a[i][j] = t表示i到j存在权为t的边,用f[n]表示从1到n的最短路径

输入

首先将a初始化为正无穷(大概2的30次方),这样a[i][j]有值就代表i到j存在一条路径

memset(a, 127, sizeof(a));
cin >> n >> m;
for (int i = 0; i < m; ++i) {
    int x, y, z;
    cin >> x >> y >> z;
    a[x][y] = min(a[x][y], z);
}

a[x][y]不直接等于z是因为可能x到y有多条路径,我们取最短的那条就可以了

注:给a赋值为a,b小的那个一般写为a = min(a, b);

dp分析

本题的思想是“求从1到i的最短路径时,所有从1到与i存在边的j的最短路径均已求出”,本质还是“求解问题前子问题结果已知”

f[5]=min(f(2)+3,f(3)+2),所以要从小到大的求出1到每个点的最短路径

memset(f, 127, sizeof(f));
f[1] = 0;
for (int i = 2; i <= n; ++i) {
    for (int j = 1; j < i; ++j) {
        if (f[j] < 1 << 30 && a[j][i] < 1 << 30) {
            f[i] = min(f[i], f[j] + a[j][i]);
        }
    }
}

f初始化为正无穷,这样f[i]有值就代表能够从1走到 i

f[1]自己到自己显然路径为0

i从小到大遍历待求的结点,j遍历所有比i小的结点

如果f[j]存在即从1出发能够到达j这个结点,并且且a[j][i]存在即j到i存在一条边

f[i]就等于自己(即对于上一个j从1到j的最短路径的值,也就是上一个状态)和f[j]+a[j][i]的最小值

内层循环结束,f[i]中留下的就是从1到i的最短路径

例如在求f[5]时f[2]和f[3]已知,f[5]为正无穷。j = 2时f[5]变成 f[2]+3=4;j = 3时f[5]取4和f[3]+2=4的最小值4

最后输出f[n]即可

例3:最长上升子序列

在这里插入图片描述

dp分析

最长上升子序列长度设为len,f[i]表示以i结尾(i必须取)的len

为了算出以i结尾的leni,可以令j从1~i,算出以j结尾的lenj,如果a[i]>a[j],那么leni = max(lenj+1, f[i])

与上一题类似,f[i]代表的是上一个状态

cin >> n;
for (int i = 1; i <= n; ++i) {
    cin >> a[i];
}
for (int i = 2; i <= n; ++i) {
    f[i] = 1;//以i结尾的len最小s
    for (int j = 1; j < i; ++j) {
        if (a[j] < a[i]) {
            f[i] = max(f[i], f[j] + 1);
        }
    }
}

最后遍历f[n]输出最大值即可。

例4:最长因子链

dp分析

跟最长上升子序列极其相似。

只需要将数组排序,将判断条件改为a[i]%a[j]==0就好了

逻辑就是但凡a[i]能够整除a[j],f[i] = max(f[i], a[j]+1)

#include <bits/stdc++.h>

#pragma GCC optimize(2)
#define endl '\n'
using namespace std;

long n, f[1001], a[1001];

int main() {
    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
    }
    sort(a + 1, a + n + 1);
    for (int i = 1; i <= n; ++i) {
        f[i] = 1;
        for (int j = 1; j < i; ++j) {
            if (a[i] % a[j] == 0) {
                f[i] = max(f[i], f[j] + 1);
            }
        }
    }
    long ans = 0;
    for (int i = 1; i <= n; ++i) {
        ans = max(ans, f[i]);
    }
    cout << ans << endl;
    return 0;
}

例5:最长公共子序列

在这里插入图片描述

在这里插入图片描述

dp分析

ai==bj,考虑a中前i-1个元素和b中前j-1个元素

ai!=bj,考虑a中前i0个元素和b中前j-1个元素(bj不取)或者a中前i-1个元素和b中前j个元素(ai不取)

首先定义和读入数据

long n, m, f[1001][1001], a[1001], b[1001];
cin >> n >> m;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
}
for (int i = 1; i <= m; ++i) {
cin >> b[i];
}

然后写动态规划

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]);//考虑a[i]不取和b[j]的情况
        if (a[i] == b[j]) {
            f[i][j] = max(f[i][j], f[i - 1][j - 1] + 1);
        }
    }
}

先令f[i][j]为a[i]不去或b[j]不取时的最长公共子序列的长度,如果a[i]==b[j],此时就可以考虑取a[i]和b[j]时最长公共子序列的长度。

f[i][j]其实就是:

在a[i]==b[j]时取其上边的值、左边的值、左上角+1中最大的那个,不相等时就取上边和左边中大的那个。两种情况可以合并为:先令f[i][j]为左边和上边中大的那个,当a[i]==b[i]时取f[i][j]和左上角+1中大的那个

最后输出[n][m]就好了

整体代码
#include <bits/stdc++.h>

#pragma GCC optimize(2)
#define endl '\n'
using namespace std;

long n, m, f[1001][1001], a[1001], b[1001];

int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
    }
    for (int i = 1; i <= m; ++i) {
        cin >> b[i];
    }
    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] << endl;
    return 0;
}

背包问题

01背包

dp分析

用f[i][j]表示前i组物品体积为j时的最大收益。

把考虑了前i个物品以后总体积为0,1,….m时的最大收益都记下来

那么考虑前i个物品,有:

第i个没取,f[i][j] = f[i - 1][j],因为第i个没取,前i个物品体积为j时的最大收益就等于前i - 1个物品体积为j时的最大收益

第i个取了,f[i][j] = f[i - 1][j - v[i]],意思就是前i个物品体积为j时的最大收益等于前i - 1个物品体积为j - v[i]时的最大收益加上第i个物品的价值

**最优子结构:**计算考虑前i个物品总体积为j时的最大收益,可以先计算考虑了前i - 1个物品总体积为j时的最大收益或前i - 1个物品总体积为j - v[i]时的最大收益

无后效性:只关心收益,不关心怎么得到这个收益

状态:用f[i][j]表示前i组物品体积为j时的最大收益

转移:f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i]]+w[i]),即取 取第i个物品和不取第i个物品收益大的那个

数据处理

定义全局变量,w[i]、v[i]分别储存每个物品的价值和体积

int n, m, f[1001][1001], w[1001], v[1001];

数据读入和保存

cin >> n >> m;
for (int i = 1; i <= n; ++i) {
	cin >> v[i] >> w[i];
}
算法实现

因为在求f[i][j]时需要用到f[i-1]的数据,所以i由小到大;需要用到f[i-1][j-v[i]]的数据,所以j由小到大将这些值依次求出。即在考虑重量为j时需要考虑j-v[i],所以j从小到大枚举

在这里插入图片描述

如图,想要求出红圈处的值需要用到黄圈内的值

这依旧体现了动态规划的核心:解决一个问题时其需要的子问题均已解决

for (int i = 1; i <= n; ++i) {
    for (int j = 0; j <= m; ++j) {
        if (j < v[i]) {
            f[i][j] = f[i - 1][j];
        } else {
            f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i]] + w[i]);
        }
    }
}
输出处理

根据f[i][j]的含义:前i个物品体积为j是的最大价值,所以输出f[n][m]即可

cout << f[n][m];
补充

如果对算法的运行过程存在疑惑,可以在输出语句前输出f数组

for (int i = 1; i <= n; ++i) {
    for (int j = 0; j <= m; ++j) {
        cout << f[i][j] << ' ';
    }
    cout << endl;
}

这样可以根据输出的结果,自己模拟算法进行的过程。这样会对代码运行逻辑的理解更为深刻。

自己模拟二维数组的动态变化过程对自己的提升是巨大的。

整体代码
#include <bits/stdc++.h>

#pragma GCC optimize(2)
#define endl '\n'
using namespace std;

long n, m, f[1001][1001], a[1001], b[1001];

int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
    }
    for (int i = 1; i <= m; ++i) {
        cin >> b[i];
    }
    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] << endl;
    return 0;
}
滚动数组优化

通过观察可知,f[i][j]的值仅与上一行有关,所以前面i - 2行都可以不用记了。所以可以使用两个数组a和b,a保存第i - 1行的信息,b根据a计算第i行的信息,一轮结束后,将b中的元素赋值给a,重复上述过程。

这种方法称为滚动数组,非常的形象。

这样的话我们就将f从1000*1000的数组简化成了两个1000的数组

所以令f表示第i - 1行数据,g表示第i行数据

int n, m, f[1001], g[1001], w[1001], v[1001];

核心部分就变成了

for (int i = 1; i <= n; ++i) {
    for (int j = 0; j <= m; ++j) {
        if (j < v[i]) {
            g[j] = f[j];
        } else {
            g[j] = max(f[j], f[j - v[i]] + w[i]);
        }
    }
    memcpy(f, g, sizeof(g));
}

memcpy的功能就是把g数组赋值给f

这样两个数组交替更改,就能够算出最终的值

更简单的做法

在这里插入图片描述

仔细观察图一,算出红圈位置的数只需要黄圈里边的数,计算过程实际上就是取红圈上边那个数和黄圈中j - v[i]位置的那个数与w[i]的和这两个数中大的那个。那么是不是可以只用一个一维数组,j从右向左,比较f[j]和f[j - v[i]]+w[i]哪个大,把大的那个存到f[j],然后j-- ?

那么假设现在第一个for枚举到了i,第二个for枚举到了j,j左边的值记录的是考虑前i - 1个物品时的最值,j右边的值记录的是考虑前i个物品时的最值

j向左移动一位时,比较f[j](考虑前i - 1总重为j时的最大值,也就是不去f[i])和f[j - v[i]] + w[i],取较大的那个更新f[j]即可

所以核心就变为

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

这样就只需要一维数组就可以了。

完全背包

dp分析

和01背包唯一的区别就是每个物品都可以用无限次。

同理,f[i][j]表示考虑前i个物品总重为j是的最大收益

  1. 第i个物品不取,考虑前i - 1个物品总重为j的情况
  2. 第i个物品取,考虑前i个物品总重为j的情况

注意此处不同点就是取第i个物品时要考虑前i个物品总重为j的情况,也就是f[i][j] = f[i][j - v[i]] + w[i]

因为第i个物品可以无限用,所以取了第i个物品还得考虑再取得情况

所以核心变为

for (int i = 1; i <= n; ++i) {
    for (int j = 0; j <= m; ++j) {
        if (j < v[i]) {
            f[i][j] = f[i - 1][j];
        } else {
            f[i][j] = max(f[i - 1][j], f[i][j - v[i]] + w[i]);//仅有这一处变动
        }
    }
}

仅仅改变了一个位置,程序的含义与以前完全不同。

优化

在这里插入图片描述

同理,想要求红圈位置的值需要黄圈内的数据。

j从小到大枚举,j左边的值记录的是考虑了前i个物品的最值,右边记录的是考虑了前i - 1个物品的最值。

j向右移动一位的过程种,需要比较f[j]和f[j - v[i]] + w[i]的值,将f[j]更新为大的那个

for (int i = 1; i <= n; ++i) {
    for (int j = 0; j <= v[i]; ++j) {
        f[j] = max(f[j], f[j - v[i]] + w[i]);
    }
}
扩展

如果要求必须恰好将背包装满该如何做?

只需要将f[n]初始化为负无穷(负二的三十次方左右),然后将f[0]=0即可。

这样如果f[j - v[i]] + w[i]不存在即物体i不能恰好把背包装满的话,f[i][j]永远都是一个负无穷大

memeset(f, 128, sizeof(f));
f[0] = 0;

注:memset作用是高效初始化数组,如果参数是128就是负无穷,如果是127就是正无穷(2的三十次方左右)

多重背包1-转化

dp分析

在这里插入图片描述

第i个物品能使用l次,那就将第i个物品拆成l个只能使用一次的物品,就转化为了01背包问题。

运算部分变为

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

因为每个物品能使用l次,就将其看作l个能使用1次的物品,在考虑第i个物品时,令后边的语句运行l[i]次。

多重背包2-定理

定理引入

在这里插入图片描述

有数学上的定律如上。

数学归纳法证明省略。

定理意思就是

n = 8时, 8 = 1 + 2 + 4 + 1

(这个1就是8-1-2-4得到的)

dp分析

那么第i个物品可以使用l[i]次,可以把它拆成logn个物品,每个物品只能用一次

所以将第二重循环更改

int res = l[i];
for (int k = 1; k <= res; res -= k,k *= 2) {
    for (int j = m; j >= v[i]; --j) {
        f[j] = max(f[j], f[j - v[i] * k] + w[i] * k);
    }
}

这样内层循环就会运行logn次,相当于把它拆成了logn个体积为k的物品

然后处理剩下的部分

for (int j = m; j >= v[i] * res; --j) {
    f[j] = max(f[j], f[j - v[i] * res] + w[i] * res);
}

这句话相当于对剩下的单独做一次01背包

最后输出f[m]即可

整体代码如下

for (int i = 1; i <= n; ++i) {
    int res = l[i];//剩下多少没分
    //每一份分成k个物品
    for (int k = 1; k <= res; res -= k,k *= 2) {
        for (int j = m; j >= v[i]; --j) {
            f[j] = max(f[j], f[j - v[i] * k] + w[i] * k);
        }
    }
    //处理剩余的
    for (int j = m; j >= v[i] * res; --j) {
        f[j] = max(f[j], f[j - v[i] * res] + w[i] * res);
    }
}
注意事项

for循环中的res -= k一定要在k *= 2前,要不然k先乘2,res再减k,此时k的值已经改变,就会出现错误

整体代码
#include <bits/stdc++.h>

#pragma GCC optimize(2)
#define endl '\n'
using namespace std;

int n, m, f[2001], l[2001], w[2001], v[2001];


int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; ++i) {
        cin >> v[i] >> w[i] >> l[i];
    }
    for (int i = 1; i <= n; ++i) {
        int res = l[i];
        for (int k = 1; k <= res; res -= k, k *= 2) {
            for (int j = m; j >= v[i] * k; --j) {
                f[j] = max(f[j], f[j - v[i] * k] + w[i] * k);
            }
        }
        for (int j = m; j >= v[i] * res; --j) {
            f[j] = max(f[j], f[j - v[i] * res] + w[i] * res);
        }
    }
    cout << f[m];
    return 0;
}

分组背包

在这里插入图片描述

dp分析

f[i][j]记录考虑前i组物品总体积为j的最大收益

对于第i组

一个都不取:

f[i][j] = f[i - 1][j],即第i组一个都不取

取了

枚举其中的物品k,取最大的那个收益

f[i][j] = max(f[i - 1][j - v[k]] + w[k])

实例解释

例如样例

在这里插入图片描述

f[1][0] = 0,因为一个都取不了

f[1][1] = 6,因为取了第一个物品,价值为6

同理

f[1][5] = 6,取第三个

f[1][6] = 9,取第一个和第三个或者取第二个

现在考虑第二组

f[2][5] = 11(下列几个数中的最大值)

f[1][5] = 6(第二组一个都不取),f[1][1] + 5 = 11(取第二组第一个),f[1][0] + 10 = 10(取第二组第二个)

同理:f[2][6] = 16(下列几个数中的最大值)

f[1][6] = 9(第二组一个都不取) ,f[1][2] + 5 = 11(取第二组第一个),f[1][1] + 10 = 16(取第二组第二个)

数据处理

用a记录组号,用vector数组存放组号为a[i]的元素的下标

int n, m, f[1001][1001], w[1001], v[1001], a[2001];
vector<int> c[1001];
cin >> n >> m;
for (int i = 1; i <= n; ++i) {
    cin >> a[i] >> v[i] >> w[i];
    c[a[i]].push_back(i);
}

这样第c[a[i]]处的数组存放的就是每个组号为a[i]的物品的下标

核心运算
for (int i = 1; i <= 1000; ++i) {
    for (int j = 0; j <= m; ++j) {
        f[i][j] = f[i - 1][j];
        for (auto k: c[i]) {
            if (v[k] <= j) {
                f[i][j] = max(f[i][j], f[i - 1][j - v[k]] + w[k]);
            }
        }
    }
}

首先是如果该组一个都不选,就将f[i][j]赋值为f[i - 1][j],后续如果f[i - 1][j - v[k]比f[i][j]大就再次赋值。

注意事项

这个操作不能改写如下代码:

for (int i = 1; i <= 1000; ++i) {
    for (int j = 0; j <= m; ++j) {
        for (auto k: c[i]) {
            if (v[k] <= j) {
                f[i][j] = max(f[i - 1][j], f[i - 1][j - v[k]] + w[k]);
            }
        }
    }
}

因为下面的代码f[i][j]只有在v[k] <= j时才会被修改且如果第i组一个都不去根本就不会进入第三层循环。

算法优化

如何将f优化为一维数组?

思想类似于01背包的优化,因为第i组仅和第i - 1组相关,其实除了多了一个遍历组内成员的操作之外,分组背包和01背包没有太大的区别。

分组背包可以理解为把01背包的第i个换成了第i组,01背包的考虑第i个物品在分组背包里变成了考虑第i组物品

所以优化和01背包完全类似,只不过j从m到0,因为01背包的j指代一个物品的重量,所以遍历到v[i]就可以结束了,但此处j的指的是考虑第i组背包在体积为j时的最大收益,并不仅仅包括一个物体,对于每一个j还需要遍历一次第i组物品,所以不能从m到v[i]。

for (int i = 1; i <= 1000; ++i) {
    for (int j = m; j; --j) {
        for (auto k: c[i]) {
            if (v[k] <= j) {
                f[j] = max(f[j], f[j - v[k]] + w[k]);
            }
        }
    }
}
整体代码
#include <bits/stdc++.h>

#pragma GCC optimize(2)
#define endl '\n'
using namespace std;

int n, m, f[1001][1001], w[1001], v[1001], a[2001];
vector<int> c[1001];

int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i] >> v[i] >> w[i];
        c[a[i]].push_back(i);
    }
    for (int i = 1; i <= 1000; ++i) {
        for (int j = 0; j <= m; ++j) {
            f[i][j] = f[i - 1][j];
            for (auto k: c[i]) {
                if (v[k] <= j) {
                    f[i][j] = max(f[i][j], f[i - 1][j - v[k]] + w[k]);
                }
            }
        }
    }
    cout << f[1000][m];
    return 0;
}

二维背包

在这里插入图片描述

dp分析

其实就是比01背包多了一重限制

那就设f[i][j][k]为考虑前i个物品总体积为j总体力为k时的最大收益

考虑前i个物品,总体积为j,总体力为x时:

第i个物品没取:

考虑前i - 1个物品,总体积为j,总体力为x的情况;

第i个物品取了:

考虑前i - 1个物品,总体积为j - v[i],总体力为x - t[i]的情况;

所以转移公式可以很轻易的类比01背包得到

在这里插入图片描述

数据处理
int n, m, k, f[101][101][101], w[1001], v[1001], t[1001];
cin >> n >> m >> k;
for (int i = 1; i <= n; ++i) {
    cin >> v[i] >> w[i] >> t[i];
}
算法设计

完全类比01背包,只不过多了一维。

for (int i = 1; i <= n; ++i) {
    for (int j = 0; j <= m; ++j) {
        for (int x = 0; x <= k; ++x) {
            f[i][j][x] = f[i - 1][j][x];
            if (j >= v[i] && x >= t[i]) {
                f[i][j][x] = max(f[i][j][x], f[i - 1][j - v[i]][x - t[i]] + w[i]);
            }
        }
    }
}
整体代码
#include <bits/stdc++.h>

#pragma GCC optimize(2)
#define endl '\n'
using namespace std;

int n, m, k, f[101][101][101], w[1001], v[1001], t[1001];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    cin >> n >> m >> k;
    for (int i = 1; i <= n; ++i) {
        cin >> v[i] >> w[i] >> t[i];
    }
    for (int i = 1; i <= n; ++i) {
        for (int j = 0; j <= m; ++j) {
            for (int x = 0; x <= k; ++x) {
                f[i][j][x] = f[i - 1][j][x];
                if (j >= v[i] && x >= t[i]) {
                    f[i][j][x] = max(f[i][j][x], f[i - 1][j - v[i]][x - t[i]] + w[i]);
                }
            }
        }
    }
    cout << f[n][m][k];
    return 0;
}

算法优化

同样类比01背包将三维数组转化为二维

跟01背包唯一不一样的地方就是有两个限制条件,对两个限制条件的处理完全一样。

for (int i = 1; i <= n; ++i) {
    for (int j = m; j > v[i]; --j) {
        for (int x = k; x >= t[i]; ++x) {
            f[j][x] = max(f[j][x], f[j - v[i]][x - t[i]] + w[i]);
        }
    }
}

单调队列

在这里插入图片描述

题目释义

第i个生物在第i - 1个生物后面出现,并且会在其后面消失。

dp分析

考虑第i天,对于第j个生物,如果b[j] < b[i],那么之后第j个生物都不用考虑了。因为在其存在期间一定会被第i个生物压制

所以可以用一个队列,存放到目前为止哪些生物是需要考虑的,即哪些生物可能在未来的某天成为攻击力最高的生物。

考虑第i天,将队列中小于b[i]的元素全部删除,因为在后续的时间里他们永远不是攻击力最高的那个。

这样队列中的元素就是单调递减的,每次攻击力最高的生物都在队首

考虑完第i个生物后,如果队首生物消失,就删除队首

实例分析

a为{3, 4, 5, 5, 5},b为{8, 9, 1, 6, 1}

第一天,队内元素为{8}

第二天,队内元素为{9},因为a[1] < a[2],所以将其删除

第三天,队内元素为{9,1}

第四天,队内元素为{9,6},因为a[4] = 6,将队列中所有小于6的值全部删掉

第五天,队内元素为{6,1},因为队首元素消失

所以每天攻击力的最大值分别为:8,9,9,9,6

实现思路
数据处理

使用数组作为队列,k代表队尾,l代表队首。

int n, c[100001][2], a[100001], b[100001];

c[i][0]存放攻击力c[i][1]存放该元素消失的天数

cin >> n;
for (int i = 1; i <= n; ++i) {
    cin >> a[i] >> b[i];
}
int k = 0, l = 1;
核心运算

如果想要在队尾加入元素,给c[++k]赋值即可,如果想删除队尾元素,只需要k–,同理,删除队首元素只需要l++

for (int i = 1; i <= n; ++i) {
    for (; k >= l && b[i] >= c[k][0]; --k);//删除比b[i]小的
    c[++k][0] = b[i];
    c[k][1] = a[i];
    cout << c[l][0] << endl;//输出队首即当前攻击力最高的生物
    for (; k >= l && c[l][1] == i; l++);//如果消失就删除(l++)
}
整体代码
#include <bits/stdc++.h>

#pragma GCC optimize(2)
#define endl '\n'
using namespace std;

int n, c[100001][2], a[100001], b[100001];

int main() {
    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i] >> b[i];
    }
    int k = 0, l = 1;
    for (int i = 1; i <= n; ++i) {
        for (; k >= l && b[i] >= c[k][0]; --k);
        c[++k][0] = b[i];
        c[k][1] = a[i];
        cout << c[l][0] << endl;
        for (; k >= l && c[l][1] == i; l++);
    }

    return 0;
}

多重背包3

在这里插入图片描述

dp分析

f[i][j]表示考虑了前i种物品总体积为j的最大收益

  1. 第i个没取,此时考虑前i - 1个物品总体积为j时的最大收益
  2. 第i个没取,枚举它取了k次,考虑前i - 1个物品总体积为j - v[i] * k的情况

转移:f[i][j]就等于f[i-1][j]枚举k取k次第i个物品中最大的那个值的大的那个。

但是这种方法复杂度是O(n^3)

算法优化

把j按照j%v[i]来分类,则只有同一类的状态间可以转移

例如:v[i] = 4,j = 13

这一类j为1,5,9,13。只有j为同一类之中的值才可能发生转移。因为是把第i个物品取了k次,f[i][j]当然只能同j为j - k * v[i]处的值产生联系

v[i] = 4 , j = 12

则该类j为0,4,8,12,也就是说f[i][j]的值只与这一类j处的值有关

在某一类中下标为k的位置,可以转移到下标在[k+1, k+l[i]]中的位置

这是显而易见的。意思就是下标为k的j处的值只能影响到下标在[k+1, k+l[i]]中的j处的值,因为物品i最多只能取l[i]次。

我们的目标是求每个j处的最大值。欸?这好像能用单调队列解决。

下标k,x之间转移时,取max(f[k] + (x - k) * w[i]),k从1到x - 1。f[k]表示某一类中下标为k时的最值

在k变化的过程中x是定值,所以把f[k] - k * w[i]放进单调队列就可以了。

算法设计
数据处理

因为采用单调队列的方法,可以读取一组数处理一组

int n, m, v, w, t, c[100001][2], f[10001];
运算

首先i从1到n遍历每个物品,读入v、w、t

for (int i = 1; i <= n; ++i) {
    cin >> v >> w >> t;

j从0到v - 1,p从j到m,p+=v[i],这样就实现了对j进行分类,对于每个j,p都会遍历这个类。

还是用k指代队尾,l指代队首,求最大值思想和上一个例子相同。

for (int j = 0; j < v; ++j) {
    int k = 0, l = 1;
    for (int p = j, x = 1; p <= m; p += v, x++) {//p指代j,遍历这一类,x指代在当前这一类中的下标
        int e = f[p] - x * w, r = x + t;//计算攻击力和持续时间
        for (; k >= l && c[k][0] <= e; --k);//删除小的
        c[++k][0] = e;
        c[k][1] = r;//存入
        f[p] = c[l][0] + x * w;
        for (; k >= l && c[l][1] == x; ++l);//如果持续时间到了就删除
    }
}

最后输出f[m]即可

整体代码
#include <bits/stdc++.h>

#pragma GCC optimize(2)
#define endl '\n'
using namespace std;

int n, m, v, w, t, c[100001][2], f[10001];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    cin >> n >> m;
    for (int i = 1; i <= n; ++i) {
        cin >> v >> w >> t;
        for (int j = 0; j < v; ++j) {
            int k = 0, l = 1;
            for (int p = j, x = 1; p <= m; p += v, x++) {//p指代j,遍历这一类
                int e = f[p] - x * w, r = x + t;
                for (; k >= l && c[k][0] <= e; --k);
                c[++k][0] = e;
                c[k][1] = r;
                f[p] = c[l][0] + x * w;
                for (; k >= l && c[l][1] == x; ++l);
            }
        }
    }
    cout << f[m];
    return 0;
}

后续内容详见下一章,欢迎关注

  • 6
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

喵寒

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值