浅谈关于动态规划问题的优化方案
很多动态规划的题目不仅仅要求正确性,还要求极快的速度,因此有时单纯的动态规划手段也可能超时,于是便需要一些适当的优化,本文主要浅略地谈一下使用单调队列或者斜率知识优化动态规划问题的手段。
1.单调队列优化
单调队列是一种具有单调性的队列,其中的元素全部按照递增或者递减的顺序排列,就比如下面这个递减队列。
假如说我们要在队尾加入一个,那么我们入队的步骤就是这样的:
发现队尾
,(q[tail]),
,则将1退出(tail--)
发现队尾,(q[tail]),
,则将2退出(tail--)
发现队尾,(q[tail]),
,则将3退出(tail--)
发现队尾,(q[tail]),
,停止退出队尾,将
入队。
经过上述步骤之后队列变为了{8,5},依然满足递减的单调性,而实际上这也就是单调队列的基本操作。而维护递增的方式也是一样的。
const int MAXN = 1000010 ; int N, A[MAXN], Q[MAXN], Head = 1, Tail = 1 ; for (int i = 1 ; i <= N ; i ++) { N = Read() ; for (int j = 1 ; j <= N ; j ++) A[j] = Read() ; Q[1] = A[1] ; //将第一个元素入队。 for (int j = 2 ; j <= N ; j ++) { while (Head <= Tail && Q[Tail] < A[j]) //如果队列不为并且队尾元素小于A[i] Tail -- ; // 弹出队尾元素 Q[++ Tail] = A[j] ; // 入队。 } }
【例题1】
我们现在有一个整数序列,长度为
,又知两个参数
和
,要求:从
序列中找出
个不相交的区间,每段区间长度
<=
,要求所有k个区间的区间和最大。
考虑最基本的,设
表示从前
个数里面选出来
个长度不超过m的不相交区间的区间和最大值,然后我们再枚举一个
,指选择
这个子区间。然后我们创造一个前缀和数组
,那么
这个区间的区间和就是
。子问题分为两块:
选入子区间,或者
不选入子区间,从
到
范围内枚举一个
使得
最大,然后与
取一个
可得答案。
for (int i = 1 ; i <= N ; i ++) Sum[i] = S[i - 1] + A[i] ; for (int i = 1 ; i <= N ; i ++) for (int j = 1 ; j <= N ; j ++) { int Ans = - Inf ; for (int k = j - M ; k <= j ; k ++) Ans = max(Ans, Dp[i - 1][k] + Sum[j] - Sum[i]) ; Dp[i][j] = max(Ans, Dp[i][j - 1]) ; }
这样的时间复杂度为
,显然太大,于是我们考虑优化。
我们可以看到的原式子是
我们发现在里面的的最优化枚举当中,sum[j]是不随k的枚举变化的,所以我们可以将sum[j]提出来变成:
可以知道在整个式子里面最耗时间的就是最后关于最大值的枚举,所以只要快速计算出来了
就可以快速计算整个式子。我们来看
的范围是在
这些区间上的最大值,也就是所有的
的区间。
我们发现这些区间的左右端点都是单调递增的,所以我们可以利用单调队列在的时间内解决这些区间。然后我们就将时间优化到了
。
【例题2】
瑰丽华尔兹(link)
一个的矩形网格。你初始站在
这。有些格子有障碍而有些没有。有
个时间段。第
个时间段从
持续到
(包括两端)这段时间内网格会向某个方向(上下左右之一)倾斜。所以每个时间段内的每个时间单位,你可以选择在原地不动,或者向倾斜的方向走一格(当然你不能走到障碍上或是走出网格)。
求你最多能走多少格。
如上图所示,黑色方块为障碍,为起始点。
按照最常的思路来看,我们设
为在k时间点,从
节点走到了
节点的时候最长走了多长。初始化
全部为
,而
=
(
为初始位置),考虑子问题就是:从那边来?
时刻是从那个方向来还是不动?我们以第
时刻向右倾斜为例。
如果是向右倾斜,那么上一层状态就是在地点,那么结合两个子问题我们可以得出
方程式:
for (int k = 1 ; k <= Len ; k ++) for (int i = 1 ; i <= N ; i ++) for (int j = 1 ; j <= M ; j ++) Dp[k][i][j] = max(Dp[k - 1][i][j], Dp[k - 1][i][j - 1] + 1) ;
那么这样的时间复杂度就是,是无法通过这个题的全部数据的。然后我们紧接着考虑怎么优化。关于位置的
枚举我们没有什么办法,但是关于
我们可以进行优化,时间点很多有
个,但是时间段
却<=
,那么我们可以将一段时间的转移全部合并起来一起算,那么就快得多了。
我们设为在第
个时间段末尾,从
走到了
点,
为第
个时间段的持续时间,可以算出是
。
首先还是的枚举,和
时间段的枚举,之后我们还有一个
的枚举,这个
枚举的是**上一个状态加上在当前这个
的时间段内一共走的步数对应倾斜方向的横、竖坐标**,如果我们继续以右倾为例,那么
,就是从完全不动到走了最多的
步,那么我们有了状态转移方程式:
由于其中的+与l的枚举并无关联,所以提出来就变成了
其实也就是枚举这个时间段之前这个人的位置在哪,也就知道了当前的是从哪里转移过来的。
之后,我们回过头来看上一道题的最后的方程式:
是不是发现格式非常的相似呢?,我们固定住之后的状态转移方程式基本是和上题一样的,所以一样可以使用单调队列优化到
。
下面针对一组样例,我们进行一遍手动模拟,以帮助更好的理解。
就用洛谷的样例吧。(第一行分别为n,m,x1,y1,k)
4 5 4 1 3
. . xx.
. . . . .
. . . x.
. . . . .
1 3 4
4 5 1
6 7 3
那么画完图之后就是这个样子:
从~
时刻的倾斜方向是右,那么纵坐标是你不变的,我们枚举纵坐标。
for (int i = 1 ; i <= K ; i ++) { int S = Read(), T = Read(), Dir = Read() ; //注意要反着DP,也就是倒退 int Len = T - S + 1 ; switch(Dir) { case 1 : for (int j = 1 ; j <= M ; j ++) DP(i, N, j, Dir, Len) ;//北面的话横坐标不变,那么我们枚举纵坐标 case 2 : for (int j = 1 ; j <= M ; j ++) DP(i, 1, j, Die, Len) ;//南面的话横坐标不变,那么我们枚举纵坐标 case 3 : for (int j = 1 ; j <= N ; j ++) DP(i, j, M, Dir, Len) ;//西面的话纵坐标不变,那么我们枚举横坐标 case 4 : for (int j = 1 ; j <= N ; j ++) Dp(i, j, 1, Dir, Len) ;//东面的话纵坐标不变,那么我们枚举横坐标 }
然后当我们的横坐标x枚举到1的时候,我们在DP函数里面定义一个now,然后是&&
&&
&&
,因为首先要保证不超过边界。然后如果我们发现右面是可以走的,那么我们就进行一个push操作。也就是关于dp[p-1][x][y]在单调队列里面的入队操作。在最前面我们已经介绍了。
inline void Push(int Now, int V) { if (V == Inf) return ;// 如果根本到不了,返回 while (Heap <= Tail && V - Now >= Q[Tail]) Tail -- ; //弹出队尾元素 Q[++ Tail] = V - Now ; Pos[Tail] = Now ; // Pos记录位置,用来判断是不是可以滑 }
而至于为什么要在里面减去一个
,是因为(x,y)这个位置不一定是在当前方向的起点上,因为之后某一步的步数减去当前的步数得到的值就是(x,y)到那一步在的点的距离,相当于一个化简~
由于=-
,当前的
=
所以
-
的时候
就是-
,所以在第0个时间段到不了这个地方,我们直接返回。然后下面其实就没什么事了,所有的push全部直接返回,最后退出DP函数。就这样进行到
(即
)=
的时候,我们发现
是一个障碍点,那么也就是说我们之前进行的所有工作全部无效,然后我们将整个队列清空,即
=
=
;
然后接着进行到=
,
=
(
行
列)的时候,我们到了起始点,而起始点的dp[0][4][1]是0,所以
!=-
,我们从终于将一个值
-
=-1入队了,那么我们当前的队列是这个样子的:
加上步数之后我们发现依然是
,所以
没有被更新(废话,你从起点走到起点需要更新
嘛),所以我们继续向下进行,因为每次
都会++,所以下面的
加上
之后就可以更新
的值了。然后进行到
=
,
=
的时候,我们发现
,大于可以
,也就是说超过了可以滑动的区间。(一共就三秒你怎么滑第四块啊~)那么我们将队首弹出,接下来我们就不能再更新ans的最大值了,
=
时完美结束。这个时候我们的行走路径大概如下:
(蓝色方块为当前方块,黄色方块为路径)
也就是说从) 我们最多可以走3块。(真是麻烦啊
继续走,我们进行到下一个时间段。
~
的时候是向北倾斜的。那么我们进行
,我们从
行
列开始
,第一次将
弹出后又入队我们不管,因为
、
的时候都不能更新
,然后到了
的时候,我们将
入队了。
然后当进行到第三次的时候我们就可以更改ans值为4了。
之后结束了第二个时间段。此时的路径大概是这样的:
最后在第三个时间段内,我们将路径更改为如下:
那么以上就是整个样例的模拟,最终我们得到数为6.
关于单调队列优化的一点总结
鉴于两者之间的转移方程的相似性,我们成功的利用单调队列优化了问题,那么回过头来看看,什么样的问题可以利用单调队列进行优化呢?我们最上面讲的单调队列是具有单调性的一种数据结构,他可以保证数据的单调性,自然也就可以留下数据的最大值或者最小值,利用了单调性,就是减少了一位枚举,减去一维,直接获得单调队列里面的最优解。并且DP可以使用单调队列优化,当且仅当
式的格式基本满足
的时候。即“=
+
中的最小/大值
为常数
”,当你发现要求
而且求可能拓展的状态有线性关系的时候,你就可以考虑单调队列优化了。
2.斜率优化
如果你有一个这样结构的式:
我们发现取里面的d[i]其实和j的枚举并没有关系,所以将d[i]提出来就变成了这样:
(只是这样的结构而已,比如也可以是,
也不一定属于
此处只是一个单独情况)
其中都是关于
的函数,并且
是单调递增的,那么我们接下来利用数学归纳法证明其决策的单调性。
首先我们假设有两个决策,
满足
并且
的决策优于
的决策。那么我们有
消去左右两端的d[i]后可得
我们知道在后面有状态
,我们为了简单起见,设
是单调递减的,那么我们就是要证明
由于是单调递减的,所以
也可以写成
的形式,其中
,那么式子变成:
然后因为
并且,所以我们有
,所以我们知道
是正确的,决策单调性也存在。然后我们将式子展开就可以得到这样一个玩意:
。
你会发现这个玩意像极了一个叫做斜率的东西(数学就是瞎出来的)
:
没错,这个东西就是我们要讲的第二个内容:斜率优化!
记斜率为
那么我们紧接着这个式子的属性,发现它和单调队列有很多相似之处,因此我们也可以根据这些特性对
进行优化:
。
我们知道是在
之前就已经输入完毕的了,那么这个式子就表示
不如
更优,所以弹出队首。
2.
。
假设在之后会出现一个使得
,那么在弹出
之后,
;
我们考虑将每一个作为一个点对放到一个平面直角坐标系当中,画一条过点
的斜率为
的直线,那么这个直线的方程式就是
,所以我们就成功的将一个决策转化到了平面直角坐标系上面。
假如说我们将所有的决策转化为点映射到图上可以得到这样一个图:
那么我们对于每一个点做一个斜率为的直线可以得到这样的一幅图
由于我们要枚举的是一个是决策最优的方案,那么我们就要根据题意,来看看是还是
,在这里我们的方程式是取的
,也就是要在所有的决策中找到一个位置最靠下的,也就是最右面那道直线。
但是这样每一条边每一条边地建、找实在是太慢,于是我们考虑如何优化。
我们知道最下面的一条边肯定会在一个下凸的凸壳上面(比如下图),那么我们就要想办法维护这个凸壳,将不可能有贡献的点删去。
那么上图中被下面的这条折现包起来的所有的点就都没有作为最优决策的机会,因此我们就要抛弃这些决策,并且有可能被选为最优决策的只有在这个下凸壳上面的点。
而对于整个下凸壳的求法,我们考虑先排一遍序,按照x[i]从小到大的顺序排序,然后由于x[i]递增,那么我们就只需要考虑在右面添加点就可以了。对于一个点v,如果它是由前面两个点组成的直线逆时针旋转过来的,那么很好,我们就将它加入目前的下凸壳,比如下图:
但是如果当前点是由前面两个点
,
所形成的直线经过顺时针旋转得到的,那么我们就要删除之前的最后一个点
,继续判断
是不是由
和
前面的点顺时针旋转得到的,直到碰到一个逆时针旋转的操作,那么停止,并将最后删除的点的前一个点连到
点上。比如下图:
删除第一个节点之后继续判断
而对于上凸壳的寻找,就是讲寻找下凸壳中所有的顺逆时针反过来就可以了。而顺时针和逆时针的判断就是根据斜率。当然你也可以这么判断:若 ,则是顺时针转的;否则是逆时针转的(
的情况是三点共线)。
那么维护完了凸壳,加下来我们要做的就是在凸壳上找到一个斜率为的切线了,而这个切线的寻找分为两种情况来考虑。(针对下凸壳)
1.当斜率是递增的时候,我们可以发现被取到的点也是越来越靠右的,所以我们只要从左到右依次删除不优的点就可以了。可以使用双向队列。
2.当斜率不单调的时候,我们就采用二分的方法。我们一直取中间的点,如果
存在并且与
点构成的直线的斜率小于
,那么
吗,接着二分;如果
存在并且与
构成的直线的斜率大于
,那么
,接着二分。如果上面两个条件都不满足,**那么
就是切点,也就是我们要找的点啦!!**
【例题3】[HNOI2008]玩具装箱(Link)
题目描述 教授要去看奥运,但是他舍不下他的玩具,于是他决定把所有的玩具运到北京。他使用自己的压缩器进行压缩,其可以将任意物品变成一堆,再放到一种特殊的一维容器中。
教授有编号为
的
件玩具,第
件玩具经过压缩后变成一维长度为
.为了方便整理,
教授要求在一个一维容器中的玩具编号是连续的。同时如果一个一维容器中有多个玩具,那么两件玩具之间要加入一个单位长度的填充物,形式地说如果将第i件玩具到第j个玩具放到一个容器中,那么容器的长度将为
制作容器的费用与容器的长度有关,根据教授研究,如果容器长度为
,其制作费用为
.其中
是一个常量。
教授不关心容器的数目,他可以制作出任意长度的容器,甚至超过
。但他希望费用最小.
输入输出格式
输入格式:
第一行输入两个整数,
.接下来
行输入
.
,
输出格式:
输出最小费用
解析
简单来说,我们有一个长度为的序列
,要求将序列分成若干段,每一段如果从
到
,整段的和为
,那么就会产生
的代价,要求得到最小的代价和。
那么就是
,那么我们就可以把式子简化成这样:
所以你可以发现如果将输入的所有加上
并且将
全部加上
的话,费用就变成了
。
设为
点的前缀和,我们得到
式子为
嗯,按照上面的节奏,我们将范围内的式子变一下:
然后我们令,式子就变成了这样:
然后把里面的平方展开
然后稍微一个移项
然后我们看这个式子的格式就很熟悉了
就是前面搞的直线的解析式!所以我们知道这么一个转化
并且我们还知道就是上面的
的截距。那么我们将所有的
点全部加到平面直角坐标系上,然后维护下凸壳就可以啦!并且你可以发现斜率是一个单调递增的哦~
并且这里还有一个很重要的地方:看上面的那个的方程是
而实际上这里并不是一个关于
的双变量,我们在对于同一个
计算的时候,求斜率坐标相见就已经抵消掉了这个
的部分。
至于凸壳的寻找方法和最优点的寻找方法上面已经有比较详细的介绍了,就不再多说,上代码讲解就好了吧。
inline double X(LL j) {return S[j] ;} inline double Y(LL i) {return F[i] + (S[i] + L - 1) * (S[i] + L - 1) ; } inline double Slope(LL i, LL j) {return (Y(j) - Y[i]) / (X(j) - X(i)) ;} int main() { N = Read(), L = Read() ; L ++ ; Head = Tail = 1 ; for (int i = 1 ; i <= N ; i ++) { LL X = Read() ; S[i] = S[i - 1] + X + i ; } for (int i = 1 ; i <= N ; i ++) { while (Head < Tail && Slope(Q[Head], Q[Head + 1]) < 2 * S[i]) Head ++ ; LL j = Q[Head] ; F[i] = F[j] + (S[i] - S[j] - L) * (S[i] - S[j] - L) ; while (Head < Tail && Slope(Q[Tail - 1], Q[Tail]) > Slope(Q[Tail], i)) Tail -- ; Q[++ Tail] = i ; } Print(F(N)) ; return 0 ; }
【例题4】 土地征用 (Link)
约翰准备扩大他的农场,眼前他正在考虑购买N块长方形的土地。如果约翰单买一块土 地,价格就是土地的面积。但他可以选择并购一组土地,并购的价格为这些土地中最大的长 乘以最大的宽。比如约翰并购一块3 × 5和一块5 × 3的土地,他只需要支付5 × 5 = 25元, 比单买合算。 约翰希望买下所有的土地。他发现,将这些土地分成不同的小组来并购可以节省经费。 给定每份土地的尺寸,请你帮助他计算购买所有土地所需的最小费用。
输入输出格式
输入格式:
_
_
输出格式:
解析
我们定义结构体中含有
分别表示一块土地的长和宽。
考虑一块土地,如果有一块土地
的
和
都大于
,那么
的存在是没有意义的,因为
是可以不耗费任何代价被
所合并的,所以它不会对答案产生任何影响。于是我们考虑去掉这样所有的土地
。
首先我们将所有土地按照长度从小到大排序,长度相同的按照宽度从小到大排序。定义一个,然后连续将所有的土地入栈,在
入栈之前将之前栈中所有宽度小于等于
的土地全部弹出,然后入栈
。那么最后在栈中的元素就是我们所希望的元素。这里的元素是按照长度从小到大,宽度从大到小的顺序有序排列的。
那么显然我们每次合并的都是一个连续的区间。考虑使用,易得状态转移方程:
其中Stack里面存的是元素的宽度,L是栈中元素的长度。(因为有些土地被抛弃了所以我们不能继续使用结构体),然而这样的时间复杂度会超时,考虑斜率优化。
我们看到后面的 ,设
然后,那么我们就得到了直线方程:
。套上斜率优化的板子即可。
inline void Put_in_Stack() { for (int i = 1 ; i <= N ; i ++) { while (Top && S[Top] <= E[i].Y) Top -- ; S[++ Top] = E[[i].Y] ; L[Top] = E[i].X ; } } inline double Calc(LL i, LL j) {return Dp[j] + S[j + 1] * L[i] ; } inline bool Sloop(LL a, LL b, LL c) { return (B[c] - B[a]) * (A[b] - A[a] - (B[b] - B[a]) * (A[c] - A[a]) >= 0) ; } int main() { N = Read() ; for (int i = 1 ; i <= N ; i ++) E[i].X = Read(), E[i].Y = Read() ; sort(E + 1, E + N + 1, CMP) ; Put_in_Stack() ; A[0] = S[1] ; for (int i = 1 ; i <= Top ; i ++) { while (Head < Tail && Calc(i, Q[Head]) >= Calc(i, Q[Head + 1])) Head ++ ; Dp[i] = Calc(i, Q[Head]) ; A[i] = S[i + 1] ; B[i] = Dp[i] ; while (Head < Tail && Slope (Q[Tail - 1], Q[tail], i)) Tail -- ; Q[++ Tail] = i ; } Print(Dp[Top]) ; return 0 ; }
总结
以上就是关于动态规划的单调队列和斜率优化的相关内容,世界上动态规划的优化远不止如此,结合具体问题,动态规划还可以套上各种数据结构,比如四边形优化,线段树优化等等,笔者在这里介绍的只是比较常用的板子类的动态规划优化。具体的优化方式还要根据实际问题进行操作。