关于DP入门

通常对于一道题,我从已知信息,未知信息以及所要求的信息进行考虑,已知信息通常是题目输入量,或可以根据题目输入量在可接受范围内预处理出来的信息,如一些前缀和,差分数组,各种数论函数的值及逆元等。

而第二个考虑的则是题目所要求的信息,这时候需要联系已知信息,通过一些结论或者式子判断哪些是我所需要求的未知信息,然后考虑使用什么样的的算法或数据结构可以在可接受时间范围内得到这些信息并存储。

而DP往往就是在求未知信息的过程中,我发现未知信息的边界是我已知的,并且我可以通过未知信息的边界来进行递推,通过上一个解决的问题和获取的信息来辅助下一个决策,并获取有效的信息。而在递推过程中,我并不需要考虑以后的决策对现在所做的决策的影响时,便考虑该题是否可以使用DP。

考虑DP首先考虑阶段,状态如何定义以及决策有哪些。阶段通常是子问题,例如在01背包问题中,dp[i][j]定义为前i个物品占用j空间时的最大价值,i便代表着阶段,当我们求出前i个物品占用j空间的最大价值时,实际上也解决了该阶段的若干子问题,这些子问题的某些特征我们称之为状态,通过定义合适的状态满足我们能做出合理的最优决策。而所谓策略即决策序列若为最优,则一定满足其包含的决策为最优。

通过以上我们可以总结出来:

1.定义状态时需考虑其无后效性的原则

2.定义状态时需保证状态的特征足够做出决策(即能够构造出来一个决策集合满足所有合法决策都在集合中)

然后我们通过一些具体的题型来实践这个过程

CF445A

Boredom

题意:
亚历克斯不喜欢无聊。这就是为什么每当他感到无聊时,他就会想出游戏。一个漫长的冬夜,他想出了一个游戏,决定玩一玩。给定一个由n个整数组成的序列a。玩家可以采取几个步骤。在一个步骤中,他可以选择序列中的一个元素(我们记为a[k])并删除它,这样所有等于a[k] + 1和a[k] - 1的元素也必须从序列中删除。这一步骤将为玩家带来a[k]点。亚历克斯是一个完美主义者,所以他决定得到尽可能多的分数。帮助他。

1 <= n <= 1e5 ,1 <= a[i] <= 1e5

考虑这道题,若以枚举n的i作为阶段,那么状态就比较难定义了,是定义为当前a[i]的取值吗?若是这样定义,空间复杂度将达到n^2的级别。刚好a[i]的值域和n相同,并且考虑到若以a[i]作为阶段,那么状态也很好定义,为此时a[i]的值,以及a[i]出现的次数,而a[i]出现的次数我们可以提前预处理出来,且该量不受状态转移的影响,因此我们可以得出方程

dp[i] = max(dp[i+1],dp[i+2]+i*cnt[i])

这里dp[i+1]所代表的决策是不通过删i与i+1和i+2得分,而dp[i+2]+i*cnt[i]所代表的则是通过删i与i+1和i+2得分

倒序枚举便可,问题边界分别是dp[0]与dp[max(a[i])]
输出结果为dp[0]

代码如下:

#include<bits/stdc++.h>
using namespace std;
#define int long long
int const maxn = 2e5+10;
int a[maxn],dp[maxn],cnt[maxn];

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

这一题很简单,但是对于阶段与状态的定义确实很好的考查与体现,有时我们只需改变一下定义便可使时间复杂度,空间复杂度以及代码难度得到优化

接下来我们看几道背包问题

P1048 [NOIP2005 普及组] 采药

该题题意就是01背包,作为最简单的一类背包问题,其特征为每个物品数量只有一个,只有选或不选两种选择,所选物品总体积不能超过Vmax,所选物品总价值最大

考虑将枚举每个物品作为阶段,状态有已选物品的个数以及当前的容积还剩多少
易得出以下方程

dp[i][j] = max(dp[i][j],dp[i-1][j - v[i]] + w[i])

时间复杂度O(n*Vmax)

这里 j <- Vmax to v[i]

