动态规划
记录
1:34 2024-2-1
1.动态规划
相关知识
- 动态规划三要素
- 状态
- 阶段
- 决策
- 动态规划三前提
- 子问题重叠性
- 无后效性:要求求解的问题不受”后续“阶段的影响
- 最优子结构性:下一阶段最优解应该能够由前面各阶段子问题的最优解导出
1.LCS和LIS和数字三角形
最长公共子序列和(LCS,Longest Common Subsequence)
最长上升子序列(LIS,Longest Increasing Subsequence)
1.LCS
最长公共子序列和, 给出俩个字符串之间最长的公共子序列的长度

dp[i][j] 定义为s和t最长的公共子序列长度
分析可知。当\(s_{i+1} = t_{j+1}\) 可知结果为 \(dp[i+1][j+1] = dp[i][j] + 1\)
对于dp[i][j+1](或dp[i+1][j])和dp[i][j]来说 dp[i][j+1](dp[i+1][j])最多比dp[i][j]大1
点击查看代码
int n, m;
char s[MAX_N], t[MAX_M];
int dp[MAX_N + 1][MAX_M + 1];
void solve() {
for(int i = 0; i < n; i++) {
for(int j = 0; j < m; j++) {
if(s[i] == t[j]) {
dp[i + 1][j + 1] = dp[i][j] + 1;
} else{
dp[i + 1][j + 1] = max(dp[i][j + 1], dp[i + 1][j]);
}
}
}
printf("%d\n", dp[n][m]);
}
2.LIS
最长上升子序列
dp[i]定义为以a[i]为末尾的最长上升子序列的长度
\(dp[i] = \max\{1, dp[j]+1 \ | \ j < i \ and \ a[j] < a[i]\}\)
点击查看代码
int n;
int a[MAX_N];
int dp[MAX_N];
void solve() {
int res = 0;
for(int i = 0; i < n; i++) {
dp[i] = 1;
for(int j = 0; j < i; j++) if(a[j] < a[i]) {
dp[i] = max(dp[i], dp[j] + 1);
}
res = max(res, dp[i]);
}
printf("%d\n", res);
}
3.数字三角形
有一个由非负整数组成的三角形,第一行只有一个数,除了最下行之外每个数的下方和右下方各有一个数
计算从第一行开始走到末尾求得的最大和
\(d[i][j] = a[i][j] + max{d[i+1][j], d[i+1][j+1]}\)
点击查看代码
递推计算
int i, j;
for(j = 1; j <= n; j++) d[n][j] = a[n][j];
for(i = n-1; i >= 1; i——)
for(j = 1; j <= i; j++)
d[i][j] = a[i][j] + max(d[i+1][j],d[i+1][j+1]);
记忆化搜索
int solve(int i, int j){
if(d[i][j] >= 0) return d[i][j];
return d[i][j] = a[i][j] + (i == n ? 0 : max(solve(i+1,j),solve(i+1,j+1)));
2.背包问题
1. 0-1背包
每个物品一件,选或者不选
dp[i+1][j]定义为从前i个物品中选出总重量不超过j的物品是总价值的最大值
也可以这样理解
dp[i][j]定义为从前i个物品中选出总重量为j的物品放入背包,得到总价值的最大值
点击查看代码
int N, W;
int w[MAX_W + 1];
int v[MAX_N + 1];
int dp[MAX_N + 1][MAX_W + 1];
//正向
void solve() {
for(int i = 0; i < N; i++) {
for(int j = 0; j <= W; j++) {
if(j < w[i]) {
dp[i + 1][j] = dp[i][j];
} else {
dp[i + 1][j] = max(dp[i][j], dp[i + 1][j - w[i]] + v[i]);
}
}
}
printf("%d\n", dp[N][W]);
}
//优化
int dp[MAX_N + 1]
void solve() {
for(int i = 0; i < N; i++) {
for(int j = W; j>=w[i]; j--) {
dp[j] = max(dp[j], dp[j-w[i]] + v[i]);
}
}
printf("%d\n", dp[W]);
}
//另一种写法
memset(f, 0xcf, sizeof(f)); // -INF
f[0][0] = 0;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= m; j++)
f[i][j] = f[i - 1][j];
for (int j = w[i]; j <= m; j++)
f[i][j] = max(f[i][j], f[i - 1][j - w[i]] + v[i]);
}
//优化 滚动数组
int f[2][MAX_M+1];
memset(f, 0xcf, sizeof(f)); // -INF
f[0][0] = 0;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= m; j++)
f[i & 1][j] = f[(i - 1) & 1][j];
for (int j = w[i]; j <= m; j++)
f[i & 1][j] = max(f[i & 1][j], f[(i - 1) & 1][j - w[i]] + v[i]);
}
int ans = 0;
for (int j = 0; j <= m; j++)
ans = max(ans, f[n & 1][j]);
//优化 单维数组
int f[MAX_M+1];
memset(f, 0xcf, sizeof(f)); // -INF
f[0] = 0;
for (int i = 1; i <= n; i++)
for (int j = m; j >= w[i]; j--)
f[j] = max(f[j], f[j - w[i]] + v[i]);
int ans = 0;
for (int j = 0; j <= m; j++)
ans = max(ans, f[j]);
0-1背包问题中重量过大时候
重新定义dp[i+1][j] 前i个物品中挑选出价值总和为j时总重量的最小值
初始化
\(dp[0][0]=0\)
\(dp[0][j]=INF\)
这里是限制了价值总和必须为j,所以这样得到的结果dp[x][j]必然达到了j价值(这里我的意思是为什么要设置为INF)
\(dp[i+1][j] = min(dp[i][j], dp[i][j-v[i]] + w[i])\)
点击查看代码
const int INF = 0x3f3f3f3f;
int N, W;
int w[MAX_W + 1];
int v[MAX_N + 1];
int dp[MAX_N + 1][MAX_N * MAX_V + 1];
void solve() {
fill(dp[0], dp[0] + MAX_N * MAX_V + 1, INF);
dp[0][0] = 0;
for(int i = 0; i < N; i++) {
for(int j = 0; j <= MAX_N * MAX_V; j++) {
if(j < v[i]) {
dp[i + 1][j] = dp[i][j];
} else {
dp[i + 1][j] = min(dp[i][j], dp[i][j - v[i]] + w[i]);
}
}
}
int res = 0;
for(int i = 0; i < MAX_N * MAX_V; i++) if(dp[N][i] <= W) res = i;
printf("%d\n", res);
}
2. 完全背包
每个物品无穷多件
dp[i+1][j]定义为从前i中物品中挑选总重量不超过j时价值的最大值
因为其他情况已经在dp[i+1][j-w[i]]中处理了
这里对应到优化的一维数组处理是正序循环,i+1的状态会从上一个i+1更新(dp[i+1][j-w[i]] -> dp[i+1][j])(01背包问题是逆序循环 这样i+1的状态从i更新)
同理可以优化为一维数组
点击查看代码
int N, W;
int w[MAX_W + 1];
int v[MAX_N + 1];
int dp[MAX_N + 1][MAX_W + 1];
//正向
void solve() {
for(int i = 0; i < N; i++) {
for(int j = 0; j <= W; j++) {
if(j < w[i]) {
dp[i + 1][j] = dp[i][j];
} else {
dp[i + 1][j] = max(dp[i][j], dp[i + 1][j - w[i]] + v[i]);
}
}
}
printf("%d\n", dp[N][W]);
}
//优化 一维数组
int dp[MAX_N + 1]
void solve() {
for(int i = 0; i < N; i++) {
//正向循环
for(int j = w[i]; j<=W; j--) {
dp[j] = max(dp[j], dp[j-w[i]] + v[i]);
}
}
printf("%d\n", dp[W]);
}
3. 多重背包

