浅谈一类优化思想:费用提前计算

推荐食用:本文同步发表于博客园

写在前面

这个知识点虽然微乎其微,但却十分有用。网上貌似没有什么人愿意整理这项内容,于是便随意记录了一下,如有不足还请指出。

不得不说我太弱了,感觉现在网上很多题解报告都很抽象模糊。所以这篇文章也主打的就是一个感性理解,可能显得讲的略有琐碎。有问题欢迎提出。

配套使用题单,除了例题外还有一些能用到这个知识点的题,但仍数量不多,欢迎私信扩容。

当前决策对未来行动的费用影响只与当前决策有关

不如从一道经典的例题入手:

任务安排

n n n 个任务按顺序分批执行,每批任务开始需要一个固定的启动时间 S S S。第 i i i 个任务花费的时间是 t i t_i ti,每个任务的花费是它完成的时刻乘上它自身的费用系数 f i f_i fi。需要找到一个最佳的分批顺序使得总费用最小。

这里我们不考虑高端的斜率优化,看看暴力的 DP 是怎么优化的。首先我们有一个显然的 O ( n 3 ) \mathcal O(n^3) O(n3) 的式子。记 T i = ∑ j = 1 i t j T_i = \sum_{j=1}^i t_j Ti=j=1itj F i = ∑ j = 1 i f j F_i = \sum_{j=1}^i f_j Fi=j=1ifj,设 g i , j g_{i,j} gi,j 表示将前 i i i 个任务分成 j j j 个组的最小花费,有转移方程:
g i , j = min ⁡ 0 ≤ k < i { g k , j − 1 + ( S × j + T i ) × ( F i − F k ) } g_{i,j} = \min_{0 \le k < i} \lbrace{g_{k,j-1} + (S \times j + T_i) \times (F_i - F_k) \rbrace} gi,j=0k<imin{gk,j1+(S×j+Ti)×(FiFk)}

我们注意到这个 DP 的状态设计之所以记录 j j j 这一维,是因为需要知道前面有多少次启动了机器,即分成了多少批任务。但是事实上,我们并不关心启动了几次机器,只关心到底因为 S S S 造成了多少费用。一批任务启动的时间 S S S 会累加到后面每一个任务上,所以我们可以将对后面任务造成的影响,累加到当前的费用中。设 g i g_i gi 表示把前 i i i 个任务分成若干个组的最小花费,有转移方程:
g i = min ⁡ 0 ≤ j < i { g j + T i × ( F i − F j ) + S × ( F n − F j ) } g_i = \min_{0 \le j < i} \lbrace{g_j + T_i \times (F_i - F_j) + S \times (F_n - F_j) \rbrace} gi=0j<imin{gj+Ti×(FiFj)+S×(FnFj)}

这样我们成功将问题优化到了 O ( n 2 ) \mathcal O(n^2) O(n2)

可以发现在这个问题中,选择一个决策会带来一个对未来的代价,而我们又不能通过在状态中再增加一维记录代价满足需求。这个代价是必然的,无论后面如何选择,都不会改变这个决策所带来的影响,这就是当前决策对未来行动的费用影响只与当前决策有关,我们可以把这个代价看作决策本身的费用,将未来的代价提前计算出来,在决策的时候就计算上它将会带来的代价,通过状态向后传递。

这类思想很经典的应用是以关路灯为母题的一类区间 DP,不妨以其为例题来探索一下。

在一条线段上有 n n n 个路灯分别在 p i p_i pi 的位置上,每个单位时间造成 c i c_i ci 的代价。一个人起初在 S S S,每个单位时间可以移动一个单位距离,走到路灯的位置可以关掉路灯,使其停止造成代价。求关掉所有路灯造成的最小代价。

