郑州大学2024年寒假训练 Day6:动态规划

这些题都很典型,以至于做起来感觉有点基础了不过动态规划的题太灵活了,新了就容易变成典型,动态规划更多偏向于思想,理论性的东西有也很重要,但是很抽象,做题的时候更是直接内化到思路里了。讲动态规划题的时候基本先说一下状态怎么设,状态转移方程是啥,最多再说一下边界是什么,没人会探讨满不满足动态规划的特性。

能用动态规划处理的问题需要满足三个性质:子问题重叠性,无后效性,最优子结构性质。拿01背包问题举例,01背包问题的题目大概长这样:有 n n n 个物品,每个物品有重量 w w w 和价值 v v v,问在不超过总重 W W W 的前提下最多能拿多少价值的物品。我们设只选取前 i i i 件物品,达到重量 j j j 的最大价值为 d p [ i ] [ j ] dp[i][j] dp[i][j]

子问题重叠性指的就是一个问题可以拆解成多个相同的子问题结构,比如这里的背包,选取/不选取第 i i i 件物品达到重量 j j j 的最大价值 d p [ i ] [ j ] dp[i][j] dp[i][j] 可以由 d p [ i − 1 ] [ j − w i ] + v i dp[i-1][j-w_i]+v_i dp[i1][jwi]+vi 或者 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j] 推来,而这两个子问题与原问题求解方式是一致的。

无后效性指的是你在求解一个子问题时无论做出什么选择,都不会影响到后续求解。比如这里的背包,你在看第 i i i 件物品的时候,无论选不选第 i i i 件物品都不会对后续求解有影响,因为你不会再看这件物品了。一个动态规划问题根据你怎么设状态就会出现后效性的问题,比如如果背包去除掉第二维的重量,你在选取物品的时候就有可能对后续选择造成影响,比如你前面选了一个很重的物品,导致后面大价值的物品选不到了,一般这时候会选择重新设状态或者多加一维。

最优子结构问题指的是一般动态规划问题求解的都是最优是多少的问题,而求解一个问题的最优答案,需要子问题也是最优答案,从最优答案推到最优答案。比如背包问题 d p [ i ] [ j ] dp[i][j] dp[i][j] d p [ i − 1 ] [ j − w i ] dp[i-1][j-w_i] dp[i1][jwi] 或者 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j] 推来时,需要这两个结果也是最优的,否则就可以找到更优的 d p [ i − 1 ] [ j − w i ] dp[i-1][j-w_i] dp[i1][jwi] 或者 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j],算出来的结果就比原来的大。

设出状态,并且使得状态的转移满足上面的三个性质,同时时间复杂度能够接受,就是动态规划问题要解决的。状态相当于描述一个局面, d p [ 状态 ] dp[状态] dp[状态] 表示的就是这个状态下的最优答案,比如背包的 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示的就是 使用前 i i i 个物品达到重量 j j j 的最优答案(最大价值)。描述出局面后,需要转移到其他局面(或者从其他局面转移到这个局面),通过枚举所有可能的转移方式从其他局面转移到当前局面,算出答案的式子就是状态转移方程,比如背包里的 d p [ i ] [ j ] = m a x { d p [ i − 1 ] [ j − w i ] + v i , d p [ i − 1 ] [ j ] } dp[i][j]=max\{dp[i-1][j-w_i]+v_i,dp[i-1][j]\} dp[i][j]=max{dp[i1][jwi]+vi,dp[i1][j]} ,状态 ( i , j ) (i,j) (i,j) 可以由 ( i − 1 , j − w i ) , ( i − 1 , j ) (i-1,j-w_i),(i-1,j) (i1,jwi),(i1,j) 转移而来,前者转移来相当于拿上第 i i i 件物品,答案就是 d p [ i − 1 ] [ j − w i ] + v i dp[i-1][j-w_i]+v_i dp[i1][jwi]+vi ,后者是不拿,答案是 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j]。边界情况就是 d p [ 0 ] [ 0 ] = 0 dp[0][0]=0 dp[0][0]=0,表示什么都不拿,重量为 0 0 0 的最大价值是 0 0 0