多重背包就是在完全背包的基础上限制了物体的数量
这是《算法竞赛进阶指南》上的定义
我这里选择定义物品的重量w,价值v,求最大的价值,下面的分组背包也是一样的定义。
解决方法有三种
- 直接拆分法
直接将每个物品都看做独立的转换为0-1背包问题 - 二进制拆分法
就是对总是某个物品的总数C[i]分解为一群数,每个数乘这个物品的重量(价值)作为这个分解出来的重量(价值),这么做可以成功是因为分解的数可以组合得到0~C[i]之间的数,这样不需要0-1拆分。 - 单调队列
点击查看代码
// 多重背包,直接拆分 ==========================================
unsigned int f[MAX_M+1];
memset(f, 0xcf, sizeof(f)); // -INF
f[0] = 0;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= c[i]; j++)
for (int k = m; k >= v[i]; k--)
f[k] = max(f[k], f[k - v[i]] + w[i]);
int ans = 0;
for (int i = 0; i <= m; i++)
ans = max(ans, f[i]);
4. 分组背包

分组背包限制了每个组里只能选择一个物品
分组背包中的循环顺序很重要,物品的循环要放在内层,不然就会像完全背包/多重背包推导的那样,可能在这个组里选择大于1的物品。
点击查看代码
// 分组背包 ==========================================
memset(f, 0xcf, sizeof(f));
f[0] = 0;
for (int i = 1; i <= n; i++)
for (int j = m; j >= 0; j--)
for (int k = 1; k <= c[i]; k++)
if (j >= v[i][k])
f[j] = max(f[j], f[j - v[i][k]] + w[i][k]);
3. 区间DP
区间DP要注意是利用区间长度作为DP的“阶段”,也就是随着长度的增加,答案逐渐计算出来(意思就是循环中有遍历长度的部分)

