动态规划——从菜鸟到入门

什么是动态规划?

动态规划,英文名为Dynamic Programming,又称DP(当然小写的dp也行),是一种通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。由于动态规划并不是某种具体的算法,而是一种解决特定问题的方法,因此它会出现在各式各样的数据结构中,与之相关的题目种类也更为繁杂。在 OI 中,计数等非最优化问题的递推解法也常被不规范地称作 DP。

dp的重要性?

在作者打过的镇赛,区赛乃至GDOI,CSP中,dp一直是常考的东西。所以学会dp尤为关键。

举个例子,2017-2022年的镇赛,区赛中dp常作为压轴题出现,甚至NHOI 2022中T5,T6都是dp。

动态规划入门

先来看这样一道题:P1216 [USACO1.5][IOI1994]数字三角形 Number Triangles(学校OJ 1351)

尽管这道题是IOI的,但不妨碍我们切掉

想必有人会考虑贪心,即如果左边大一点就往左边走,右边大一点就忘右边走。看到给出的样例,就知道这个方法不可行了。如果还想继续从第二大值,第三大值等等情况考虑,你就发现深陷其中无法自拔了。

接下来我们通过这道例题详细分析以下如何做一道动态规划的题目:

Step 1:设计状态

对于动态规划,设计一个状态是做题的第一步。这里初学者可能不知道状态是什么,但是请先耐心看下去。

比如这道题,我们定义一个二维数组 \(f_{i,j}\) 表示的是从起点 \((1,1)\) 走到当前点 \((i,j)\) 的最大值。这里 \(f_{i,j}\) 就表示一个状态。

一般而言,设计的状态通常都是由所需要的答案决定,如本题需要的就是从起点 \((1,1)\) 走到终点的最大值。