总结,子问题重叠性,无后效性,最优子结构性质是能够正确使用动态规划的三个基本条件,状态,状态转移方程,边界情况是动态规划的三个组成部分。

实际上动态规划经常被人说像递推,或者是记忆化搜索,确实是像。


A - 斐波那契数列

思路:

这个应该不能叫动态规划吧。。。它的状态转移方程只能由一种局面推来,这就是普通的递推。

f [ i ] f[i] f[i] 表示斐波那契数列第 i i i 个数。状态转移方程是 f [ i ] = f [ i − 1 ] + f [ i − 2 ] f[i]=f[i-1]+f[i-2] f[i]=f[i1]+f[i2],边界条件是 f [ 1 ] = f [ 2 ] = 1 f[1]=f[2]=1 f[1]=f[2]=1

code:

#include <iostream>
#include <cstdio>
using namespace std;
typedef long long ll;
const int maxn=35;

int n,x;
ll a[maxn];

int main(){
	cin>>n;
	a[1]=a[2]=1;
	for(int i=3;i<=30;i++)
		a[i]=a[i-1]+a[i-2];
	while(n--){
		cin>>x;
		cout<<a[x]<<endl;
	}
	return 0;
}

B - 台阶问题

思路:

因为是求和,所以不用考虑子结构最优性质。考虑到我们在第 i i i 级台阶的时候可以有前 k k k 级台阶的任何一级过来。因此设 d p [ i ] dp[i] dp[i] 表示走到第 i i i 级台阶的方法数,那么 d p [ i ] = ∑ j = i − k i − 1 d p [ j ] dp[i]=\sum_{j=i-k}^{i-1}dp[j] dp[i]=j=iki1dp[j],边界就是 d p [ 0 ] = 1 dp[0]=1 dp[0]=1(底部相当于第0级台阶)。

code:

#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=1e5+5;
typedef long long ll;
const ll mod=100003;

int n,k;
ll dp[maxn];

int main(){
	cin>>n>>k;
	dp[0]=1;
	for(int i=1;i<=n;i++){
		for(int j=max(0,i-k);j<i;j++)
			dp[i]=(dp[i]+dp[j])%mod;
	}
	cout<<dp[n];
	return 0;
}

C - 最长上升子序列

思路:

这是一类经典问题,最长上升子序列(LIS,Longest Increasing Subsequence)。上升,下降,不下降,不上升这几个问题是一样的。这边说说 O ( n 2 ) O(n^2) O(n2) 的动态规划思想,这里有贪心+二分的 O ( n l o g n ) O(nlogn) O(nlogn) 的做法

d p [ i ] dp[i] dp[i] 表示以位置 i i i 的元素结尾的最长上升子序列的长度。那么它可以由 1 ≤ k < i 1\le k\lt i 1k<i 中满足 a i > a k a_i\gt a_k ai>ak 的位置推过来,所以算到位置 d p [ i ] dp[i] dp[i] 的时候,枚举 k k k 并尝试更新答案即可。边界条件可以看作是什么都不选的序列长度 d p [ 0 ] = 0 dp[0]=0 dp[0]=0,这样每个位置至少可以从位置 0 0 0 推来,或者可以直接看作是每个位置只选它自己的序列长度。

上面的讲解的博客提到了一个比较重要的 Dilworth 定理:对于一个偏序集,最少链划分等于最长反链长度,这里是Dilworth 定理证明

code:

#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=5005;

int n,a[maxn],dp[maxn],maxx;

int main(){
	cin>>n;
	for(int i=1;i<=n;i++){
		cin>>a[i];
		dp[i]=1;//只选它自己,最小长度为1
		for(int j=1;j<i;j++)
			if(a[i]>a[j])
				dp[i]=max(dp[i],dp[j]+1);
		maxx=max(maxx,dp[i]);
	}
	cout<<maxx;
	return 0;
}

D - 数字三角形

思路:

在这里插入图片描述
这么看三角形不太好映射到数组上,所以我们把元素都靠左,得到:

7 
3 8
8 1 0
2 7 4 4
4 5 2 6 5