石子合并问题,随着长度增加解决问题
F[l,r] 表示从第l堆到第r堆合并需要消耗的最少的体力
\(
F[l,r] = min_{l \le k < r} {F[l, k] + F[k + 1, r]} + \sum_{i=l}^r A_i
\)
点击查看代码
// 例题:石子合并
memset(f, 0x3f, sizeof(f)); // INF
for (int i = 1; i <= n; i++) {
f[i][i] = 0;
sum[i] = sum[i-1] + a[i]; // 前缀和
}
for (int len = 2; len <= n; len++) // 阶段
for (int l = 1; l <= n - len + 1; l++) { // 状态:左端点
int r = l + len - 1; // 状态:右端点
for (int k = l; k < r; k++) // 决策
f[l][r] = min(f[l][r], f[l][k] + f[k+1][r]);
f[l][r] += sum[r] - sum[l-1];
}
4. 树形DP
这部分直接抄的书上的,感觉是理解了,但是自己不一定能直接写下来
树形DP就是在树上进行状态转移

F[x,0]表示从以x为根的子树中邀请一部分职员参会,并且x不参加舞会时,快乐指数总和的最大值。此时x的子节点(直接下属) 可以参会, 也可以不参会。
F[x,1]表示从以x为根的子树中邀请一部分职员参会,并且x不参加舞会时,快乐指数总和的最大值。此时x的所有子节点(直接下属)都不能参会。
\(F[x,0] = \sum_{s \in Son(x)} max(F[s,0], F[s,1])\)
\(F[x,1] = H[x] + \sum_{s \in Son(x)} F[s,0]\)
\(Son(x)\) 表示x的子节点集合
点击查看代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<vector>
using namespace std;
vector<int> son[10010];
int f[10010][2], v[10010], h[10010], n;
void dp(int x) {
f[x][0] = 0;
f[x][1] = h[x];
for (int i = 0; i < son[x].size(); i++) {
int y = son[x][i];
dp(y);
f[x][0] += max(f[y][0], f[y][1]);
f[x][1] += f[y][0];
}
}
int main() {
cin >> n;
for (int i = 1; i <= n; i++) scanf("%d", &h[i]);
for (int i = 1; i < n; i++) {
int x, y;
scanf("%d %d", &x, &y);
v[x] = 1; // x has a father
son[y].push_back(x); // x is a son of y
}
int root;
for (int i = 1; i <= n; i++)
if (!v[i]) { // i doesn't have a father
root = i;
break;
}
dp(root);
cout << max(f[root][0], f[root][1]) << endl;
}
1. 背包类树形DP


点击查看代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<vector>
using namespace std;
vector<int> son[310];
int f[310][310], s[310], n, m;
void dp(int x) {
f[x][0] = 0;
for (int i = 0; i < son[x].size(); i++) { // 循环子节点(物品)
int y = son[x][i];
dp(y);
for (int t = m; t >= 0; t--) // 倒序循环当前选课总门数(当前背包体积)
for (int j = 0; j <= t; j++) // 循环更深子树上的选课门数(组内物品)
f[x][t] = max(f[x][t], f[x][t-j] + f[y][j]);
/* 或者
for (int j = t; j >= 0; j--)
if (t + j <= m)
f[x][t+j] = max(f[x][t+j], f[x][t] + f[y][j]);
这两种写法j分别用了正序和倒序循环
是为了正确处理组内体积为0的物品(本题正序倒序都可以AC是因为体积为0的物品价值恰好也为0)
请读者结合0/1背包问题中DP的“阶段”理论思考 */
}
if (x != 0) // x不为0时,选修x本身需要占用1门课,并获得相应学分
for(int t = m; t > 0; t--)
f[x][t] = f[x][t-1] + s[x];
}
int main()
{
cin >> n >> m;
for(int i = 1; i <= n; i++)
{
int x;
cin >> x >> s[i];
son[x].push_back(i);
}
memset(f, 0xcf, sizeof(f)); // -INF
dp(0);
cout << f[0][m] << endl;
}
2. 二次扫描与换根法


