《算法笔记》学习记录 Part 5 动态规划

第十一章 动态规划专题


11.1 动态规划的递归写法和递推写法

动态规划(Dynamic Programming,DP)是一种用来解决一类最优化问题的算法思想。简单来说,动态规划将一个复杂的问题分解成若干个子问题,通过综合子问题的最优解来得到原问题的最优解。需要注意的是,动态规划会将每个求解的子问题的解记录下来,这样当下一次碰到同样的子问题时,就可以直接使用之前的记录的结果,而不是重复计算

一般可以使用递推或者递归的写法来实现动态规划,其中递归写法在此处又称作记忆化搜索

11.1.1 动态规划的递归写法

以斐波那契数列为例   

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)。动态规划通过记录重叠子问题的解,来使下次碰到相同的子问题时直接使用之前记录的结果,以此避免大量重复的计算

11.1.2 动态规划的递推写法

数塔问题

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).

一个问题必须拥有最有重叠子结构和最优子结构,才能使用动态规划解决

11.2 最大连续子序列和

给定一个数字序列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;i<n;i++){
		scanf("%d",&A[i]);
	}
	//边界
	dp[0] = A[0];
	for(int i=1;i<n;i++){
		//状态转移方程
		dp[i] = max(A[i],dp[i-1]+A[i]);
	}
	//dp[i]存放以A[i]结尾的连续序列的最大和,需要遍历i得到最大的才是结果
	int k = 0;
	for(int i=1;i<n;i++){
		if(dp[i]>dp[k])
			k=i;
	}
	printf("%d",dp[k]);
}

此处顺便介绍无后效性的概念,状态的无后效性 是:当前状态记录了历史信息,一旦当前状态确定,就不会再改变,且未来的决策只能在已有的一个或若干个状态的基础上进行,历史信息只能通过已有的状态去影响未来的决策。

11.3 最长不下降子序列(LIS)

最长不下降子序列(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<n;i++){
		scanf("%d",&A[i]);
	}
	int ans = -1; //记录最大的dp[i]
	for(int i=1;i<=n;i++){
		dp[i]=1;		//边界初始条件(即先假设每个元素自成一个子序列)
		for(int j=1;j<i;j++){
			if(A[i]>=A[j] && (dp[j]+1 > dp[i]) ){
				dp[i] = dp[j] + 1;//状态转移方程,用以更新dp[i]
			}
		}
		ans = max(ans,dp[i]);
	}
	printf("%d",ans);
}

11.4 最长公共子序列(LCS)

最长公共子序列(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]);
}

11.5 最长回文子串

问题:给出一个字符串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<len;i++){
		dp[i][i]=1;
		if(i<len-1){
			if(S[i]==S[i+1]){
				dp[i][i+1] = 1;
				ans = 2;		//初始化时注意当前最长回文子串
			}
		}
	}
	//状态转移方程
	for(int L=3;L<=len;L++){	//枚举子串的长度
		for(int i=0;i+L-1<len;i++){	//枚举子串的起始端点
			int j = i+L-1;	//子串的右端点
			if(S[i]==S[j] && dp[i+1][j-1]==1){
				dp[i][i] = 1;
				ans = L;		//更新最长回文子串长度
			}
		}
	}
	printf("%d",ans);
}

时间复杂度为O(n2),还有复杂度为O(nlogn)的二分+字符串ash做法,复杂度为O(n)的Manacher做法

11.6 DAG最长路径

问题一:给定一个有向无环图,怎么求解整个图的索欧路径中权值之和最大的那条

令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<n;j++){
		if(G[i][j] != INF){
			dp[i] = max(dp[i],G[i][j]+DP(i));
		}
	}
	return dp[i];
}

由于出度为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;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]
}
//调用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;j++){
		if(G[i][j] != INF){
			dp[i] = max(dp[i],DP(j)+G[i][j]);
		}
	}
	return dp[i];
}

11.7 背包问题

11.7.1 多阶段动态规划问题

有一类动态规划可解的问题,它可以描述成若干个有序的阶段,且每个阶段的状态只和上一个阶段的状态有关,称为多阶段动态规划问题。

11.7.2 01 背包问题

问题:有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);
}
11.7.3 完全背包问题

有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 )

        边界:dp[0][v] = 0  (0<=v<=V)
改写成一维形式 状态转移方程: dp[v] = max { dp[v] , dp[ v - w[i] + c[i] ]      当(1<=i<=n,w[i]<=v<=V )
                      边界:dp[v] = 0 (0<= v <= V)
这里的v的枚举顺序是正向枚举(01是逆向枚举)
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]]);
		}
	}






  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

猪突猛进!

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值