dp 学习笔记

一.前言

动态规划是一种 OI 中常用的算法思想,广泛分布于各种算法,可以解决大量的问题。本文将着重介绍常见的一些动态规划类型与优化,欢迎纠错与指正。

本文夜间模式食用更佳(其实是分割线效果更明显)

二.背包问题

基本的背包问题都类似以下形式:

  • \(n\) 个物品,每种物品都有一个价值 \(w\) 和体积 \(c\),每种物品数量为 \(k_i\),有一个背包容积为 \(m\),用一些物品装背包使得物品总价值最大.

01背包

最基础的一种背包。

模板中 $ k_i=1 $ 的情况。

\(f_{i,j}\) 代表i件物品填充j体积的最大收益

可得方程 \(f_{i,j}=\max(f_{i-1,j},f_{i-1,j-c_i}+w_i)\)

容易发现此时空间复杂度是 \(O(nm)\) 的,我们因为转移时只需要用到 \(i-1\) 维的状态,所以可以滚动数组,但注意此时需要倒叙枚举 \(j\),同时不用进行 \(f_{i,j}=f_{i-1,j}\) 的转移。

空间复杂度降低至 \(O(m)\)

由于 \(b_i\) 属性的影响,直接 01 背包明显不对,于是考虑预处理排序,按照 \(-b_j \cdot c_i>-b_i \cdot c_j\) 的顺序排序后跑 01 背包即可。


01 背包还有一种特殊情况:必须装满。这时我们将数组除 \(f_0\) 外的位置全部赋值为负无穷,也就是说负无穷代表“无解”,而能被装满的状态一定能从 \(f_0\) 得到转移,从而解决问题。

完全背包

模板中 $ k_i=\infty $ 的情况。

\(f_{i,j}\) 代表i件物品填充j体积的最大收益

可得方程 \(f_{i,j}=\max\limits_{k=1}(f_{i-1,j},f_{i-1,j-k\cdot c_i}+k\cdot w_i)\)

例题:疯狂的采药

名义是无限取,但其实最多取 \(\dfrac{m}{c_i}\) 个,所以也算是多重背包的一种,同样可以使用下文的多重背包优化进行优化。

多重背包

模板中 \(k_i\) 为任意值的情况。和完全背包一样的做法,只不过 \(k\) 有上界。

方程:

\(f_{i,j}=\max\limits_{k=1}^{k_i}(f_{i-1,j},f_{i-1,j-k\cdot c_i}+k\cdot w_i)\)

容易看出,多重背包的复杂度是 \(O(nmk)\) 的,十分的劣,这里一共有两种常用优化方法。

二进制分组优化

我们考虑把这 \(k\) 个物品分组,同组的物品合并为一个新物品。

我们采取这样一种分组策略:将 \(k\) 拆为 若干个二进制数加和(不重),同时保证次数连续递增,最后多余的部分单独成为一组。

我们看一个例子:

\[18=1+2+4+8+3=2^0+2^1+2^2+2^3+3 \]

我们可以惊讶的发现,现在新分出来的这五个数也可以通过搭配凑出 \(18\) 一下的每个数!也就是说,这么分组过后对于答案没有影响,但只需要遍历 \(\log k\) 个数,复杂度变为 \(O(nm\log k)\)

单调队列优化

单调队列优化具体说明参见第八小节。

首先考虑原方程 \(k_i=1\) 的情况:

此时需要对长度固定的一段区间取 max,很明显是一个单调队列板子。

接着考虑 \(k_i\) 任意的情况,我们可以将第二位按模 \(k_i\) 余数划分,对于每个剩余类都形成滑动窗口问题,进行单调队列优化。

复杂度为 \(O(nm)\)。注意无法与二进制分组优化共用。

带有特殊要求的背包

\(k\) 优问题

求 01 背包的第 \(k\) 优解。

考虑多设置一维,\(f_{i,j}\) 表示体积为 \(i\) 时第 \(j\) 优解。(这里已经使用了 01 背包的滚动数组优化,否则空间爆炸),对于 \(f_i\) 我们考虑转移,这是转移需要对 \(f_{i}\) 与 f_{i-c_i} 两个数组取前 \(k\) 优的值,考虑归并排序的思想,可以做到 \(O(k)\) 的转移,总复杂度为 \(O(nmk)\)

板子题,同时使用了前文提到的必须装满情况的处理方法。

三.区间 dp

区间 dp 是一种比较另类的 dp。

一般情况下,dp 对象是一个区间。定义 \(dp_{i,j}\) 为对于区间 \([i,j]\) 的结果,通过若干子区间 \(dp_{l,r}\) 的 dp 值推出当前区间的值。

由于大多数 dp 求解顺序都是由子结构推整体,所以应优先求解小区间,可以这么理解:

一般情况下,先穷举区间长度 \(len\),再穷举区间左端点 \(l\),则右端点 \(r=l+len-1\)