点击查看代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
int d[200010], v[200010], f[200010], deg[200010];
int head[200010], ver[400010], edge[400010], Next[400010];
int n, T, tot, root, ans;
void add(int x, int y, int z) {
ver[++tot] = y, edge[tot] = z, Next[tot] = head[x], head[x] = tot;
}
void dp(int x) {
v[x] = 1; // 访问标记
d[x] = 0;
for (int i = head[x]; i; i = Next[i]) { // 邻接表存储
int y = ver[i];
if (v[y]) continue;
dp(y);
if (deg[y] == 1) d[x] += edge[i]; // edge[i]保存c(x,y)
else d[x] += min(d[y], edge[i]);
}
}
void dfs(int x) {
v[x] = 1;
for (int i = head[x]; i; i = Next[i]) {
int y = ver[i];
if (v[y]) continue;
if (deg[x] == 1) f[y] = d[y] + edge[i];
else if (deg[y] == 1) f[y] = d[y] + min(f[x] - edge[i], edge[i]);
else f[y] = d[y] + min(f[x] - min(d[y], edge[i]), edge[i]);
dfs(y);
}
}
int main() {
cin >> T;
while (T--) {
tot = 1;
cin >> n;
tot = 1;
for (int i = 1; i <= n; i++)
head[i] = f[i] = d[i] = deg[i] = v[i] = 0;
for (int i = 1; i < n; i++) {
int x, y, z;
scanf("%d%d%d", &x, &y, &z);
add(x, y, z), add(y, x, z);
deg[x]++, deg[y]++;
}
int root = 1; // 任选一个点为源点
dp(root);
for (int i = 1; i <= n; i++) v[i] = 0;
f[root] = d[root];
dfs(root);
int ans = 0;
for (int i = 1; i <= n; i++)
ans = max(ans, f[i]);
cout << ans << endl;
}
}
5.环形与后效性
1.环形结构上的动态规划问题
解决的策略有俩种:
- 通过执行俩次dp来解决 第一次在任意位置断开,按照线性dp求解,第二次通过过适当的条件和赋值, 保证计算出的状态等价于把断开的位置强制相连。
- 可以通过在任意位置断开环为链,然后复制一倍在末尾解决。



