DP 入门

基础知识

在这之前,我们先讲一下基础知识。

DP 的本质

DP 的本质是什么?很简单,一句话概括:用旧的状态推出新的状态的过程叫 DP。说的再直白一点,就是递推与递归。因为两者为互逆运算,所以 DP 自然就有了这两种写法。

我至今记得我们小学老师教我的关于 DP 的一句话:过去改变现在,现在改变未来,但过去改变不了未来。听着很深奥,感觉都已经上升到哲学层面了,实际上我一解释就很明了了,所以我决定就不解释了,让你们自己悟,等到哪天突然想起来了这句话后再回来看看。(顺便涨点阅读量)

DP的两种实现方法

上面我们已经说过:DP 的两种实现方式就是递推与递归。

那有的同学就会说:那那个递归的时间复杂度不是远远高于递推吗,怎么他俩还放一起了?那是因为你忘了一个东西:记忆化搜索。没错,这里的 DP 就是拿来记忆化的。

举个例子,就拿斐波那契数列( 1 , 2 , 3 , 5 , 8 , … 1,2,3,5,8,\dots 1,2,3,5,8,)来讲吧。

递推写法:

cin>>n;//求第n个斐波那契数
dp[1]=1,dp[2]=2;
for(int i=3;i<=n;i++)
{
	dp[i]=dp[i-1]+dp[i-2];
}
cout<<dp[n];

记忆化搜索写法:

void dfs(int x)
{
	if(x==1)
	{
		return 1;
	}
	if(x==2)
	{
		return 2;
	}
	if(dp[x]!=-1)
	{
		return dp[x];
	}
	return dp[x]=dfs(x-1)+dfs(x-2);
}

这下就很明了了,如果觉得还是很模模糊糊的同学可以再理解理解上面的两种写法。

空间优化 DP

空间优化 DP 是 DP 十分十分重要但又很基础的一个东西,因为 DP 的空间优化几乎就只有一种:滚动数组。

滚动数组,顾名思义,就是让 DP 数组“滚起来”,比如这样一个 DP:dp[i][j]=max(dp[i][j],dp[i][j-1]),这时 DP 数组的第二维就只与这一项和前一项两项有关,所以我可以把 dp[N][M] 改成 dp[N][2],这样就大大减小了空间,有时我们甚至能把这个数组压到一维,在背包里会重点讲。

一维 DP

一维 DP 是 DP 中最最最基本的,为方便讲解,让我们拿出一道水题:

输⼊ n ( n ≤ 100000 ) n(n\le100000) n(n100000) n n n 个整数,输出该序列中最⼤的连续⼦序列的和。

(这道题是我从我们训练的题中拿出来的一道水题。)

好家伙,题目越短,事情越大。

我们单独对这 n n n 个整数中的一个看,我们先不思考怎么求最大连续子序列和,我们先思考在以 i i i 为结尾的情况下求连续和有那些情况。

首先我们可以与上一个连续子序列和连在一起,但只能是上一个点的,也可以与之前的断开,自己单独做一个子序列,然后在这两个之间做个选择就行了,由此我们得到了状态转移方程( d p i dp_i dpi 表示以 i i i 为结尾的最大连续子序列和, a i a_i ai 是输入的数组):

d p i = max ⁡ ( d p i − 1 + a i , a i ) dp_i=\operatorname{max}(dp_{i-1}+a_i,a_i) dpi=max(dpi1+ai,ai)

然后在所有的子序列中找出最大的就行了。

附上代码:

#include<bits/stdc++.h>
#define int long long
using namespace std;
int n,ans,a[100006],dp[100006];
signed main()
{
	cin>>n;
	for(int i=1;i<=n;i++)
	{
		cin>>a[i];
	}
	for(int i=1;i<=n;i++)
	{
		dp[i]=max(a[i],dp[i-1]+a[i]);
	}
	for(int i=1;i<=n;i++)
	{
		ans=max(ans,dp[i]);
	}
	cout<<ans;
	return 0;
}