这样可以很容易用二维数组存储,对某个位置 ( i , j ) (i,j) (i,j) 上的数,可以由 ( i − 1 , j − 1 ) (i-1,j-1) (i1,j1) ( i − 1 , j ) (i-1,j) (i1,j) 上的数推理过来。设 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示到位置 ( i , j ) (i,j) (i,j) 为止路径上的和最大是多少。位置 ( i , j ) (i,j) (i,j) 的最大值只能由位置 ( i − 1 , j − 1 ) (i-1,j-1) (i1,j1) ( i − 1 , j ) (i-1,j) (i1,j) 的最大值推过来,而这两个位置同理也可以由这种思想向上推。这个状态转移同时满足子问题重叠,无后效性和子问题重叠三个条件,因此可以使用动态规划。

然后我们在推理的时候发现,我们在推理第 i i i 行的时候,只关心第 i − 1 i-1 i1 行的每个位置的最大值,因此我们可以只用两个一维数组,而不需要两维数组。进一步观察发现,我们算第 j j j 个位置的最大值的时候,只需要看上一行的第 j j j j − 1 j-1 j1 位置位置的值。所以我们直接在上一行的值的数组中计算下一行第 j j j 个位置的 d p dp dp 值,然后直接存放在这个数组的第 j j j 个位置上, j j j 从大到小枚举就可以保证下一次计算用到的值不会被覆盖,枚举结束后上一行的值就被覆盖为了这一行的值,这样空间上我们就优化掉了一维。这是个比较常规的优化思路。

code:

#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=1005;

int n,a[maxn],dp[maxn];

int main(){
	cin>>n;
	for(int i=1;i<=n;i++){
		for(int j=1;j<=i;j++)
			cin>>a[j];
		for(int j=i;j>=1;j--)
			dp[j]=max(dp[j],dp[j-1])+a[j];
	}
	int maxx=0;
	for(int i=1;i<=n;i++)
		maxx=max(maxx,dp[i]);
	cout<<maxx<<endl;
	return 0;
}

E - 最大子段和

思路:

这个东西还挺常用的,两种思路,一种优化暴力 O ( n l o g n ) O(nlogn) O(nlogn),一种动态规划 O ( n ) O(n) O(n)

优化暴力的思想很简单,就是枚举右端点,对每个右端点,枚举左端点使得字段和最大。不过这样是 O ( n 3 ) O(n^3) O(n3) 的,考虑优化。瓶颈出现在区间求和和找左端点使得区间和最大,区间求和可以用前缀和来优化,找左端点可以对右端点以前的所有左端点前缀和放进堆里或者set里,取出的第一个就是最小的左端点前缀和,区间和就是最大的。

动态规划的思想是,设以位置 i i i 为结尾的最大字段和是什么,位置 i i i 上的数要么归入到前一个位置的最大子段中,要么自成一段,状态转移方程就是 d p [ i ] = m a x { d p [ i − 1 ] + a [ i ] , a [ i ] } dp[i]=max\{dp[i-1]+a[i],a[i]\} dp[i]=max{dp[i1]+a[i],a[i]}

code:

优化暴力:

#include <iostream>
#include <cstdio>
#include <set>
using namespace std;

int n;

int main(){
	cin>>n;
	set<int> S;
	int maxx=-2e9;
	for(int i=1,t,s=0;i<=n;i++){
		S.insert(s);
		cin>>t;
		s+=t;
		maxx=max(maxx,s-*S.begin());
	}
	cout<<maxx;
	return 0;
}

动态规划:

#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=2e5+5;

int n,a[maxn],dp[maxn];

int main(){
	cin>>n;
	for(int i=1;i<=n;i++)
		cin>>a[i];
	for(int i=1;i<=n;i++)
		dp[i]=max(dp[i-1]+a[i],a[i]);
	int maxx=-2e9;
	for(int i=1;i<=n;i++)
		maxx=max(maxx,dp[i]);
	cout<<maxx;
	return 0;
} 

F - 采药

思路:

万物起源,01背包。这东西超级常用,很多问题都可以划归成背包问题,而背包问题基本都可以划归成01背包。

