DP 总结
DP,即动态规划(Dynamic Programming),是数学中比较冷门的学科,但在信息学中的用处很大。主要有(基础):
- 背包问题
- 区间 DP
- 状态压缩 DP
- 树形 DP
更深入的有:
- 插头 DP
- 概率 DP
DP 特征:无后效性,最优子结构,子问题重叠。
-
无后效性:也就是当前决策完后,后面的决策不再受当前的决策的影响。
-
最优子结构:最优解的子问题也是最优的
-
子问题重叠:一个决策不会是独立的,可能在后面的决策中用到。
背包问题:
0-1 背包
- 题意:
给出物品的重量 \(w_i\) 和价值 \(v_i\),每个物品只能选一次,问最大价值。
- 思路:
设方程 \(f_{i,j}\) 为取前 \(i\) 个物品,容量为 \(j\) 时的最大价值。
则 \(f_{i,j}=\max\{f_{i-1,j}, f_{i-1,j-w_i}+v_i\}\)。什么意思呢:如果当前物品不选,那么直接从 \(i-1\) 转移过来(\(f_{i-1,j}\))。如果当前选,那么这个除了物品之外的总容量为 \(j - w_i\),那我们直接调用 \(f_{i-1,j-w_i}\) 加上选的价值即可。
点击查看代码
for (int i = 1; i <= m; i++) {
for (int j = t; j >= w[i]; j--) {
dp[j] = max(dp[j], dp[j - w[i]] + e[i]);
}
}
完全背包
- 题意:
给出物品的重量 \(w_i\) 和价值 \(v_i\),每个物品可以选无限次,问最大价值。
- 思路:
考虑 \(O(n^3)\) 暴力,枚举 \(k\) 为选择物品的数量(\(k\le \dfrac{w_i}{j}\)),所以转移方程为:
\(f_{i,j}=\max\{f_{i-1,j\times k}+v_i\times k\}\)。
怎么优化呢?
发现对于 \(f_{i,j-w_i}\) 由 \(f_{i,j-w_i\times k}\) 转移过来,所以我们就不用枚举 \(k\) 了,即 \(f_{i,j}\) 从 \(f_{i,j-w_i}\) 转移过来。
然后可以滚动数组优化掉第一维:
点击查看代码
for (long long i = 1; i <= m; i++) {
for (long long j = e[i]; j <= t; j++) {
dp[j] = max(dp[j], dp[j - e[i]] + w[i]);
}
}
多重背包
其实就朴素的多重背包。
转移方程:\(f_{i,j}=\max\limits_{k=0}^nk_i\{f_{i-1,j-w_i \times k}+v_i\times k\}\)
混合背包
就是上面三种背包的结合。判断一下做出相应匹配背包即可。
分组背包
- 题意:
有 \(n\) 件物品和一个大小为 \(m\) 的背包,第 \(i\) 个物品的价值为 \(w_i\),体积为 \(v_i\)每个物品属于一个组,同组内最多只能选择一个物品。求背包能装载物品的最大总价值。
- 思路:
就是对于每组求一次 0-1 背包即可。
点击查看代码
for (int k = 1; k <= ts; k++) {
for (int i = m; i >= 0; i--) {
for (int j = 1; j <= cnt[k]; j++) {
if (i >= w[t[k][j]]) {
dp[i] = max(dp[i], dp[i - w[t[k][j]]] + c[t[k][j]]);
}
}
}
}
区间 DP
顾名思义,就是在区间上进行 DP。
DP 顺序为从区间长度小的转移到大的上来。所以我们可以枚举长度 \(k\),然后枚举两个端点 \(i,j\),\(j\) 为 \(i+k-1\)。然后我们就在这个区间 \([i,j]\) 做操作。
- 题意:
有一个环,每次可以选择相邻两个合并,如果前一颗能量珠的头标记为 \(m\),尾标记为 \(r\),后一颗能量珠的头标记为 \(r\),尾标记为 \(n\),则聚合后释放的能量为 \(m\times r\times n\),头标为 \(m\),尾标为 \(n\)。求最大代价。
- 思路:
首先破环为链。
显然 \(f_{i,j}=\max\{f_{i,j}, f{i,k}+f_{k+1,j}+a_i \times a_{k+1} \times a_{j+1}\}\)。
就是由区间 \([i,k]\) 和区间 \([k+1,j]\) 的最大值相加得到的。然后还要加上珠子合并所产生的代价。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1e2 + 100;
int n, a[MAXN], dp[MAXN][MAXN], ans;
int main() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
a[i + n] = a[i];
}
for (int l = 2; l <= n; l++) {
for (int i = 1; i + l - 1 <= n << 1; i++) {
int j = i + l - 1;
for (int k = i; k < j; k++) {
ans = max(ans, dp[i][j] = max(dp[i][j], dp[i][k] + dp[k + 1][j] + a[i] * a[k + 1] * a[j + 1]));
}
}
}
cout << ans;
return 0;
}
状压 DP
也就是状态压缩 DP,具体的,我们把 \(x\) 拆分成二进制形式把他存到 \(dp\) 数组里进行处理,所以题目给出的 \(n\) 必须很小,一般是在 \(21\) 以内。
Tips:
- 判断一个数字 \(x\) 进制下第 \(y\) 位是不是等于 \(1\)。
方法:if(((1 << (y − 1)) & x))。
- 将一个数字 \(x\) 二进制下第 \(y\) 位更改成 \(1\)。
方法:x = x | (1 << (y − 1))。
- 将一个数字 \(x\) 二进制下第 \(y\) 位更改成 \(0\)。
方法:x = x & ~(1 << (y − 1))。
显然我们先要预处理出每一个点间的距离,这个可以用 \(O(n^2)\) 暴力实现,然后考虑怎么 DP。
我们设 \(dp_{i,j}\) 表示走到 \(i\),经过路径的二进制为 \(j\) 的最短距离,显然初始化是 \(dp_{i,2^{i-1}}=a_{0,i}\)。
然后我们暴力枚举所有的二进制形式 \(k\),然后我们枚举当前可以到达的点 \(i\),也就是满足 \(k \& 2^{i-1} \neq 0\),即 \(i\) 这个点在 \(k\) 中做过没有,然后再去枚举所有可以从 \(i\) 出发到达的点 \(j\),\(j\) 要满足 \(i \neq j\) 并且 \(k \& 2^{j-1} \neq 0\)。那我们就可以得到转移方程:\(dp_{i,k}=\min\{dp_{i,k}, dp_{j, k - 2^{i - 1}} + dis_{i,j}\}\)。
答案:\(\min\limits_{i=1}^n dp_{i,2^n-1}\)。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
int t, n, m;
double x[16], y[16], f[16][16], dp[16][1 << 16];
double Manhattan(double x1, double y_1, double x2, double y2) {
return sqrt(1.0 * (x1 - x2) * (x1 - x2) + 1.0 * (y_1 - y2) * (y_1 - y2));
}
int main() {
cin >> n;
memset(dp, 127, sizeof(dp));
for (int i = 1; i <= n; i++) {
cin >> x[i] >> y[i];
}
x[0] = y[0] = 0;
for (int i = 0; i <= n; i++) {
for (int j = i + 1; j <= n; j++) {
f[i][j] = f[j][i] = Manhattan(x[i], y[i], x[j], y[j]);
}
}
for (int i = 1; i <= n; i++) {
dp[i][1 << (i - 1)] = f[0][i];
}
for (int S = 1; S < (1 << n); S++) {
for (int i = 1; i <= n; i++) {
if ((S & (1 << (i - 1))) == 0) {
continue;
}
for (int j = 1; j <= n; j++) {
if (i == j) {
continue;
}
if ((S & (1 << (j - 1))) == 0) {
continue;
}
dp[i][S] = min(dp[i][S], dp[j][S - (1 << (i - 1))] + f[i][j]);
}
}
}
double maxx = 10000000.0;
for (int i = 1; i <= n; i++) {
maxx = min(maxx, dp[i][(1 << n) - 1]);
}
cout << fixed << setprecision(2) << maxx << '\n';
return 0;
}
树形 DP
普通的树形 DP
顾名思义,就是在树上进行 DP,一般和 dfs 一起出现。
这类问题我们优先考虑以当前节点 \(x\) 为根的贡献是多少再去转移。
设 \(dp_{x,0/1}\) 如果 \(x\) 去或者不去的最优答案,那么如果 \(x\) 去,那么 \(x\) 的下属都不会去,也就是 \(dp_{x,1}=\sum dp_{y,0}+a_x\)(其中 \(y\) 是 \(x\) 的下属)。那么如果 \(x\) 不去,那么 \(x\) 的下属都会去,即 \(dp_{x,0}=\sum \max\{dp_{y,0}, dp_{y,1}\}\)。
最后答案就是 \(\max\{dp_{n,0}, dp_{n,1}\}\)。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
vector<int> son[1000001];
int n, dp[100001][2], flag[100001], r[100001];
void dfs(int u, int fa) {
dp[u][0] = 0;
for (int i = 0; i < son[u].size(); i++) {
int v = son[u][i];
dfs(v, u);
dp[u][0] += max(dp[v][1], dp[v][0]);
dp[u][1] += dp[v][0];
}
}
int main() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> r[i];
dp[i][1] = r[i];
}
for (int i = 1; i < n; i++) {
int l, k;
cin >> l >> k;
son[k].push_back(l);
flag[l] = 1;
}
for (int i = 1; i <= n; i++) {
if (!flag[i]) {
dfs(i, 0);
cout << max(dp[i][0], dp[i][1]);
return 0;
}
}
return 0;
}
换根 DP
换根 DP 也叫二次扫描法,是特殊的一种树形 DP。
分为几个步骤:
-
从节点 \(x\) 开始 dfs 得到以 \(x\) 为根整个树的答案
-
换根,试将根换成与 \(x\) 有边相连的点统计最优答案。
例题:luogu P3478 [POI2008] STA-Station
显然我们可以枚举每个 \(i\) 去 dfs 出以 \(i\) 为根的树的答案,再去统计最大值,但是显然时间复杂度 \(O(n^2)\) 会 \(\texttt{TLE}\)。
结论:\(f_x = f_y + n - 2 \times siz_x\),其中 \(x\) 是 \(y\) 的儿子。
为什么呢?当以 \(y\) 为根的变为 \(x\) 为根,那么 \(x\) 所有的儿子的深度就会减 \(1\),也就是整体减去 \(siz_x\),那么其他不在 \(x\) 儿子的节点的深度就会加 \(1\),也就是整体加 \((n - siz_x)\),那么 \(f_x = f_y - siz_x + (n - siz_x) = f_x = f_y + n - 2 \times siz_x\)。而 \(siz_x\) 我们可以一次 dfs 得到,时间复杂度 \(O(n)\)。
点击查看代码
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int MAXN = 7 * 1e6 + 100;
vector<int> G[MAXN];
long long dis[MAXN], sum, a[MAXN], id = 1, size[MAXN], n;
void dfs(int u) {
size[u] = 1;
for (auto i : G[u]) {
if (dis[i]) {
continue;
}
dis[i] = dis[u] + 1;
dfs(i);
size[u] += size[i];
}
}
void dfs1(int u, int fa) {
a[u] = a[fa] + n - 2 * size[u];
for (auto i : G[u]) {
if (i == fa) {
continue;
}
dfs1(i, u);
}
}
signed main() {
cin >> n;
for (int i = 1; i < n; i++) {
int x, y;
cin >> x >> y;
G[x].push_back(y);
G[y].push_back(x);
}
dis[1] = 1;
dfs(1);
for (int i = 1; i <= n; i++) {
sum += dis[i];
}
sum -= n;
a[1] = sum;
for (int i = 0; i < G[1].size(); i++) {
int v = G[1][i];
dfs1(v, 1);
}
for (int i = 1; i <= n; i++) {
if (a[i] > sum) {
sum = a[i];
id = i;
}
}
cout << id << endl;
return 0;
}
数位 DP
形如问 \([L,R]\) 中有多少个满足条件的数,考虑前缀和思想:\(f(R)-f(L-1)\),\(f(x)\) 表示 \([1,x]\) 满足条件的数。
然后我们采用记忆化来写数位 DP。
-
\(x\) 表示当前 dfs 到第 \(x\) 位,注意从高位枚举到低位。
-
\(sum\) 表示第 \(x\) 位的答案。
-
\(vis\) 表示上一位是否是满足条件的最大值,具体的,如果范围是 \(672281\),第 \(1\) 位如果是 \(6\),那么第 \(2\) 位就只能取 \(0\sim 7\) 了,否则第二位就可以取 \(0\sim 9\) 了。
点击查看代码
#include <bits/stdc++.h>
#define int unsigned long long
using namespace std;
const int N = 1e5 + 100;
const int mod = 1e9 + 7;
int L, R, a[N], f[200][200], t, cnt;
int dfs(int x, int sum, bool vis) {
// cout << x << endl;
if (!x) { // 第 0 位
return sum % mod;
}
if (!vis && f[x][sum]) { // 有答案了
return f[x][sum] % mod;
}
int r = vis ? a[x] : 9, ans = 0; // 具体见 vis 的定义
for (int i = 0; i <= r; i++) {
ans = (ans + dfs(x - 1, sum + i, vis && i == r ? 1 : 0) % mod) % mod; // 从高位开始,数码和 + i,vis 的大小
}
if (!vis) { // 记忆化
f[x][sum] = ans % mod;
}
return ans % mod;
}
int solve(int n) { // 分解
cnt = 0;
memset(a, 0, sizeof(a));
while (n) {
a[++cnt] = n % 10;
n /= 10;
}
return dfs(cnt, 0, 1) % mod;
}
signed main() {
cin >> t;
while (t--) {
cin >> L >> R;
cout << (solve(R) - solve(L - 1) + mod) % mod << endl;
}
return 0;
}
好题:
设 \(dp_{i,j}\) 为前 \(i\) 个物品 \(A\) 机器做了 \(j\) 分钟时,\(B\) 机器的最少时间。
然后我们分三种情况讨论:
当前物品为 \(k\):
-
给 \(A\) 做,\(B\) 的时间不变,为 \(dp_{i-1,j-t1_i}\)。
-
给 \(B\) 做,那么 \(A\) 的时间不变,\(dp_{i,j}=dp_{i-1,j}+t2_i\)。
-
给 \(A,B\) 同时做,那么时间都要变,\(dp_{i,j}=dp_{i-1,j-t3_i}+t3_i\)。
初始化:\(dp\) 数组全部赋成极大值,显然 \(dp_{0,0}=0\)。
答案:\(\min\limits_{i=1}^n\{\max\{i,dp_{n,i}\}\}\)。
但是题目的空间限制为 \(125 \texttt{MB}\),肯定会 \(\texttt{MLE}\),那么怎么优化呢?
观察到 \(dp_{i,j}\) 都是从 \(dp_{i-1,x}\) 推来的,所以我们类似 01 背包的想法,把第一维 \(i\) 滚掉,把 \(j\) 的循环从大到小就可以了。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 2e5 + 100;
int n, f[MAXN], t1, t2, t3, ans = 2e9;
// f[i][j] 表示前 i 个物品,A 机器做了 j 分钟,B 机器做的最小值
// f[i][j] = max({f[i - 1][j] + t2[i], f[i - 1][j - t3[i]] + t3[i], f[i - 1][j - t1[i]});
// 空间 125 MB 会 MLE,优化掉一维,j 循坏改从大到小
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n;
for (int k = 1; k <= n; ++k) {
cin >> t1 >> t2 >> t3;
for (int j = 20000; j >= 0; --j) {
if (t2) {
f[j] += t2;
} else {
f[j] = 2e9;
}
if (t1 && j >= t1) {
f[j] = min(f[j], f[j - t1]);
}
if (t3 && j >= t3) {
f[j] = min(f[j], f[j - t3] + t3);
}
}
}
for (int i = 0; i <= 20000; i++) {
ans = min(ans, max(i, f[i]));
}
cout << ans;
return 0;
}
设 \(dp_{i,j,0}\) 表示 \(i \sim j\) 中形如 \(\texttt{**...***}\) (均为 \(\texttt{*}\))的答案。
\(dp_{i,j,1}\) 表示 \(i \sim j\) 中形如 \(\texttt{(...)}\) (括号匹配) 的答案。
\(dp_{i,j,2}\) 表示 \(i \sim j\) 中形如 \(\texttt{(...)***(...)***}\) (左起括号,右接 \(\texttt{*}\))的答案。
\(dp_{i,j,3}\) 表示 \(i \sim j\) 中形如 \(\texttt{(...)***(...)***(...)}\) (左起括号,右接括号)的答案。
\(dp_{i,j,4}\) 表示 \(i \sim j\) 中形如 \(\texttt{***(...)***(...)***}\) (左起 \(\texttt{*}\),右接 \(\texttt{*}\))的答案。
考虑怎么转移:
-
\(s_j\) 为 \(\texttt{*}\) 或 \(\texttt{?}\) 时, \(dp_{i,j,0} = dp_{i,j-1,0}\)。
-
\(dp_{i,j,1}\) 可以包括其他合法的序列,但是要满足括号要匹配,\(dp_{i,j,1} = dp_{i-1,j+1,0} + dp_{i-1,j+1,2} + dp_{i-1,j+1,3} + dp_{i-1,j+1,4}\)。
-
根据 \(dp_{i,j,2}\) 的定义,可以分成 \(\texttt{(...)***(...)***(...)}\) 加上一个 \(\texttt{(...)}\),根据乘法原理,也就是 \(dp_{i,j,2} = \sum\limits_{t=i}^{j-1}dp_{i,j,3} \times dp_{i,j,0}\)
-
左起括号,右边随便,只有 \(2,3\) 符合(\(3\) 包含 \(1\)),所以状态转移方程为 \(dp_{i,j,3}=\sum\limits_{t=l}^{r-1}((dp_{i,t,2} + dp_{i,t,3} \times dp_{t+1,j,1})) + dp_{i,j,1}\),乘上 \(dp_{i,t,3}\) 是因为 \(dp_{i,j,3}\) 的定义是括号结尾,所以要加上一个括号。\(dp_{i,j,1}\) 的话就是加上 \(\texttt{(...)}\) 的情况。
-
发现 \(dp_{i,j,4}\) 的定义其实是 \(dp_{i,j,2}\) 的定义的逆序列,所以方程式的位置调换一下即可,也就是 \(dp_{i,j,4} = \sum\limits_{t=l}^{r-1}dp_{i,t,0} \times dp_{t+1,j,3}\)。
答案:\(dp_{1,n,3}\)。
初始化:\(dp_{i,i-1,0}=1\)。
注:代码中 \(dp\) 数组的下标比上面的思路增加 \(1\)。
点击查看代码
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int MAXN = 5e2 + 100;
const int mod = 1e9 + 7;
int n, k, dp[MAXN][MAXN][6];
string s;
// dp[i][j][1] i ~ j **..**
// dp[i][j][2] i ~ j (...)
// dp[i][j][3] i ~ j (...)***(...)***
// dp[i][j][4] i ~ j (...)***(...)***(...)
// dp[i][j][5] i ~ j ***(...)***(...)
bool check(int x, int y) {
return (s[x] == '?' || s[x] == '(') && (s[y] == '?' || s[y] == ')');
}
signed main() {
cin >> n >> k >> s;
s = ' ' + s;
for (int i = 1; i <= n; i++) {
dp[i][i - 1][1] = 1;
}
for (int l = 2; l <= n; l++) {
for (int i = 1; i + l - 1 <= n; i++) {
int j = i + l - 1;
if (l <= k && (s[j] == '*' || s[j] == '?')) {
dp[i][j][1] = dp[i][j - 1][1];
}
if (l < 2) {
continue;
}
dp[i][j][2] = check(i, j) * (dp[i + 1][j - 1][1] + dp[i + 1][j - 1][3] + dp[i + 1][j - 1][4] + dp[i + 1][j - 1][5]) % mod;
for (int t = i; t < j; t++) {
dp[i][j][3] = (dp[i][j][3] + (dp[i][t][4] * dp[t + 1][j][1]) % mod) % mod;
dp[i][j][4] = (dp[i][j][4] + (dp[i][t][3] + dp[i][t][4]) * dp[t + 1][j][2]) % mod;
dp[i][j][5] = (dp[i][j][5] + (dp[i][t][1] * dp[t + 1][j][4] % mod) % mod);
}
dp[i][j][4] = (dp[i][j][4] + dp[i][j][2]) % mod;
}
}
// for (int i = 1; i <= n; i++) {
// for (int j = 1; j <= n; j++) {
// for (int k = 1; k <= 4; k++) {
// cout << "k=" << k << " i=" << i << " j=" << j << " =" << dp[i][j][k] << endl;
// }
// }
// }
cout << dp[1][n][4] % mod << endl;
return 0;
}
luogu P6280 [USACO20OPEN] Exercise G
明显 \(k = \text{lcm}\{a_i\}\),其中 \(a_i\) 为环的数量。
我们设 \(dp_{i,j}\) 为前 \(i\) 个质数和为 \(j\) 的 \(k\) 的方案数,显然我们可以枚举每一个质数 \(p\) 的指数幂 \(p_{k}^i\),显然 \(dp_{i,j}=\sum dp_{i-1,j-p_{k}^i} \times p_{k}^i\)。然后按照套路发现 \(dp_{i,x}\) 由 \(dp_{i-1,y}\) 转移而来,所以我们可以滚动数组优化掉一维,\(j\) 改为从大到小。
点击查看代码
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int MAXN = 1e3 + 1;
int n, m, f[MAXN], b[MAXN], cnt;
bool vis[MAXN];
void init() {
for (int i = 2; i <= MAXN; i++) {
if (!vis[i]) {
b[++cnt] = i;
}
for (int j = 1; j <= cnt && b[j] * i <= MAXN; j++) {
vis[i * b[j]] = 1;
if (i % b[j] == 0){
break;
}
}
}
}
signed main() {
cin >> n;
init();
f[0] = 1;
for (int i = 1; i <= cnt; i++) {
for (int j = n; j >= b[i]; j--) {
for (int k = b[i]; k <= n; k *= b[i]) {
if (j >= k) {
f[j] += f[j - k] * k;
}
}
}
}
int sum = 0;
for (int i = 0; i <= n; i++) {
sum += f[i];
// cout << f[i] << ' ';
}
// cout << '\n';
// cout << f[n];
cout << sum << endl;
return 0;
}
难度:*2000
设 \(f_i\) 表示长度为 \(i\) 的字符串的最小代价:
如果 \(i\) 是偶数:\(f_i = min(f_{i - 1} + x, f_{\frac{i}{2}} + y)\)
因为你可以从 \(i - 1\) 直接加上一个字符,代价为 \(x\),也可以从 \(\frac{i}{2}\) 直接翻倍,代价为 \(y\)。
如果 \(i\) 是奇数:\(f_i = min(f_{i - 1} + x, f_{\frac{i}{2} + 1} + y + x)\)
\(i - 1\) 部分和上面一样,然而你也可以从 \(\frac{i}{2} + 1\) 处直接翻倍,但是这样会多出一个字符,必须花费 \(x\) 才能消除。
点击查看代码
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e7 + 100;
int n, x, y, dp[N];
// dp[i] 表示长度为 i 的字符串的最小代价:
// i 是偶数:min(dp[i - 1] + x, dp[i / 2] + y)
// i 是奇数:min(dp[i - 1] + x, dp[i / 2 + 1] + y + x)
signed main() {
cin >> n >> x >> y;
memset(dp, 127, sizeof(dp));
dp[0] = 0;
for (int i = 1; i <= n; i++) {
if (i & 1) {
dp[i] = min(dp[i - 1] + x, dp[i / 2 + 1] + y + x);
} else {
dp[i] = min(dp[i - 1] + x, dp[i / 2] + y);
// dp[1] = 62
// dp[2] =
}
}
cout << dp[n];
return 0;
}
参考:oi-wiki dp 专题,题解 P1433 【吃奶酪】,题解 P7914 【[CSP-S 2021] 括号序列】。

浙公网安备 33010602011771号