怎么样,是不是很简单?别急,这只是开胃菜。洛谷上的题一抓一大把呢。

最长上升子序列(LIS)

最长上升子序列(LIS)是 DP 的经典题目,在正式讲之前,我先说几个概念:

  • 子序列:指在一个长度为 n n n 的序列选出 m m m 个数所组成的序列叫做子序列,子序列可以不是连续的,但先后顺序要与原序列中的顺序相符。
  • 上升子序列:指保证 i < j i\lt j i<j a i < a j a_i\lt a_j ai<aj a i a_i ai 指这个子序列的第 i i i 位是什么, a j a_j aj 一样)的子序列。
  • 最长上升子序列:指长度最长上升子序列

概念可能有点抽象,我们来举个例子:假设有一个序列 A = { 5 , 1 , 3 , 2 , 4 } A=\{5,1,3,2,4\} A={5,1,3,2,4},那么序列 { 5 , 4 } \{5,4\} {5,4} 就是它的子序列,但不是上升子序列, { 3 , 4 } \{3,4\} {3,4} 就是上升子序列,但不是最长上升子序列,而序列 { 1 , 2 , 4 } \{1,2,4\} {1,2,4} { 1 , 3 , 4 } \{1,3,4\} {1,3,4} 就是最长上升子序列。但是 { 2 , 3 } \{2,3\} {2,3} 就不是子序列,应为它俩在原序列中的顺序反了。

解释清楚概念之后,下面的就好讲多了。然后我们就要思考“对于以当前这个点为结尾的最长上升子序列怎么求”这个问题。

很简单,我们设一个 dp[i] 表示以 i i i 号位置为结尾的 LIS(为了方便,我后面都写简写),那我们要接上这个点,只有在前面的小于这个点的数的点中找最大的,然后和这个点拼上就行了,状态转移方程如下:

d p i = max ⁡ j = 1 i − 1 { d p i , d p j + 1 } dp_i=\max_{j=1}^{i-1}\{dp_i,dp_j+1\} dpi=j=1maxi1{dpi,dpj+1}

代码就不需要我写了吧。

总而言之,LIS 的经典方法就是两层循环套一个 DP,时间复杂度 O ( n 2 ) O(n^2) O(n2)

LIS 优化之二分优化(非 DP 做法)

如果题目中的 n n n 很大,这是又该怎么办呢?很简单,用点贪心的思想就对了。

假设有这样一个序列: { 5 , 3 , 1 , 2 , 2 , 4 , 3 , 1 } \{5,3,1,2,2,4,3,1\} {5,3,1,2,2,4,3,1}。现在要你找它的 LIS,因为我们要尽可能的满足这个条件: i < j i\lt j i<j a i < a j a_i\lt a_j ai<aj。那么我们用贪心的思想就可以很容易想到:只要这个子序列的前面越小,整个序列就越有可能是 LIS,所以说我们只需要用一个 queue 或者是数组(建议用数组,因为后面还要二分)保存当前的 LIS,那么一个点如果大于当前的队尾,就可以直接和前面的子序列连上,如果不是,那么就找到最后一个小于它的,因为这样前面的序列就可以更小,更符合后面的发展,而因为整个数组是有序的,所以这个过程我们可以用二分来解决。

核心代码(主要是怕你们抄代码):

int find(int x)//二分查找 
{
	int l=1,r=cnt,mid;
	while(l<=r)
	{
		mid=l+r>>1;//包不会错的啊
		if(q[mid]>=x)
		{
			r=mid-1;
		}
		else
		{
			l=mid+1;
		}
	}
	return l;
}
int main()
{
	//一堆输入...
	q[++cnt]=a[1];//q 是我先开始说的那个辅助数组,a 是我输入进来的序列 
	for(int i=2;i<=n;i++)
	{
		if(a[i]>q[cnt])//直接接入 
		{
			q[++cnt]=a[i];
		}
		else//换成更有利条件 
		{
			q[find(a[i])]=a[i];
		}
	}
	//又是一大堆输出... 
}

