文章目录
DAG上的动态规划
DAG图,即有向无环图。
紫薯上说:有向无环图上的动态规划是学习动态规划的基础。
因为有很多问题可以转换为求DAG上最短路、最长路或路径计数的问题。
问题要素分割:
种类\求的结果 | 最短路 | 最长路 | 路径计数 |
---|---|---|---|
不固定起点终点 | 最短路问题 | 最长路问题 | |
固定起点 | 不常见 | 完全背包问题 | |
固定终点 | 不常见 | 完全背包问题 | |
固定起点和终点 | 没见过 | 一定要装满的完全背包问题 |
DAG(有向无环图):
二元关系的问题转换成图上问题
二元关系: 简单来说,就是两个事物之间的关系。
在数学上,二元关系指,有一个集合S,S中的所有元素都是二元有序对。
S = { [x1:y1], [x2:y2] ,…,[xn:yn] }
当然,序列X和序列Y中的元素可以不仅仅是一对一的关系,也可以一对多甚至多对多。
S = { [x1:y1], [x1:y2] ,…,[xm:yn] }
特殊的,单个集合A上的二元关系R指的是A×A={<x,y>|xεA,yεA}
在图中,我们可以使集合S = A∪B,用R表示S中某两个元素的的二元关系。
A={x1,x2,…,xm},B={y1,y2,…,yn} -> S = {s1,s2,…,sn}
R(si,sj)
建图:
把S中的每个元素看作一个结点,如果R(si,sj),那么就建立一条si到sj到有向边。
序列 | |||||
---|---|---|---|---|---|
A | 1 | 1 | 2 | 3 | 4 |
B | 0 | 5 | 6 | 2 | 3 |
转换为图:
问题举例
嵌套矩形
嵌套矩形问题。有n个矩形,每个矩形可以用两个整数a、b描述,表示它的长和宽,矩形X(a,b)可以嵌套在矩形Y(c,d)中,当且仅当a<c,b<d,或者b<c,a<d(相当于把矩形X旋转90°)。例如,(1,5)可以嵌套在(6,2)内,但不能嵌套在(3,4)内。你的任务是选出尽量多的矩形排成一行,使得除了最后一个之外,每一个矩形都可以嵌套在下一个矩形内。如果有多解,矩形编号的字典序应尽量小。
分析:
结点假设:对于每一个矩形都把它看作一个结点。
边路假设:对于矩形X可以嵌套在矩形Y中,可以说XY具有嵌套关系R(x,y),设置 x->y 的有向边。
那么问题转换为,求DAG中的最长路序列,且字典序最小。
任意起点的DAG中最长路
完全背包——硬币问题
硬币问题。有n种硬币,面值分别为V1,V2,…,Vn每种都有无限多。给定非负整数S可以选用多少个硬币,使得面值之和 恰好 为S? 输出硬币数目的最小值和最大值。1≤n≤100,0≤S≤10000,1≤Vi≤S.
分析:
背包问题也能转换成DAG问题。
结点假设:
把每一种面值 和 任意种的任意数量面值之和(不大于S) 看做一个结点。
边路假设:
1.当以0为起点、S为终点时,任意两个结点,当 Vi < Vj 时,设置 i->j 的有向边。即,保证每条路上的结点的值 递增 即可。
1.当以S为起点、0为终点时,任意两个结点,当 Vi > Vj 时,设置 i->j 的有向边。即,保证每条路上的结点的值 递减 即可。
经过这样转换后,我们要求的就是 在从起点出发到达终点的所有路径中,最短路和最长路。
那么问题转换为,DAG上 固定起点和终点 的最短路和最长路。
固定起点和终点的DAG中最长路和最短路
e.g.
现在有面值为2、5、6的无限量硬币,S为10。
结点:
2、2+2、5、6、2+5、2+6、2+2+5、2+2+6
即2、4、5、6、7、8、9、10
边路:
实际的处理过程中,对于单调的结点序列可以不需要设置其他数据结构来存储结点的信息。
(下面指单调递增的结点序列, 0为起点,S为终点 , V 表示当前结点的值,Vi表示第i种硬币的面值 )
结点间的跳转只需要遍历所有的硬币面值,当满足 S >= V + vi 时,表示能从当前结点V到 V+Vi结点。
不固定起点终点的最长路及字典序
在动态规划求解中,最重要的两个概念就是 状态 和 状态转移方程。
状态:
设d(i)表示从结点i出发的最长路长度。
状态转移方程:
d
(
i
)
=
max
{
d
(
j
)
+
1
∣
(
i
,
j
)
∈
E
}
d(i)=\max \{d(j)+1 \mid(i, j) \in E\}
d(i)=max{d(j)+1∣(i,j)∈E}
E为边集
d数组要全部初始化为0
int dp(int i) {
int & ans = d[i]; //用ans以取址的方式来保存当前的状态,既使得状态简洁,且传址几乎不费时间。
if(ans > 0) return ans; // 如果当前状态 访问过 直接返回
ans = 1; // 没访问过,给它一个初始值
for(int j = 1;j <=n; j++){
if(G[i][j]) ans = max(ans,dp(j)+1); // 如果能到达下一个结点,那么更新当前结点的值
}
return ans; // 返回当前状态
}
max函数,当相等时返回的是前一个,即当前状态不变。
这样可以保证当前路径的字典序最小。
main(){
int ans_i = -1;
int maxx = -1;
for(int j = 1;j <=n; j++){
//因为起点不固定,所以要把所有结点都遍历一遍,以防有结点落下。
if(maxx < dp(i)){
maxx = d[i];
ans_i = i;
}
}
print_ans(ans_i);//字典序打印从 ans_i 开始
}
以下图为例子
在遍历执行dp(i)的过程中,d[]的变化是这样的:
执行dp(1):
d | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
dp(1) | 1 | 0 | 0 | 0 | 0 |
dp(4) | 1 | 0 | 0 | 1 | 0 |
dp(5) | 1 | 0 | 0 | 1 | 1 |
返回dp(4) | 1 | 0 | 0 | 2 | 1 |
返回dp(1) | 3 | 0 | 0 | 2 | 1 |
执行dp(2):
d | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
dp(2) | 3 | 1 | 0 | 2 | 1 |
dp(3) | 3 | 1 | 1 | 2 | 1 |
返回dp(2) | 3 | 2 | 1 | 2 | 1 |
dp(4)已存在直接返回 | 3 | 2 | 1 | 2 | 1 |
返回dp(2) | 3 | 3 | 1 | 2 | 1 |
之后的dp(3…5)都已经存在,直接结束。
从上面可以发现,不管是什么时候进入了dp(i),只要dp(i)执行完结束后返回,那么d[i]中存的值必然是 从结点i出发的最长路长度。
对于找到的每组 i 和 j 只要 d[i] == d[j] + 1 且 G[i][j] == 1,那么i->j。必然是所求最长路中的一段路
所以当i和j都从1开始时,找到的第一组 i和j,就是字典序最短的期中一段路。
void print_ans(){
printf("%d ",i);
for(int j = 1;j <=n;j++){
if(G[i][j] && d[i] == d[j] + 1){
print_ans(j); // 找到j后进入打印j
break; //找到第一组后就停止
}
}
}
tips:
- 声明了一个变量来保存当前状态,非常简练。
依此类推,可以求得 固定起点或固定终点的最短路/最长路,不固定起点终点的最短路。
固定终点的最长路和最短路
以完全背包问题——硬币问题为例
硬币问题。有n种硬币,面值分别为V1,V2,…,Vn每种都有无限多。给定非负整数S可以选用多少个硬币,使得面值之和 恰好 为S? 输出硬币数目的最小值和最大值。1≤n≤100,0≤S≤10000,1≤Vi≤S.
状态:
d(i) 表示,从0开始以i为终点的最长路径长度和最短路径长度
状态转移方程:
d
(
i
)
=
max
{
d
(
i
)
,
d
(
i
−
v
[
j
]
)
+
1
∣
(
i
,
j
)
∈
E
}
d(i)=\max \{d(i), d(i - v[j])+1 \mid(i, j) \in E\}
d(i)=max{d(i),d(i−v[j])+1∣(i,j)∈E}
除了从图的角度理解外,也可以理解为,对于容量为i的背包,对于大小为Vi的硬币,如果放入则为容量为i的背包,可以放入的数量d(i) 为 d(i-v[i]) + 1。否则放入,则为它自身。取其中最大的哪那一个
dp的几种写法
int dp(int i){
int &ans = d[i];
if(d[i] != -1) return d[i]; //有可能出现长度为0的点,且用-1表示没计算过
ans = -(1<<30) ; //ans 应该为一个很小的值 负(2的33次方),这样用来表示当前 无解
for(int j = 1;j <= n;j++){
if(i >= v[j]) //如果 i-v[j] 结点 存在,则可以从 i 结点到 i-v[j] 结点,即如果放得下,就判断哪个大
ans = max(ans,dp(i - v[j]) + 1);
}
}
也可以用vis数组来判断有没有访问过,这样比较容易读代码
int dp(int i){
int &ans = d[i];
if(vis[i]) return d[i]; //有可能出现长度为0的点,且用-1表示没计算过
vis[i] = 1;
ans = -(1<<30) ; //ans 应该为一个很小的值 负(2的33次方),这样用来表示当前 无解
for(int j = 1;j <= n;j++){
if(i >= v[j]) //如果 i-v[j] 结点 存在,则可以从 i 结点到 i-v[j] 结点,即如果放得下,就判断哪个大
ans = max(ans,dp(i - v[j]) + 1);
}
}
我们知道,上面的记忆化搜索的形式是可以写成递推式的。
当要同时求最短路和最长路时,写成递推式更方便
minv[0] = 0;
maxv[0] = 0;
for(int i = 1;i <= S;i++){
minv[i] = INF;
maxv[i] = -INF;
}
for(int i = 1;i <= S;i++){
//下面的内容相当于dp(i);
for(int j = 1;j <= n;j++){
if(i >= v[j] ){
minv[i] = min(minv[i],minv[i - v[j]] + 1);
maxv[i] = max(maxv[i],maxv[i - v[j]] + 1);
//这里因为i是从 1 到 S 的,所以在这之前,已经把dp(i)算出来了。
}
}
}
输出字典序方法
递归输出字典序最小的方案
//d为dp数组,这样可以是存最长路的数组或最短路的数组
print_ans(int *d,int S){
for(int i = 1;i <= n;i++){
//i从1开始,所以只要有第一个可以从 S 走到 S - v[i] 那么字典序最小
if(S >= v[i] && d[S] == d[S - v[i]] + 1){
printf("%d ",i);
print_ans(d,S-v[i]);
break;
}
}
}
也可以在dp过程中把字典序最小的存下来
minv[0] = 0;
maxv[0] = 0;
int min_ans[];
int max_ans[];
for(int i = 1;i <= S;i++){
minv[i] = INF;
maxv[i] = -INF;
}
for(int i = 1;i <= S;i++){
//下面的内容相当于dp(i);
for(int j = 1;j <= n;j++){
if(i >= v[j] ){
//如果 i 能到达i - v[j] 且 minv[i] > minv[i - v[j]] + 1
if(minv[i] > minv[i - v[j]] + 1){
minv[i] = minv[i - v[j]] + 1;
min_ans[i] = j; //用来存,最短路中,i结点的下一个结点。
}
//如果 i 能到达i - v[j] 且 maxv[i] < maxv[i - v[j]] + 1
if(maxv[i] < maxv[i - v[j]] + 1){
maxv[i] = maxv[i - v[j]] + 1;
max_ans[i] = j; //用来存,最短路中,i结点的下一个结点。
}
}
}
}
此时的输出为
void print_ans(int *d,int S){
while(S){
printf("%d ",S);
S = d[S]; //找S的下一个结点
}
}
tips:
- 对于无解和没访问过,每种特殊值,都应该与其他特殊值区分开。
- 用vis数组来判断有没有访问过,这样比较容易读代码。
- 如果状态比较复杂,可以用map来保存状态值,这样只需要调用if(d.count(S)) 就能知道S是否算过。
刷表法和填表法
填表法:
传统的递推法,表示 “对于每个状态i,计算f(i)”,这就是填表法.
填表法需要去手动找对于计算f(i)的所有前依赖项。
刷表法:
对于每个状态i,更新f(i)所影响到的所有状态
即,按照拓扑序,枚举所有的起始点i,然后开始枚举所有边(i,j),更新d(j) = max(d(j),d(i) + 1)
每次这样得到的j不一定是得到最后的解,这只是一个更新方程。
且只有每个状态所依赖的状态 对 它的影响相互独立时才能用。
tips
-
在编写主程序之前,要测试建图过程是否正确。
-
记忆化搜索中,声明一个变量来保存当前状态(的地址),使得程序非常简练。
-
对于无解和没访问过,每种特殊值,都应该与其他特殊值区分开。
-
用vis数组来判断有没有访问过,这样比较容易读代码。
-
如果状态比较复杂,可以用map来保存状态值,这样只需要调用if(d.count(S)) 就能知道S是否算过。