因为每个物品只能选一次,若正序枚举,则存在该阶段决策时也用到了在该阶段中之前的决策,这就意味着选了多个该物品,因此倒序枚举

考虑优化该dp方程的空间复杂度,发现对于每个i,所需要的阶段信息只有i-1的,即阶段之间的信息传递只包含两个阶段。考虑将第一维设为2,并在每个阶段结束后,将i^=1。如下:

dp[i][j] = max(dp[i][j],dp[i^1][j-v[i]]+w[i])

i^=1;

这种优化方式叫滚动数组,也算DP里一种常用的技巧,在01背包中人们常常省去第一维,这种方法与以上写法等价,但在滚动数组的长度大于2时,i的每次更新方式应变为i = (i+1)%n。

P1616 疯狂的采药

该题就是完全背包模板,完全背包要求基本跟01背包一样,唯一不同在于每个物品的数量不做限制

读者一定发现了这样的条件意味着在同一阶段中此时的决策可以利用同一阶段中之前做过的决策,所以可以直接正序枚举j即可

#include<bits/stdc++.h>
using namespace std;
const int N=1e4+5,M=1e7+5;
long long dp[M];
int a[N],b[N];
int main(){
	int t,m;
	cin>>t>>m;
	for(int i=1;i<=m;i++){
		cin>>a[i]>>b[i];
	}
	for(int i=1;i<=m;i++){
		for(int j=a[i];j<=t;j++){
				dp[j]=max(dp[j],dp[j-a[i]]+b[i]);
		}
	}
	cout<<dp[t];
	return 0;
}

多重背包问题

这个没找到题,但其与01背包不同的一点是每个物品有一个数量限制c[i]

首先考虑将该问题转化为01背包问题然后进行优化

若转为01背包的话,物品数量会激增,但我们可以通过倍增的思想进行二进制优化。
考虑对于一种物品i,数量为c[i],可将其分为log2((c[i]))个物品,每个物品的价值为相应的数量乘价值,再根据转化后的物品进行01背包,实现不难,如下:

#include<bits/stdc++.h>
using namespace std;

int const maxn = 1e5 + 10;

int w[maxn],v[maxn];
int cnt;
int dp[maxn];



int main(){
	int n,V;
	cin >> n >> V;
	for(int i = 1;i <= n;i ++){
		int w1,v1,c1;
		cin >> w1>>v1>>c1;
		int tmp = 1;
		while(tmp <= c1){
			v[++cnt] = tmp*v1;
			w[++cnt] = tmp*w1;
			c1-=tmp;
			tmp*=2;
		}
		if(tmp){
			v[++cnt] = tmp*v1;
			w[++cnt] = tmp*w1;
		}
	}
	for(int i = 1;i <= cnt;i ++){
		for(int j = V;j >= v[i];j++){
			dp[j] = max(dp[j],dp[j-v[i]]+w[i]);
		}
	}
	cout << dp[V];
	return 0;
}

但该题还有一种较为巧妙的优化方法,利用单调队列进行优化,但我还没学会,学会了再update,可能就在我写单调队列优化那有了。

通天之分组背包

这个题就是加了组的限制,每个组只能选一个,跟01背包一样没啥难度,没啥可说的。

二维背包

特点是代价有两个变量v1[i],v2[i],相对的容积也有两个变量V1,V2。该种背包可以和01背包,完全背包以及多重背包相混合,也没什么值得特别说的地方,无非是更新的时候多了一个限制条件罢了。

背包求最优方案总数

这种题型也很套路,只需要建两个数组,一个dp数组正常背包,一个f数组计算方案,然后正常走背包,在每次dp更新的时候,f数组也从相应的状态上+1就行。

CF366C

Dima and Salad

题意:

有 n 个水果,每个水果有两个属性:美味值和卡路里值。现在选用若干个(至少 1 个)水果制作一份特殊的沙拉,沙拉的美味值为所选的水果的美味值的和,沙拉的卡路里值为所选水果的卡路里值的和。沙拉的美味值恰好是卡路里值的 K 倍。请计算该沙拉美味值最大为多少。

1<=n<=100

