文章目录
- [一. 线性dp](#一. 线性dp)
-
- [1. 数字三角形](#1. 数字三角形)
- [2. 最长上升子序列](#2. 最长上升子序列)
- [3. 最长公共子序列](#3. 最长公共子序列)
- [二. 区间dp](#二. 区间dp)
-
- [1. 石子合并](#1. 石子合并)
- [三. 计数类dp](#三. 计数类dp)
-
- [1. 整数划分](#1. 整数划分)
- [四. 状态压缩dp](#四. 状态压缩dp)
-
- [1. 蒙德里安的梦想](#1. 蒙德里安的梦想)
- [2. 最短Hamilton路径](#2. 最短Hamilton路径)
- [五. 树形dp](#五. 树形dp)
-
- [1. 没有上司的舞会](#1. 没有上司的舞会)
- [六. 记忆化搜索](#六. 记忆化搜索)
-
- [1. 滑雪](#1. 滑雪)
本篇文章是在Acwing种学习动态规划的笔记题解,其中的计数类dp不是很理解,就没有往上写。
一. 线性dp
线性dp的意思就是对于求解每一个f[i]
或者说是f[i][j]
的时候都是一个接着一个的求解,或者是一层接一层的求解,而这个样子是线性的一个过程,所以叫做线性dp.
1. 数字三角形
这道题要求我们选出一条从顶部走到底部的路径,然后使其经过的路径之和最大。
遇到这种dp问题我们首先应该做出如下的分析:
- 状态表示:
- 集合 :
f[i][j]
表示从起点到(i,j)这个点的所有路径。 - 属性 : 对于所有的路径求出
max
. - 状态计算:
- 我们观察下图可以发现,其实想要到达
f[i][j]
这个点来说只有从左上方 和右上方 下来才能到达。
- 所以可以得出状态计算的方程是:
f[i][j] = max(f[i - 1][j - 1] + g[i][j], f[i - 1][j] + g[i][j])
有了以上这些步骤代码实现起来就比较容易了
要注意初始化要多初始化一列,不然取到边界的时候会出现问题。
cpp
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 510, INF = 1e9;
int n;
int g[N][N],f[N][N];
int main()
{
scanf("%d",&n);
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
scanf("%d",&g[i][j]);
//Init
for (int i = 0; i <= n; i++)
for (int j = 0; j <= i + 1; j++)
f[i][j] = -INF;
f[1][1] = g[1][1];
for (int i = 2; i <= n; i++)
for (int j = 1; j <= i; j++)
f[i][j] = max(f[i - 1][j - 1] + g[i][j], f[i - 1][j] + g[i][j]);
int res = -INF;
for (int i = 1; i <= n; i++)
res = max(res,f[n][i]);
printf("%d\n",res);
return 0;
}
2. 最长上升子序列
这道题要求我们求出数组中的一段序列,序列必须是严格递增的,但是不一定要连续,要求序列长度最长的。
我们还是首先首先来对这个问题进行动态规划的经典分析:
- 状态表示:
- 集合:
f[i]
表示第i个数的所有上升序列长度。 - 属性:对于所有的序列长度进行取
max
- 状态计算:
- 对于下图中的j 会从起始位置开始,一直遍历到 j - 1的位置,如果发现nums[j] < nums[i] 那么我们就选f[i] 和 f[j] + 1的最大值.
- 所以在其计算过程中,是从1 ~ i - 1的这个区间内进行遍历。
- 状态计算方程:
f[i] = max(f[i] , f[j] + 1)
代码如下:
cpp
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int n;
int nums[N],f[N];
int main()
{
scanf("%d",&n);
for (int i = 1; i <= n; i++)
scanf("%d",&nums[i]);
for (int i = 1; i <= n; i++)
{
f[i] = 1; //对于第i个数本身永远都会有1的长度
for (int j = 1; j < i; j++)
if(nums[j] < nums[i])
f[i] = max(f[i],f[j] + 1);
}
int res = 0;
for (int i = 1; i <= n; i++)
res = max(res,f[i]);
printf("%d\n",res);
return 0;
}
3. 最长公共子序列
这道题要求我们求出两个字符串中的最长公共子序列,例如上图中的3是因为两个字符串中的公共子序列是 abd
没有比这个更长的公共子序列了,所以返回3.
首先还是对其进行 y式dp法 (haha):
- 状态表示:
- 集合:
f[i][j]
表示字符串 a 中前 i 个字符和字符串 b 中前 j 个字符的公共子序列的所有情况。 - 属性:对这些所有子序列取最大的长度。
- 状态计算:
- 我们对于两个字符串中的字符,对其只有会有4种情况
但是呢其实对于第一种都不选的情况判不判断无所谓,因为中间两种情况就已经将其包含在内了。第一种情况永远是这俩里面最小的那个,所有不需要对其再进行一次max操作。
代码如下:
c
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int n,m;
char a[N],b[N];
int f[N][N];
int main()
{
scanf("%d%d",&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);
}
printf("%d\n",f[n][m]);
return 0;
}
二. 区间dp
上面的线性dp可以发现的一点就是在就f数组的时候,都会接着前所求过的,一层接一层的求,所以被称为线性,而区间dp呢,则是一个区间一个区间的求,将其分成两个区间,两两合并的求。
1. 石子合并
- 状态表示:
- 集合:
f[i][j]
表示从第 i 堆到第 j堆和并的所有代价。 - 属性: 对所有的代价取
min
. - 状态计算:
- 下图中发现 k 从left 开始一直枚举到 right - 1的位置
- 计算其中所有合并的可能,然后取出最小的那一个就是24
- 所以可以得出状态计算的方程为:
f[l][r] = min(f[l][r], f[l][k]) + f[k + 1][r] + s[r] - s[l - 1]
我们在上述中直接用到了f[1,2]的最小代价,那么f[1,2]的代价又是如何来的,1~2即长度为2,1 ~ 3 长度为3 1 ~ 4 长度为4,所以是按照长度区间进行求解。
c
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 310;
int n;
int s[N],f[N][N];
int main()
{
scanf("%d",&n);
for (int i = 1; i <= n; i++)
scanf("%d",&s[i]);
for (int i = 1; i <= n; i++)
s[i] += s[i - 1];
for (int len = 2; len <= n; len++)
{
for (int i = 1; i + len - 1 <= n; i++)
{
int l = i, r = i + len - 1;
f[l][r] = 1e8;
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]);
}
}
printf("%d\n",f[1][n]);
return 0;
}
三. 计数类dp
1. 整数划分
可以将这道题目抽象成一种完全背包问题,与其不一样的是集合属性的意义不同。
完全背包问题:前 i 个物品中体积不超过 j 的全部方案的价值 取Max
正数划分:前 i 个物品中体积恰好是 j 的全部方案 总数
-
状态表示
-
集合:
f[i][j]
表示前 i 个物品中(1~i中)选择体积恰好是 j 的所有方案 -
属性:取所有方案和
-
状态计算:
把这个问题转化成完全背包问题,1~n 是物品的体积v也是价值v
然后像完全背包问题那也那样子优化一下
f[i][j] = f[i - 1][j] + f[i - 1][j - i] + f[i - 1][j - 2i] + ... +f[i - 1][j - si];
f[i][j - i] = f[i - 1][j - i] + f[i - 1][j - i - i]+...+f[i - 1][j - si];
f[i][j] = f[i - 1][j] + f[i][j - i];
对其进行一维数组的优化:
f[j] = f[j] + f[j - i]
二维:
c
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010, mod = 1e9+7;
int n;
int f[N][N];
int main()
{
scanf("%d",&n);
//前i个物品容量为0的时候也就是不需要选的,也算是一种方案
//最开始都不选的方案数是1
for (int i = 0; i <= n; i++)
f[i][0] = 1;
for (int i = 1; i <= n; i++)
for (int j = 0; j <= n; j++)
{
f[i][j] = f[i - 1][j];
if(j >= i)
f[i][j] = (f[i - 1][j] + f[i][j - i]) % mod ;
}
printf("%d\n",f[n][n]);
return 0;
}
一维:
c
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010, mod = 1e9+7;
int n;
int f[N];
int main()
{
scanf("%d",&n);
//前i个物品容量为0的时候也就是不需要选的,也算是一种方案
//最开始都不选的方案数是1
// for (int i = 0; i <= n; i++)
// f[i][0] = 1;
f[0] = 1;
for (int i = 1; i <= n; i++)
for (int j = i; j <= n; j++)
{
f[j] = (f[j] + f[j - i]) % mod ;
}
printf("%d\n",f[n]);
return 0;
}
四. 状态压缩dp
状态压缩dp,当我满怀好奇的打开了这一章节,结果不出所料,蒙了,蒙大法了。
这类dp方式呢一般是将问题的状态转化成二进制位的形式,每个二进制代表一种状态,用整数或者位运算来计算状态。
下面这两道题,一天,真就一天的时间才搞懂。
1. 蒙德里安的梦想
题目要求我们将给定的 N*M的棋盘切割,其实也可以当成填充去做。
- 状态表示:
- 集合:
f[i][j]
表示前i - 1
列已经全部摆放完成,并且伸到第i
列是j
的所有方案数 - 属性:
count
,求出所有的方案数就好了。 - 状态计算:
- y总的思路是只摆放合法 的行 (1*2),你摆放完所有的行之后,列没有地方可以选了,只能挨个放,所以求出所有的行的摆放方式就好了,但一定要注意是合法的摆放。
- 我们已经知道了
f[i][j]
代表的是啥意思了,而这个j
是一个整数存储的,但是我们真正操作的时候,是操作它的二进制数,比如下图:j 是1001,同时代表了左边那些溢出来,那些没有溢出来。
- 上图的
f[i][j]
该如何确定自身的方案数呢?如果真遍历到了i
的时候那么其实f[i - 1][]
的其实已经是固定的了,它的方案已经是求出来的了。 - 所以我们拿一个变量
k
去遍历上一列的所有的方案.而此时我们就需要判断合法二字了。 - 首先(j & k) == 0,看下图可以发现,j & k即可发现其是否相交,相交肯定是不行的。
- 其次 st[j | k] 必须是合法的,st[i | j]:这个表达式是在判断新状态 i | j 是否是合法状态,即是否可以放置连续的偶数个方块。st 数组中存储了每个状态是否为合法状态的信息。
- 然后将所有合法 的k全部加起来就好了
f[i][j] += f[i - 1][k]
我们首先得预处理出所有的情况 就是 0 ~ 2^n 种
下面是代码:
cpp
#include <iostream>
#include <cstring>
#include <algorithm>
#include <vector>
using namespace std;
typedef long long LL;
const int N = 12, M = 1 << N;
int n,m;
LL f[N][M];
bool st[M];
int main()
{
while(cin >> n >> m, n || m)
{
//
for (int i = 0; i < 1 << n; i++)
{
int cnt = 0;
bool is_valid = true;
for (int j = 0; j < n; j++)
{
if (i >> j & 1) //碰上1
{
if(cnt & 1) //判断连续的空位置是否是偶数
{
is_valid = false;
break;
}
}
else
cnt++;
}
//最后一段
if (cnt & 1) is_valid = false;
st[i] = is_valid;
}
memset(f,0,sizeof f);
f[0][0] = 1; //空棋盘有一种方案
//按照列去放,所以是 <= m; f[m][0] 则可以表示前 m - 列已经放好,并且伸到m列的状态是0,也就是没有的方案总数。
for (int i = 1; i <= m; i++)
for (int j = 0; j < 1 << n; j++)
for (int k = 0; k < 1 << n; k++)
if ((j & k) == 0 && st[j | k])
f[i][j] += f[i - 1][k];
printf("%lld\n",f[m][0]);
}
return 0;
}
上面的代码还可以及进行优化,将dp循环种的if条件提取出来,不用每次都及进行判断,只需要判断一次,将合法的j所对应合法的k放入一个二维数组中去就好了.
f[i][j] = f[i - 1][state[j][k]]
代码如下:
cpp
#include <iostream>
#include <algorithm>
#include <vector>
#include <cstring>
using namespace std;
const int N = 12, M = 1 << N;
typedef long long LL;
int n, m;
LL f[N][M];
bool st[M];
vector<int> state[M];
int main()
{
while (cin >> n >> m, n || m)
{
//首先预处理处所有的格子伸展情况有哪些可以正好存放竖着的1*2小方格
for (int i = 0; i < 1 << n; i++)
{
int cnt = 0;
bool is_valid = true;
for (int j = 0; j < n; j++)
{
if (i >> j & 1) //遇到1
{
if (cnt & 1) //遇到1时候连续空格子是奇数,就意味着肯定无法摆满。
{
is_valid = false;
break;
}
cnt = 0;
}
else //积累0的次数
cnt++;
}
//最后一段
if (cnt & 1) is_valid = false;
st[i] = is_valid;
}
//预处理所有的组合.
//每个数i 可以有多少个与其成功组成的数j.
for (int i = 0; i < 1 << n; i++)
{
state[i].clear();
for (int j = 0; j < 1 << n; j++)
if ((i & j) == 0 && st[i | j]) //不冲突,并且正好能填满剩余的小方块。
state[i].push_back(j);
}
memset(f, 0, sizeof f);
f[0][0] = 1;
for (int i = 1; i <= m; i++)
for (int j = 0; j < 1 << n; j++) //所有符合条件的方案数加入即可。
for (auto k : state[j])
f[i][j] += f[i - 1][k];
cout << f[m][0] << endl;
}
return 0;
}
2. 最短Hamilton路径
这道题目同样是用二进制的数来表示状态,上一题种是表示哪一个位置伸出或者没伸出,而在该问题种怎会表示,哪一个顶点访问过或者未访问过.
-
状态表示:
-
集合:
f[i][j]
i
表示那些顶点被用过,j
表示现在在哪一个顶点上。f[i][j]
则表示路径长度了呗。
表示i
(二进制)这个状态(可以到达的点)下到达j
的最短路径 -
属性:将所有的路径取
min
。 -
状态计算:
-
我们首先需要知道在此题种的状态压缩到底是利用二进制如何压缩的。
比如: 0 -> 1 -> 2 -> 4
可以用二进制: 10111来表示
此二进制下的前三位对应着012顶点,第5位对应着4顶点。
1则表示这些顶点被用过了。 -
然后我们对每一个二进制状态进行遍历,最后
f[(1 << n)][n - 1]
这个状态不就是说所有顶点都使用过,然后当前在 n - 1这个顶点上,这也就是我们所求的答案。 -
然后我们在遍历的过程中,尝试取更新所有的顶点距离,利用k变量来迭代。
-
比如说到
j
顶点时候,我们拿所有情况的k
来更新它 -
就是说当前这个状态
i
去掉j
这个顶点,然后经过k
这个顶点再到达j
. -
f[i][j] = min (f[i][j]),f[i - (1 << j)][k] + w[k][j]
代码如下:
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 20, M = 1 << N;
int n;
int f[M][N],w[N][N];
int main()
{
scanf("%d",&n);
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
scanf("%d",&w[i][j]);
memset(f, 0x3f, sizeof f);
f[1][0] = 0; //f[0....01][0]第一个顶点的状态,在0的位置,也就是起点到起点的距离是0
for (int i = 0; i < 1 << n; i++)
for (int j = 0; j < n; j++)
if(i >> j & 1) //当前的顶点合法是1,也就是说当前的顶点状态。
{
for (int k = 0; k < n; k++)
if(i - (1 << j) >> k & 1) //当前的状态先除掉j,之后经过k之后的距离 和 直接到j的距离取最小值
f[i][j] = min(f[i][j],f[i - (1 << j)][k] + w[k][j]);
}
printf("%d\n",f[(1 << n) - 1][n - 1]);
return 0;
}
五. 树形dp
树形dp则是在树上进行dp,因为树的操作大多都和递归有关系,所以树形dp一般也是用递归来做。
1. 没有上司的舞会
- 状态表示:
- 集合:
f[u][0]
表示以u
为根节点不选择u
的所有快乐指数方案。f[u][1]
表示以u
为根节点选择u
的所有快乐指数方案。 - 属性:将两种方案取
Max
. - 状态计算:
- 我们从题目中可以得知不能出现一个人是另一个人的直接上司.
- 那么其实针对于每个节点,其自己同样也是根节点,那么对于每个根节点都有上述集合中所出现的两种状态,选自己或者不选自己。
- 而所对应的则是选了自己就不能选择其孩子,而不选自己就可以选择其孩子。
- 所以我们可以得出两个计算方程:
- 我们设
s
为节点u
的孩子,而i
则是u
的第i
个孩子。 - 不选根节点:
f[u][0] += max(f[si][0],f[si][1])
- 因为没有选择节点
u
所以其孩子你选不选都可以,取最大值。 - 选择根节点:
f[u][1] += f[si][0]
- 因为选了根节点
u
,所以只有一种情况,不选其孩子。
代码如下:
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 6010;
int n;
int hahppy[N],f[N][2];
int h[N],e[N],ne[N],idx;
bool hasFather[N];
void Add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void dfs(int u)
{
f[u][1] = hahppy[u];
for (int i = h[u]; i != -1; i = ne[i])
{
int j = e[i];
dfs(j);
f[u][0] += max(f[j][0],f[j][1]);
f[u][1] += f[j][0];
}
}
int main()
{
scanf("%d",&n);
for (int i = 1; i <= n; i++)
scanf("%d",&hahppy[i]);
memset(h, -1, sizeof h);
for (int i = 0; i < n - 1; i++)
{
int a,b;
scanf("%d%d",&a,&b);
hasFather[a] = true;
Add(b,a);
}
int root = 1;
while (hasFather[root])
root++;
dfs(root);
printf("%d\n",max(f[root][0],f[root][1]));
return 0;
}
六. 记忆化搜索
记忆化搜索是一种通过记录已经遍历过的状态的信息,从而避免对同一状态重复遍历的搜索实现方式,它确保了每一个状态只计算一次,采用递归的方式。
这其实也是属于搜索的一种,之前在bfs和dfs中遇到过像滑雪这类题目,当时也就是用了这类方式做的。
1. 滑雪
- 状态表示:
- 集合:
f[i][j]
表示顶点(i,j)
的所有方案总数。 - 属性:对所有的方案总数取
Max
- 状态计算:
- 这个状态计算我个人感觉有点像dfs ,以自己为中心,然后向上下左右4个方位进行扩散,如果
f[i][j]
当前的状态已经有值了,就直接返回那个值就好了,因为那个值一定是最优解,在计算的时候对于每个f[i][j]
取上下左右4个方向的最大值。 - 最后将所有的
f[i][j]
取出最大值就好了.
c
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 310;
int n,m;
int g[N][N];
int f[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 != -1) return v;
v = 1;
for (int i = 0; i < 4; i++)
{
int a = x + dx[i], b = y + dy[i];
if (a >= 0 && a < n && b >= 0 && b < m && g[x][y] < g[a][b])
v = max(v,dp(a,b) + 1);
}
return v;
}
int main()
{
scanf("%d%d",&n,&m);
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
scanf("%d",&g[i][j]);
int res = 0;
memset(f, -1, sizeof f);
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
res = max(res,dp(i,j));
printf("%d\n",res);
return 0;
}