这里关键就是断开后[1~N] 就会导致第一天肯定不能获得休息的体力,对于第N天来说就算断开了也不影响获取
所以第二次dp我们要讨论第一天肯定能获得体力,这个前提是第N天要休息,但是这里不需要额外处理就可以使用。
给出的状体转移方程中i只利用了前一天的值,所以可以用滚动数组来优化
滚动数组dp[i & 1] i为奇数时候 i&1 = 1,为偶数时,i&1=0,就可以滚动处理。
收获的话:滚动数组
(改了一会 想改成断开为链的方法。。没改成)
点击查看代码
#include<vector>
#include<map>
#include<algorithm>
#include<cstdio>
#include<cstring>
#define rep(i, a, n) for (auto i = a; i < (n); ++i) // repeat
#define repe(i, a, n) for (auto i = a; i <= (n); ++i) // repeat and equal
#define revrep(i, a, n) for (auto i = n; i > (a); --i) // reverse repeat
#define revrepe(i, a, n) for (auto i = n; i >= (a); --i)
#define all(a) a.begin(), a.end()
#define sz(a) (int)(a.size());
#define mem(a,b) memset(a,b,sizeof(a))
#define lb(x) ((x) & -(x)) // lowbit
#define pb push_back
#define qb pop_back
#define pf push_front
#define qf pop_front
#define MAX_N 4005
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
typedef pair<int, int> pii;
typedef vector<int> vi;
typedef vector<vi> vvi;
template<class T> inline bool chmax(T &a, T b) { if (a < b) { a = b; return 1; } return 0; }
template<class T> inline bool chmin(T &a, T b) { if (b < a) { a = b; return 1; } return 0; }
int N, B;
int a[MAX_N * 2];
// dp[i, j, 1] 前i个小时休息了j个小时并且第i个小时正在休息,累计恢复的体力的最大值
// dp[i, j, 0] 表示前 i 个小时休息了j个小时,并且第 i 个小时没有在休息,累计恢复的体力的最大值。
// 前面没有休息那么这次休息就不计入了
// dp[i, j, 1] = max(dp[i - 1, j - 1, 0], dp[i - 1, j - 1, 1] + a[i]);
// dp[i, j, 0] = max(dp[i - 1, j, 0], dp[i - 1, j, 1])
// int dp[MAX_N][MAX_N][2];
// 滚动数组进行优化
int dp[2][MAX_N][2];
int main() {
int result = 0;
scanf("%d%d", &N, &B);
for(int i = 1; i <= N; i++) {
scanf("%d", &a[i]);
}
// 滚动数组优化
memset(dp, 0x80, sizeof(dp));
dp[1 & 1][0][0] = 0;
dp[1 & 1][1][1] = 0;
for(int i = 2; i <= N; i++) {
for(int j = 0; j <= i; j++) {
dp[i & 1][j][0] = max(dp[(i - 1) & 1][j][0], dp[(i - 1) & 1][j][1]);
if(j > 0) dp[i & 1][j][1] = max(dp[(i - 1) & 1][j - 1][0], dp[(i - 1) & 1][j - 1][1] + a[i]);
}
}
result = max(dp[N & 1][B][0], dp[N & 1][B][1]);
memset(dp, 0x80, sizeof(dp));
dp[1 & 1][1][1] = a[1];
for(int i = 2; i <= N; i++) {
for(int j = 0; j <= i; j++) {
dp[i & 1][j][0] = max(dp[(i - 1) & 1][j][0], dp[(i - 1) & 1][j][1]);
if(j > 0) dp[i & 1][j][1] = max(dp[(i - 1) & 1][j - 1][0], dp[(i - 1) & 1][j - 1][1] + a[i]);
}
}
result = max(result, dp[N & 1][B][1]);
printf("%d\n", result);
//执行两次dp
memset(dp, 0x80, sizeof(dp));
// 第一次dp 第N个小时和下一天第1个小时不相连
dp[1][0][0] = 0;
dp[1][1][1] = 0;
for(int i = 2; i <= N; i++) {
for(int j = 0; j <= i; j++) {
dp[i][j][0] = max(dp[i - 1][j][0], dp[i - 1][j][1]);
if(j > 0) dp[i][j][1] = max(dp[i - 1][j - 1][0], dp[i - 1][j - 1][1] + a[i]);
}
}
result = max(dp[N][B][0], dp[N][B][1]);
// 第二次dp
// 这次可以强制第N个小时和第1个小时都在休息
memset(dp, 0x80, sizeof(dp));
dp[1][1][1] = a[1];
for(int i = 2; i <= N; i++) {
for(int j = 0; j <= i; j++) {
dp[i][j][0] = max(dp[i - 1][j][0], dp[i - 1][j][1]);
if(j > 0) dp[i][j][1] = max(dp[i - 1][j - 1][0], dp[i - 1][j - 1][1] + a[i]);
}
}
result = max(result, dp[N][B][1]);
printf("%d\n", result);
}
2.有后效性的状态转移方程
优化
1.状态压缩
若集合大小为N,集合内的数不大于K,那么可以利用\([0, K^N - 1]\) 来表示状态
比如使用0,1表示是否访问了某个元素,有N个元素,可以用\([0, 2^N - 1]\)来表示是否访问了元素

点击查看代码
// 输入
int n;
int d[MAX_N][MAX_N];
int dp[1 << MAX_N] [MAX_N]; //记忆化搜索使用的数组
// 已经访问过的节点集合为S, 当前位置为v
int rec(int S, int v) {
if (dp[S][v] >= 0 ) {
return dp[S][v];
}
if(S == (1 << n) - 1 && v == 0) {
// 已经访问过所有节点并回到0号点
return dp[S][v] = 0;
}
int res = INF;
for(int u = 0; u < n; u++) {
if(!(S >> u) & 1) {
// 下一步移动到顶点u
res = min(res, rec(S | 1 <<u, u) + d[v][u]);
}
}
return dp[S][v] = res;
}
void solve() {
memset(dp, -1, sizeof(dp) );
printf( "%d\n", rec(0, 0));
}
2.倍增优化
倍增,可以利用指数来扩增区间
3.数据结构优化DP
利用数据结构来帮助优化,比如线段树、树状数组、离散化等。
因为有些递推式子可能需要区间上的信息,利用数据结构来优化。

浙公网安备 33010602011771号