作为考察范围最广,考察次数最多的算法,当然要开一篇博客来复习啦。
子曰:温故而知新,可以为师矣
我复习DP时有一些自己对DP的理解,也就分享出来吧。
——正片开始——
动态规划算法,即Dynamic Programming(以下简称为DP),是解决多阶段决策过程最优化问题的高效数学方法。自从1999年IOI出了一道名为"数字三角形"的题后,DP题就在OI竞赛中广为流传。而上面提到的"数字三角形",现在就是DP的一道入门题。
递推和DP的关系:
很多人会混淆递推和DP,递推只是DP的一种实现方式。我们提到的DP是一种高效的算法,实现方式主要是递推和记忆化搜索,但是DP和递推很像的一点就是,它们都是利用子问题来搞定原问题。
我举一个例子,斐波那契数列,相信大家都不陌生。
我们知道Fib的递推式:F(x) = F(x-1) + F(x-2),可以通过第x-1项和第x-2项推出第x项。
如果我们从DP的角度来看Fib,我们可以把求第x项看成原问题,然后我们把求第x-1项和求第x-2项看成子问题,我们就把"求第x项"这个原问题划分成了"求第x-1项"和"求第x-2项"两个子问题,然后继续划分子问题并求解,最后利用所有的子问题得到原问题的解。
很多DP的入门博客都会仔细详解DP的三个基本性质,解题步骤和使用DP的特征。
不明白这些的,我从@mengmengdastyle的blog中摘取了这一段给你们看:
(2) 动态规划包含三个重要的概念:
- 最优子结构
- 边界
- 状态转移方程
(3)解题的一般步骤是:
1. 找出最优解的性质,刻画其结构特征和最优子结构特征;
2. 递归地定义最优值,刻画原问题解与子问题解间的关系;
3. 以自底向上的方式计算出各个子问题、原问题的最优值,并避免子问题的重复计算;
4. 根据计算最优值时得到的信息,构造最优解。
(4)使用动态规划特征:
1. 求一个问题的最优解
2. 大问题可以分解为子问题,子问题还有重叠的更小的子问题
3. 整体问题最优解取决于子问题的最优解(状态转移方程)
4. 从上往下分析问题,从下往上解决问题
5. 讨论底层的边界问题
以上。
本文注重思路和解题技巧,对于基本知识不多赘述。
我们先来分析一道题,通过这道题让你明白DP是用来干什么的。
给你一个高度为x(1<=x<=2x10^5)的楼梯,每次可以向上走1级或者2级,问你走到顶一共有多少种走法。
输入样例:
10
输出样例:
89
我们来分析一下这道题,假如x=1,答案你可以很轻松的得到,等于1,如果x=2呢?答案是2,也很容易得到。
但是我们的x是可以很大的,我们不可能对于每个x都手算出结果存下来。
于是我们考虑分解问题,将原问题分解成若干子问题,然后对于每个子问题也分解下去。
“大事化小,小事化了。”
那我们怎么分解这个问题呢?如果我们要求走3级的走法数,首先我们看看能否用走1级和走2级的方案数凑出走3级的方案数。于是,我们发现,确实可以。我们走3级的走法数就是走1级的走法数+走2级的走法数。
至此这个问题得解了:走x级的走法数=走x-1级的走法数+走x-2级的走法数。
这题就是求一个斐波那契数列,我们利用递推得到答案。
介绍更困难的题目前,我打算讲一讲DP本身。
在OI竞赛中,我们使用计算机计算答案。但是我们用计算机只能记录下一些状态,我们需要利用记录下来的状态去求解,于是我们要尝试把问题定义成一个状态。很多人在讲DP的时候,说需要“递归”的定义状态。对于这种说法,我们一般用两个字来形容:扯淡。我们确实需要定义状态,但是,每个问题都可以被定义成状态,我们要做的首先就是思考怎么去定义它,一般来说就是思考我们存什么变量,算什么变量,用这些变量怎么得出解。
我们一般把问题划分成不同阶段,每个阶段有不同状态。但是,我们并不需要算出所有状态存下来,我们每一步只需要存最优的答案就行了,这就是DP用来求最优解的原因。既然问题都是可以划分成阶段和状态的,那么,某一阶段的最优解就一定可以通过之前阶段的最优解得到。
但是,如果我们仅通过前一个阶段的答案算不出当前阶段的答案呢?如果我们需要前面所有阶段的答案呢?
如果在问题的每个阶段,一个状态都可以转移到下一个阶段的多个状态,那我们计算解的时间复杂度就是指数级别的,也就是说我们并不能用DP来解决这个问题。这种,前面的决策会影响后面的情况,就被称为有后效性。
还记得DP的三个要素之一吗?无后效性。
记得搜索的入门题吗?01迷宫。
如果我们要找到从起点到终点的最短路径,我们可以只保存当前阶段的状态吗?显然不行。想想我们当时做的时候保存的状态?{x,y,step},以及一个vis数组。由于题目要求我们求的路径最短,我们必须知道之前走过的所有位置。
即使我们当前在同一个位置,我们之前走的路线不同,也是会影响到我们后面选择走的路径的,因为我们不会走已经标记vis过的格子了。
所以我们必须保存每个阶段经历过的所有状态才能得到下一个阶段的解。
这就是有后效性的问题的一个例子。
如果我们需要记录之前所有的状态,我们的复杂度就是指数级的,但是DP呢?
我们并不需要记录之前的所有状态,我们当前的决策并不受之前状态的组合的影响了,就可以多项式时间内出答案了。
引用一段@X丶dalao的blog:
每个阶段只有一个状态->递推;
每个阶段的最优状态都是由上一个阶段的最优状态得到的->贪心;
每个阶段的最优状态是由之前所有阶段的状态的组合得到的->搜索;
每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到而不管之前这个状态是如何得到的->动态规划。
每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到
这个性质叫做最优子结构;
而不管之前这个状态是如何得到的
这个性质叫做无后效性。
好了,现在我们讲题。
网上各种什么,让你彻底学懂DP啊,特别的DP入门教程啊,其实都不如自己多写点DP题来的实在...
下面我将从几道例题开始,从易到难慢慢打开DP的大门。
1. 石子合并:
有n堆石子排成一列,每堆石子有一个重量w[i], 每次合并可以合并相邻的两堆石子,一次合并的代价为两堆石子的重量和w[i]+w[i+1]。问安排怎样的合并顺序,能够使得总合并代价达到最小。
首先我们来划分阶段,我们有一坨长度为n的石子堆,我们每次合并后,石子堆的数量都会减少,那我们就从这里切入。
直观地想,我们可能会这样划分阶段:
我们要合并石子,肯定就要找一个地方,把它两边的石子合并起来。
设f[x]表示合并了x次的最小总代价。立刻就能发现不对...我们选定不同的地方来合并,每次的答案时不同的,也就是说f[x]的值不定,这时肯定是得不到最优解的。有人可能会有疑问,f[x]不是定义成了最小定义的代价了吗?
那你回去仔细看看上面说的关于状态定义的内容。
所以我们需要重新定义状态,这里给出一种划分方法,我们用f[i,j]表示合并区间左端点为i,右端点为j的这段区间合并成一堆石子的最优值。
为什么这么定义呢?这就涉及到一类问题:区间DP。
对于区间DP,我们利用区间长度作为阶段,用左右端点表示状态。这种定义方法可以解决大部分的区间DP问题了,但是遇到一些难题,我们还需要加维度来解决。
我们上面提到过,要合并两堆石子,我们就要循环一个分界点,我们定义一个分界点k,枚举这个分界点找最优解。这个过程我们称之为——决策!
然后我们利用决策转移状态(用子问题求解出原问题):
下面这个式子就是我们常听到的“状态转移方程”:
f[i,j] = f[i][k] + f[k+1][j] + cost(i,j),其中cost(i,j)表示合并两堆石子的代价。
然后我们思考一下状态的可选范围,i表示左端点,j表示右端点。
i: 1~n-len+1,j:i+len-1,这样我们就保证了既不超出边界,又能保证我们的阶段是区间长度len。
阶段就是len:2~n
这种做法时间复杂度是O(n^4),我们发现没法简化定义了,于是我们O(n^3)预处理出cost(i,j),再O(n^3)DP得出答案。
伪代码大概是这样的:
for(i,1,n) for(j,i,n) for(k,i,j) cost(i,j)+=w[k] for(len,2,n) for(i,1,n-len+1) j=i+len-1 for(k,i,j) f[i][j]=min(f[i][j],f[i][k]+f[k+1][r]+cost(i,j))
如果还是不太理解的可以仔细去看看这道题的题解,博主这一篇博客只打算讲思路,不仔细讲例题。接下来我们看这样一道题:没有上司的舞会
题目描述已经说了,它们的关系像一棵有根树,那我们就在树上DP。这种依赖树形结构的DP我们也把它们划分为一类:
树形DP。
这时候可能就有人想问了,既然也是一类DP,它的阶段划分是不是也和区间DP一样,有套路呢?
没错。树形DP依赖树形结构,那么我们很容易想到树的性质,父亲和子节点的关系一一对应,我们可以通过子节点的信息计算父节点的信息。也就是,这类题已经帮我们划分好阶段了,节点从深到浅的顺序就是我们的阶段,我们用一个从上到下的遍历来进行DP,对于每个子节点x先往下递归在它的每个子节点进行DP,再在回溯的时候从子节点向节点x转移状态。这样我们需要做的就是定义状态了。定义状态也很容易,我们一般选择每个节点的编号x作为状态的第一维,再根据不同题目的需求加维进行DP。
回到这道题目上来,我们根据上面的内容,先定义第一维为节点编号,然后我们会发现,一个节点的信息值只与它参不参加舞会有关系,于是我们定义f[x][0/1]为它参加/不参加时的值。
题目也明确说了,如果一个人的直接上司参加了舞会他就不会参加,那我们就可以轻松的得到状态转移方程:
f[x][0]+=max(f[y][0],f[y][1]),f[x][1]+=f[y][0],y∈son(x)
然后递归求解就好了。
写到这里我发现我实在是讲不完所有的DP类型了,于是我们后面会跳过几种DP分为下一篇来谈。
放到下一篇讲的DP(状压DP,计数DP,数位DP,概率与期望DP,所有优化方法)
那么我们就回过来讲DP中一类很特殊的问题:背包问题
什么是背包问题?我们先从基础的0/1背包开始,逐步分析背包问题的模型。
给你n个物品,其中,第i个物品的体积为wi,价值为vi。再给你一个容积为m的背包,现在让你在不超过容积的范围内选出一些物品装入背包让价值尽可能大。
首先我们可能会想到用贪心来解决这道题目,但是贪心很显然是错误的。
我们贪心的策略很显然是每次选择“性价比”最高的物品,也就是wi/vi最大的物品。
但是,对于0/1背包问题,贪心选择之所以不能得到最优解是因为:
它无法保证最终能将背包装满,部分闲置的背包空间使每公斤背包空间的价值降低了。
明白这一点后,我们考虑用DP求解。
如何划分阶段呢?很显然,我们依次考虑是否选择每件物品,然后我们还需要知道现在背包用了多少容积。
那我们就设f[i][j]为前i件物品中装了j体积的物品的最大价值。
这里我们用另外一种思路来想转移方程式,我们不分解这个问题,我们直接考虑状态怎么推进。
我们前i件物品中装j体积的最大价值很显然是由前i-1件物品装了某体积的物品,再考虑选不选择当前这件物品转移过来的。
那我们很容易就可以得到如下的转移方程:
f[i][j]=max(
f[i-1][j],//选这件物品
if(j>=wi)
f[i-1][j-wi]+v[i] //不选这件物品
else
f[i-1][j] //选不了这件物品
)
阶段:前i件物品(i∈[1,n])
状态:当前的容积(j∈[m,0])
这样我们的空间复杂度是O(n^2)的,考虑优化空间。
我们发现第一位是可以省略的(我们按顺序依次考虑每个物品即可)。
于是...就变成了这样:
for(int i=1;i<=n;i++) for(int j=m;j>=w[i];j--) f[j]=max(f[j],f[j-w[i]]+v[i]);
我们每个物品只能放一次,而我们的f[j]要通过f[j-w[i]]计算得到。
如果,我们使用正序循环,从w[i]到m,那么我们可能出现这种情况:
f[j]被f[j-wi]+vi更新过,当我们j增加到j+w[i]时,f[j+w[i]]又有可能被f[j]+vi更新,而同时它们都处于阶段i,也就是说,我们在一个阶段内的两个状态间发生了转移,相当于第i个物品被使用了多次(如果后面又更新了),不符合0/1背包的要求。
所以我们要倒序循环,这样我们的j会一直缩小,不会出现“同阶段间转移”的情况,所以,问题至此得解。
接下来我们要讲完全背包,思考这样一个问题:
给你n种物品,每种物品有无数个,其中,第i种物品的体积为wi,价值为vi。再给你一个容积为m的背包,现在让你在不超过容积的范围内选出一些物品装入背包让价值尽可能大。
注意它和0/1背包问题的区别,0/1背包每种物品只有一个,而完全背包有无数个。
细心的读者可能发现了,我们上面说,当我们正序循环时,相当于一个物品被使用了多次,符合完全背包的要求...那我们只要正序循环,是不是就?
还是太想当然了啊,当然没错。
然后我们讲讲多重背包吧,还是一个类似的问题:
给你n种物品,每种物品有ci个,其中,第i种物品的体积为wi,价值为vi。再给你一个容积为m的背包,现在让你在不超过容积的范围内选出一些物品装入背包让价值尽可能大。
每种物品从无数个变成了ci个,也就是有了限制,怎么做呢?
水题啊!我们把每种物品看成ci个不同的物品不就好了?然后跑一遍0/1背包,问题不久得解了咩?
天真啊,这样的时间复杂度可是 $O(m*\sum\limits_{i=1}^nc_i)$ 的啊(第一次用Latex有点不习惯)
那咋整啊?我们又延伸出了:单调队列优化DP。
DP的种类真是数不胜数...不过优化DP是下一篇的内容,这里不再叙述。
不想用单调队列优化DP来解决多重背包的话,我们可以二进制拆分多重背包。
我们大概是这样一个拆分思路,把每一种物品拆成log个不同物品。
大概是这样拆:
int cnt=0; for(int i=1;i<=n;i++){ int a,b,c; cin>>a>>b>>c; for(int j=1;j<=c;j<<=1){ v[++cnt]=j*a,w[cnt]=j*b; c-=j; } if(c)//剩下拆不掉的部分,直接当新物品 v[++cnt]=c*a,w[cnt]=c*b; }
思路还是很简单的,但是很巧妙。拆完就是一个0/1背包了,很水。
我佛了...还有个分组背包没讲...这篇博客都写了2天了QuQ,DP真难讲。
限于篇幅和时间,树上的背包问题我留到下一篇的开头...
给出分组背包的模型:
给你n组物品,每组物品有ci个,其中,第i组第j个物品的体积为wi,j,价值为vi,j。再给你一个容积为m的背包,现在让你在不超过容积的范围内每组至多选一个物品装入背包让价值尽可能大。
分组背包是一类树形DP的很重要的组成Part,所以熟练掌握它还是很重要滴。
这个问题我们怎么做呢?
考虑用线性DP解决(...雾),我们要满足“每组至多选择一个物品”的要求,就可以利用“阶段”线性增长的特性,把物品组数作为阶段,只要选了一个第i组的物品,就转移状态到下一个阶段就好了^-^。然后仿照0/1背包的做法,设f[i][j]表示从前i组中选出总体积为j的物品放入了背包,物品的最大价值和。
f[i,j]=max{
f[i-1,j],//不选第i组的物品喵
max{f[i-1,j-wik]+vik},(1<=k<=ci)//选第i组的某个物品k喵->是做决策哒
}
上面那东西不是我敲的...但是她不让我删掉
我们还是可以省略掉第一维,别问,问就是这是背包问题。为什么呢?因为我们可以用j的倒序循环来控制阶段i的状态只能由阶段i-1转移得到。
至此问题得解,给出代码:
for(int i=1;i<=n;i++) for(int j=m;j>=0;j--) for(int k=1;k<=c[i];k++) if(j>=w[i][k]) f[j]=max(f[j],f[j-w[i][k]+v[i][k]);
总结一下,这篇博客我们接触并初步学习了动态规划算法,并对DP的本质有了一定的了解,明白了设计DP算法求解问题的一般思路。没错,设计DP算法,DP算法迷人的地方就在于,对于每道DP题,都需要自己去设计一个合理且高效的DP算法去解决问题,这也是DP难的地方。除此之外,我们还学习了几种常见的DP模型,加深了对“阶段,状态,决策”的理解。DP题要难可以难上天,要简单可以一眼秒,但是本质上看它们都是考察同一个东西:脑子。DP题其实不难个鬼,只要你理解了DP的基本实现方法,稍加思考,把问题转化一下,就很容易想到如何用DP去求解答案。
对于这种依靠思维的题目,其实不用写很多题。虽然刷题是必不可缺的,但是对于写过的每道DP题,都确保自己理解了思路,明白了为什么这样设计DP,就可以总结出一套自己应对DP题的方法和技巧,每个人写DP题的方法都是不尽相同的。希望通过这篇博客,能让你喜欢上动态规划算法。
更深层次难度更高的DP,我会在下一篇博客里讨论。不过就连这篇博客我都写了整整两天,下一篇可能我要写挺久了。除非我够肝。
你们的点赞就是我最大的动力(其实我就是想自己整理整理...),感谢你们的支持。