感兴趣的同学可以自己下来写一写另一种求 LIS 的方法(反正代码在那)。

二维 DP

讲了一大堆,终于是来到了二维 DP了。

二维 DP 其实比一维 DP 高级不到哪去,也就是多开了一维而已,但它真正的魅力在于:这个新开的这意味不只是图像上的,还可能是数位(数位 DP)、一个二进制编码(状压 DP)、一个开头和一个结尾(区间 DP)、价值(背包 DP)、当前所在节点(树形 DP)等等。可见,二维 DP 就是我们后面要讲的 DP 的基础。

没什么可讲的,直接看一道例题

棋盘上 A A A 点有⼀个过河卒,需要⾛到⽬标 B B B 点。卒⾏⾛的规则:可以向下、或者向右。同时在棋盘上 C C C 点有⼀个对⽅的⻢,该⻢所在的点和所有跳跃⼀步可达的点称为对⽅⻢的控制点。因此称之为“⻢拦过河卒”。棋盘⽤坐标表示, A A A ( 0 , 0 ) (0, 0) (0,0) B B B ( n , m ) (n, m) (n,m),( n , m n,m n,m 为不超过 20 20 20 的整数),同样⻢的位置坐标是需要给出的。现在要求你计算出卒从 A A A 点能够到达 B B B 点的路径的条数,假设⻢的位置是固定不动的,并不是卒⾛⼀步⻢⾛⼀步。

这道题洛谷上有,我们训练的题单里也有,所以我就拿来当例题了。

这是二维 DP 最经典的题目:平面几何上做 DP。因为每个点可以向右走或向下走,所以每个点能走道的路径的方案总数就是它上面的点的方案总数加上左边那个点的方案总数(应为上面的可以走下来,左边的可以往右走走到),所以状态转移方程就推出来了:

d p i , j = d p i , j − 1 + d p i − 1 , j dp_{i,j}=dp_{i,j-1}+dp_{i-1,j} dpi,j=dpi,j1+dpi1,j

但这个里面有马拦着啊。这也很好解决,只要马能到的地方不走就行了。

最后记得初始化:马没拦下之前的点都可以初始化为 1 1 1

AC 代码:

#include<bits/stdc++.h>
using namespace std;
int n,m,b[26][26],i,j,x,y;
long long a[26][26];
int main()
{
	cin>>n>>m>>x>>y;
	b[x][y]=1;
	b[x+2][y+1]=1;
	b[x+2][y-1]=1;
	b[x+1][y+2]=1;
	b[x+1][y-2]=1;
	b[x-2][y+1]=1;
	b[x-2][y-1]=1;
	b[x-1][y+2]=1;
	b[x-1][y-2]=1;
	for(i=0;i<=n;i++)//马拦下之前的点都是 1
	{
		if(b[i][0]==1)
		{
			break;
		}
		a[i][0]=1;
	}
	for(j=0;j<=m;j++)
	{
		if(b[0][j]==1)
		{
			break;
		}
		a[0][j]=1;
	}
	for(i=1;i<=n;i++)
	{
		for(j=1;j<=m;j++)
		{
			if(b[i][j]==0)
			{
				a[i][j]=a[i-1][j]+a[i][j-1];
			}
		}
	}
	cout<<a[n][m];
	return 0;
}

很简单?别慌,这才橙题,接下来我要开始找一些有难度的题了:

⼀个吉他⼿准备参加⼀场演出。他不喜欢在演出时始终使⽤同⼀个⾳量,所以他决定每⼀⾸歌之前他都需要改变⼀次⾳量。在演出开始之前,他已经做好⼀个列表,⾥⾯写着每⾸歌开始之前他想要改变的⾳量是多少。每⼀次改变⾳量,他可以选择调⾼也可以调低。⾳量⽤⼀个整数描述。输⼊⽂件中整数 beginLevel,代表吉他刚开始的⾳量,整数 maxLevel,代表吉他的最⼤⾳量。⾳量不能⼩于 0 0 0 也不能⼤于 maxLevel。输⼊中还给定了n个整数 c 1 , c 2 , c 3 , . . . , c n c_1,c_2,c_3,...,c_n c1,c2,c3,...,cn,表示在第i⾸歌开始之前吉他⼿想要改变的⾳量是多少。吉他⼿想以最⼤的⾳量演奏最后⼀⾸歌,你的任务是找到这个最⼤⾳量是多少。