1<=k<=10

1<=a[i]<=100

1<=b[i]<=100

考虑该题使用背包求解,但该题并没有所谓体积限制,而是关于关于b[i]的限制条件根据所选a[i]的总和在发生变化。但我们不妨设想该题的最终情况,一定是选了一些沙拉其总美味值是总卡路里值的k倍,那么就意味着选的这些沙拉的各自美味值减去各自的卡路里值的k倍之后的和,即Σ(a[i] - k*b[i])==0.

那么现在我们可以考虑令c[i] = a[i] - k*b[i]为代价,令0为容量,a[i]为价值写01背包,

但是若直接莽写就会发现由于有些c[i]为负数,下标成负的了。这里给出两种解决方法,一种是用一个数组存正的c[i],并用d数组存dp值,另一个数组存负的c[i]对于负的c[i]枚举其相反数,用p数组存dp值,然后枚举容量,用d[i]+p[i]更新ans,由于i相等所以d数组所计算的c[i]与p数组所计算的c[i]也互为相反数相加为零,符合题意。

另一种方法是令计算的dp数组下标同时加上一个很大的值,这样相当于将0向正数方向移了很多,然后就正常写决策就行。
这里给出第一种方法的实现方式:

#include<bits/stdc++.h>
using namespace std;
#define int long long
int const maxn = 1e2+10;
int const M = 1e5+10;
int d[M],p[M];
int a[maxn],b[maxn],c[maxn];
signed main(){
	ios::sync_with_stdio(false);
	int n,k;
	cin >> n >> k ;
	int mx = 100000;
	for(int i = 1;i <= mx;i ++){
		d[i] = p[i] = -1e9;
	}
	for(int i = 1;i <= n;i ++){
		cin >> a[i];
	}
	for(int i = 1;i <= n;i ++){
		cin >> b[i];
	}
	for(int i = 1;i <= n;i ++){
		c[i] = a[i] - b[i]*k;
	}
	for(int i = 1;i <= n;i ++){
		for(int j = mx;j >= abs(c[i]);j--){
			if(c[i]>=0){
				d[j] = max(d[j],d[j-c[i]] + a[i]);
			}else {
				p[j] = max(p[j],p[j+c[i]]+a[i]);
			}
		}
	}
	int ans = 0;
	for(int i=mx;i >= 0;i--){
		ans = max(ans,d[i]+p[i]);
	}
	if(!ans){
		cout <<-1;
	}else {
		cout << ans;
	}
	return 0;
}

CF788C

The Great Mixing

题意:有k种可乐,第i瓶可乐的CO2浓度是a[i]/1000,问要配置出浓度n/1000的可乐,最少需要几瓶可乐。

1<=n<=1000

1<=a[i]<=1000

1<=k<=1e6

该题与上题类似,考虑到目标是n并且由于是浓度,往往只有大配小才能配出来n,所以我们一开始令c[i] = a[i] - n,然后分成d数组和p数组来解决问题,本题适合读者自行思考,巩固这种套路题的做题步骤

代码如下:

#include<bits/stdc++.h>
using namespace std;
#define int long long
int const maxn = 1e6+10;
int const M = 1e3+10;

int d[maxn],p[maxn];
int a[maxn];


signed main(){
    
    ios::sync_with_stdio(false);
	int n,k;
	cin >> n >> k ;

	for(int i = 1;i <= k;i ++){
		cin >> a[i];
		a[i]-=n;
		if(a[i]==0){
			return cout << 1,0;
		}
	}
    memset(d,0x3f,sizeof d);
    memset(p,0x3f,sizeof p);
    sort(a+1,a+k+1);
	k = unique(a+1,a+k+1) - a - 1;
	int sum = 3e5;
    d[0] = 0;
    p[0] = 0;

	for(int i = 1;i <= k;i ++){
	
		if(a[i]>0){
			for(int j = a[i] ;j <= sum;j++){
				d[j] = min(d[j],d[j - a[i]]+1); 
			}
			
		}else {
			for(int j = -a[i];j<=sum;j ++){
				p[j] = min(p[j],p[j + a[i]]+1);
			}
		}
	}
	int ans = 1e9+10;
	for(int i = 1;i <= sum;i++){
		ans = min(ans,d[i]+p[i]);
	}
	if(ans ==0||ans ==1e9+10){
		cout <<-1;
	}else {
		cout <<ans;
	}
	

	return 0;
}