这里和上面说的背包是一个东西,就是把重量的名字改成了时间,同理可以改成体积等等。设 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示选用前 i i i 个物品凑出时间 j j j 的最大价值。那么使用第 i i i 件物品得到的用时 j j j 有两种选择:

  1. 选第 i i i 件物品,这时从状态 d p [ i − 1 ] [ j − t i ] + v i dp[i-1][j-t_i]+v_i dp[i1][jti]+vi 推过来。
  2. 不选第 i i i 件物品,这时从状态 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j] 推过来。

边界条件就是 d p [ 0 ] [ 0 ] = 0 dp[0][0]=0 dp[0][0]=0 ,也就是什么都不选到达用时0的价值是0。

这个地方可以和上面的数字三角形一样优化掉第一维。我们在选取第 i i i 件物品的时候只看选了前 i − 1 i-1 i1 件物品每个用时的最大价值。对位置 j j j d p dp dp 值,只看上一行的 j j j j − t i j-t_i jti d p dp dp 值,不看后面的,所以覆盖掉后面的 d p dp dp 值也没有关系。因此我们还是从总用时 T T T t i t_i ti 枚举,然后覆盖存储算出来的 d p dp dp 值即可。

code:

#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=1005;

int T,m;

int dp[maxn];

int main(){
	cin>>T>>m;
	for(int i=1,w,v;i<=m;i++){
		cin>>w>>v;
		for(int j=T;j>=w;j--){
			dp[j]=max(dp[j],dp[j-w]+v);
		}
	}
	int maxx=0;
	for(int i=1;i<=T;i++)
		maxx=max(maxx,dp[i]);
	cout<<maxx;
	return 0;
}

G - 装箱问题

思路:

还是01背包问题,只不过我们要求的东西稍有不同。要求剩余体积最小,其实就是求占用的体积最大。背包的时候会算出选出前 i i i 件物品占用体积 j j j 的最大价值,换句话说我们就得到了选出前 i i i 件物品占用体积 j j j 的一种方案。由于不需要记录最大价值,我们直接让 d p [ i ] [ j ] = 1 dp[i][j]=1 dp[i][j]=1 表示存在一种选出前 i i i 件物品占用体积 j j j 的方案,那么我们跑一遍背包之后从 V V V 向前枚举一遍,找到最大的 j j j ,使得 d p [ n ] [ j ] = 1 dp[n][j]=1 dp[n][j]=1 即可,这个 j j j 就是所有方案中占用体积最大的方案。

code:

#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=20005;

int n,V;

bool dp[maxn];

int main(){
	cin>>V>>n;
	dp[0]=true;
	for(int i=1,v;i<=n;i++){
		cin>>v;
		for(int j=V;j>=v;j--)
			dp[j]|=dp[j-v];
	}
	for(int i=V;i>=0;i--)
		if(dp[i]){
			cout<<V-i;
			break;
		}
	return 0;
}

H - 集合 Subset Sums

思路:

这个也常用。因为所有元素都要分到两个集合之一,因此两个集合元素之和是确定的,就是所有元素之和。因此只要其中一个集合的元素和是总和的一半,那么另一个集合的元素和也是一半。只要确定了其中一个集合,就能推出另一个集合,就得到了一种可行的分配方案。

假设总和为 x = n ∗ ( n + 1 ) 2 x=\frac{n*(n+1)}2 x=2n(n+1)。现在问题就变成了在 n n n 元素中选取若干个,使得它们总和为 x 2 \frac x2 2x。一个显然的结论是,如果 x 2 = n ∗ ( n + 1 ) 4 \frac x2=\frac{n*(n+1)}4 2x=4n(n+1) 不是整数,那么就说明不存在任何一种分配方式使得等于 x 2 \frac x2 2x,结果就是 0 0 0,直接特判返回,防止跑dp的时候出错。

x = n ∗ ( n + 1 ) 4 x=\frac{n*(n+1)}4 x=4n(n+1),选取 n n n 个物品的若干个,使得总和为 x x x,这不就是01背包问题么。我们要找占用重量为 x x x 的方案总数。设 d p [ i ] [ j ] dp[i][j] dp[i][j] 为使用前 i i i 个数凑到 j j j 的方案数,假设现在要尝试使用第 i i i 个数(也就是 i i i ),状态转移方程就是 d p [ i ] [ j ] = d p [ i − 1 ] [ j − i ] + d p [ i − 1 ] [ j ] dp[i][j]=dp[i-1][j-i]+dp[i-1][j] dp[i][j]=dp[i1][ji]+dp[i1][j](两种情形的方案数相加)。