区间 dp 有几种比较常见的转移方法,在此一一列举一下

题目中带有这些关键词大概率是区间 dp:

区间合并

枚举分界点

例题:[NOI1995] 石子合并

对于 \(dp_{i,j}\),枚举断点 \(k\),则易得方程:

\[dp_{i,j} = max(dp_{i,k} + dp_{k+1,j} + cost_{i,j}) \]

由于序列是一个环,所以把序列复制一遍,破环为链的经典思想。

四.状压 dp

五.树上 dp

树上 dp,顾名思义,是一种在树上进行的 dp。

其形式一般为这样:

点击查看代码
void tree_dp(int x,int fa){//x为当前节点,fa为父亲节点 
	if(speical(x)){//某些题目存在关键点需要特殊处理 
		...;
	}
	for(int i=0;i<g[x].size();i++){
		int to=g[x][i];
		if(to==fa){//因为一般建的都是双向边,防止死循环 
			continue;
		}
		tree_dp(to,x);//优先转移儿子节点 
		dp[x]=...; //转移当前节点 
	}
	return;
} 

朴素树上 dp

都是模板题,按照上文模板形式写出 dp 即可 AC。

没有上司的舞会

Strategic game

这里讲一道好题:

[USACO08JAN]Cell Phone Network G

首先容易看出一个性质:父亲结点,当前节点,儿子节点一定存在一个信号塔。

可得状态

\(f_{i,0}\) 表示 \(i\) 被自己覆盖的最小花费
\(f_{i,1}\) 表示 \(i\) 被儿子覆盖的最小花费
\(f_{i,2}\) 表示 \(i\) 被父亲覆盖的最小花费

方程也可容易得出:

\[f_{i,0}+=\min(f_{to,0},f_{to,1},f_{to,2}) \]

\[f_{i,1}=f_{i,0}+sigma(\min(f_{to,0},f_{to,1}) ) \]

\[f_{i,2}+=\min(f_{to,0},f_{to,1}) \]

换根 dp

通常用于计算无根树中根(深度,子树大小)会影响的一些信息。

对于每个点为根的情况,我们从它的父亲推出它的值(父亲子树深度加一,当前点子树深度减一,利用此性质 dp)

首先暴力求出 \(f_1\) 答案,同时预处理深度,子树大小。

\(f_i\)\(i\) 为根时的答案,则有 \(f_i=f_{fa}-siz_i+n-siz_i\)。解决问题。

换根 dp 其实可以同时维护更多信息,本体并没有将其作用发挥到极致。

六.图上 dp

图上 dp,顾名思义,是一种在图上进行的 dp。

特别地,这里我们只讨论有向图的情况。

同时,假设有边 \(x \rightarrow y\),则我们定义 \(x\)\(y\) 的上行节点。

首先,从主观可以想到,每一个点的权值一定由其所有上行节点推出,也就是当其上行节点 dp 值全部转移完成后,该节点才可以进行 dp 转移。

由此可见,进行图上 dp 的图必须无环,所以说这个图一定为 DAG。

但是如何确定处理结点的顺序呢?这里有一种叫拓扑排序的算法:

设置一个集合 \(S\),里面是所有入度为 \(0\) 的点,同时也声明一个栈 \(L\),用于存储拓扑序。

每次从 \(S\) 中取出一个点 \(x\)放入 \(L\), 然后将 \(x\) 的所有边\((x,to_1),(x,to_2),.....\)删除。对于边(x,to_i),若将该边删除后点 \(to_i\) 的入度变为 \(0\),则将 \(to_i\) 放入 \(S\) 中。

重复此过程直至 \(S\) 为空,\(L\)中即为拓扑序。

最后,按照拓扑序逐项 dp 转移即可。

七.数位 dp

数位 dp,思想主要是将 dp 对象当成一个数的每一位,来统计答案的一种 dp 方法。

问题主要形式为:给两个整数 \(l,r\),求区间 \([l,r]\) 之间 xxx 的数量。

为方便,数位 dp 一般以记忆化搜索实现,是一个很模块化的过程。其模板如下:

ll dfs(int x,int s,bool l,bool z){
	/*
	x:当前处理到第几位(更高位已经处理完毕)
	s:视题而异,额外维护的一些信息
	l:当前是否达到上界
	z:上一位是否为前导0 
	*/
	if(x==0) return s;
	if(f[x][s][l][z]!=-1) return f[x][s][l][z];
	int lim;
	if(l) lim=a[x];
	else lim=9;
	ll sum=0;
	for(int i=0;i<=lim;i++){
		sum+=dfs(x-1,s+(),(l&(i==lim)),(z&(i==0)),d);
	}
	return f[x][s][l][z]=sum;
}

这里详细解释一下上界标记 \(l\),的含义(感觉这里最难理解)。