接下来总结一下关于区间DP的内容,在之前如背包问题中,将所枚举到的前i个物品作为阶段,这种阶段之间往往只需要根据之前特定的某个阶段进行联系,而区间DP则将区间作为DP的阶段,将所枚举的断点以及其他一些信息作为状态进行决策,在从之前阶段获取信息时都是从被该区间包含的小区间内获取信息,这保证了最优子结构以及无后效性原则,而区间DP的时间复杂度往往也就是O(n^3)最外层枚举长度,内层枚举左端点并获得右端点,最内层枚举断点,这个顺序刚好代表了 阶段——状态——决策的顺序,循环顺序不能搞错否则会出大问题。

CF1312E

Array Shrinking

题意:给定一个数组a[i],以下操作可以执行任意次数:选择一对相邻相等的元素即a[i] == a[i+1]。用一个值为a[i] + 1的元素替换它们。在每次这样的操作之后,数组的长度将减少1(并相应地重新计算元素)。你能得到的数组a的最小可能长度是多少?

1<=n<=500
1<=a[i]<=1000

该题之所以能想到使用区间DP去做,一是因为我们仔细分析对于数组中的一段可合并值,最终合并的值是确定的,并且其满足
dp[l][r] = min(dp[l][r],dp[l][mid]+dp[mid+1][r]+w);
w与mid和mid+1有关,即状态足够做出决策,并且数据范围n <= 500,O(n^3)可以接受

考虑决策的情况,当合并两个区间时,对w有影响的只有mid与mid+1的值,若a[l][mid]==a[mid+1][r]则可以将长度减小1,并将a[l][r]更新为a[l][mid]+1

本题较为基础,也是一类区间DP题的代表,即将区间合并后会删去某些数,且合并的条件与左右两个小区间有关,代码如下

#include<bits/stdc++.h>
using namespace std;

int const maxn = 5e2+10;
int const M = 1e3 + 10;

int w[maxn][maxn],dp[maxn][maxn];


int main(){
	int n;
	cin >> n ;
	memset(dp,0x3f,sizeof dp);
	for(int i = 1;i <= n;i ++){
		cin >> w[i][i];
		dp[i][i] = 1;
	}
	for(int len = 2;len <= n;len++){
		for(int l = 1;l <= n-len+1;l ++){
			int r = l + len - 1;
			for(int mid = l;mid < r;mid++){
				dp[l][r] = min(dp[l][r],dp[l][mid]+dp[mid+1][r]);
				if(dp[l][mid]==1&&dp[mid+1][r]==1&&w[l][mid]==w[mid+1][r]){
					dp[l][r] = min(dp[l][r],1);
					w[l][r] = w[l][mid]+1;
				}
			}
		}
	}
	cout << dp[1][n];
	return 0;
}

CF607B

Zuma

题意:给一个长度为n的串,每次都可以挑选一个回文的连续字串进行消除,删除后,剩余的串将连接在一起,形成一个新的串,求把串全部删除完需要的最小次数
1<=n<=500
1<=color[i]<=n
分析题目,会发现该题与上题有一个共同点,都是将一段连续的区间进行某种操作,并且我们可以发现对大区间进行操作的结果只跟被它包含的小区间有关,例如我们要找在[l,r]内的最小次数,可以想到若(l,r)已经删去,对于l与r,若两者相等,那也就等价于两者最终组成的串是回文串(考虑中间是回文串和不是回文串对两者组成的串都不影响),即可以将代价[l,r]直接更新为(l,r)。并且由于每次这样更新区间改变长度是2,为了好处理一些,我们先预处理出所有长度为2和1的区间的解决次数。然后令len从3开始枚举。实现不难如下:

#include<bits/stdc++.h>

using namespace std;
int const maxn = 5e2+10;