如果我们不知道有费用提前计算这么个东西,那么设计的状态可能是 f i , j , t i m , 0 / 1 f_{i,j,tim,0/1} fi,j,tim,0/1 表示关掉了 [ i , j ] [i,j] [i,j] 这个区间的灯,已经进行了 t i m tim tim 的时间,现在在左/右端点的最小花费。转移则为关掉上一个路灯的花费,加上总共用过的时间乘当前选的路灯的功率,这样设计复杂度必然爆炸。

但是我们现在会了新东西。 类似上一道题,我们发现之所以要记录 t i m tim tim 这一维度,是因为我们要知道现在关掉的灯运行了多久,也就是曾经的决策影响现在的代价。事实上一个路灯只要在一个时刻没有被关掉,它就会不断产生代价,我们可以在决策选择哪一个路灯时,将所有开着的路灯造成的代价作为关掉它的费用一块计算出来,提前累加在这个状态中。这样我们便成功压缩掉了一维,记 f i , j , 0 / 1 f_{i,j,0/1} fi,j,0/1 表示已经关掉了 [ i , j ] [i,j] [i,j] 区间的路灯,现在在左/右端点的最小花费。得到了转移方程:
f i , j , 0 = min ⁡ { f i + 1 , j , 0 + d i s i + 1 → i × ∑ k = 1 ∣ k ∉ [ i + 1 , j ] n f i + 1 , j , 1 + d i s j → i × ∑ k = 1 ∣ k ∉ [ i + 1 , j ] n f i , j , 1 = min ⁡ { f i , j − 1 , 0 + d i s i → j × ∑ k = 1 ∣ k ∉ [ i , j − 1 ] n f i , j − 1 , 1 + d i s j − 1 → j × ∑ k = 1 ∣ k ∉ [ i , j − 1 ] n f_{i,j,0} = \min \begin{cases} f_{i+1,j,0} + dis_{i+1 \to i} \times \sum_{k=1|k \notin [i+1,j]}^n \\ f_{i+1,j,1} + dis_{j \to i} \times \sum_{k=1|k \notin [i+1,j]}^n \\ \end{cases} \\ f_{i,j,1} = \min \begin{cases} f_{i,j-1,0} + dis_{i \to j} \times \sum_{k=1|k \notin [i,j-1]}^n \\ f_{i,j-1,1} + dis_{j-1 \to j} \times \sum_{k=1|k \notin [i,j-1]}^n \\ \end{cases} fi,j,0=min{fi+1,j,0+disi+1i×k=1∣k/[i+1,j]nfi+1,j,1+disji×k=1∣k/[i+1,j]nfi,j,1=min{fi,j1,0+disij×k=1∣k/[i,j1]nfi,j1,1+disj1j×k=1∣k/[i,j1]n

距离与功率和可以使用前缀和什么的优化,这里不多赘述。

这一类问题还包括但不限于[SDOI2008] Sue 的小球修缮长城 Fixing the Great Wall[BalticOI 2009 Day1] 甲虫,思想和做法都是相同类似的。

做完了经典的模型,再来些不一样的题看看吧。

[CF441E] Valera and Number

给出一个数 x x x,对其进行 m m m 次操作,分别为 p % p\% p% 的概率对它 × 2 \times 2 ×2 ( 100 − p ) % (100-p)\% (100p)% 的概率对它 + 1 +1 +1。求该数最终二进制下末尾 0 0 0 的个数的期望。

这两个操作分别对应着二进制下在末尾插入一个 0 0 0,将末尾一串 1 1 1 变成 0 0 0 再将最后一个 0 0 0 变成 1 1 1。这个 + 1 +1 +1 操作造成的进位十分棘手,怎么样才能尽量减小它的影响呢?考虑时空倒流,从后往前操作。注意到一旦出现了一个 × 2 \times 2 ×2,那么在时间轴上往前的一个 + 1 +1 +1 操作将变成 + 2 +2 +2,不会再对最低位造成影响。

示例

上图的 2 → 6 → 14 → 30 2 \to 6 \to 14 \to 30 261430 的最低位的 0 0 0 一直没有受到影响。那么我们设 f i , j , k f_{i,j,k} fi,j,k 表示进行了末尾的 i i i 次操作,最后连着进行了 k k k × 2 \times 2 ×2 的操作(即通过 × 2 \times 2 ×2 在末尾获得了 k k k 0 0 0),在这 k k k 0 0 0 之前造成了 + j +j +j 的影响。可以发现进行了 × 2 \times 2 ×2 操作,如果当前的 j j j 为偶数,则可以看做进行了 j / 2 j / 2 j/2 + 1 +1 +1,末尾多进行了一个 × 2 \times 2 ×2(即多获得了一个 0 0 0);如果当前的 j j j 为奇数,则相当于 k k k 0 0 0 前面的第一个数是 1 1 1,无论怎么操作后面 0 0 0 的数量都将不再变化,可以直接计算贡献。所有操作进行完之后,我们可以将初始值看做先进行了 x x x + 1 +1 +1,再把这部分贡献算上。

这样我们得到了一个 O ( m 3 ) \mathcal O(m^3) O(m3) 的做法。能不能继续优化呢?我们回顾前面讲的两道题的答案计算式子,对于任务安排,我们的答案是 C o s t ( 花费 ) = T i m e ( 时间 ) × f ( 费用系数 ) Cost(\text{花费}) = Time(\text{时间}) \times f(\text{费用系数}) Cost(花费)=Time(时间)×f(费用系数);对于关路灯,我们的答案是 W ( 功 ) = P ( 功率 ) × T ( 时间 ) W(\text{功}) = P(\text{功率}) \times T(\text{时间}) W()=P(功率)×T(时间)。没错,正是因为他们的计算为一次函数,具有线性性,所以我们可以提前计算,直接累计(不是一次函数的状况读者可以思考一下为什么不行,后文还会阐述)。不要忘记了,我们的期望本身也具有线性性!所以在 × 2 \times 2 ×2 j j j 为偶数的时候,我们把通过 × 2 \times 2 ×2 多获得的那一个 0 0 0 单独计算出来,权值为 1 1 1,直接乘上概率累加到答案中即可。这样我们便可以省掉 k k k 这一维,记 f i , j f_{i,j} fi,j 为进行了末尾 i i i 次操作,使得若干个 0 0 0 之前有 + j +j +j 的影响。记一个数 i i i 末尾 0 0 0 的个数为 s u m i sum_i sumi,答案就是 ∑ i = 0 k − 1 ∑ j = 0 ∣ j % 2 = 0 i f i , j × p + ∑ i = 0 k f k , i × s u m x + i \sum_{i=0}^{k-1} \sum_{j=0|j \% 2 = 0}^i f_{i,j} \times p + \sum_{i=0}^k f_{k,i} \times sum_{x+i} i=0k1j=0∣j%2=0ifi,j×p+i=0kfk,i×sumx+i

参考代码:

cin>>x>>k>>p;
p/=100,dp[0][0]=1.0;
for(int i=0;i<k;++i)
	for(int j=0;j<=i;++j)
		if(dp[i][j])
		{
			if((j&1)^1) dp[i+1][j/2]+=dp[i][j]*p,ans+=dp[i][j]*p*1ll; //这里为了方便理解,因为权值为1
			dp[i+1][j+1]+=dp[i][j]*(1-p);
		}
for(int i=0;i<=k;++i) ans+=__builtin_ctz(x+i)*dp[k][i];
cout<<fixed<<setprecision(9)<<ans<<endl;

[ARC126D] Pure Straight

给定一个长度为 n n n 序列 A A A,有 ∀ A i ∈ [ 1 , k ] \forall A_i \in [1,k] Ai[1,k]。每次操作可以交换两个相邻的元素,求最少操作多少次可以使得 A A A 中存在一个区间为 ∃ p , A p = 1 , A p + 1 = 2 , ⋯   , A p + k − 1 = k \exists p,A_p = 1,A_{p+1} = 2,\cdots,A_{p+k-1} = k p,Ap=1,Ap+1=2,,Ap+k1=k

注意到这个 k k k 特别小,我们可以考虑一下状压 DP。我们设 f i , S f_{i,S} fi,S 表示当前考虑到了序列的前 i i i 个数,最终答案中已经排好了 S S S 二进制位上为 1 1 1 的数并连在了一起。当我们决策一个新的数的时候,可以选择放入最终答案,也可以选择不放入最终答案。我们先假设新插入的这个数已经紧贴在先前排好的序列右边了,要插入的话直接按照规则插入,不需要计算从别的地方移动过来的费用。如果选择放进最终答案,费用就是把他移动到相应的位置,即以其为结尾的序列的逆序对的个数;如果不选择放进最终答案,那么代价就是让最后答案中所有比它小的数或所有比它大的数“跨过它”,但是每个数具体被跨过了几次,我们并不好知道。考虑费用提前计算,把“被跨过了几次”转换成“有多少个数跨过了它”,将费用均摊在每个剩下选中的每个点上。那么就是所有比他小的数都要跨过它一次,或者所有比它大的数都要跨过它一次,两者贪心取 min ⁡ \min min 即可。这时我们注意到一个 f i f_i fi 的决策只与 f i − 1 f_{i-1} fi1 有关,于是又节省掉一维,时间复杂度 O ( n × 2 k ) \mathcal O(n \times 2^k) O(n×2k)

参考代码:

cin>>n>>k;
memset(dp,0x3f,sizeof dp); dp[0]=0;
for(int i=1;i<=n;++i) 
{
	cin>>x; --x;
	for(int j=all;~j;--j)
		if(dp[j]!=INF)
		{
			if(!(j>>x&1)) dp[j|(1<<x)]=min(dp[j|(1<<x)],dp[j]+btp(j&(~((1<<x)-1))));
			dp[j]+=min(btp(j),btp(j^all));
		}			
}
cout<<dp[(1<<k)-1]<<endl;

现在再来系统总结一下使用这类优化方法的情景:

  1. 无论以后发生什么,当前对未来的代价都不会被改变,可以将对未来的代价当做当前决策本身的费用提前计算。
  2. 对状态增加一维来记录决策对未来的影响造成的复杂度代价过高,不能接受,考虑直接在当前代价中提前计算。
  3. 时间观即从过去考虑当前。
  4. 对未来的代价是线性的关系,根据线性性可以直接累加。

对于第四点,我们将在后文讨论不是线性的另一种状况。

当前决策对未来的贡献与未来有关

依然通过一道例题来引入:

方块消除

给一个长度为 n n n 的方块序列,每个方块有一个颜色,每次消除一段颜色相同长度为 x x x 的方块,并获得 x 2 x^2 x2 的分数,消除后剩下的方块会合并起来。寻找一种最优的消除方式使得最终得分尽可能大,求最大的得分。

依然是序列上的操作,考虑使用使用区间 DP 来解决。首先我们把颜色相同段缩成一个点,记录颜色和长度。套路的,我们设 f i , j f_{i,j} fi,j 表示消除了 [ i , j ] [i,j] [i,j] 获得的最大得分,那么对于除去合并的消除转移方程也是十分的好写 f i , j = max ⁡ ( f i + 1 , j + l e n i 2 , f i , j − 1 + l e n j 2 ) f_{i,j} = \max(f_{i+1,j} + len_i^2,f_{i,j-1} + len_j^2) fi,j=max(fi+1,j+leni2,fi,j1+lenj2)。那么怎么跨区间合并呢?如果我们保存状态记录当前决策每一段方块是否被消除,依旧是奇大无比的神秘复杂度,无法承受。类比关路灯的“费用提前计算”,我们预先计算得分,考虑当前消掉了一个长度为 L \mathcal L L 的方块,之前剩下了一个长度为 T \mathcal T T 的方块,但是他们并不能简单的合并,因为现在的得分函数是二次函数而非线性关系, L 2 + T 2 ≠ ( L + T ) 2 \mathcal L^2 + \mathcal T^2 \neq (\mathcal L + \mathcal T)^2 L2+T2=(L+T)2,也就是过去的贡献和我们现在消去的长度是相关的,将时态向后整体推一个,便引出了我们的主题:当前决策对未来的贡献与未来有关

既然不能从过去考虑现在,那能不能从现在考虑未来呢?如果我们在当前决策把未来可能出现的情况提前计算好,通过状态传递到了未来,那到了未来是不是能直接把这个决策拿来用,是不是就能够进行转移了呢?不妨设 f i , j , k f_{i,j,k} fi,j,k 表示消掉了 [ i , j ] [i,j] [i,j] 这个区间,且后面有 k k k 个位置和 j j j 位置合并在一起消掉了。对于这个题,考虑为什么只需要记录 j j j 右边有多少个和它一起消掉了呢?这里引用一下论文里的证明:

假设现在有四个位置 i < j < k 1 < k 2 i < j < k_1 < k_2 i<j<k1<k2 且我们处理好了 [ i , j ] [i,j] [i,j],记“关系”的含义为两个位置未来会连在一起被消掉。假如说有关系 i → k 1 i \to k_1 ik1 j → k 2 j \to k_2 jk2,那也就是说 [ j + 1 , k 2 − 1 ] [j + 1,k_2 - 1] [j+1,k21] 间的块要在 j j j 被消掉前消掉,所以 j j j 之前的所有点不能往这个区域内连关系。因此我们得到了 [ i , j − 1 ] [i,j - 1] [i,j1] 内的块只能往 [ i , j ] [i,j] [i,j] 上面连关系,而只有 j j j 自己能往 j j j 往后的区域连。

有了这个关系,我们的方程便十分的好写了。如果 j j j 和后面 k k k 个块一起消掉,就有 f i , j , k = f i , j − 1 , 0 + ( k + 1 ) 2 f_{i,j,k} = f_{i,j-1,0} + (k+1)^2 fi,j,k=fi,j1,0+(k+1)2;如果在 [ i , j − 1 ] [i,j-1] [i,j1] 这个区间中还有和 j j j 颜色相同的块 x x x,那么我们可以先消除 [ x + 1 , j − 1 ] [x+1,j-1] [x+1,j1],然后再合并 x x x j j j 一起消除,于是有 f i , j , k = f i , x , k + 1 + f x + 1 , j − 1 , 0 f_{i,j,k} = f_{i,x,k+1} + f_{x+1,j-1,0} fi,j,k=fi,x,k+1+fx+1,j1,0。最后的答案即为 f 1 , n , 0 f_{1,n,0} f1,n,0。我们需要枚举端点 i , j i,j i,j,以及右边合并个数 k k k,还有在考虑区间 [ i , j ] [i,j] [i,j] 的时候对于第二个决策枚举左边的断点 x x x,因此时间复杂度 O ( n 4 ) \mathcal O(n^4) O(n4)

回顾这个问题,我们起初想使用费用提前计算,把每次对未来的贡献摊在当前自己身上。可是发现未来的决策并不只于当前决策有关,还与未来本身状态相关。于是我们又开了一维状态,将目光放长远,预测未来可能出现的状况并计算,记录在状态中传递到未来,并在未来直接使用,这依然是一类费用提前计算的问题。

如同上一门类一样,这类问题很经典的应用是以[IOI2005]Riv 河流为母题的一类树形 DP,不妨以其为例题来探索一下。

给定一棵 n n n 个点的树,点边均带权。你可以选取 k k k 个关键点(根节点本身为关键点且不计算在 k k k 个之内),使得每个节点到离他最近的是关键点的祖先(记为 F i F_i Fi)的权值和最小,权值和的定义为 ∑ i n d i s i → F i × v a l i \sum_{i}^{n} dis_{i \to F_i} \times val_i indisiFi×vali

不失一般性的,我们可以设 f i , j , 0 / 1 f_{i,j,0/1} fi,j,0/1 表示现在在 i i i 号节点,我们在 i i i i i i 的子树中选取了 j j j 个关键点,有没有选取 i i i 的最小权值和。但是这样我们很快发现了问题——我们并不容易知道具体有多少个节点选择了 i i i 为祖先关键点,无法统计答案。既然我们不知道有多少个节点选择了 i i i 为祖先关键点,那我们不妨反着考虑:我们能不能很方便的知道当前节点选择了谁作为祖先关键点呢?如果我们知道了这个,那我们只需要把 i i i 上的答案再合并到 F i F_i Fi 上就好了。

考虑 我们刚刚学会 的“当前决策对未来的贡献与未来有关”,这个句子和我们想搞得“当前节点对祖先的贡献与祖先有关”简直就是排比句啊!我们可以预测选择的节点是谁,并把它记录在状态中,于是我们就得到了设 f i , j , k , 0 / 1 f_{i,j,k,0/1} fi,j,k,0/1 表示现在在 i i i 号节点,我们在 i i i i i i 的子树中选取了 j j j 个关键点,其中 i i i 的祖先关键点为 k k k,有没有选取 i i i 的最小权值和。首先我们遍历整棵树,在每个节点上先枚举它的祖先关键点是谁,再枚举在它和它的子树中选了几个关键点,对于每个不同数量的关键点的决策做一个类似于树上背包的东西,即对于每一个子节点枚举在它和其子树中共选择了 1 ∼ k 1 \sim k 1k 个关键点,综上我们得到了一个时间复杂度为 O ( n 2 k 2 ) \mathcal O(n^2k^2) O(n2k2)优秀 算法。

这种树形 DP 将本应在当前节点计算的费用延后到它的子孙上,预测并记录子孙的状态,类似的题目也是层出不穷,例如[NOI2006] 网络收费[NOI2008] 奥运物流

因为笔者见识较少,所以对于第二种问题遇到的并没有第一种那么多,因此也没有准备更多的例题。

我们不如趁此再来总结一下使用这类优化方法的情景:

  1. 未来的费用并不只于当前代价相关,还与未来本身的状态相关。
  2. 通过增加一维状态来记录对未来的预测,从而在未来能够直接使用。
  3. 时间观从当前考虑未来。
  4. 对未来的代价并非线性的关系,不能简单的累加。

一点小小的扩展

在上文中,我们通过改变时间观,用“费用提前计算”这种特殊的方法有效的优化了许多 DP 式子。事实上有关费用提前计算的一些技巧不止适用于 DP 的优化,我们以一个例子大概了解一下:

[SCOI2007] 修车

n n n 个车主来修车,总共有 m m m 个维修技术人员,不同技术人员对不同的车维修时间不同,现在需要安排维修顺序使得顾客平均等待时间最少。求最小平均等待时间。

平均等待时间最少,就是总等待时间最少。类似于提前计算中的第二个题,一个人到底等了多长时间我们并不好计算,但是一个人到底被等了多长时间我们是好知道的,所以我们费用提前计算,把一个人等的时间摊到每个对其来说需要被等的人身上。我们将维修人员拆点,连边表示第 i i i 个车主的车由第 j j j 个维修人员修,且是这个维修人员修的倒数第 k k k 辆车,那么费用即为 k × t i m i , j k \times tim_{i,j} k×timi,j,因为倒数第 k k k 个维修,算上其自己会共有 k k k 个人等待。那么剩下的就是费用流板子了。

套路是死的,但是人的脑子是活的;问题是做不完的,但是思想是在总结经验和大胆猜想中不断提升的。只要我们敢于探索,勇于尝试,总有一天能够实现身为 OIer 的梦想。凭问孰与努力争锋。

参考资料与致谢

  • 徐源盛 《对一类动态规划问题的研究》
  • 部分题解与题目来自 do_while_true 的汇总和指导
  • 感谢 Larry76My_Youth 的阅读意见反馈
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值