具体地,举一个例子,假设当前枚举的上界为 \(1800\),假设已经取完最高位为 \(1\),次高位为 \(8\),那么如果第三位再取 \(9\),就会超过上界,这种情况不应统计。所以,当前几位都与上界契合时,不可以随便取,最大只能取到上界的这一位,\(l\) 即是为区分这两种情况设立的标记。

八.单调队列优化

单调队列就是一种队列内的元素有单调性(单调递增或者单调递减)的队列,答案(也就是最优解)就存在队首,而队尾则是最后进队的元素。因为其单调性所以经常会被用来维护区间最值或者降低DP的维数已达到降维来减少空间及时间的目的。

看一道例题:滑动窗口

要求的是每连续的 \(k\) 个数中的最大(这里只说最大,最小同理)值,很明显,当一个数进入所要 "寻找" 最大值的范围中时,若这个数比其前面的数要大,前面的数不再可能是最大值。这时将前面比其小的数全部 \(pop\),再将该数真正 \(push\) 进队尾。

维护这样一个递减的队列,不仅减少了比较次数,而且由于队列是递减的,队头必定是查询区域内的最大值,因此输出时只需输出队头即可。

显而易见,时间复杂度为 \(O(n)\),得到了极大的优化。

如果那这种数据结构放在 dp 上呢?

例题:[NOI2005] 瑰丽华尔兹

【AC后会补上的讲解】

九.前缀和优化

当 dp 式子形如这种形式时:

\(dp_i=f(\sum\limits_{j=l}^rdp_j)\)

(\(f\) 函数为视题目而定的特殊处理)

可以定义一个 \(sum\) 数组存 \(dp\) 数组的前缀和,\(f\) 函数内部的计算优化为 \(O(1)\),得到优化。

例题:[CSP-S 2021] 括号序列

一眼的区间 dp。首先想到可以设置 \(dp_{i,j}\) 表示区间 \([i,j]\) 形成合法序列的方案数,但由于不同形式的括号序列形成方法不同,朴素做法会算重,这是可以限制旨在一个方向上转移,复杂度 \(O(n^2)\)

套一个前缀和优化即可。

总结:前缀和优化很容易理解,发现并使用也很容易。

唯一有点复杂的也就是配上滚动数组了。

十.线段树/树状数组优化

前排小提示:由于线段树能做的树状数组也能做,所以本章常规情况只讲解线段树。

Part1 常规情况

这里的常规情况指单纯的用区间/单点的修改与查询进行优化。

例题:CF115E Linear Kingdom Races

讲解打完之后写。

Part2 二维偏序

这种情况,我们的树状数组九可以派上用场了。

例如这道题:[USACO11FEB]Generic Cow Protests G

首先有朴素 dp 状态:\(dp_{i}\) 表示第 \(i\) 只牛结尾的方案数。

转移也很容易:

\(sum\) 数组为 \(dp\) 数组的前缀和,则有

\[dp_i=\sum^{sum_i-sum_j \geq 0}_{j} dp_j,j<i \]

注意到只有两个变量 \(i,j\),再盘一下 \(i,j\) 之间的关系,可得:

  • \(j < i\)

  • \(sum_j \leq sum_i\)

可以转化为二维偏序问题,将 \(sum_i\) 离散化,此时变量 \(i\) 天然有序,二维数点形成,用树状数组维护即可。

code:

点击查看代码
#include<iostream>
#include<algorithm>
#include<cstring>
#include<vector>
using namespace std;
int n,a[100005],c[100005],sum[100005];
int ta[100005],ans=1; 
const int mod=1000000009;
int lowbit(int x){
	return x&-x;
}
void add(int x,int val){
	while(x<=n){
		ta[x]+=val;
		ta[x]%=mod;
		x+=lowbit(x);
	}
}
int query(int x){
	int sum=0;
	while(x>0){
		sum+=ta[x];
		sum%=mod;
		x-=lowbit(x);
	}
	return sum;
}
int main(){
	cin>>n;
	for(int i=1;i<=n;i++){
		cin>>a[i];
		sum[i]=sum[i-1]+a[i];
	}
	for(int i=0;i<=n;i++){
		c[i+1]=sum[i];
	}
	sort(c+1,c+n+2);
	int tot=unique(c+1,c+n+2)-c-1;
	for(int i=0;i<=n;i++){
		sum[i]=lower_bound(c+1,c+tot+1,sum[i])-c;
	}
	add(sum[0],1);
	for(int i=1;i<=n;i++){
		ans=query(sum[i]);
		add(sum[i],ans);
	}
	cout<<ans<<endl;
	return 0;
}

INF-1.链接区

dp 状态设计小技巧

OI wiki

INF.结语

相比于 dp 世界的无穷无尽,本文的剖析仍仅为皮毛。

祝大家都能学好 dp,也祝大家都能前程似锦。

\(\mathcal{The}\) \(\mathcal{ end.}\)

posted @ 2023-02-12 20:36  Aurora_Borealis  阅读(78)  评论(1编辑  收藏  举报