第十一章 动态规划专题
动态规划(Dynamic Programming,DP)是一种用来解决一类最优化问题的算法思想。简单来说,动态规划将一个复杂的问题分解成若干个子问题,通过综合子问题的最优解来得到原问题的最优解。需要注意的是,动态规划会将每个求解的子问题的解记录下来,这样当下一次碰到同样的子问题时,就可以直接使用之前的记录的结果,而不是重复计算
一般可以使用递推或者递归的写法来实现动态规划,其中递归写法在此处又称作记忆化搜索
以斐波那契数列为例
int F(int n){
if(n==0 || n==1) return 1;
else return F(n-1)+F(n-2);
}
为了避免重复计算,可以开一个一维数组dp,用以保存已经计算过的结果,其中dp[n]记录F[n]的结果,并用dp[n]=-1表示F(n)当前还没有被计算过
int dp[MAXN];
int F(int n){
if(n==0 || n==1) return 1;
if(dp[n] != -1) return dp[n];
else{
dp[n] = F(n-1)+F(n-2);
return dp[n];
}
}
复杂度从指数级别降低到了线性级别,通过这个例子可以引申出一个问题,如果一个问题可以被分解为若干个子问题,且这些子问题会重复出现,那么就称这个问题拥有重叠子问题(Overlapping Subproblems)。动态规划通过记录重叠子问题的解,来使下次碰到相同的子问题时直接使用之前记录的结果,以此避免大量重复的计算
dp[1][1] = max(dp[2][1],dp[2][2]) + f[1][1]
dp[i][j] = max ( dp[i+1][j] , dp[i+1][j+1] ) + f[i][j] ,把dp[i][j]称为问题的状态,这个式子称为状态转移方程
数塔的最后一层dp值总是等于元素本身,即dp[n][j] == f[n][j] (1<=j<=n),把这种可以直接确定其结果的部分称为边界
而动态规划的递归写法总是从边界出发,通过状态转换方程扩散到整个dp数组
void num_tower(){
int f[MAXN][MAXN],dp[MAXN][MAXN];
int n;
scanf("%d",&n);
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
scanf("%d",&f[i][j]); //输入数塔
}
}
//边界
for(int j=1;j<=n;j++){
dp[n][j] == f[n][j];
}
//从第n-1层不断往上计算dp[i][j]
for(int i=n-1;i>=1;i--){
for(int j=1;j<=i;j++){
//状态转移方程
dp[i][j] = max(dp[i+1][j],dp[i+1][j+1])+f[i][j];
}
}
printf("%d\n",dp[1][1]);
}
如果一个问题的最优解可以由其子问题的最优子结构有效的构造出来,那么称这个问题拥有最优子结构(Optimal Substructure).
一个问题必须拥有最有重叠子结构和最优子结构,才能使用动态规划解决
给定一个数字序列A1,A2,……,An,求 i , j (1<=i<=j<=n) , 使得Ai+……Aj最大,输出这个最大和
令dp[i]表示以A[i]作为末尾的连续序列的最大和(这里是说A[i]必须作为连续序列的末尾)
状态转移方程:dp[i] = max{ A[i] , dp[i-1] + A[i] }
边界为dp[0] = A [0] ,由此从小到大枚举i,即可得到整个 dp 数组,接着输出dp[0] , dp[1] , .... dp[n-1]中的最大值即为最大连续子序列的和
void max_seq(){
int A[MAXN],dp[MAXN];
int n;
scanf("%d",&n);
for(int i=0;idp[k])
k=i;
}
printf("%d",dp[k]);
}
此处顺便介绍无后效性的概念,状态的无后效性 是:当前状态记录了历史信息,一旦当前状态确定,就不会再改变,且未来的决策只能在已有的一个或若干个状态的基础上进行,历史信息只能通过已有的状态去影响未来的决策。
最长不下降子序列(Longest Increasing Sequence,LIS):
在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是不下降的(非递减的)。
令dp[i]表示以A[i]结尾的最长不下降子序列长度(和最大连续子序列和问题一样,以A[i]结尾是强制的要求)
状态转移方程: dp[i] = max { 1 , dp[j] +1 } ( j = 1 , 2 , .... , i-1 && A[j] < A[i] )
边界:dp[i] = 1 ( 1<= i <= n )
显然,dp[i]只与小于 i 的 j 有关,因此只要让 i 从小到大遍历即可求出整个dp数组
//最长不下降子序列LIS
const int N = 100;
void LIS(){
int A[N],dp[N];
int n;
scanf("%d",&n);
for(int i=0;i=A[j] && (dp[j]+1 > dp[i]) ){
dp[i] = dp[j] + 1;//状态转移方程,用以更新dp[i]
}
}
ans = max(ans,dp[i]);
}
printf("%d",ans);
}
最长公共子序列(Longest Common Subsequence,LCS):
给定两个字符串(或数字序列)A和B,求一个字符串,使得这个字符串是A和B的最长公共部分(子序列可以不连续)。
令 dp[i][j ]表示字符串A的 i 号位和B的 j 号位之前的LCS长度(下标从1开始)
状态转移方程: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] } , 当 A[i] != B[j]
边界:dp[i][0] = dp[0][j] = 0 ; ( 0<=i<=n , 0<=j<=m )
这样dp[i][j]只与其之前的状态有关,由边界出发就可以得到整个dp数组,最终dp[n][m]就是需要的答案 ,时间复杂度为O(mn).
void LCS(){
char A[N],B[N];
int dp[N][N];
int n;
gets(A+1); //从下标为1开始读入
gets(B+1);
int lenA = strlen(A+1);
int lenB = strlen(B+1);
//边界
for(int i=0;i<=lenA;i++){
dp[i][0] = 0;
}
for(int j=0;j<=lenB;j++){
dp[0][j] = 0;
}
//状态转移方程
for(int i=1;i<=lenA;i++){
for(int j=1;j<=lenB;j++){
if(A[i] == B[j]){
dp[i][j] = dp[i-1][j-1]+1;
}else{
dp[i][j] = max(dp[i-1][j],dp[i][j-1]);
}
}
}
//dp[lenA][lenB]是答案
printf("%d",dp[lenA][lenB]);
}
问题:给出一个字符串S,求S的最长回文子串的长度
样例:“PATZJUJZTACCBCC”的最长回文子串为“ATZJUJZTA”,长度为9
令dp[i][j] 表示S[i] 至 S[j] 所表示的子串是否是回文串,是则为1,不是为0
状态转移方程:dp[i][j] = dp[i+1][j-1] , 当 S[i] = S[j]时
dp[i][j] = 0 , 当S[i] != S[j]时
边界:dp[i][i] = 1 , dp[i][i+1] = (S[i] == S[i+1] ) ? 1 : 0
根据递推写法从边界出发的原理,注意到边界表示的是长度为1和2的子串,且每次转移时都对子串的长度减了1,因此不妨考虑按子串的长度和子串的初始位置进行枚举,即第一遍将长度为3的子串的dp值全部求出,第二遍通过第一遍结果计算出长度为4的子串的dp值……这样就可以避免状态无法转移的问题。
//最长回文子串
void LPS(){
char S[MAXN];
int dp[MAXN][MAXN];
gets(S);
int len = strlen(S),ans=1;
memset(dp,0,sizeof(dp));
//边界
for(int i=0;i
时间复杂度为O(n2),还有复杂度为O(nlogn)的二分+字符串ash做法,复杂度为O(n)的Manacher做法
问题一:给定一个有向无环图,怎么求解整个图的索欧路径中权值之和最大的那条
令dp[i] 表示从 i 号顶点出发能获得的最长路径长度
dp[i] = max { dp[j] + length[i->j] | (i,j)∈E }
可以按照逆拓扑序列的顺序求解dp数组,也可以用递归
//使用邻接矩阵存储
int DP(int i){
if(dp[i]>0) return dp[i]; //dp[i]已计算得到
for(int j=0;j
由于出度为0的顶点出发的最长路径长度为0,因此边界为这些顶点的dp值(0)。但具体实现中不妨对整个dp数组初始化为0,这样dp函数当前访问的顶点i的出度为0时就会返回dp[i]=0 ( 以此作为dp的边界),而出度不是0的顶点则会递归求解,递归过程中遇到已经计算过的顶点则直接返回对应的dp值,于是从程序逻辑上按照了拓扑序列的顺序进行。
如果知道最长路径具体是哪条呢?
开一个int型choice数组记录最长路径上顶点的后继顶点,这样就可以像Dijkstra算法中那样求解最长路径了,只不过由于choice数组存放的是后继结点,因此使用迭代即可。如下,如果最终可能有多条最长路径,将choice数组该为vector类型的数组即可
int DP(int i){
if(dp[i]>0) return dp[i]; //dp[i]已计算得到
for(int j=0;jdp[i]){ //可以获得更长的路径
dp[i] = temp; //覆盖dp[i]
choice[i]=j; //i号顶点的后继顶点是j
}
}
}
return dp[i]; //返回计算完毕的dp[i]
}
//调用printPath前需要先得到最大的dp[i],然后将i作为路径起点传入
void printPath(int i){
printf("%d",i);
while(choice[i]!=-1){
i = choice[i];
printf("->%d",i);
}
}
问题二:固定终点,求DAG的最长路径长度
假设规定的终点为T,那么可以令dp[i]表示从i号顶点出发到达终点T能获得的最长路径长度。
动态转换方程:dp[i] = max { dp[j] + length[ i -> j ] | (i,j)∈E }
动态转换方程和上一个问题是一样的,两个问题的区别在于边界。在第一个问题中没有固定终点,因此所有出度为0的顶点的dp值为0是边界。但是在这个问题里固定了终点,因此边界应当为dp[T] = 0.那么可不可以像之前的做法那样,对整个dp数组赋值为0?不行,由于从某些顶点出发可能无法到达终点T,因此如果按之前的做法会得到错误的结果(例如出度为0的顶点会得到0),这从含义上来说是不对的。合适的做法是初始化dp数组为一个负的大数,来保证“无法到达终点”的含义得意表达(INF);然后设置一个vis数组来表示顶点是否已经被计算
int DP(int i){
if(vis[i]) return dp[i]; //dp[i]已经计算到
vis[i] = true;
for(int j=0;j
有一类动态规划可解的问题,它可以描述成若干个有序的阶段,且每个阶段的状态只和上一个阶段的状态有关,称为多阶段动态规划问题。
问题:有n件物品,每件物品的重量是w[i],价值为c[i]。现在有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都只有 1 件。
令dp[i][v]表示前i件物品(1<=i<=n , 0<=v<=V ) 恰好装入容量为v的背包所能获得的最大价值
考虑对第 i 件物品的选择策略,有两种策略:
策略一:不放入第i件物品,那么问题转化为前 i-1 件物品恰好装入容量v的背包中所能获得的最大价值,即dp[i-1][v]
策略二:放入第i件物品,那么问题转化为前 i-1 件物品恰好装入容量为 v - w[i] 的背包中所能获得的最大价值,
即 dp[i-1] [v-w[i]+c[i] ]
状态转移方程:dp[i][v] = max { dp[i-1][v] , dp[i-1][v - w[i] + c[i] ] } 当 ( 1<= i <= n , w[i] <= v <= V)
注意到dp[i][v]只与之前的状态dp[i-1][]有关,所以可以枚举 i 从1到n,v从0到V,通过 边界 dp[0][v] = 0 (0<=v<=V) (即前0件物品放入任何容量v的背包中都能获得价值0)就可以吧整个dp数组递推出来。而由于dp[i][v]表示的是恰好为v的情况,所以需要枚举dp[n][v] (0<=v<=V) , 取其最大值才是最后的结果。
for(int i=1;i<=n;i++){
for(int v=w[i];v<=V;v++){
dp[i][v] = max(dp[i-1][v],dp[i-1][v-w[i]+c[i]]);
}
}
时间复杂度和空间复杂度都是O(nV),其中时间复杂度已经无法再优化,但是空间复杂度可以再优化,只开一个一维数组dp[v],枚举方向改变从i为1到n,v从V到0
状态转移方程:dp[v] = max { dp[v] , dp[ v-w[i] + c[i] ) 当(1<=i<=n , w[i]<=v<=V )
for(int i=1;i<=n;i++){
for(int v=V;v>=w[i]lv--){
dp[v] = max(dp[v],dp[v-w[i]+c[i]]);
}
}
const int maxn = 100; //物品最大件数
const int maxv = 1000; //V的上限
int w[maxn],c[maxn],dp[maxv];
void bag01(){
int n,V;
scanf("%d%d",&n,&V);
for(int i=1;i<=n;i++){
scanf("%d",&w[i]);
}
for(int i=1;i<=n;i++){
scanf("%d",&c[i]);
}
//边界
for(int v=0;v<=V;v++){
dp[v]=0;
}
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]]);
}
}
//寻找dp[0...V]中最大即为答案
int max=0;
for(int v=0;v<=V;v++){
if(dp[v]>max){
max = dp[v];
}
}
printf("%d\n",max);
}
有n种物品,每种物品的单件重量为w[i] ,价值为c[i] 。现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都有无穷件。
完全背包和01背包的区别在于:完全背包的物品数量每种有无穷件,选取物品时对同一种物品可以选1件,选2件……只要不超过容量V就行,而01背包的数量物品每种只有一件。
同样令dp[i][v]表示前i件物品恰好放入容量为v的背包中能获得的最大价值。同样两种策略
策略一:不放入第 i 件物品,那么dp[i][v] = dp[i-1][v] (和01背包一样)
策略二:放入第 i 件物品,这里处理和01背包不一样,因为01背包的每个物品只能选择一个,因此选择放入第i件物品就意味着必须转移到dp[i-1][v-w[i] ] 这个状态,但完全背包不同,完全背包如果选择放第 i 件物品之后并不是转移到dp[i-1][v-w[i] ],而是转移到dp[i][v-w[i] ],这是因为每种物品可以放任意件(在容量限制内)。
得到 状态转移方程:dp[i][v] = max { dp[i-1][v] , dp[i][ v - w[i] + c[i] ] 当(1<=i<=n , w[i]<=v<=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]]);
}
}