请注意,设计出一个好的状态非常重要!请注意,设计出一个好的状态非常重要!请注意,设计出一个好的状态非常重要!(重要的话说三遍!!!

Step 2:设计转移方程

显然,设计好一个状态后,我们需要考虑这个状态的答案应该如何计算得到的,即设计转移方程

例如本题,我们可以画一个表格分析。我们可以自行在电脑前画出这个表格。

显然,我们发现,有两种可以走到当前点 \((i,j)\) 的方法:

  1. \((i-1,j-1)\xrightarrow{}(i,j)\),这时问题转化为从 \((1,1)\) 走到 \((i-1,j-1)\) 的最大值,最后再加上当前取 \((i,j)\) 格子的价值 \(a_{i,j}\),即\(f_{i-1,j-1}+a_{i,j}\)
  2. \((i-1,j)\xrightarrow{}(i,j)\),这时问题转化为从 \((1,1)\) 走到 \((i-1,j)\) 的最大值,最后再加上当前取 \((i,j)\) 格子的价值 \(a_{i,j}\),即\(f_{i-1,j}+a_{i,j}\)

我们要的是最大值,所以对两种情况取一个最大值即可得到当前状态 \(f_{i,j}\) 的答案。即

\[f_{i,j}=\max(f_{i-1,j-1}+a_{i,j},f_{i-1,j}+a_{i,j}) \]

或者可以写成

\[f_{i,j}=\max(f_{i-1,j-1},f_{i-1,j})+a_{i,j} \]

f[i][j]=max(f[i-1][j-1]+a[i][j],f[i-1][j]+a[i][j]);//写法1
f[i][j]=max(f[i-1][j-1],f[i-1][j])+a[i][j];//写法2

Step 3:确定初始化

这个转移方程是一定要有初始化的。显然我们的起点为 \((1,1)\),所以我们就可以初始化 \(f_{1,1}=a_{1,1}\)。这样,双重循环时,\(i\) 就要从 \(2\) 开始枚举了。

当然,我们也可以认为,\((1,1)\)\((0,0)\) 或者 \((0,1)\) 得来,那么就初始化 \(f_{0,0}=f_{0,1}=0\) 了。当如果我们把 \(f\) 数组定义在 main 函数外面,就不需要了,因为初始的时候就已经等于 \(0\) 了。

Step 4:确定答案

根据题意,我们知道终点可以是最后一行的任意一个格子,即 \(f_{n,i}\) ,这里的 \(i\) 可以是 \(1\sim n\) 的其中一个,即 \(1\le i\le n\)。不妨枚举这个 \(i\),然后对于所有的 \(f_{n,i}\) 求一个最大值即可。

#include<bits/stdc++.h>
using namespace std;
const int N=1005;
int n;
int a[N][N],f[N][N];
int ans;
int main(){
//	f[0][0]=f[0][1]=0;//初始化
	scanf("%d",&n);
	for(int i=1;i<=n;i++)
		for(int j=1;j<=i;j++)
			scanf("%d",&a[i][j]);
	for(int i=1;i<=n;i++)
		for(int j=1;j<=i;j++)
			f[i][j]=max(f[i-1][j-1],f[i-1][j])+a[i][j];
	for(int i=1;i<=n;i++)ans=max(ans,f[n][i]);
	printf("%d",ans);
	return 0;
}

总结

这是我总结的dp的四步走,熟记这四个步骤你可以完成大部分的简单dp,从而入门dp。

对于这道题的一个题外话

如果设计了不一样的状态,那么我们就可能会有不一样的转移方程。其实即使设计了一样的状态,也可能会有不一样的方程,而最后的答案也可能会有所不同。

如本题可以用倒推的方案去设计,即从这两个方案入手。最后的答案时结尾具体代码就不展示了,留给读者自行思考吧。

\[(i,j)\xleftarrow{}(i+1,j) \]

\[(i,j)\xleftarrow{}(i+1,j+1) \]

练习

下面给几道例题巩固一下:

Frog 1

按照刚才的步骤,我们先设计一下状态。设 \(f_i\) 表示跳到第 \(i\) 个的最大的答案。

显然,第 \(i\) 个只能由 \(i-1\) 格或者 \(i-2\) 个跳到,所以问题就应该从 \(f_{i-1}\)\(f_{i-2}\) 转移得来,转移方程应该为

\[f_i=\max(f_{i-1}+|h_i-h_{i-1}|,f_{i-2}+|h_i-h_{i-2}|) \]

初始化为 \(f_1=0,f_2=|h_2-h_1|\)

而最后的答案就为 \(f_n\) 啦。

Frog 2

和上面那一题差不多,区别就是能跳 \(k\) 格而已,那么这里就请读者自行思考一下吧。对于新手,更需要注意的是初始化和转移的时候的一些小细节。

最大子段和

虽然这道题是可以不用动态规划来解的,但是为了锻炼我们的dp能力,所以还是试一下吧。这题还是比较经典的,并且请注意一下我下面的用词。

\(f_i\) 以第 \(i\) 个数为末尾的最大的答案。

对于第 \(i\) 个数,由两种情况:

  1. 接上以第 \(i-1\) 个数为末尾的情况,问题转化为以第 \(i-1\) 个数为结尾的最大的答案,答案应该为 \(f_{i-1}+a_i\)
  2. 直接单独成立一个,答案即自己本身\(a_i\)

也是取两种方案的最大值就可以了。

\(f_i=\max(f_{i-1}+a_i,a_i)\) 或者 \(f_{i}=\max(f_{i-1},0)+a_i\)

初始化为 \(f_1=a_1\),或者 \(f_0=0\)(即不需要)。

需要注意的是,这里的答案并不是 \(f_n\),因为最大子段和并不一定是以 \(n\) 为结尾的。但我们可以枚举这个东西以 \(i\) 结尾,答案就是 \(f_i\),并对所有的情况取最大值即可。

for(int i=1;i<=n;i++)
{
	scanf("%d",&a);
	f[i]=max(f[i-1],0)+a;//可以发现,甚至不需要开数组 
	ans=max(ans,f[i]);
}
printf("%d",ans);

又上锁妖塔

这道题和我们前面做的有一点不同,因为他有一个小小的限制,即每跳跃一次后就不能再进行跳跃了。如果我们还像上面那样设计状态的话,显然是不能处理这个限制的。

但是,我们可以开两个数组,从而做到分开设计啊。。。

\(jump_i\) 表示当前跳到第 \(i\) 层楼(就是最后一步的操作为跳)。

\(climb_i\) 表示当前爬到第 \(i\) 层楼(最后一步的操作为爬)。

这样我们就可以分开讨论啦。

根据题意,每次跳跃可以跳 \(2\) 层楼,而且跳跃的前一个操作不可能是跳跃,所以问题就转化成了求爬到第 \(i-1\) 层(即跳了 \(1\) 层)和爬到第 \(i-2\) 层(即跳了 \(2\) 层)的最小值,所以方程为 \(jump_i=\min(climb_{i-1},climb_{i-2})\)

而爬就没有限制了,爬的上一个操作既可以是跳跃,也可以是爬,所以类似的,问题转化为跳到 \(i-1\) 层和爬到 \(i-1\) 层的最小值,所以方程为 \(climb_i=\min(jump_{i-1},climb_{i-1})\)

同样的道理,第 \(n\) 层的时候我们不知道究竟是跳的还是爬的,所以一样取一个 \(\min\) 就行了。

至于初始化。。。自己想吧(doge

背包问题

背包问题是动态规划的一个小分支。其类型都可以转化成这样:

给一个体积为 \(V\) 的背包,给出 \(n\) 种物品,每种物品有一定的体积和价值,求可以获得的最大值。

根据物品的数量,背包问题又分成以下三种。

  1. 0/1背包:每种物品的数量只有一个。
  2. 多重背包:会给出每种物品的数量。
  3. 完全背包:每种物品的数量有无限个。

0/1背包

首先给出一道经典题目(学校OJ1354),这道题目足够经典了吧。。。

解释一下下面的 \(n\) 表示物品的数量,\(V\) 表示背包的体积。

首先还是我们的第一步,设计状态。设计 \(f_{i,j}\) 表示前 \(i\) 个物品放入体积为 \(j\) (这道题是时间)的背包能获得的最大价值。

接下来,第二步考虑如何转移。对于当前的第 \(i\) 个物品,有选/不选两种情况,现在我们从这里思考如何转移。(下面的 \(v\) 表示体积,\(w\) 表示价值)

  1. 选,前提是 \(j\ge v\),那么就将问题转化为将前 \(i-1\) 个物品放入体积为 \(j-v\) 的背包能获得的最大价值,此时 \(f_{i,j}=f_{i-1,j-v}+w\)
  2. 不选,那么就将问题转化为将前 \(i-1\) 个物品放入体积为 \(j\) 的背包能获得的最大价值,此时 \(f_{i,j}=f_{i-1,j}\)

还是要取最大值,所以 \(f_{i,j}=\max(f_{i-1,j},f_{i-1,j-v}+w)\)

for(int i=1;i<=n;i++)
{
	for(int j=0;j<=V;j++)
	{
		f[i][j]=f[i-1][j];
		if(j>=w)f[i][j]=max(f[i][j],f[i-1][j-v]+w);
	}
}

不难理解,答案就是 \(f_{n,V}\) 了。

时间/空间复杂度为 \(O(nV)\)

优化空间

事实上,我们可以只用一维数组来表示这些状态,即可以将第一维的 \(i\) 去掉。

我们可以看到,要想得到 \(f_{i,j}\),我们需要知道 \(f_{i-1,j}\)\(f_{i-1,j-v}\),由于我们使用二维数组保存中间状态,所以可以直接取出这两个状态。

当我们使用一维数组存储状态时,\(f_j\) 表示在执行i次循环后(此时已经处理 \(i\) 个物品),前 \(i\) 个物体放到容量 \(j\) 时的最大价值,即之前的 \(f_{i,j}\)与二维相比较,它把第一维隐去了,但是二者表达的含义还是相同的。只不过针对不同的 \(i\)\(f_j\) 一直在重复使用,所以,也会出现第i次循环可能会覆盖第 \(i-1\)次循环的结果。但是我们可以从转移方程发现,\(i-1\) 被覆盖了并没有什么影响,因为在接下来计算的 \(i+1\) 中,并不需要使用到 \(i-1\)

不过需要注意的是,当我们把二维数组变成一维数组后,需要仔细思考一下 \(j\) 的循环。请注意,在0/1背包中,\(j\) 的枚举一定是倒序!!!

优化了空间后,变成了 \(O(V)\)

for(int i=1;i<=n;i++)
{
	for(int j=V;j>=0;j--)
		if(j>=v)f[j]=max(f[j],f[j-v]+w);
	/*	
	或者可以这么写: 
	for(int j=V;j>=v;j--)f[j]=max(f[j],f[j-v]+w);
	*/
}

这里详细解释一下,为什么是倒序。

尝试自己模拟算出 \(f\) 数组,就会发现,正序和倒序的区别了。

比如现在举一个例子,\(v=1,w=2\)。如果是正序的话,那么显然 $f_1=2,f_2=4,f_3=6,\cdots $如果是倒序的话,就只是 \(f_1=f_2=f_3=f_4=\cdots =2\)。这里的区别是因为计算 \(f_{j}\) 需要使用到旧的 \(f_{j-v}\) ,然而,如果正序的话,就先把 \(f_{j-v}\) 更新了,才会轮到 \(f_{j}\) 更新。

不过正序也是有用的,适合于完全背包。

多重背包

具体的什么不多说,其实就是0/1背包再加一个数量而已,即再加一个循环。

for(int i=1;i<=n;i++)
{
	for(int k=1;k<=num;k++)//物品的数量
		for(int j=V;j>=v;j--)
			f[j]=max(f[j],f[j-v]+w);
}

时间复杂度为 \(O(nVnum)\)

这对于一般的多重背包也是够的了,但是还是有优化的方式,使得这个时间复杂度能更小一点,比如可以使用二进制优化的方法去使其优化到 \(O(nV\log num)\),也可以使用单调队列优化到 \(O(nV)\)。相比于后者,二进制优化其实更好实现。

那既然写了,就顺嘴提一下吧。首先将数量 \(num\) 分解为 \(2^0+2^1+2^2+\cdots +Res\),其中 \(Res\) 表示剩下的数。比如 \(11=2^0+2^1+2^2+4\)。接下来,我们就将这 \(11\) 个,按刚才的 \(1,2,4,4\) 分组。下面我们把每一组的物品都当成一个物品,即体积 \(v\in\{1,2,4,4\}\),价值 \(w\in\{2,4,8,8\}\)\(4\) 个物品。

不难发现,用这里任意的数组合可以得到 \(1\sim num\) 的任意数。这就是二进制优化的原理。由于将 \(n\) 分解的时候大概产生 \(\log n\)的数量,所以时间复杂度就是 \(O(nV\log num)\) 了。

这只是题外话了,具体我们只要掌握一开始给的那种就行了。

完全背包

经典题目(学校OJ1357)

这里不同的一点就是,数量是无限个的。但是显然不能装无限个物品,你会想到保留足够的数量就行了吧。比如这个背包的体积为 \(5\),物品的体积为 \(2\) ,那么只需要保留 \(2\) 个就行了。(因为最多只用到 \(2\) 个)。然而这里不是重点

重点在这里。如果保留的数量依然很多个的话,那么这种方法就不太好用了。接下来给出完全背包的一种小优化。

其实前面也有提到,再 \(j\) 为倒序的时候,就是一个0/1背包的问题,如果 \(j\) 为正序的时候,就是完全背包了。为什么呢?主要是0/1背包每个物品只有一个,然而完全背包却有无限个。

还是举 \(v=1,w=2\) 的例子。我们发现,计算出来的 \(f\) 数组中, \(f_1=2,f_2=4,f_3=6,\cdots\cdots\)。这里计算 \(f_j\) 时会用到新的 \(f_{j-v}\)。但是并没有问题,原因就是因为物品的数量是无限个的,这是你就可以把从 \(f_{j-v}\)\(f_j\) 认为是你再拿了一个物品。

for(int i=1;i<=n;i++)
{
	for(int j=v;j<=V;j++)f[j]=max(f[j],f[j-v]+w);
}

于是我们愉快的发现,时间复杂度又变成了 \(O(nV)\) 了。

关于背包的初始化

emmm,上面忘记说了

其实也挺简单的

memset(f,0,sizeof(f));

因为根据定义,\(f_j\) 表示放入体积为 \(j\) 的最大价值。但是我们一开始什么都还没放,所以当然全部都是 \(0\) 啊。

练习

P1049 [NOIP2001 普及组] 装箱问题(0/1背包,不过要清楚这时候状态该是什么?初始化应该是什么?)(学校OJ1356)

P1679 神奇的四次方数(告诉你们,mwj不会这道题)(学校OJ1358)

课后例题

既然都学会了,那么来试一试真题吧。

P8816 [CSP-J 2022] 上升点列(学校OJ1294)

结言

dp的路上还很漫长,但是绝对也值得期待。

本来还想继续写各种dp的,比如区间dp,树形dp,状压dp,数位dp,等等。但是因为就这样吧。实质是我鸽了,有时间就写吧。

话说不放学校OJ的网站真不是我懒,而是我怕有别的人过来乱搞。

posted @ 2023-02-12 20:38  大眼仔Happy  阅读(983)  评论(0)    收藏  举报