动态规划

记录
1:34 2024-2-1

1.动态规划

相关知识

  • 动态规划三要素
    • 状态
    • 阶段
    • 决策
  • 动态规划三前提
    • 子问题重叠性
    • 无后效性:要求求解的问题不受”后续“阶段的影响
    • 最优子结构性:下一阶段最优解应该能够由前面各阶段子问题的最优解导出

1.LCS和LIS和数字三角形

最长公共子序列和(LCS,Longest Common Subsequence)
最长上升子序列(LIS,Longest Increasing Subsequence)

1.LCS

最长公共子序列和, 给出俩个字符串之间最长的公共子序列的长度

dp[i][j] 定义为s和t最长的公共子序列长度

\[ dp[i+1][j+1] = \begin{cases} max(dp[i][j] + 1, dp[i][j+1], dp[i+1][j]) (s_{i+1} = t_{j+1}) \\ max(dp[i][j+1], dp[i+1][j]) (其他) \end{cases} \]

分析可知。当\(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+1][j] = \begin{cases} dp[i][j] \qquad \qquad \qquad (j < w[i]) \\ max(dp[i][j], dp[i][j-w[i]] + v[i]) (其他) \end{cases} \]

也可以这样理解
dp[i][j]定义为从前i个物品中选出总重量为j的物品放入背包,得到总价值的最大值

\[ dp[i][j] = max(\begin{cases} dp[i-1][j] \qquad \qquad \qquad \qquad \qquad \qquad \qquad 不选择第i个物品\\ dp[i-1][j-w[i]] + v[i]) \qquad j >= w[i] \qquad 选择第i个物品 \end{cases}) \]

点击查看代码
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] = \begin{cases} dp[i][j] \qquad \qquad \qquad (j < w[i]) \\ max(dp[i][j], dp[i+1][j-w[i]] + v[i]) (其他) \end{cases} \]

因为其他情况已经在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. 分组背包


分组背包限制了每个组里只能选择一个物品

\[ dp[i][j] = \max{ \begin{cases} dp[i-1][j] \qquad 不选第i组的物品 \\ \max_{1 \le k \le C_i} dp[i-1][j-w_{ik}] + v[i][k] 选第i组的某个物品k \end{cases} } \]

分组背包中的循环顺序很重要,物品的循环要放在内层,不然就会像完全背包/多重背包推导的那样,可能在这个组里选择大于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.环形结构上的动态规划问题

解决的策略有俩种:

  1. 通过执行俩次dp来解决 第一次在任意位置断开,按照线性dp求解,第二次通过过适当的条件和赋值, 保证计算出的状态等价于把断开的位置强制相连。
  2. 可以通过在任意位置断开环为链,然后复制一倍在末尾解决。



这里关键就是断开后[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

利用数据结构来帮助优化,比如线段树、树状数组、离散化等。
因为有些递推式子可能需要区间上的信息,利用数据结构来优化。

4.单调队列优化

5.斜率优化

6.四边形不等式

题目记录

leetcode-5-最长回文子串
POJ--1179 Polygon (区间DP)

posted @ 2024-02-02 21:49  57one  阅读(36)  评论(0)    收藏  举报