动态规划(待完善)
动态规划
动态规划的两个要求:
最优子结构:大问题的(最优)解可以由小问题的(最优)解推出。比如说我们有一个计算总分的闯关游戏,为了赢得最后的大奖,每一关我们都得使出浑身解数,前 n 轮总得分最多的先决条件必然是前 n-1 轮总得分最多,也就是说大问题(前 n 轮总得分最多)的最优解可以由小问题(前 n-1 轮总得分最多)的最优解推出。在楼梯这个题中,大问题 f(n) 的解(走到 n 级台阶的走法数量)可以由小问题 f(n−2) 和 f(n−1) 的解推出。注意在问题拆解过程中不能无限递归,也就是说经过有限次的拆解后,我们一定能找到一个不能被继续拆解并且能够计算出答案的子问题,这一点非常重要,为了帮助大家理解,后面我们会举一个无限递归的例子)。
无后效性:未来与过去无关,一旦得到了一个小问题的解,如何得到它的解的过程不影响大问题的求解。就好像比赛中我们得了多少分,就能得到什么奖,一旦总分确定,具体哪个题目得多少分其实是无所谓的。在楼梯这个题中,要求出 f(n),只需要知道 f(n−1) 和 f(n−2) 的值,而它们到底是怎么得到的已经不关键了。
动态规划的两个元素:
状态:求解过程进行到了哪一步,可以理解为一个子问题。在楼梯这个题中,状态指的是我们走到了第几级台阶。
转移:从一个状态(小问题)的(最优)解推导出另一个状态(大问题)的(最优)解的过程。在楼梯这个题中,我们需要使用 f(n-2) 和 f(n-1) 推导出 f(n) 的值,也就是说,f(n) 由 f(n-2) 和 f(n-1) 转移而来。
背包问题
01背包
有\(n\)种物品,每件物品的体积为\(v_i\),价值为\(w_i\),每件物品只能选择一次如何选择物品使得体积不超过\(m\)的价值最大。
\(O(n*m)\) 做法
状态:从前\(i\)个物品中选择体积不超过\(j\)的最大价值
状态转移:\(dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i]),j >= v[i]\)
int n, m, dp[1001][1001], v[1001], w[1001];
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 = 0; j <= m; j ++) {
if(j < v[i]) {
dp[i][j] = dp[i - 1][j];
}
else {
dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]);
}
}
}
cout << dp[n][m];
}
空间优化
int n, m, dp[1001], v[1001], w[1001];
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 --) {
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
cout << dp[m];
}
\(O(n*\sum{v_i})\) 做法
int n, m, v[1001], w[1001], dp[1001][100001];
int main () {
cin >> n >> m;
for(int i = 1; i <= n; i ++) {
cin >> v[i] >> w[i];
}
memset(dp, 0x3f, sizeof dp);
dp[0][0] = 0;
for(int i = 1; i <= n; i ++ ) {
for(int j = 0; j <= MAX_N * MAX_V; j ++) {
if(j < w[i]) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = min(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
}
}
}
int res = 0;
for(int i = 0; i < MAX_N * MAX_V; i ++) if(dp[n][i] < m) {
res = i;
}
}
完全背包、
有\(n\)种物品,每种物品的体积为\(v_i\),价值为\(w_i\),每种物品可以选择无限次,如何选择物品使得体积不超过\(m\)的价值最大。
\(O(n*m)\)做法
状态:前\(i\)个物品中选择体积不超过\(j\)的最大价值
状态转移:\(dp[i][j] = max(dp[i - 1][j], dp[i][j - v[i]] + w[i]), j >= v[i]\)
int n, m, dp[1001][1001], v[1001], w[1001];
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 = 0; j <= m; j ++) {
if(j < v[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i][j - v[i]] + w[i]);
}
}
cout << dp[n][m];
}
空间优化
int n, m, dp[1001], v[1001], w[1001];
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 = v[i]; j <= m; j ++) {
dp[j] = max(d[j], dp[j - v[i]] + w[i]);
}
}
cout << dp[m];
}
多重背包
有\(n\)种物品,每种物品的体积为\(v_i\),价值为\(w_i\),个数为\(l_i\),如何选择物品使得体积不超过\(m\)的价值最大。
简单得看成是有\(\sum{L_i}\)个物品的01背包来实现即可
\(O(n*m*l)\)做法
int n, m,dp[1001], v[1001], w[1001], l[1001];
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 ++) {
for(int k = 1; k <= l[i]; k ++) {
for(int j = m; j >= v[i]; j --) {
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
}
cout << dp[m];
}
\(O(n * m * log(l))\)二进制(倍增)优化
将\(l_i\)分解为\(1,2,4,8,...,2^{k -1},l_i - (2^k - 1)\),再进行一次01背包
int n, m, dp[2001], v[2001], w[2001], l[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 r = l[i];
for(int k = 1; k <= r; r -= k, k *= 2) {
for(int j = m; j >= k * v[i]; j --) {
dp[j] = max(dp[j], dp[j - k * v[i]] + w[i] * k);
}
}
for(int j = m; j >= r * v[i]; j --) {
dp[j] = max(dp[j], dp[j - r * v[i]] + r * w[i]);
}
}
cout << dp[m];
}
\(O(n * m)\) 单调队列优化
在最原始的做法中,我们将状态转移分为两类:
那么我们发现发生转移的状态只存在于\(j\ mod\ v\) 相等的情况下,比如体积为\(3\)的物体,\(j = 1\),那么转移就只会在\(1 - 4, 1 - 7, 1 - 10...\)之间发生,我们可以定义这一类的下标为\(1,2,3,4...\),对应\(1,4,7,10...\),那么我们需要得到的结果是使得每一个下标对应的位置的结果最大,并且下标为\(i\)的贡献在下标为\(i + l\)的时候就要结束,那么这个问题就转化成了求滑动窗口最大值。
那么我们的转移方程就变成了\(dp[i][p + v * k] = dp[i - 1][p] + (y - x) * w\),其中\(y\)表示当前所要求的值的下标,\(x\)表示单调队列队头元素的下标。接下来我们再变化一下式子,使得单调队列维护的价值为静态价值。
再来解释一下这个转移方程,\(dp[i][p + v * k]\) 表示当前所要维护的答案,\(dp[i - 1][p] - x * w\) 表示单调队列队头元素的价值,\(y * w\) 表示当前位置所需补偿的价值。
int dp[100001];
int main () {
int n, m;
cin >> n >> m;
for(int i = 1; i <= n; i ++) {
int v, w, l;
cin >> v >> w >> l;
// mod v 意义下分类
for(int j = 0; j < v; j ++) {
// q[0] 下标, q[1] 对答案的贡献
deque<int> q[2];
// 状态转移方程:dp[i][p + v * k] = dp[i - 1][p] + (y - x) * w;
for(int p = j, x = 1; p <= m; p += v, x ++) {
int e = dp[p] - x * w, n = x + l;
while(!q[1].empty() && q[1].back() <= e) {
q[0].pop_back(), q[1].pop_back();
}
q[0].push_back(n), q[1].push_back(e);
dp[p] = q[1].front() + w * x;
while(!q[0].empty() && q[0].front() == x) {
q[0].pop_front(), q[1].pop_front();
}
}
}
}
cout << dp[m];
}
分组背包
有\(n\)种物品,每个物品的属于第\(a_i\)组,体积为\(v_i\),价值为\(w_i\),如何在每组最多选择一件物品的情况下使得体积不超过\(m\)的价值最大。
\(O(n * m)\)做法
状态:从前第\(i\)组物品中选择体积不超过\(j\)的最大价值
状态转移:\(dp[i][j] = max(dp[i - 1][j],dp[i - 1][j - v[x]] + w[x]), x\in c[i]\)
int n, m, a[1001], v[1001], w[1001], dp[1001][1001];
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 ++) {
//这组不选物品
dp[i][j] = dp[i - 1][j];
//这组选择物品
for(int x : c[i]) {
if(j >= v[x]) {
dp[i][j] = max(dp[i][j], dp[i - 1][j - v[x]] + w[x]);
}
}
}
}
cout << dp[1000][m];
}
空间优化
int n, m, a[1001],v[1001], w[1001], dp[1001];
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 <= n; i ++) {
for(int j = m; j >= 0; j --) {
for(int x : c[i]) {
if(j >= v[x]) {
dp[j] = max(dp[j], dp[j - v[x]] + w[x]);
}
}
}
}
cout << dp[m];
}
二维背包
有\(n\)种物品,每种物品的体积为\(v_i\),价值为\(w_i\),搬运需要的体力为\(t_i\),如何选择物品使得体积不超过\(m\),体力不超过\(e\)的价值最大。
\(O(n * m * e)\)做法
状态:前\(i\)个物品中选择体积不超过\(j\),体力不超过\(k\)的最大价值
状态转移:\(dp[i][j][k] = max(dp[i - 1][j][k], dp[i - 1][j - v[i]][k - t[i]] + w[i]),j >= v[i], k >= t[i]\)
const int N = 101;
int n, m, e, v[N], w[N], t[N], dp[N][N][N];
int main () {
cin >> n >> m >> e;
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 k = 0; k <= e; k ++) {
dp[i][j][k] = dp[i - 1][j][k];
if(j >= v[i] && k >= t[i]) {
dp[i][j][k] = max(dp[i][j][k], dp[i - 1][j - v[i]][k - t[i]] + w[i]);
}
}
}
}
cout << dp[n][m][e];
}
空间优化
const int N = 101;
int n, m, e, v[N], w[N], t[N], dp[N][N];
int main () {
cin >> n >> m >> e;
for(int i = 1; i <= n; i ++) {
cin >> v[i] >> w[i] >> t[i];
}
for(int i = 1; i <= n; i ++) {
for(int j = m; j >= v[i]; j --) {
for(int k = e; k >= t[i]; k --) {
dp[j][k] = max(dp[j][k], dp[j - v[i]][k - t[i]] + w[i]);
}
}
}
cout << dp[m][e];
}
区间DP
用于解决大区间可以由小区间递推得到的一类DP问题。
石子合并
[P1880 NOI1995] 石子合并 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
首先先考虑石子排成一排的情况。假设存在一条边界线,边界线左边的石子已经合并完成,代价为\(f[l][x]\);边界线右边的石子也已经合并完成,代价为\(f[x + 1][r]\),那么最后将两边石子合并的总代价为\(f[l][x] + f[x +1][r] + \sum_{i=l}^r{a_i}\),我们发现这是一个可以用递归来解决的问题,最后只需要解决两堆石子合并的代价就可以了。因此,我们可以依次枚举这个边界线\(x\),取当中的最小代价和最为这个区间的代价。
#include<bits/stdc++.h>
using namespace std;
int n, a[15], s[15];
int solve(int l, int r) {
if(l == r) {
return 0;
}
int all = 1e9;
for(int x = l; x < r; x ++) {
all = min(all, solve(l, x) + solve(x + 1, r) + s[r] - s[l - 1]);
}
return all;
}
int main () {
int n;
cin >> n;
for(int i = 1; i <= n; i ++) {
cin >> a[i];
}
for(int i = 1; i <= n; i ++) {
s[i] = s[i - 1] + a[i];
}
cout << solve(1, n);
}
但是上述代码中的递归过程存在很多的重复计算,这时我们需要用一个数组把过程记录下来,再次递归到这个过程的时候就可以直接引用这过程的值避免重复计算,这便是记忆化搜索。
#include<bits/stdc++.h>
using namespace std;
int n, a[510], s[510], dp[510][510];
int solve(int l, int r) {
if(dp[l][r] != -1) {
return dp[l][r];
}
if(l == r) {
return dp[l][r] = 0;
}
int all = 1e9;
for(int x = l; x < r; x ++) {
all = min(all, solve(l, x) + solve(x + 1, r) + s[r] - s[l - 1]);
}
return dp[l][r] = all;
}
int main () {
int n;
cin >> n;
fill(dp[0], dp[0] + 510 * 510, -1);
for(int i = 1; i <= n; i ++) {
cin >> a[i];
}
for(int i = 1; i <= n; i ++) {
s[i] = s[i - 1] + a[i];
}
cout << solve(1, n);
}
我们再来考虑如何实现区间DP,我们根据递归的原理,我们应该首先解决简单的问题,再逐步求解大问题,那么最小的问题是区间长度为\(1\)的情况,最大的问题是区间长度为\(n\)的情况,那么我们就可以从区间长度由小到大依次解决这个区间的问题。
#include<bits/sdtdc++.h>
using namespace std;
int n, a[510], dp[510][510], s[510];
int main () {
cin >> n;
for(int i = 1; i <= n; i ++) {
cin >> a[i];
}
for(int i = 1; i <= n; i ++) {
s[i] = s[s - 1] + a[i];
}
for(int len = 1; len <= n; len ++) {
for(int i = 1; i <= n - l; i ++) {
int j = i + l;
int all = 1e9;
for(int x = i; x < j; x ++) {
all = min(all, dp[i][x] + dp[x + 1][j] + s[j] - s[i - 1]);
}
dp[i][j] = all;
}
}
cout << dp[1][n];
}
我们再来考虑石子围成环的情况。我们把链视为视为环缺少一条边的情况,这样我们只需要枚举链的起始位置即可,时间复杂度为\(O(n^4)\)。我们可以可以将链复制一份接到后面,这样的新链包括了环可能的所有起始位置,时间复杂度\(O(n^3)\)。
#include<bits/stdc++.h>
using namespace std;
int n, a[510], s[510], dp[510][510];
int main () {
cin >> n;
for(int i = 1; i <= n; i ++) {
cin >> a[i];
a[i + n] = a[i];
}
for(int i = 1; i <= 2 * n; i ++) {
s[i] = s[i - 1] + a[i];
}
for(int len = 1; len <= n; len ++) {
for(int i = 1; i <= 2 * n - len; i ++) {
int j = i + len;
int all = 1e9;
for(int x = i; x < j; x ++) {
all = min(all, dp[i][x] + dp[x + 1][j] + s[j] - s[i - 1]);
}
dp[i][j] = all;
}
}
int res = 1e9;
for(int i = 1; i <= n; i ++) {
res = min(res, dp[i][i + n - 1]);
}
cout << res << '\n';
}
括号序列
括号序列 - 题目 - Daimayuan Online Judge
根据括号序列的定义,我们可以建立如下转移方程:
#include <bits/stdc++.h>
using namespace std;
int n, dp[510][510];
string s;
int main () {
cin >> n >> s;
for(int l = 1; l <= n; l ++) {
for(int i = 0; i < n - l; i ++) {
int j = i + l;
int all = 0;
if((s[i] == '(' && s[j] == ')') || (s[i] == '[' && s[j] == ']')) {
all = dp[i + 1][j - 1] + 2;
}
for(int x = i; x < j; x ++) {
all = max(all, dp[i][x] + dp[x + 1][j]);
}
dp[i][j] = all;
}
}
cout << dp[0][n - 1];
}
序列删除
序列删除 - 题目 - Daimayuan Online Judge
建立如下状态转移方程:
这个式子表示在保留$a[i] $和 \(a[j]\)的区间\(a[i,j]\)中所需的最小值,那么\(dp[i][x] + dp[x][j]\)表示保留\(a[i],a[x],a[j]\)的区间\(a[i,j]\)中所需的最小值,那么再将\(a[x]\)删去就能得到我们所需的状态。
#include<bits/stdc++.h>
using namespace std;
int n, a[510], dp[510][510];
int main () {
cin >> n;
for(int i = 1; i <= n; i ++) {
cin >> a[i];
}
for(int l = 3; l <= n; l ++) {
for(int i = 1; i <= n - l + 1; i ++) {
int j = i + l - 1;
int all = 1e9;
for(int x = i + 1; x <= j - 1; x ++) {
all = min(all, dp[i][x] + dp[x][j] + a[x] * a[i] * a[j]);
}
dp[i][j] = all;
}
}
cout << dp[1][n];
}
小技巧:在分不清边界是否越界的时候多写一条判断语句来跳出循环
树形DP
没有上司的舞会
没有上司的舞会 - 题目 - Daimayuan Online Judge
首先先用记忆化搜索来实现一下这题,每个员工都有去和不去两种选择,如果去的话,他的直接下属就不能去;如果选择不去的话,他的直接下属可以选择去和不去。那么我们写的dfs将变量都记录下来,用数组来存储过程的值。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 1e5 + 10;
vector<int> e[N];
ll dp[N][2];
int a[N];
int n;
//ok = 1表示可以选择去或者不去,= 0表示只能选择不去
ll dfs(int x, bool ok) {
if(dp[x][ok] != -1) {
return dp[x][ok];
}
ll res = 0;
ll all = 0;
//选择不去
for(int it : e[x]) {
all += dfs(it, 1);
}
res = all, all = 0;
//选择去
if(ok) {
all += a[x];
for(int it : e[x]) {
all += dfs(it , 0);
}
}
res = max(res, all);
return dp[x][ok] = res;
}
int main () {
memset(dp, -1, sizeof dp);
cin >> n;
for(int i = 1; i <= n; i ++) {
cin >> a[i];
}
for(int i = 2; i <= n; i ++) {
int l, k;
cin >> l >> k;
e[k].push_back(l);
}
ll res = 0;
for(int i = 1; i <= n; i ++) {
res = max(res, dfs(i, 1));
}
cout << res <<'\n';
}
接下来我们尝试写出转移方程。我们用\(dp[i][t]\)来表示\(i\)号员工选择去和不去的两种情况。那么\(dp[i][1]\)表示去,\(dp[i][0]\)表示不去,那么可以写出如下转移方程:
那么我们考虑的转移是从叶节点开始的,那么我们就可以用后序遍历最后求出根节点的价值
#include<bits/stdc++.h>
using namespace std;
const int N = 1e6 + 10;
long long a[N], dp[N][2], n;
vector<int> e[N];
long long dfs(int x) {
dp[x][0] = 0, dp[x][1] = a[x];
for(int it : e[x]) {
dfs(it);
dp[x][1] += dp[it][0];
dp[x][0] += max(dp[it][0], dp[it][1]);
}
return max(dp[x][1], dp[x][0]);
}
int main () {
int n;
cin >> n;
for(int i = 2; i <= n; i ++) {
int x;
cin >> x;
e[x].push_back(i);
}
for(int i = 1;i <= n; i ++) {
cin >> a[i];
}
cout << dfs(1);
}