我们找到的所有方案数有一半是重复的,比如 1 , 2 , 3 1,2,3 1,2,3 ,我们找第一个集合的方案数为 2 2 2,对应 { 1 , 2 } \{1,2\} {1,2} { 3 } \{3\} {3},另一个集合是 { 3 } \{3\} {3} { 1 , 2 } \{1,2\} {1,2},这两种情况实际上一样的。

用若干个数凑出一个数的思想和这个题很像。

code:

#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=45;

int n,x;
long long dp[maxn*maxn];

int main(){
	cin>>n;
	if(n%4 && (n+1)%4){
		cout<<0;
		return 0;
	}
	x=(n+1)*n/4;
	dp[0]=1;
	for(int i=1;i<=n;i++){
		for(int j=x;j>=i;j--){
			dp[j]+=dp[j-i];
		}
	}
	cout<<(dp[x]>>1)<<endl;
	return 0;
}

I - yyy2015c01 的 U 盘

思路:

题意比较拗口,大体上就是让我们在放入U盘的文件总大小不超过容量 S S S 的前提下,用最大体积尽可能小的文件得到价值 p p p

还是跑01背包,不过需要预先做些处理,我们先用体积尽量小的文件来更新每个占用容量下的最大价值,这样如果某个时刻得到了价值大于等于p的 d p dp dp 值,最小的体积就是当前使用的文件的体积。设 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示使用前 i i i 个物品得到占用容量为 j j j 的最大价值,其余的还是正常的01背包。

code:

#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
const int maxn=1e3+5;
const int inf=1e9;

int n,p,S;
/*
把文件按大小从小到大排序
dp[i]表示使用i容量的最大价值 
依次尝试加入文件,某个时刻得到价值大于等于p的立即停止 
*/

int dp[maxn];

struct docu{
	int sz,val;
	bool operator<(const docu x)const{return sz<x.sz;};
}item[maxn]; 

int main(){
	cin>>n>>p>>S;
	for(int i=1;i<=n;i++)
		cin>>item[i].sz>>item[i].val;
	sort(item+1,item+n+1);
	
	for(int i=1,w,v;i<=n;i++){
		w=item[i].sz;
		v=item[i].val;
		for(int j=S;j>=w;j--){
			dp[j]=max(dp[j],dp[j-w]+v);
			if(dp[j]>=p){
				cout<<w<<endl;
				return 0;
			}
		}
	}
	puts("No Solution!");
	return 0;
}

J - NASA的食物计划

思路:

不是经典的01背包了,因为出现了两个限制条件:重量和体积,而普通的01背包只限制了一个。所以我们这里多开一维限制条件即可。

d p [ i ] [ j ] [ k ] dp[i][j][k] dp[i][j][k] 表示选前 i i i 个物品达到重量为 j j j,体积为 k k k 的最大卡路里。状态转移方程为 d p [ i ] [ j ] [ k ] = m a x { d p [ i − 1 ] [ j − w i ] [ k − v i ] , d p [ i − 1 ] [ j ] [ k ] } dp[i][j][k]=max\{dp[i-1][j-w_i][k-v_i],dp[i-1][j][k]\} dp[i][j][k]=max{dp[i1][jwi][kvi],dp[i1][j][k]}。同理压掉第一维,

code:

#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=55;
const int maxm=405;

int n,H,T;
int dp[maxm][maxm];

int main(){
	cin>>H>>T>>n;
	for(int i=1,x,y,z;i<=n;i++){
		cin>>x>>y>>z;
		for(int j=H;j>=x;j--){
			for(int k=T;k>=y;k--){
				dp[j][k]=max(dp[j][k],dp[j-x][k-y]+z);
			}
		}
	}
	int maxx=0;
	for(int i=1;i<=H;i++){
		for(int j=1;j<=T;j++){
			maxx=max(maxx,dp[i][j]);
		}
	}
	cout<<maxx;
	return 0;
}
  • 5
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值