动态规划
背包问题
DP 考虑两个维度:
- 状态表示: f ( i , j ) f(i, j) f(i,j) 表示的含义
- 状态计算:本质就是集合的划分
01 背包 -> 物品只能用一次
有 N N N 件物品和一个容量是 V V V 的背包,每件物品只能使用一次。
第 i i i 件物品的体积是 v i v_i vi,价值是 w i w_i wi。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
f ( i , j ) f(i, j) f(i,j) 表示从 0 0 0 ~ i i i 个物品中选,放入容量为 j j j 的背包中的最大价值
f ( i , j ) = M a x ( f ( i − 1 , j ) , f ( i − 1 , j − v i ) + w i ) f(i, j) = Max( f(i-1,j), f(i-1, j-v_i) + w_i) f(i,j)=Max(f(i−1,j),f(i−1,j−vi)+wi)
- f ( i − 1 ) ( j ) f(i - 1)(j) f(i−1)(j):不选第 i i i 个物品的集合的最大价值
- f ( i − 1 ) ( j − v ( i ) ) + w ( i ) f(i-1)(j-v(i))+w(i) f(i−1)(j−v(i))+w(i):选第 i i i 个物品的集合的最大价值(将第 i i i 个物品的体积减去,求剩下集合中的最大价值)
二维实现:
#include <iostream>
using namespace std;
const int N = 1010;
int n, m; // 物品数量, 背包容量
int v[N]; // 体积
int w[N]; // 价值
int f[N][N]; // f[i][j], 前 i 个物品中体积不超过 j 的最大价值
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 = 1; j <= m; j++) // 背包
{
// 当前背包容量装不下第 i 个物品, 则价值等于前 i-1 个物品
if (j < v[i]) f[i][j] = f[i - 1][j];
// 当前背包容量能装下第 i 个物品, 选择是否装第 i 个物品
else f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i]] + w[i]);
}
cout << f[n][m] << endl;
return 0;
}
滚动数组优化成一维:
- 因为计算 i 时只用到了 i - 1 时的状态,所以可以直接将 i 这一维删掉,使用滚动数组
- 第二次遍历要倒序遍历,防止用已经更新的值再次更新
#include <iostream>
using namespace std;
const int N = 1010;
int n, m; // 物品数量, 背包容量
int v[N]; // 体积
int w[N]; // 价值
int f[N];
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--) // 背包
f[j] = max(f[j], f[j - v[i]] + w[i]);
cout << f[m] << endl;
return 0;
}
完全背包 -> 物品可以用无限次
有 N 种物品和一个容量是 V 的背包,每种物品都有无限件可用。
第 i i i 种物品的体积是 v i v_i vi,价值是 w i w_i wi。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
f ( i ) ( j ) f(i)(j) f(i)(j) 表示从 0 0 0 ~ i i i 个物品中选,放入容量为 j j j 的背包中的最大价值
朴素做法:
#include <iostream>
using namespace std;
const int N = 1010;
int n, m; // 物品数量, 背包容量
int v[N], w[N]; // 体积, 价值
int f[N][N]; // f[i][j], 前 i 个物品中体积不超过 j 的最大价值
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 = 1; j <= m; j++) // 背包
for (int k = 0; k * v[i] <= j; k++) // 完全背包不限物品放入次数, 遍历物品的个数
f[i][j] = max(f[i][j], f[i - 1][j - v[i] * k] + w[i] * k);
cout << f[n][m] << endl;
return 0;
}
朴素做法优化:
f[i , j ] = max( f[i-1,j] , f[i-1,j-v]+w , f[i-1,j-2*v]+2*w , f[i-1,j-3*v]+3*w , .....)
f[i , j-v]= max( f[i-1,j-v] , f[i-1,j-2*v]+w , f[i-1,j-3*v]+2*w , .....)
由上式,可得出如下递推关系: f[i][j] = max(f[i, j-v] + w, f[i-1][j])
#include <iostream>
using namespace std;
const int N = 1010;
int n, m; // 物品数量, 背包容量
int v[N], w[N]; // 体积, 价值
int f[N][N]; // f[i][j], 前 i 个物品中体积不超过 j 的最大价值
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 = 1; 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]);
cout << f[n][m] << endl;
return 0;
}
滚动数组优化:
#include <iostream>
using namespace std;
const int N = 1010;
int n, m; // 物品数量, 背包容量
int v[N], w[N]; // 体积, 价值
int f[N];
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++) // *
f[j] = max(f[j], f[j - v[i]] + w[i]);
cout << f[m] << endl;
return 0;
}
01 背包 与 完全背包
01背包 和 完全背包 最终写法的区别,仅在内层循环的遍历顺序上。
01 背包最终写法:
int n, m; // 物品数量, 背包容量
int v[N], w[N]; // 体积, 价值
int f[N];
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]);
完全背包最终写法:
int n, m; // 物品数量, 背包容量
int v[N], w[N]; // 体积, 价值
int f[N];
for (int i = 1; i <= n; i++)
for (int j = v[i]; j <= m; j++) // *
f[j] = max(f[j], f[j - v[i]] + w[i]);
多重背包 -> 物品可以用指定次数
有 N N N 种物品和一个容量是 V V V 的背包。
第 i i i 种物品最多有 s i s_i si 件,每件体积是 v i v_i vi,价值是 w i w_i wi。
求解将哪些物品装入背包,可使物品体积总和不超过背包容量,且价值总和最大。
#include <iostream>
using namespace std;
const int N = 1010;
int n, m; // 物品数量, 背包体积
int v[N], w[N], s[N]; // 体积, 价值, 数量
int f[N][N]; // f[i][j], 前 i 个物品中体积不超过 j 的最大价值
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> v[i] >> w[i] >> s[i];
for (int i = 1; i <= n; i++) // 物品
for (int j = 1; 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);
cout << f[n][m] << endl;
return 0;
}
线性 DP
数字三角形
题目:898. 数字三角形 - AcWing题库
LeetCode:120. 三角形最小路径和 - 力扣(LeetCode)
给定一个如下图所示的数字三角形,从顶部出发,在每一结点可以选择移动至其左下方的结点或移动至其右下方的结点,一直走到底层,要求找出一条路径,使路径上的数字的和最大。
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
从上往下递推:
#include <iostream>
#include <cstring>
using namespace std;
const int N = 510, INF = 1e9;
int n;
int a[N][N];
int dp[N][N]; // dp[i][j] 从起点到 (i, j) 路径的最大值
int main() {
cin >> n;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
cin >> a[i][j];
// 初始化 DP 数组的值为负无穷
for (int i = 0; i <= n; i++)
for (int j = 0; j <= i + 1; j++)
dp[i][j] = -INF;
dp[1][1] = a[1][1]; // 初始状态
for (int i = 2; i <= n; i++)
for (int j = 1; j <= i; j++)
dp[i][j] = max(dp[i - 1][j - 1], dp[i - 1][j]) + a[i][j];
// 找出最后一行的最大值
int res = -INF;
for (int i = 1; i <= n; i++) res = max(res, dp[n][i]);
cout << res << endl;
return 0;
}
从下往上递推:用 dp
数组存储原数组
#include<bits/stdc++.h>
using namespace std;
const int N = 510;
int dp[N][N];
int n;
int main() {
cin >> n;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
cin >> dp[i][j];
for (int i = n; i >= 1; i--)
for (int j = i; j >= 1; j--)
dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + dp[i][j];
cout << dp[1][1] << endl;
return 0;
}
最长上升子序列
题目:895. 最长上升子序列 - AcWing题库
LeetCode:300. 最长递增子序列 - 力扣(LeetCode)
给定一个长度为 N N N 的数列,求数值严格单调递增的子序列的长度最长是多少。
#include <iostream>
using namespace std;
const int N = 1010;
int n;
int a[N];
int dp[N]; // f[i] 以 i 结尾的上升子序列的长度
int main()
{
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
int res = 1;
for (int i = 1; i <= n; i++)
{
dp[i] = 1; // 只有 a[i] 一个数
for (int j = i - 1; j >= 1; j--)
if (a[j] < a[i])
dp[i] = max(dp[i], dp[j] + 1);
res = max(res, dp[i]);
}
cout << res << endl;
return 0;
}
最长公共子序列
题目:897. 最长公共子序列 - AcWing题库
LeetCode:1143. 最长公共子序列 - 力扣(LeetCode)
给定两个长度分别为 N N N 和 M M M 的字符串 A A A 和 B B B,求既是 A A A 的子序列又是 B B B 的子序列的字符串长度最长是多少。
两个字符串子序列的问题,可以往 二维 dp 的角度去思考。
#include <iostream>
using namespace std;
const int N = 1010;
int n, m;
char a[N], b[N];
int f[N][N]; // 由 a 前 i 个元素, b 前 j 个元素构成的最长子序列的长度
int main()
{
cin >> n >> m;
scanf("%s%s", a + 1, b + 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] << endl;
return 0;
}
最短编辑距离
题目:902. 最短编辑距离 - AcWing题库
LeetCode:72. 编辑距离 - 力扣(LeetCode)
给定两个字符串 A A A 和 B B B,现在要将 A A A 经过若干操作变为 B B B,可进行的操作有:
- 删除–将字符串 A A A 中的某个字符删除。
- 插入–在字符串 A A A 的某个位置插入某个字符。
- 替换–将字符串 A A A 中的某个字符替换为另一个字符。
现在请你求出,将 A A A 变为 B B B 至少需要进行多少次操作。
1.在 a 最后删除一个字符使两串相等 f[i][j] = f[i - 1][j] + 1
2.在 a 最后增加一个字符使两串相等 f[i][j] = f[i][j - 1] + 1
3.在 a 最后替换一个字符使两串相等 f[i][j] = f[i - 1][j - 1] + 1
4.不对 a 最后一个字符操作(说明 a 和 b 最后一个字符已经相等),操作 a 的其他字符使两串相等 f[i][j] = f[i - 1][j - 1]
#include <iostream>
using namespace std;
const int N = 1010;
int n, m;
char a[N], b[N];
int f[N][N]; // f[i][j] a 的前 i 个字母转换成 b 的前 j 个字母使用的最少操作数
int main()
{
scanf("%d%s", &n, a + 1);
scanf("%d%s", &m, b + 1);
// 初始化 dp 数组
// 只能使用增加操作
for (int i = 0; i <= m; i++) f[0][i] = i;
// 只能使用删除操作
for (int i = 0; i <= n; i++) f[i][0] = i;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
{
// 不需要进行操作
if (a[i] == b[j]) f[i][j] = f[i - 1][j - 1];
// 取 删除, 新增, 修改 的最小值
else f[i][j] = min(min(f[i - 1][j], f[i][j - 1]), f[i - 1][j - 1]) + 1;
}
cout << f[n][m] << endl;
return 0;
}
编辑距离
#include <iostream>
#include <cstring>
using namespace std;
const int N = 1010;
int n, m;
char str[N][N]; // 存储给定的 n 个字符串
int f[N][N]; // a[0..i] 转化到 b[0..j] 的最少操作次数
int minDistance(char a[], char b[])
{
int la = strlen(a + 1), lb = strlen(b + 1);
for (int i = 0; i <= la; i++) f[i][0] = i;
for (int i = 0; i <= lb; i++) f[0][i] = i;
for (int i = 1; i <= la; i++)
for (int j = 1; j <= lb; j++)
{
if (a[i] == b[j]) f[i][j] = f[i - 1][j - 1];
else f[i][j] = min(min(f[i - 1][j], f[i][j - 1]), f[i - 1][j - 1]) + 1;
}
return f[la][lb];
}
int main()
{
cin >> n >> m;
for (int i = 0; i < n; i++) cin >> (str[i] + 1);
while (m--) {
int res = 0;
char s[N];
int limit;
cin >> (s + 1) >> limit;
for (int i = 0; i < n; i++)
if (minDistance(str[i], s) <= limit) res++;
cout << res << endl;
}
return 0;
}
区间 DP
区间 dp 问题枚举时
- 第一维通常是枚举区间长度,并且一般
len = 1
时用来初始化,枚举从len = 2
开始 - 第二维枚举起点
i
(右端点j
自动获得j = i + len - 1
)
区间 DP 常用模板:
for (int len = 1; len <= n; len++) {
// 区间长度
for (int i = 1; i + len - 1 <= n; i++) {
// 枚举起点
int j = i + len - 1; // 区间终点
if (len == 1) {
dp[i][j] = 初始值
continue;
}
for (int k = i; k < j; k++) {
// 枚举分割点,构造状态转移方程
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + w[i][j]);
}
}
}
石子合并
设有 N 堆石子排成一排,其编号为 1,2,3,…,N。
每堆石子有一定的质量,可以用一个整数来描述,现在要将这 N 堆石子合并成为一堆。
每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻,合并时由于选择的顺序不同,合并的总代价也不相同。
问题是:找出一种合理的方法,使总的代价最小,输出最小代价。
#include <iostream>
#include <cstring>
using namespace std;
const int N = 310;
int n;
int s[N]; // 前缀和
int f[N][N]; // f[i][j] 将第 i 堆石子和第 j 堆石子合并的代价的最小值
int main()
{
cin >> n;
for (int i = 1; i <= n; i++) cin >> s[i];
// 处理前缀和
for (int i = 1; i <= n; i++) s[i] += s[i - 1];
for (int len = 2; len <= n; len++) // 第一维: 区间长度
{
for (int l = 1; l + len - 1 <= n; l++) // 第二维: 区间起始点
{
int r = l + len - 1; // 区间终点
f[l][r] = 0x3f3f3f;
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]);
}
}
cout << f[1][n] << endl;
return 0;
}
计数类 DP
整数划分
完全背包:
#include <iostream>
using namespace std;
const int N = 1010, M = 1e9 + 7;
int n;
int dp[N]; // dp[i] 表示放满容量为 i 的背包的方案数
int main()
{
cin >> n;
dp[0] = 1;
for (int i = 1; i <= n; i++) // 物品
for (int j = i; j <= n; j++) // 背包
dp[j] = (dp[j] + dp[j - i]) % M;
cout << dp[n] << endl;
return 0;
}
另一种思考:f(i, j) 表示总和为 i,总个数为 j 的方案数
这个有点难理解。。。
#include <iostream>
using namespace std;
const int N = 1010, M = (1e9 + 7);
int n;
int f[N][N]; // 总和为 i, 总个数为 j 的方案数
int main()
{
cin >> n;
f[1][1] = 1;
for (int i = 2; i <= n; i++) // 总和
for (int j = 1; j <= i; j++) // 个数
// 最小值是 1: f[i - 1][j - 1]
// 最小值 > 1, 相当于给每个数 - 1: f[i - j][j]
f[i][j] = (f[i - 1][j - 1] + f[i - j][j]) % M;
int res = 0;
for (int i = 1; i <= n; i++) res = (res + f[n][i]) % M;
cout << res << endl;
return 0;
}
数位统计 DP(TODO)
难度较高,面试不常见,略过…
状态压缩 DP(TODO)
难度较高,面试不常见,略过…
树形 DP
没有上司的舞会
#include<cstring>
#include <iostream>
using namespace std;
const int N = 6010;
int n;
int happy[N]; // 职工的高兴度
int h[N], e[N], ne[N], idx;
// f[u][0] 所有以 u 为根的子树中选择,并且不选 u 这个点的方案
// f[i][1] 所有以 u 为根的子树中选择,并且选 u 这个点的方案
int f[N][2];
bool has_father[N]; // 判断当前节点是否有父节点
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void dfs(int u)
{
f[u][1] = happy[u]; // 选择当前 u 节点
for (int i = h[u]; i != -1; i = ne[i]) // 遍历树的子节点
{
int j = e[i]; // 子节点
dfs(j);
// 不选 u, 子节点 选 或 不选
f[u][0] += max(f[j][1], f[j][0]);
// 选 u, 子节点 只能不选
f[u][1] += f[j][0];
}
}
int main()
{
cin >> n;
for (int i = 1; i <= n; i++) cin >> happy[i];
memset(h, -1, sizeof h);
// 建树
for (int i = 1; i < n; i++)
{
int a, b;
cin >> a >> b;
has_father[a] = true;
add(b, a);
}
int root = 1;
while (has_father[root]) root++; // 寻找根节点
dfs(root);
// 不选根节点 或 选根节点
cout << max(f[root][0], f[root][1]) << endl;
return 0;
}
记忆化搜索
滑雪
#include <cstring>
#include <iostream>
using namespace std;
const int N = 310;
int n, m; // 网格行列
int f[N][N]; // 从 h[i][j] 位置开始滑的路径的最大值
int h[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 != 0) return v; // 已经计算过则直接返回答案
v = 1;
for (int i = 0; i < 4; i++)
{
// 下一个位置
int xx = x + dx[i], yy = y + dy[i];
// 必须在边界范围内
if (xx >= 1 && xx <= n && yy >= 1 && yy <= m && h[xx][yy] < h[x][y])
v = max(v, dp(xx, yy) + 1); // 更新
}
return v;
}
int main()
{
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
cin >> h[i][j];
int res = 0;
// 可以从任意点开始
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
res = max(res, dp(i, j));
cout << res << endl;
return 0;
}