这也是我们训练的一道题。

一开始看到这道题时,我也没思路,因为要求最大音量,这 DP 不管怎么设都不好求状态转移方程啊。

但当我再次看向题目,我看到了这句话:⾳量不能⼩于 0 0 0 也不能⼤于 maxLevel。这说明什么?说明音量的最大值就是 maxLevel,而不可能更高了。我只需要从大到小枚举一下那些成立,然后去最大的就行了。这样,我们的 DP 就从求值变成了标记。

那怎么标呢?题目中说了:第 i i i 首歌可以调 c i c_i ci 格音量。所以设 d p i , j dp_{i,j} dpi,j 表示在第 i i i 首歌用 j j j 的音量能否成立,那么从 d p i , j − c i dp_{i,j-c_i} dpi,jci d p i , j + c i dp_{i,j+c_i} dpi,j+ci 都可以得到当前音量,由此我们得到了状态转移方程为:

d p i , j = d p i − 1 , j − c i ∣ d p i − 1 , j + c i dp_{i,j}=dp_{i-1,j-c_i}|dp_{i-1,j+c_i} dpi,j=dpi1,jcidpi1,j+ci

但要注意的是: j − c i ≥ 0 , j + c i ≤ m a x L e v e l j-c_i\ge0,j+c_i\le maxLevel jci0,j+cimaxLevel

AC 代码:

#include<bits/stdc++.h>
#define int long long
using namespace std;
int n,bl,ml,a[56],dp[56][1006];
signed main()
{
	cin>>n>>bl>>ml;
	for(int i=1;i<=n;i++)
	{
		cin>>a[i];
	}
	dp[0][bl]=1;
	for(int i=1;i<=n;i++)
	{
		for(int j=0;j<=ml;j++)
		{
			if(j-a[i]>=0)//我也不知道没声音怎么听演唱会...
			{
				dp[i][j]=(dp[i][j]|dp[i-1][j-a[i]]);
			}
			if(j+a[i]<=ml)
			{
				dp[i][j]=(dp[i][j]|dp[i-1][j+a[i]]);
			}
		}
	}
	for(int i=ml;i>=0;i--)
	{
		if(dp[n][i])
		{
			cout<<i;
			return 0;
		}
	}
	cout<<-1;
	return 0;
}

这里再提供一种思路:如果对于上一场中当前音量是可以达到的,那么这一场中的 j + c i j+c_i j+ci j − c i j-c_i jci 也是可以达到的,注意事项与上面一样。

最长公共子序列(LCS)

最长公共子序列(LCS)是二维 DP 的经典题目,指的是两个序列的最长的相同的子序列,这是我们一般会定义一个 dp[i][j] 用来表示以第一个序列的第 i i i 位为结尾、第二个序列的第 j j j 位为结尾的 LCS,具体的状态转移方程如下:

d p i , j = max ⁡ { d p i − 1 , j , d p i , j − 1 , d p i − 1 , j − 1 ( if  a i = b i ) } dp_{i,j}=\max\{dp_{i-1,j},dp_{i,j-1},dp_{i-1,j-1}(\text{if}\ a_i=b_i)\} dpi,j=max{dpi1,j,dpi,j1,dpi1,j1(if ai=bi)}

我来解释一下:首先不管你这两个序列的这一位相不相等,当前的 LCS 必然是 a a a 序列不要这一位后的 LCS 或 b b b 序列不要这一位后的 LCS,也就是 max ⁡ \max max 的前半部分,而如果这两个相等,那我们可以在 a a a 序列和 b b b 序列都去掉这一位的情况下多家当前这一位,也就是最后的那个式子。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值