int a[maxn];
int dp[maxn][maxn];

int main(){
	int n;
	cin >> n ;
	memset(dp,0x3f,sizeof dp);
	for(int i = 1;i <= n;i ++){
		cin >> a[i];
		dp[i][i] = 1;
		
	}
	for(int i = 2; i <= n;i ++){
		if(a[i-1]==a[i])dp[i-1][i] = 1;
		else dp[i-1][i] = 2;
	}
	for(int len = 3;len <= n;len ++){
		for(int l = 1; l <= n - len + 1;l ++){
			int r = l + len - 1;
			if(a[l]==a[r]){
				 dp[l][r] = dp[l+1][r-1];
			}
			for(int mid = l;mid < r;mid++){
				dp[l][r] = min(dp[l][r],dp[l][mid]+dp[mid+1][r]);
			}
		}
	}
	cout << dp[1][n];
	return 0;
}

区间DP除此之外还常常和前缀和等技巧综合使用,这使得我们引用DP值时需要考虑高维前缀的形式来做题。但总的来说对于一道区间DP题,阶段和状态往往并不难想,更重要的是要把决策的情况都考虑到。

树形DP

树形DP个人认为就是在树上以点或边为阶段,以该点或该边是否染色或其他操作作为状态,来进行决策的DP,当然也有一些例如换根和二次扫描的类型,作者主要就写三种类型的树形DP

Luogu

P1352没有上司的舞会

题意:某大学有 n 个职员,编号为 1...n。他们之间有从属关系,也就是说他们的关系就像一棵以校长为根的树,父结点就是子结点的直接上司。现在有个周年庆宴会,宴会每邀请来一个职员都会增加一定的快乐指数a[i],但是呢,如果某个职员的直接上司来参加舞会了,那么这个职员就无论如何也不肯来参加舞会了。所以,请你编程计算,邀请哪些职员可以使快乐指数最大,求最大的快乐指数。

1<=n<=6e3

考虑以点为阶段,先找到入度为0的点确定为树的根,即dp的边界值,然后进行dfs,易考虑到若此点染色,那么其儿子一定不染色,若此点不染色,那么其儿子染色不染色都无关紧要,由儿子的dp值更新父亲的DP值,然后本题结束。

#include<bits/stdc++.h>
using namespace std;
vector<int> G[6004];
int s[6004];
int r[6004],dp[6004][2],cnt[6004];
void dfs(int id){
	dp[id][0] = 0;
	dp[id][1] = r[id];
	for(int i = 0;i < G[id].size();i++){
		int to = G[id][i];
		dfs(to);
		dp[id][0]+=max(dp[to][0],dp[to][1]);
		dp[id][1]+=dp[to][0];
	}
	
}
int main(){
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	int n;
	cin>>n;
	for(int i = 1;i <= n;i++)cin>>r[i];
	for(int i = 1,j,k;i<=n-1;i++){
		cin>>j>>k;
		G[k].push_back(j);
		cnt[j]++;
	}
	int root;
	for(int i = 1;i<=n;i++){
		if(cnt[i]==0){
			root = i;
		}
	}
	dfs(root);
	cout<<max(dp[root][1],dp[root][0]);
	return 0;
}

还有一道类似的题LuoguP2015二叉苹果树,但这道题是考虑是否将边染色,然后直接莽01背包,读者可自已尝试完成
LuoguP2014 [CTSC1997] 选课
题意:在大学里每个学生,为了达到一定的学分,必须从很多课程里选择一些课程来学习,在课程里有些课程必须在某些课程之前学习,如高等数学总是在其它课程之前学习。现在有N门功课,每门课有个学分,每门课有一门或没有直接先修课(若课程a是课程b的先修课即只有学完了课程a,才能学习课程b) 。一个学生要从这些课程里选择M门课程学习,问他能获得的最大学分是多少?

