【PAT】5. 动态规划
动态规划的递归和递推写法
- 如果一个问题可以被分解为若干个子问题,且这些子问题会重复出现,那么就称这个问题拥有重叠子问题(Overlapping Subproblems)
- 如果一个问题的最优解可以由其子问题的最优解有效的构造出来,那么称这个问题拥有最优子结构(Optimal Substructure)
- 一个问题必须拥有重叠子问题和最优子结构,才能使用动态规划去解决。
- 状态的无后效性是指:当前状态记录了历史信息,一旦当前状态确定,就不会再改变,且未来的决策只能在已有的一个或若干个状态的基础上进行,历史信息只能通过已有的状态去影响未来的决策
- 必须设计一个无后效性的状态以及相应的状态转移方程,这也是动态规划的核心
最大连续子序列和
给定一个数组序列 A 1 , A 2 , . . . , A n A_1,A_2,...,A_n A1,A2,...,An,求 i , j ( 1 ≤ i ≤ j ≤ n ) i,j(1\leq i\leq j\leq n) i,j(1≤i≤j≤n),使得 A i + . . . + A j A_i+...+A_j Ai+...+Aj最大,输出这个最大和。
- 暴力解法( O ( n 3 O(n^3 O(n3)):枚举左端点和右端点(即枚举i,j)需要 O ( n 2 ) O(n^2) O(n2)的复杂度,而计算 A i + . . . + A j A_i+...+A_j Ai+...+Aj需要 O ( n ) O(n) O(n)复杂度
- 记录前缀和( O ( n 2 ) O(n^2) O(n2)):预处理 S [ i ] = A 1 , A 2 , . . . , A i S[i]=A_1,A_2,...,A_i S[i]=A1,A2,...,Ai,这样 A i + . . . + A j = S [ j ] − S [ i − 1 ] A_i+...+A_j=S[j]-S[i-1] Ai+...+Aj=S[j]−S[i−1]
- 动态规划(O(n)),其实左端点的枚举是没有必要的。
- 令状态dp[i]表示以A[i]作为末尾的连续序列的最大和
- 状态转移方程
dp[i] = max{A[i],dp[i-1]+A[i]}
,边界dp[0]=A[0];
最长不下降子序列(LIS)
最长不下降子序列(Longest Increasing Sequence, LIS):在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是不下降(非递减)的。
令dp[i]表示以A[i]结尾的最长不下降子序列长度,这样对A[i]来说就会有两种可能:
- 如果存在A[i]之前的元素A[j](j < i),使得
A[j]<=A[i]
且dp[j] + 1 > dp[i]
(即把A[i]跟在以A[j]结尾的LIS后面,形成一条更长的不下降子序列) - 如果A[i]之前的元素都比A[i]大,那么A[i]就只好自己形成一条LIS,但是长度为1.
最后以A[i]结尾的LIS长度就是步骤1,2中能形成的最大高度。
- 状态转移方程
dp[i]=max{1,dp[j]+1} (j=1,2,...,i-1 && A[j]<A[i])
- 边界
dp[i]=1(i~[1,n])
最长公共子序列(LCS) 2
最长公共子序列(Longest Common Subsequence, LCS):给定两个字符串(或数字序列)A和B,求一个字符串,使得这个字符串是A和B的最长公共部分(子序列可以不连续)
令dp[i][j]表示字符串A的i号位和字符串B的j号位之前的LCS长度(下标从1开始),根据A[i]和B[j]的情况,分为两种决策:
- 若
A[i]==B[j]
,则字符串S与字符串B的LCS增加了一位,有dp[i][j] = d[i-1][j-1] + 1
。 - 若
A[i]!=B[j]
,则字符串A的i号位和字符串B的j号位之前的LCS无法延长,因此dp[i][j]将会继承dp[i-1][j]与dp[i][j-1]中的较大值,即有dp[i][j]=max{dp[i-1,j],dp[i][j-1]}
。 - 边界:
dp[i][0] = dp[0][j] = 0
最长回文子串 3
给出一个字符串S,求S的最长回文子串的长度
令dp[i][j]表示S[i]至S[j]所表示的子串是否是回文子串,是则为1,不是为0。根据S[i]是否等于S[j],可以把转移情况分为两类:
- 若
S[i]==S[j]
,那么只要S[i+1]至S[j-1]是回文子串,S[i]至S[j]就是回文子串,令dp[i][j] = dp[i+1][j-1]
- 若S[i]!=S[j],那么一定不是回文子串,令
d[i][j]=0
- 边界:
dp[i][i]=1,dp[i][i+1]=(S[i]=S[i+1])?1:0
。
注意:如果按照i和j从小到大的顺序来枚举子串的两个端点,然后更新dp[i][j],会无法保证dp[i+1][j-1]已经被计算过,从而无法得到正确的dp[i][j]。
注意到边界表示的是长度为1和2的子串,且每次转移时都对子串的长度减了1,不妨考虑按子串的长度和子串的初始位置进行枚举,即可以先枚举子串长度L(L是可以取到整个字符串的长度S.len()的),再枚举左端点i,这样右端点i+L-1也可以直接得到
- 也可以通过二分+字符串hash,复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)
- 最优秀的是Manacher算法,复杂度为 O ( n ) O(n) O(n)
数塔DP
将一些数字排成数塔的形状,第n层有n个数字。现在从第一层走到第n层,每次只能走向下一层连接的两个数字中的一个,问:最后将路径上所有数字相加后得到的和最大是多少。
令dp[i][j]表示从第i行第j个数字出发的到达最底层的所有路径中能得到的最大和。状态转移方程为dp[i][j]=max(dp[i+1][j],dp[i+1][j+1]) + f[i][j]
;由于数塔的最后一层的dp值总是等于元素本身,即边界为dp[n][j]=f[n][j](1<=n<=j)
。
DAG最长路
给定一个有向无环图,怎么样求解整个图的所有路径中权值之和最大的那条。
- 令dp[i]表示从i号顶点出发能获得的最长路径长度,所有dp[i]的最大值就是整个DAG的最长路径长度
- 求解dp数组:如果从第i号顶点出发能直接到达顶点 j 1 , j 2 , . . . j k j_1,j_2,...j_k j1,j2,...jk,而 d p [ j 1 ] , d p [ j 2 ] , . . . , d p [ j k ] dp[j_1],dp[j_2],...,dp[j_k] dp[j1],dp[j2],...,dp[jk]均已知,那么有 d p [ i ] = m a x { d p [ j ] + l e n g t h [ i → j ] ∣ ( i , j ) ∈ E } dp[i]=max\left \{ dp[j]+length[i\rightarrow j] |\left ( i,j \right )\in E \right \} dp[i]=max{dp[j]+length[i→j]∣(i,j)∈E},因此我们需要逆拓扑序列的顺序来求解dp数组,或者使用递归的方法,不求出逆递归数组也能计算dp数组
- 由于由出度为0的顶点出发的最长路径长度为0,因此边界为这些顶点的dp值为0,具体实现中对整个dp数组初始化为0。递归求解出度不是0的顶点,递归过程中遇到已经计算过的顶点则直接返回对应的dp值。
- 求解具体最长路径(类比Dijkstra):开一个int型choice数组记录最长路径上顶点的后继结点。如果最终可能有多条最长路径,将choice数组改为vector类型的数组即可
- 如果DAG中有多条路径,选取字典序最小的那条:只需要让遍历i的邻接点的顺序从小到达即可(下面代码自动实现了这个功能)
- 如果令dp[i]表示以i号顶点结尾能获得的最长路径长度,只要把求解公式变为
d
p
[
i
]
=
m
a
x
{
d
p
[
j
]
+
l
e
n
g
t
h
[
j
→
i
]
∣
(
j
,
i
)
∈
E
}
dp[i]=max\left \{ dp[j]+length[j\rightarrow i] |\left ( j,i \right )\in E \right \}
dp[i]=max{dp[j]+length[j→i]∣(j,i)∈E}(相应的求解顺序变为拓扑序),同样可以得出结果,但不能直接得到字典序最小的方案。因为字典序的大小总是先根据序列中较前的部分来判断,因此序列中越靠前的顶点,其dp值应当越后计算。
int DP(int i){ //i为源点 if(dp[i] > 0){ return dp[i]; //dp[i]已计算得到 } for(int j = 0; j < n; j++){//遍历i的所有边 if(G[i][j] != INF){ int temp = DP(j) + G[i][j]; //单独计算,防止if中调用DP函数两次 if(temp > dp[i]){ //可以获得更长的路径 dp[i] = temp; //覆盖dp[i] choice[i] = j; //i号顶点的后继结点是j } } } return dp[i]; //返回计算完毕的dp[i] } //调用printfPath前需要先得到最大的dp[i],然后将i作为路径起点传入 void printPath(int i){ printf("%d", i); while(choice[i] != -1){ //choice数组初始化为-1 i = choice[i]; pritnf("->%d", i); } }
固定终点,求DAG的最长路径长度
- 令dp[i]表示从i号顶点出发到达终点T能获得的最长路径长度,状态转移方程和上面一样,但是边界有很大区别,设置边界为
dp[T]=0
,并且初始化dp数组为一个负的大数,来保证“无法到达终点”的含义的以表达(即-INF);然后设置一个vis数组表示顶点是否已经被计算,int DP(int i){ if(vis[i]){ return dp[i]; } vis[i] = true; for(int j = 0; j < n; j++){ if(G[i][j] != INF){ dp[i] = max(dp[i], DP(j) + G[i][j]); } } return dp[i]; }
背包问题
多阶段动态规划问题:问题可以描述成若干个有序的阶段,且每个阶段的状态只和上一个阶段的状态有关。01背包问题就是这样的例子
01背包问题
有n件物品,每件物品的单件重量为w[i],价值为c[i]。现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都只有一件。
令dp[i][v]
表示前i件物品恰好装入容量为v的背包中所能获得的最大价值。考虑对第i件物品的选择策略,有两种策略:
- 不放第i件物品,那么问题转化为前i-1件物品恰好装入容量为v的背包中所能获得的最大价值,也即
dp[i-1][v]
- 放第i件物品,那么问题转化为前i-1件物品恰好装入容量为
v-w[i]
的背包中所能获得的最大价值,即dp[i-1][v-w[i]]+c[i]
因此状态转移方程为 d p [ i ] [ v ] = m a x { d p [ i − 1 ] [ v ] , d p [ i − 1 ] [ v − w [ i ] ] + c [ i ] } ( 1 ≤ i ≤ n , w [ i ] ≤ v ≤ V ) dp[i][v]=max\left \{ dp[i-1][v] , dp[i-1][v-w[i]]+c[i] \right \}\left (1\le i\le n,w[i]\le v \le V \right ) dp[i][v]=max{dp[i−1][v],dp[i−1][v−w[i]]+c[i]}(1≤i≤n,w[i]≤v≤V);
边界为 d p [ 0 ] [ v ] = 0 ( 0 ≤ v ≤ V ) dp[0][v]=0\left (0\le v\le V \right ) dp[0][v]=0(0≤v≤V)(即前0件物品放入任何容量为v的背包中都只能获得价值0)。
注意到dp[i][v]
只与之前的状态dp[i-1][]
有关,所以可以枚举i从1到n,v从0到V,通过边界来递推出整个dp数组。由于dp[i][v]
表示的是恰好为v的情况,所以要枚举
d
p
[
n
]
[
v
]
(
0
≤
v
≤
V
)
dp[n][v]\left(0\le v\le V \right )
dp[n][v](0≤v≤V),取其最大值才是最后的结果。时间复杂度为
O
(
n
V
)
O(nV)
O(nV)
for(int i = 1; i <= n; i++){ //前i件物品
for(int v = w[i]; v <= V; v++){ //容量至少为w[i],最大为V
dp[i][v]=max(dp[i-1][v],dp[i-1][v-w[i]]+c[i]);
}
}
优化空间复杂度:注意到状态转移方程中计算dp[i][v]时总是只需要dp[i-1][v]左侧部分的数据,且当计算dp[i+1][]部分时,dp[i-1]的数据又完全又不到了(只需要用到dp[i][]),因此不妨直接开一个一维数组dp[v]
(即把第一维省去),枚举方向改变为i从1到n,v从V到0(逆序!),状态转移方程改变为
d
p
[
v
]
=
m
a
x
{
d
p
[
v
]
,
d
p
[
v
−
w
[
i
]
+
c
[
i
]
}
(
1
≤
i
≤
n
,
w
[
i
]
≤
v
≤
V
)
dp[v]=max\left \{ dp[v] , dp[v-w[i]+c[i] \right \}\left (1\le i\le n,w[i]\le v \le V \right )
dp[v]=max{dp[v],dp[v−w[i]+c[i]}(1≤i≤n,w[i]≤v≤V)。注意v的枚举顺序是从右往左,这样的技巧称为滚动数组,优化的空间复杂度为
O
(
V
)
O(V)
O(V)
for(int i = 1; i <= n; i++){
for(int v = V; v >= w[i]; v--){
dp[v]=max(dp[v],dp[v-w[i]+c[i]);
}
}
特别说明:如果是二维数组存放,v的枚举是顺序还是逆序都无所谓;如果使用一维数组存放,则v的枚举必须是逆序。
完全背包问题
有n件物品,每件物品的单件重量为w[i],价值为c[i]。现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都有无穷件。
和01背包一样,令dp[i][v]
表示前i件物品恰好装入容量为v的背包中所能获得的最大价值。考虑对第i件物品的选择策略,有两种策略:
- 不放第i件物品,那么
dp[i][v] = dp[i-1][v]
,这一步和01背包一样 - 放第i件物品,并不是转移到
dp[i-1][v-w[i]]
这个状态,而是转移到dp[i][v-w[i]]
,因为放了第i件物品后还可以继续放第i件物品,直到第二维的v-w[i]
无法保持大于等于0为止。
状态转移方程为 d p [ i ] [ v ] = m a x { d p [ i − 1 ] [ v ] , d p [ i ] [ v − w [ i ] ] + c [ i ] } ( 1 ≤ i ≤ n , w [ i ] ≤ v ≤ V ) dp[i][v]=max\left \{ dp[i-1][v] , dp[i][v-w[i]]+c[i] \right \}\left (1\le i\le n,w[i]\le v \le V \right ) dp[i][v]=max{dp[i−1][v],dp[i][v−w[i]]+c[i]}(1≤i≤n,w[i]≤v≤V)
边界为 d p [ 0 ] [ v ] = 0 ( 0 ≤ v ≤ V ) dp[0][v]=0(0\le v \le V) dp[0][v]=0(0≤v≤V)
一维形式的状态转移方程为 d p [ v ] = m a x { d p [ v ] , d p [ v − w [ i ] + c [ i ] } ( 1 ≤ i ≤ n , w [ i ] ≤ v ≤ V ) dp[v]=max\left \{ dp[v] , dp[v-w[i]+c[i] \right \}\left (1\le i\le n,w[i]\le v \le V \right ) dp[v]=max{dp[v],dp[v−w[i]+c[i]}(1≤i≤n,w[i]≤v≤V)
边界为 d p [ 0 ] [ v ] = 0 ( 0 ≤ v ≤ V ) dp[0][v]=0(0\le v \le V) dp[0][v]=0(0≤v≤V)
写成一维形式后和01背包完全相同,唯一区别在于这里v的枚举顺序是正向枚举,而01背包的一维形式中v必须是逆向枚举。
for(int i = 1; i <= n; i++){
for(int v = w[i]; v <= V; v++){
dp[v]=max(dp[v],dp[v-w[i]+c[i]);
}
}
总结
(1)最大连续子序列和
令状态dp[i]
表示以A[i]
作为末尾的连续序列的最大和
(2)最长不下降子序列(LIS)
令dp[i]
表示以A[i]
结尾的最长不下降子序列长度
(3)最长公共子序列(LCS)
令dp[i][j]
表示字符串A的i
号位和字符串B的j
号位之前的LCS长度
(4)最长回文子串
令dp[i][j]
表示S[i]
至S[j]
所表示的子串是否是回文子串
(5)数塔DP
令dp[i][j]
表示从第i行第j个数字出发的到达最底层的所有路径中能得到的最大和
(6)DAG最长路
令dp[i]
表示从i号顶点出发能获得的最长路径长度
(7)01背包
令dp[i][v]
表示前i件物品恰好装入容量为v的背包中所能获得的最大价值
(8)完全背包
令dp[i][v]
表示前i件物品恰好装入容量为v的背包中所能获得的最大价值
-
1~4:当题目与序列或者字符串(记为A)有关时,可以考虑把状态设计成下面两种形式,然后根据端点特点去考虑状态转移方程。其中XXX均为原问题的表述
- 令
dp[i]
表示以A[i]
结尾(或开头)的XXX。 - 令
dp[i][j]
表示A[i]
至A[j]
区间的XXX。
- 令
-
5~8:它们的状态设计都包含了某种“方向”的意思,那么分析题目中的状态需要几维来表示,然后对其中的每一维采取下面的某一个表述
- 恰好为i
- 前i
在每一维的含义设置完毕后,dp数组的含义可以设置成“令dp数组表示恰好为i(或前i)、恰好为j(或前j)…的XXX”,接下来通过端点的特点去考虑状态转移方程。