动态规划笔记
看完了紫书第九章还是有一点迷茫,dp果然是一个比较抽象和困难的知识点,这里结合学长(CSDN账号:蹲坑玩手机)上课的课件和看完紫书后自己的一些思考来记录一下dp学习的心得。
一、基础dp
1.性质:
- 拥有子问题,子问题最优解(即拥有最优子结构),对于一个原问题解最优,其子问题必定也是最优,同时原问题的最优解依赖于其子问题的最优解
- 子问题重复性,一个子问题可能会影响多个不同的下一阶段的原问题
- 无后效性,即此时的之前状态无法直接影响未来的决策,换句话说就是之前的每个状态如何得来并不影响未来对此时(当前)状态的利用或者查找,因为我们最后对此时(当前)状态的利用只考虑结果不考虑过程。
2.处理:
- 保存的信息不够:加数组维度
- 时间复杂度:优化手段
- 优化手段
- 时间:线段树
- 空间:滚动数组、多开一个数组记录上一阶段的所有状态值
- 填表法和刷表法
- 填表法:利用已知信息修改当前值
- 刷表法:利用当前已知信息来修改其它相关值
二、基础dp题型
1.爬楼梯
- 定义:dp[n] 代表达到第n阶需要的步数
- 方程
dp[n] = dp[n - 1] + dp[n - 2]+....
(这里方程的取决于题目给出条件) - 初始化:
dp[1] = dp[0] = 1
;(走到第0级和第一级方案数为1) - 答案:
dp[n]
2.数塔问题
- 定义:
dp[i][j]
表示:到第i行第j列的能获得的最大的收益 - 方程:
dp[i][j] = dp[i + 1][j] + dp[i + 1][j + 1]
- 初始化:
dp[n][j] = a[n][i]
- 答案:
dp[1][1]
3. 硬币问题
- 定义:
dp[n]
表示凑齐n面值所用的最少的张数 - 方程:
dp[i] = min(dp[i], dp[i - coin[i]] + 1)
- 初始化:
dp[0] = 0
- 答案:
dp[n]
4. 最大子段和
- 定义:
dp[i]
表示第i项必选往前的最大的字段的和 - 方程:
dp[i] = max(dp[i - 1] + nums[i], nums[i])
(考虑到dp[i-1]是否为负数的情况) - 初始化:
dp[0] = -inf
- 答案:dp[1 ~ n]的最大值
5. LIS(最长上升子序列)
- 定义:
dp[i]
第i项必选往前找的所能找的最长上升子序列的长度 - 转移:
dp[i] = max{dp[j] + 1} a[j] < a[i]
- 答案:dp[1 ~ n]的最大值
- 线段树优化
6. LCS(最长公共子序列)
- 定义:
dp[i][j]
表示a字符串选前i长度和b字符串选前j长度的最长公共长度 - 转移:
dp[i][j] = dp[i - 1][j - 1] + 1 : a[i] == b[j]
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
- 答案:
dp[n][m]
7. DAG(有向无环图)
- 有时候dp的最大最小值问题可以转化为求最短路最长路或者方案数的图论问题,然后就结合图论的知识点和dp的知识点结合解决。
8. 背包问题
-
01背包(每个物品只有一个)
- 最基础的背包问题 01背包问题
- 状态转移方程:
dp[i][j]=max(dp[i-1][j-v[i]]+w[i],dp[i][j])
- 可以优化掉第一维,不过要每一次循环从后往前
-
完全背包(每个物品无限)
- 01背包的衍生,代码的长相和01背包就一点不一样
- 状态转移方程:
dp[i][j]=max(dp[i][j-v[i]]+w[i],dp[i][j])
- 同样可以优化掉第一维,每一次循环依然从前往后
-
多重背包(每个物品规定个数)
- 转化为01背包问题就可以了
- 主要代码部分
for (int j = 1; j <= m; ++j) {
dp[i][j] = dp[i - 1][j];
for (int k = 1; k <= s; ++k) {
if (j >= k * v) {
dp[i][j] = max(dp[i][j], dp[i - 1][j - v * k] + k * w);
}
}
}
三、区间dp
- 将某个区间的状态进行保存
dp[l][r]
保存[l, r]里面的状态,- 例题:石子合并:
dp[l][r]表示合并[l,r]区间的最小答案
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
int n, w[305], sum[305], dp[305][305];
int main() {
scanf("%d\n", &n);
memset(dp, 0x3f, sizeof dp);
for (int i = 1; i <= n; ++i) {
scanf("%d", w + i);
sum[i] = sum[i - 1] + w[i];
dp[i][i] = 0;
}
for (int len = 2; len <= n; ++len) { // 枚举区间长度
for (int i = 1, j = i + len - 1; j <= n; ++j, ++i) { // 枚举左右区间 [i, j]
for (int k = i; k < j; ++k) {
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + sum[j] - sum[i - 1]);
}
}
}
printf("%d", dp[1][n]);
return 0;
}
四、树形dp
-
普通树形dp
- dp的核心思想是状态的转移,而在树中,我们可以通过树的子节点来转移出父节点代表的的状态
- 没有上司的舞会 : 树上最大独立集,
dp[i][0/1]表示以第i号结点为根的子树且i号结点选1(不选0)的最大快乐值
代码:
#include <iostream> #include <algorithm> #include <vector> using namespace std; const int N = 6e3 + 10; vector<int> mp[N]; int du[N], hp[N], dp[N][2]; void dfs(int u, int fa) { dp[u][1] = hp[u]; for (int v : mp[u]) { if (v == fa) continue; dfs(v, u); dp[u][0] += max(dp[v][1], dp[v][0]); dp[u][1] += dp[v][0]; } } int main() { int n; cin >> n; for (int i = 1; i <= n; ++i) cin >> hp[i]; int u, v; while (cin >> u >> v) { mp[u].push_back(v); mp[v].push_back(u); du[u] += 1; } int root = 1; for (int i = 1; i <= n; ++i) if (du[i] == 0) root = i; dfs(root, -1); cout << max(dp[root][1], dp[root][0]); return 0; }
- 树的重心:重心是指树中的一个结点,如果将这个结点删除后剩余的各个连通块中结点数的最大值最小,则称为树的重心
- 树的重心的一些重要性质:
- 一棵树最少有一个重心,最多有两个重心,若有两个重心,则他们相邻(即连有直接边)
- 树上所有点到某个点的距离和里,到重心的距离和最小;若有两个重心,则其距离和相同
- 若以重心为根,则所有子树的大小都不超过整棵树的一半
- 在一棵树上添加或删除一个叶子节点,其重心最多平移一条边的距离
- 两棵树通过连一条边组合成新树,则新树重心在原来两棵树的重心的连线上
- 树的重心的一些重要性质:
- 树的直径:树上两点间距离的最大值
- 树的中心:一个结点,对于每一个点的距离的最大值最小
-
树形背包
- 本质就是分组背包
#include <cstdio>
#include <cstring>
#include <algorithm>
#include <vector>
#include <iostream>
#include <cctype>
#define ll long long
#define pb emplace_back
using namespace std;
const int M = 105, N = 1e3 + 5, inf = 1e9;
vector<int> mp[M];
int root, n, m, dp[M][M], w[M], v[M], last[M];
// 刷表法
int dfs(int u) { // 0(nnm)
int sum = v[u];
dp[u][v[u]] = w[u]; // 必须选当前的根的物品
for (int& son : mp[u]) {
int siz = dfs(son);
//m = 100000
//sum = 100
for (int i = v[u]; i <= min(sum, m); ++i) last[i] = dp[u][i];
for (int i = v[u]; i <= min(sum, m); ++i) { // 遍历之前更新u的决策
for (int j = 0; j <= siz; ++j) {
if (i + j > m) continue;
dp[u][i + j] = max(dp[u][i + j], last[i] + dp[son][j]); // 刷表法
}
}
// for (int i = 100000; i >= v[u]; --i)
// for (int i = 100; i >= v[u]; --i)
// for (int i = m; i >= v[u]; --i) { // 01背包
// for (int j = 0; j <= siz; ++j) { // 分组每一组的每个物品(决策)
// if (i - j < v[u]) continue;
// dp[u][i] = max(dp[u][i], dp[u][i - j] + dp[son][j]); // 填表法
// }
// }
sum += siz;
}
return sum;
}
int main() {
scanf("%d %d\n", &n, &m);
for (int i = 1; i <= n; ++i) {
int p;
scanf("%d %d %d\n", &v[i], &w[i], &p);
if (p != -1) mp[p].pb(i);
else root = i;
}
dfs(root);
printf("%d\n", dp[root][m]);
return 0;
}
五、状压dp
-
对二维数组中每一行的状态进行压缩成二进制处理,用二进制01来表示每个位置的两种状态
-
二进制的处理
- 检查是否有相邻的1:
if ((i & (i >> 1)) != 0)
1 2 3 4 1100 - 检查a是够是b的子集
if ((a & b) == a)
证明a是b的子集,if ((a | b) == b)
证明a是b的子集
- 检查是否有相邻的1:
-
例题:小国王 LOJ 10170
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <string>
#include <vector>
using namespace std;
using ll = long long;
const int N = 1 << 10;
ll dp[15][N + 5][110];
int cal(int x) { // 计算二进制中 1 的个数
int ans = 0;
// while (x) {
// if ((x & 1) == 1) ans += 1;
// x >>= 1;
// }
while (x) {
ans += 1;
x &= (x - 1);
}
return ans;
}
int main() {
int n, m;
cin >> n >> m;
int len = (1 << n); // 3 111 1 << 3 [0 ~ 1000)
dp[0][0][0] = 1;
// O(n * 2^n * 2^n * k)
// O(10 * 100 * 100 * 100) = O(<1e8)
for (int i = 1; i <= n; ++i) { // 第i行
for (int s1 = 0; s1 < len; ++s1) {// 当前行的状态
// 判断s1的状态不能有相邻的两个国王放在一起
if (s1 & (s1 >> 1)) continue;
for (int s2 = 0; s2 < len; ++s2) { // 上一行的状态
// 判断s2的状态不能有相邻的两个国王放在一起
if (s2 & (s2 >> 1)) continue;
// 101 100
/*
s1 = 101
s2 = 100
*/
// 一下判断的是s1和s2冲突的情况
if (s1 & s2) continue;
/*
s1 = 100
s2 = 010
*/
if ((s1 >> 1) & s2) continue;
/*
s1 = 001
s2 = 010
*/
if ((s1 << 1) & s2) continue;
for (int k = 0; k <= m; ++k) { // 上一行的个数
//if (dp[i - 1][s2][k] == 0) continue;
dp[i][s1][k + cal(s1)] += dp[i - 1][s2][k];
}
}
}
}
ll ans = 0;
for (int i = 0; i < len; ++i) ans += dp[n][i][m];
cout << ans << endl;
return 0;
}
六、数位dp
- 数位dp:有时候,我们会被要求找出满足某些条件的数字,但是我们要满足这些条件更加倾向于把这个数当作字符串来处理,通过对每一位数字来进行状态转移。
- 例题:数位dp入门题 不要62
- 递归的模板
int num[100], dp[100];
int dfs(int indx, int limit, /*参数根据题意来添加*/) {
if (indx == 0) {
return 1;// 根据题意来返回
}
int &ref = dp[indx];
if (!limit && ref != -1) return ref;
int res = 0;
int up = (limit ? num[indx] : 9);
for (int i = 0; i <= up; ++i) {
// 更新res
dfs(indx - 1, limit && i == up);
}
if (!limit) ref = res;
return res;
}
int solve(int x) {
if (!x) return 1; // 根据题意来决定返回值
int len = 0;
while (x) {
num[++len] = x % 10;
x /= 10;
}
return dfs(len, 1);
}
int main() {
memset(dp, - 1, sizeof dp);
// 此行省略读入
cout << solve(r) - solve(l - 1) endl;
}
- 递推的模板
void prework() {
// 对每一位未超过最高数时的信息进行预处理
}
int solve(int x) {
int num[10], len = 0, res = 0; // 不同题意res初始化不同,即一般是0算不算的区别
if (!x) return 1;// 返回什么根据题意来
while (x) { // 抠每一位出来
num[++len] = x % 10;
x /= 10;
}
int last = 0; // 记录前几位需要信息,初始化根据题意来
for (int i = len; i >= 1; --i) {
for (int j = 0; j < num[i]; ++j) { // 注意如果题目对前导0有要求可在第一位开始从1开始再到25行处处理第一位如果是0的情况
if (/*j 和 last放在一起不符合题意*/) continue;
res += // 对每一位填未超过此位的数字时的答案进行累加,一般这里利用到prework预处理出来的东西
}
// 来到这一步说明此位填num[i]
if (/*此位填num[i]不符合题意*/) break;
last += // 更新last
if (last /*符合题意*/ && i == 1) {
// 更新res
}
}
for (int i = len - 1; i >= 1; --i) { // 假设不能有前导0,则枚举从次高位开始到低位
for (int j = 1; j < 10; ++j) { // 枚举每一位都放1~9
res += // 更新res
}
}
return res;
}
int main() {
prework();
// 此行省略读入
cout << solve(r) - solve(l - 1) endl;
}
七、闫式dp分析法(重点)
- 闫式dp分析法来源于Acwing网站的创始人闫学灿,该方法是对dp问题的思考过程。虽然使用该方法并不能降低dp问题本身的难度,但是我们可以通过这个方法去分析所有的dp问题。
1.集合的角度看问题
首先我们需要思考这么一个问题----我们为什么要用dp来解决一些问题?或者说,为什么dp能解决一些暴力无法解决的问题?
我们要想到这样一个事实,暴力枚举之所以会超时,是因为我们是在个体的角度看问题。通过暴力来枚举每一个个体的状态所以才导致了时间复杂度的不足。
但是如果换一个角度想,在实际问题中,每一个个体其实都可以看作一个集合的问题。如果我们把问题划分为集合,那么我们眼中的将是一个个集合而不是个体,从而降低了数量级。
举一个不太严谨的例子:一个老师如果要知道40人的班级的考试最高分,如果一个个的查,那么就要查遍40人。但是如果老师事先分好10人一组,让小组长来事先统计出本组最高分,那么老师只要比较4个组的最高分就可以知道全班的最高分,这样就轻松多了。
然后你可能会有疑问:小组长不一样要查遍班里每一个人吗?只是老师的工作量少了啊但是全班总的工作量没有变啊!别急,假设班级有一个人的成绩搞错了,那么在修改过后重新统计最高分,我该怎么办呢?
如果我们没有分组,老师重新暴力搜索就又要把全班全遍历一遍。但是分组后老师只需要看看是哪一个组的同学成绩被修改了,最后只要那个小组的组长修改本组状态,最后老师再修改全班的状态就行,其余3个组的状态不需要进行任何改动。
这里,老师统计成绩的角度从来就不是一个一个的人,而是被分成的4个小组、4个集合。这就是从集合角度看问题。
2.集合的状态表示
- 集合的描述一般是有套路的:所有满足条件1,条件2的元素的集合。
这个条件1和条件2一般对应到我们的状态表示的每一维。 - 集合的表示:我们通常用一个多维数组
dp[i][j][k]...
来表示集合,每一维都表示一个状态。例如在上面成绩统计的例子中,我们可以用dp[i]
来表示第i组的最高分,集合里的每一个元素都有一个共同点----是第i组的成员,如果状态约束变多就加数组的维度,如用dp[i][0]
来表示第i组中男生的最高分dp[i][1]
来表示女生的最高分。这样我们就做到了用集合的角度看待问题。 - 集合的意义:我们要确定集合里存下的是什么东西,这里根据题意确定。它可以是最大值、最小值、数量等。在上面的例子中
dp[i]
存下的是成绩的最大值。 - 注意:集合表示的方法不唯一,并不是所有表示方法都能解决问题!比如举例中的表示方法只是方便大家更好的理解集合角度看问题的思想,实际上计算起来不能很好的体现动态规划的优势。
只有确定好了集合的表示方法和表示意义,我们才能继续求解dp问题。
3.集合的状态计算
确定了集合的状态后我们开始集合的状态计算。这里用楼梯问题来作为例题讲解:(一共有n级台阶,一个人一次可以爬一级台阶或两级台阶,请问爬到第n级有几种方法?)
-
第一步、化零为整,确定状态表示和意义:
dp[i]
代表爬到第i级的方案数。 -
第二步、化整为零:首先了解到我们答案是最大的集合
dp[n]
,我们试着把最大的的问题拆分成子问题(小集合)- 由题意得 要爬到第n级,我们先要爬到n-1级或者第n-2级,问题就转化成了求
dp[n-1]
和dp[n-2]
,然后求dp[n-1]
又转化成了求dp[n-2]
和dp[n-3]
…这样,我们把要求的集合越分越小,直到分到最小的集合dp[0]
和dp[1]
。这样我们就完成了集合的划分,把 大集合的任务分成了若干个小子集合的任务 。 - 我们发现,最小的集合
dp[0]
和dp[1]
显然全部等于1。到0层的方案数显然全部为1种就不解释了,而到第一层只能从0到1,所以也显然为1。如果状态表示的方法没错的话,我们会发现最小的子集合的值是显然的。
- 由题意得 要爬到第n级,我们先要爬到n-1级或者第n-2级,问题就转化成了求
-
第三步、状态计算
- 状态转移方程:在完成划分后,我们要通过小集合的答案来求出大集合的答案了。而小集合的状态求出大集合的状态是要按照规律来的,这个规律就是状态转移方程。有时方程要根据题意来求出,有时候方程要自己进行推导。在本题中根据题意可得,方程为
dp[i]=dp[i-1]+dp[i-2]
。要是无法理解就继续看下去。 - 通过
dp[0]
和dp[1]
全部等于1,我们求dp[2]
:如果要走到第2级台阶,我们只能从第1级或者第0级台阶走到。那么走到第2级的方案数就是走到第0级的方案数和走到第1级的方案数之和,即dp[2]=dp[0]+dp[1]=1+1=2
。同理dp[3]=dp[1]+dp[2]=1+2=3
…最后我们通过方程不断转移,就能得到dp[n]
的值了。
- 状态转移方程:在完成划分后,我们要通过小集合的答案来求出大集合的答案了。而小集合的状态求出大集合的状态是要按照规律来的,这个规律就是状态转移方程。有时方程要根据题意来求出,有时候方程要自己进行推导。在本题中根据题意可得,方程为
4.分析流程图
闫式dp分析法都可以使用上述图来进行分析。
作者:Avalon Demerzel,喜欢我的博客就点个赞吧,更多紫书知识点请见作者专栏《紫书学习笔记》