该题是一道树上背包问题,其特点是物品有依赖其他的物品的性质,唯有选了它所依赖的物品,它才能够被选择,由于其类似于树上遍历时要遍历过父亲才能遍历到其子树,我们可以考虑对于这种背包建立一个类似于树的结构然后进行DP。
考虑将每个点作为阶段,该点所有的容量为状态,决策为是否选择儿子的最优决策
在该题中,我们从该课的先修课朝该课连边,然后在dfs的过程中,我们先枚举儿子,然后对儿子进行dfs,可以想到,在最后一层,即递归边界时,需要将所有状态更新为相应的值,而在向上传递的过程中,我们先枚举父节点的容量再枚举该子节点的容量进行转移,在一个节点结束后我们将该节点的分数也进行更新,最后都更新到0节点,即我们新建的无先修课的节点,就可得到最终答案。

#include<bits/stdc++.h>
using namespace std;
int const maxn = 310;
int const M = 6e3+10;
vector<int> G[maxn];
int n,m;
int dp[maxn][maxn];
int w[maxn];
void dfs(int u){
	for(int i = 0;i < G[u].size();i++){
		int v = G[u][i];
		dfs(v);
		for(int t = m;t >= 0;t --){
			for(int j = t;j >= 0;j --){
				dp[u][t] = max(dp[u][t],dp[u][t-j]+dp[v][j]);
			}
		}
	}
	if(u){
		for(int t = m;t > 0;t --){
			dp[u][t] = dp[u][t-1] + w[u];
		}
	}
}

int main(){
	
	cin >> n >> m ;
	for(int i = 1;i <= n;i ++){
		int a;
		cin >> a >> w[i];
		G[a].push_back(i);
	}
	dfs(0);
	cout << dp[0][m];
	return 0;
}`

在树形DP中还有一类常见的题型,常用到换根法和二次扫描

LuoguP1395 会议

题意:有一个村庄居住着n个村民,有n-1条路径使得这n个村民的家联通,每条路径的长度都为1。现在村长希望在某个村民家中召开一场会议,村长希望所有村民到会议地点的距离之和最小,那么村长应该要把会议地点设置在哪个村民的家中,并且这个距离总和最小是多少?若有多个节点都满足条件,则选择节点编号最小的那个点。

该题的特殊部分在于是棵无根树,我们需要确定一个根使得所有点的深度之和最小,考虑先将1号点作为根进行dfs,然后我们得到了每个点的子树(包括自己)以及1号点到所有点的距离之和,这时候我们就将点作为阶段,考虑每次将根传递给儿子时总距离的变化,易得儿子的子树到根的距离都减1,且所有非儿子子树节点到根的距离都加1,从1号点开始向下传递,最后遍历更新ans即可。

#include<bits/stdc++.h>
using namespace std;
int const maxn = 5e4+10;

	int n;
int siz[maxn],dep[maxn],dp[maxn];
vector<int> G[maxn];
void dfs(int u,int fa){
	
	siz[u] = 1;
	for(int i = 0;i < G[u].size();i++){
		int v = G[u][i];
		if(G[u][i]==fa)continue;
		
		dep[v] = dep[u] + 1;
		
		dfs(v,u);
		siz[u]+=siz[v];
		
	}
	dp[1]+=dep[u];
}
void dfs1(int u,int fa){
	for(int i = 0;i < G[u].size();i ++){
		int v = G[u][i];
		
		if(v==fa)continue;
		
		dp[v] = dp[u] - siz[v] + n - siz[v];
		dfs1(v,u);
	}
	
}

int main(){
cin >> n ;
	for(int i = 1;i < n;i ++){
	    int x,y;
	    cin >> x >> y ;
	    G[x].push_back(y);
	    G[y].push_back(x);
	}
	dfs(1,0);
	
	dfs1(1,0);
	int ans = 1e9,pos;
	for(int i = 1;i <= n;i ++){
		if(dp[i]<ans){
			pos = i;
			ans =dp[i];
		}
	}
	cout << pos<<' '<<ans;
	return 0;
	
}

这些都是些比较基础的DP类型,之后会写一篇关于DP优化的博客(主要是现在DP优化都快忘光了),接下来就会写点关于数据结构方面的东西,复习数据结构。

posted @ 2023-08-08 20:07  瑞恩尼lower  阅读(85)  评论(1)    收藏  举报