Slope Trick 优化 DP 学习笔记

引入

slope trick 是一类用来优化 dp 的方法,当 dp 数组可以表示为为凸函数,且定义域内连续,斜率为整数,可以考虑 slope trick。

Slope Trick 中的凹/凸函数

为了不混淆概念我们将凹/凸函数称为下凸函数和上凸函数,统称为凸函数。

max ⁡ ( x , 0 ) \max(x,0) max(x,0) 就是个凸函数,因为 max ⁡ ( x , 0 ) = { 0 x ≤ 0 x x > 0 \max(x,0)=\begin{cases}0 & x\le 0 \\ x & x>0 \end{cases} max(x,0)={0xx0x>0。注意到这是一个以 x = 0 x=0 x=0 为分界点,两边斜率分别为 0 0 0 1 1 1 的下凸函数。

考虑这样一个凸函数:

f ( x ) = { − x + 2 x < − 4 x + 10 − 4 ≤ x < 2 2 x + 8 x ≥ 2 f(x)=\begin{cases} -x+2 & x<-4 \\ x+10 & -4\le x<2 \\ 2x+8 & x\ge 2 \end{cases} f(x)= x+2x+102x+8x<44x<2x2

x = − 4 x=-4 x=4 分界点两边的斜率一个是 − 1 -1 1,一个是 1 1 1,相差大于 1 1 1,但可以假定存在斜率为 0 0 0 的函数,同样用 x = − 4 x=-4 x=4 分界点与两端分开,我们可以用它的第一段函数以及若干个分界点来表示它,即 f ( x ) = − x + 2 ∣ { − 4 , − 4 , 2 } f(x)=-x+2\mid \{-4,-4,2\} f(x)=x+2{4,4,2}。这样,每一个分界点都代表斜率变化 1 1 1,由于函数连续,也就可以确定整个分段函数。

有一个有意思的性质是,两个凸函数对应位置相加,表现为第一段函数的斜率和截距分别相加,分界点取并集。

即若有 f ( x ) = k 1 x + b 1 ∣ S 1 f(x)=k_1x+b_1\mid S_1 f(x)=k1x+b1S1 g ( x ) = k 2 x + b 2 ∣ S 2 g(x)=k_2x+b_2\mid S_2 g(x)=k2x+b2S2

f ( x ) + g ( x ) = ( k 1 + k 2 ) x + ( b 1 + b 2 ) ∣ ( S 1 ∪ S 2 ) f(x)+g(x)=(k_1+k_2)x+(b_1+b_2)\mid (S_1\cup S_2) f(x)+g(x)=(k1+k2)x+(b1+b2)(S1S2)

我们可以利用这一性质来合并两个凸函数。

有哪些函数是凸函数呢?绝对值函数, max ⁡ \max max 函数, min ⁡ \min min 函数都可以表示为凸函数。

Slope Trick 优化 DP

平移与合并

考虑这样一个 DP 式子:

f i , j = min ⁡ ∣ j − k ∣ ≤ d { f i − 1 , k } + ∣ i − k ∣ f_{i,j}=\min_{|j-k|\le d}\{f_{i-1,k}\}+|i-k| fi,j=jkdmin{fi1,k}+ik

其中, j j j 没有范围限制,对 k k k ∣ j − k ∣ ≤ d |j-k|\le d jkd,初始 f 0 , i = 0 f_{0,i}=0 f0,i=0

f i , j f_{i,j} fi,j 其实可以表示为一个关于 j j j 的函数,而且是个下凸函数(由归纳法,假设它成立,每次相当于加绝对值函数,所以还是个下凸函数)。而最优决策点就是斜率为 0 0 0 的线段两端点,若不存在就为最靠近 0 0 0 的那个端点。

那怎么维护 dp 的转移呢?首先不难发现先令 f i , j ← min ⁡ ∣ j − k ∣ ≤ d { f i − 1 , k } f_{i,j}\leftarrow\min_{|j-k|\le d}\{f_{i-1,k}\} fi,jminjkd{fi1,k},相当于将左边斜率小于 0 0 0 的部分左移 d d d 距离,再将右边斜率大于 0 0 0 的部分右移 d d d 距离,中间斜率为 0 0 0 部分延长至左右两端点(可以画出图来)。

(另外,如果取的是前缀 min,那就相当于推平了右边的堆,所以只用维护左堆。)

实现上,我们只需用一个大根堆维护斜率小于 0 0 0 部分的分界点,一个小根堆维护斜率大于 0 0 0 部分的分界点(类似于对顶堆),以及第一段函数的斜率。平移时,将两个堆分别打上标记即可。

接着,要令 f i , j ← f i , j + ∣ i − k ∣ f_{i,j}\leftarrow f_{i,j}+|i-k| fi,jfi,j+ik,即合并两个函数。注意到 ∣ i − k ∣ |i-k| ik 的分界点为 { k , k } \{k,k\} {k,k} (斜率变化了两次),而第一段斜率为 − 1 -1 1。因此,令第一段斜率减 1 1 1,并将两个 k k k 丢到左边的堆里。注意每进行一步需要保证左边堆 s i z e = − k size=-k size=k,除非点数小于 − k -k k

取最优决策点

接着,如何找到 f i f_i fi 的最优决策点呢?其实就是左边堆的堆顶,如果不存在,就选右边堆的堆顶,于是就可以找到最优决策点。

统计答案

有两种方法。

第一种是根据第一段斜率和分界点还原出最终的分段函数,但这样没法输出方案。

第二种是根据最终的决策点倒推出前面的决策点。一般来说,倒推之前位置时尽可能选择那个位置的最优决策点,如果不行,就取最靠近且合法的决策点。这样可以得到方案。

截取

如果我们把上面的式子改成

f i , j = min ⁡ ∣ j − k ∣ ≤ d ∧ j ∈ [ − ( i − 1 ) d , ( i − 1 ) d ] { f i − 1 , k } + ∣ i − k ∣ f_{i,j}=\min_{|j-k|\le d\wedge j\in[-(i-1)d,(i-1)d]}\{f_{i-1,k}\}+|i-k| fi,j=jkdj[(i1)d,(i1)d]min{fi1,k}+ik

j j j 有了区间限制,该怎么做呢?

我们考虑 i i i 对应的 j j j 的定义域(合法区间)是什么,假定初始合法状态为 f 0 , 0 f_{0,0} f0,0,那么每次定义域最多扩大 d d d,因此合法区间为 [ − ( i − 1 ) × d , ( i − 1 ) × d ] [-(i-1)\times d,(i-1)\times d] [(i1)×d,(i1)×d],将决策点加入堆时,分别与 − ( i − 1 ) × d -(i-1)\times d (i1)×d ( i − 1 ) × d (i-1)\times d (i1)×d max ⁡ \max max min ⁡ \min min 即可。

为什么这样做不会导致之后这个决策点变成不合法的呢?因为规定的定义域也为 [ − ( i − 1 ) d , ( i − 1 ) d ] [-(i-1)d,(i-1)d] [(i1)d,(i1)d],在平移的同时,定义域也在扩大,所以一定不会平移到不合法的点。

例题

f i , j f_{i,j} fi,j 表示第 i i i 时刻在 j j j 点所受的伤害最小值,于是可以列出状态转移方程

f i , j = { min ⁡ ∣ k − j ∣ ≤ T i − T i − 1 ∧ − T i ≤ j ≤ T i { f i − 1 , k } + max ⁡ ( 0 , X i − j ) D i = 0 min ⁡ ∣ k − j ∣ ≤ T i − T i − 1 ∧ − T i ≤ j ≤ T i { f i − 1 , k } + max ⁡ ( 0 , j − X i ) D i = 1 f_{i,j}=\begin{cases} \min\limits_{|k-j|\le T_i-T_{i-1}\wedge -T_i\le j\le T_i}\{f_{i-1,k}\}+\max(0,X_i-j)&D_i=0\\ \min\limits_{|k-j|\le T_i-T_{i-1}\wedge -T_i\le j\le T_i}\{f_{i-1,k}\}+\max(0,j-X_i)&D_i=1 \end{cases} fi,j= kjTiTi1∧−TijTimin{fi1,k}+max(0,Xij)kjTiTi1∧−TijTimin{fi1,k}+max(0,jXi)Di=0Di=1

对于取 min ⁡ \min min 操作,由于 ∣ k − j ∣ ≤ T i − T i − 1 |k-j|\le T_i-T_{i-1} kjTiTi1,就让左边向左平移 T i − T i − 1 T_i-T_{i-1} TiTi1,右边向右平移 T i − T i − 1 T_i-T_{i-1} TiTi1,中间斜率为 0 0 0 的部分自然延伸。

max ⁡ \max max 函数,就是两个下凸函数合并,在左堆加入决策点 X i X_i Xi,如果 D i = 0 D_i=0 Di=0,那么 max ⁡ \max max 函数第一段斜率为 − 1 -1 1,否则为 0 0 0,将当前维护的第一段斜率加上即可。

有区间限制,所以加入堆时要和 T i T_i Ti min ⁡ \min min ,和 − T i -T_i Ti max ⁡ \max max。这样做无论后面怎么平移这个决策点依然是合法的。

从后往前倒推答案时,如果两个决策点 p o s i pos_i posi p o s i + 1 pos_{i+1} posi+1 相差大于 T i + 1 − T i T_{i+1}-T_i Ti+1Ti,就让 p o s i pos_i posi 变成最近且合法的决策点。

代码如下:

#include <bits/stdc++.h>
#define ll long long
#define ull unsigned long long
#define lp(i, j, n) for(int i = j; i <= n; ++i)
#define dlp(i, n, j) for(int i = n; i >= j; --i)
#define mst(n, v) memset(n, v, sizeof(n))
#define mcy(n, v) memcpy(n, v, sizeof(v))
#define INF 1e18
#define MAX4 0x3f3f3f3f
#define MAX8 0x3f3f3f3f3f3f3f3f
#define pii pair<int, int>
#define pll pair<ll, ll>
#define co(x) cerr << (x) << ' '
#define cod(x) cerr << (x) << endl
#define fi first
#define se second
#define eps 1e-8
#define lc(x) ((x) << 1)
#define rc(x) ((x) << 1 ^ 1)
#define pb(x) emplace_back(x)

using namespace std;

const int N = 200010;

ll n, T[N], D[N], X[N], ltg, rtg, pos[N];
priority_queue<ll> lq;
priority_queue<ll, vector<ll>, greater<ll>> rq;

signed main() {
    // freopen(".in", "r", stdin);
    // freopen(".out", "w", stdout);
#ifndef READ
    ios::sync_with_stdio(false);
    cin.tie(0);
#endif
    cin >> n;
    lp(i, 1, n) cin >> T[i] >> D[i] >> X[i];
    int k = 0;
    lp(i, 1, n) {
        ltg -= T[i] - T[i - 1], rtg += T[i] - T[i - 1];
        k += D[i] ? 0 : -1;
        while(!rq.empty() && (int)lq.size() < -k) lq.push(rq.top() + rtg - ltg), rq.pop();
        ll t = max(min(X[i], T[i]), -T[i]);
        lq.push(t - ltg);
        while((int)lq.size() > -k) rq.push(lq.top() + ltg - rtg), lq.pop();
        pos[i] = lq.size() ? lq.top() + ltg : rq.top() + rtg;
    }
    ll ans = 0;
    dlp(i, n - 1, 1) {
        ll dt = T[i + 1] - T[i];
        if(pos[i + 1] > pos[i]) pos[i] = max(pos[i], pos[i + 1] - dt);
        else pos[i] = min(pos[i], pos[i + 1] + dt);
        ans += D[i] ? max(0ll, pos[i] - X[i]) : max(0ll, X[i] - pos[i]);
    }
    ans += D[n] ? max(0ll, pos[n] - X[n]) : max(0ll, X[n] - pos[n]);
    cout << ans << endl;
    return 0;
}

设当前在点 u u u 使到叶子结点导火索长度都为 i i i 的最小代价为 f u , i f_{u,i} fu,i,设 w u , v w_{u,v} wu,v 表示结点 u u u 到结点 v v v 导火索长度,可以列出转移方程

f u , i = f u , i + min ⁡ 0 ≤ j ≤ i { f v , i − j + ∣ w u , v − j ∣ } f_{u,i}=f_{u,i}+\min_{0\le j\le i}\{f_{v,i-j}+|w_{u,v}-j|\} fu,i=fu,i+0jimin{fv,ij+wu,vj}

首先令 f v , i ′ ← min ⁡ { f v , i − j + ∣ w u , v − j ∣ } f_{v,i}'\leftarrow \min\{f_{v,i-j}+|w_{u,v}-j|\} fv,imin{fv,ij+wu,vj},即将两个凸包合并。关于这一部分的合并,由于 min ⁡ \min min 函数斜率左边是 − 1 -1 1,右边是 1 1 1,结论就是只需要删去右边的分界点(右边变成斜率为 1 1 1),将中间部分向右平移 w u , v w_{u,v} wu,v (这样同时相当于令左半部分向上平移 w u , v w_{u,v} wu,v)。可以自己画图推导,这里不过多赘述。

显然 f v ′ f_{v}' fv 斜率不会大于 1 1 1,所以可以只用一个堆来维护。然后将 f u f_u fu f v ′ f_v' fv 合并,合并时可以用可并堆,但直接启发式合并比较方便。另外根据子结点的儿子数量可以直接推出最子节点最右边斜率是多少,从而把右边的分界点弹出。

最后统计答案时,由于 f 1 , 0 f_{1,0} f1,0 为所有边的长度和,而从左到右斜率又依次减一,所以直接用所有边长度总和一次减去所有分界点横坐标即可得到最终答案。

代码如下:

/*
    Program: P3642.cpp
    Author: 1l6suj7
    DateTime: 2023-12-22 21:38:00
    Description:
*/

#include <bits/stdc++.h>
#define ll long long
#define lp(i, j, n) for(int i = j; i <= n; ++i)
#define dlp(i, n, j) for(int i = n; i >= j; --i)
#define mst(n, v) memset(n, v, sizeof(n))
#define mcy(n, v) memcpy(n, v, sizeof(v))
#define INF 1e18
#define MAX4 0x3f3f3f3f
#define MAX8 0x3f3f3f3f3f3f3f3f
#define mkp(a, b) make_pair(a, b)
#define pii pair<int, int>
#define pll pair<ll, ll>
#define co(x) cerr << (x) << ' '
#define cod(x) cerr << (x) << endl
#define fi first
#define se second
#define eps 1e-8

using namespace std;

const int N = 300010;

struct edge { int v, nxt, w; } E[N];
int en, hd[N];

void add(int u, int v, int w) { E[++en] = { v, hd[u], w }, hd[u] = en; }

int n, m, in[N];
priority_queue<ll> q[N];

void dfs(int now) {
    for(int i = hd[now]; i; i = E[i].nxt) {
        int v = E[i].v;
        dfs(v);
        while(in[v] > 1) q[v].pop(), --in[v];
        ll t1 = 0, t2 = 0;
        if(!q[v].empty()) t1 = q[v].top(), q[v].pop();
        if(!q[v].empty()) t2 = q[v].top(), q[v].pop();
        q[v].push(t1 + E[i].w), q[v].push(t2 + E[i].w);
        if(q[now].size() < q[v].size()) swap(q[now], q[v]);
        while(!q[v].empty()) q[now].push(q[v].top()), q[v].pop();
    }
}

signed main() {
    //freopen(".in", "r", stdin);
    //freopen(".out", "w", stdout);
#ifndef READ
    ios::sync_with_stdio(false);
    cin.tie(0);
#endif
    cin >> n >> m;
    int fa, w;
    ll ans = 0;
    lp(i, 2, n + m) cin >> fa >> w, add(fa, i, w), ++in[fa], ans += w;
    dfs(1);
    lp(i, 1, in[1]) q[1].pop();
    int cnt = 1;
    while(!q[1].empty()) ans -= q[1].top(), q[1].pop();
    cout << ans << endl;
    return 0;
}

其他题目:

[CTSC2009] 序列变换

[ARC070E] NarrowRectangles

[CF1534G] A New Beginning

  • 26
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值