寂静的海底

上一条 :海风不懂
下一条 :NOIP 2023 游记

DP 凸优化学习笔记

FromOceans ·2023-09-27 浏览量 :35

OI

好久没系统地写一个算法相关内容的学习笔记了,主要是我学习 dp 凸优化部分有意义,有象征性的例题。

目前网上很多题解都有点讲的不明不白的感觉,很多甚至都连基本知识都没说清楚就开始 Slope Trick 了,这困扰了我许久。

我认为通过这篇文章可以比较清晰地了解 dp 凸优化的入门知识 和 Slope Trick 相关知识。

$\text{Introduction.}$

很多时候我们会遇到一种二维或更高维的 dp,而其更新形式常见为每项对前面的一项取 $\min$ 或是加上某个数再取 $\min$ 等,这种时候直接维护所有 dp 值往往单次转移复杂度会达到 $O(siz)$,或者更高。而如果可以证明这个 dp 值构成的点是一个凸包,则往往通过使用数据结构维护这个凸包可以优化到 $O(\log siz)$。

下凸壳:简单地说就是两点之间斜率单调不减的图形,上凸壳则是单调不增。

称一个数组是凸的当且仅当其差分数组是单调的,默认下凸。

  • 凸壳与凸壳的和是凸壳
  • 凸壳与凸壳的闵可夫斯基和是凸壳

闵可夫斯基和:$c_{k}=\min_{i+j=k}a_i+b_j$, $f_i$ 更新 $f_{i+1}$ 可以看作 $f_i$ 和 $\{0,0\}$ 做闵可夫斯基和,$f_i + kc(k\leq v)$ 更新 $f_{i+k}$ 可以看作 $f_i$ 和 $\{0,c,2c...,kc\}$ 做闵可夫斯基和。

两个凸包 $A,B$ 求闵可夫斯基和可以 $O(|A|+|B|)$ 地完成,过程即归并两个凸包的差分数组:

$A=\{\color{red}{0,1,3,7}\} \to \{\color{red}{1,2,4}\}$
$B=\{\color{blue}{0,0,3,6}\} \to \{\color{blue}{0,3,3}\}$

$\{\color{blue}{0},\color{red}{1},\color{red}{2},\color{blue}{3},\color{blue}{3},\color{red}{4}\} \to C= \{0,0,1,3,6,9,13\}$

也可以 $O(|A|\log |B|)$ 或 $ (|A|\log (|B|/|A|)) $ 地使用数据结构将一者的差分数组插入另一者的差分数组中完成。


$\text{Problems}.$

CF13C

$f_{i,j}$ 表示目前填到 $i$ 值为 $j$ 最小代价。

$f_{i,j}=\min{f_{i-1,k}}+|a_i-j|$。

把 dp 数组拍平,变成 $f_i$,考虑每次转移对 $f$ 数组的变化。

就是先取前缀 $\min$,然后加上一个绝对值函数。

取前缀 $\min$ 是和 $\{0,0,\dots,0\}$ 做闵可夫斯基和,加绝对值函数是和凸函数求和。所以可以归纳证明 $f$ 是下凸包。

对凸包的变化:

加上一个绝对值函数,相当于将顶点左边的斜率 $-1$,将顶点右边的斜率 $+1$,而取前缀 $\min$ 相当于将凸包后方一段差分为正的段位置删去,替换为差分为 $0$ 的段,因为这些开始上升的段会被取 $\min$ 成前面的最小值。

上面这些操作当然可以使用平衡树直接维护二元组 $(x,y)$ 表示一段差分 $x$ 有 $y$ 个,然后支持区间加,将一个区间分为两个部分(类似 ODT),理解即可,暂时不必急于实现该冗杂的做法。

实现往往可以使用更优雅的 Slope Trick 进行维护,具体在本文的下一个,好写而且常数小,但没有那么好理解,建议先完全理解对凸包的维护过程再学习对应的数据结构和技巧。


CF1534G

对于一条确定的左下到右上的路径,每个点到它最近的切比雪夫距离一定是这条路径和这个点在同一条斜率为 $-1$ 的点处达到最小值。

$f_{i,j}$ 表示目前走了 $i$ 步,纵坐标为 $j$ 的最小代价,则有

$f_{i,j}= \min\{f_{i-1,j},f_{i-1,j-1}\}+\sum_{x_p+y_p=i}|y_p-j|$

(从左下到右上一层一层走,每个点在其对应的层被统计答案)

仍然把 $f$ 数组拍平,观察每次转移时 $f$ 的变化。

考虑从某个点这层走到下一个点的层。

每个位置可以走到的位置就是 $[x,x+c]$ 更新其后若干个位置,其中 $c$ 表示与上一个点的层数只差(每层最大让横坐标增加 $1$),就是与 $\{0,...,0\} (c+1\text{个}0)$ 作闵可夫斯基和,即将 $0$ 插入到凸包中对应位置。

加上一个绝对值函数,相当于将顶点左边的斜率 $-1$,将顶点右边的斜率 $+1$。

可以直接用平衡树维护凸包,支持插入和区间加(可能需要分裂开一个连续段)。

更简洁的 Slope Trick 可以在理解后看本文下方。

Bakejoon18755

因为 $k$ 是固定的,所以我们可以考虑求出每条边的贡献,其下界为 $2\min(s,k-s)$ 其中 $s$ 为一侧的点数,归纳构造可以达到下界,在此不费篇幅证明。

不难想到一个 $O(n^2)$ 的 dp:

$f_{u,j}$ 表示 $i$ 子树内选了 $j$ 个的最小代价。

然后 $f_{u,i}+f_{v,j}\to f'_{u,i+j}$ 合并完所有儿子后加上向父亲的边的代价: $f_{u,i}\gets f_{u,i}+2\min(s,k-s)$。

加 $2\min(s,k-s)$ 是加凸函数,而 $f_{u,i}+f_{v,j}\to f'_{u,i+j}$ 是闵可夫斯基和,所以每个点的 $f_{u,j}$ 构成了一个下凸包。

加上 $2\min(s,k-s)$ 可以根据 $k$ 的奇偶分出两段或三段:斜率为 $-2,0,2$,直接对平衡树进行区间加即可。

合并凸包可以采用启发式合并,将小凸包的元素插入大凸包,直接启发式合并复杂度 $O(n\log^2 n)$,使用 Finger Serch 复杂度优化至 $O(n\log n)$,跑的很快。

Gym102331J

首先有 dp:$f_{u,0/1,i}$ 表示 $u$ 子树内选择 $i$ 个,$0$ 表示 $u$ 钦定不选, $1$ 表示可以选择或不选。转移是这样:

$$\begin{aligned}

f'_{u,0,i+j}&\gets f_{u,0,i}+f_{son,1,j}\\ f'_{u,1,i+j}&\gets +\min \{f_{u,0,i}+f_{son,0,j - 1} + w,f_{u,1,i}+f_{son,1,j}\}

\end{aligned}$$

因为是问题本身是带权匹配,可以建出费用流模型,得到 $f_{u,0/1,i}$ 分别上凸。

然后我们就可以用闵可夫斯基和 $O(|A|+|B|)$ 地合并两个点 $f$ 数组了,而这样最坏复杂度仍然是 $O(n^2)$ 的,考虑优化:树上问题,复杂度和大小相关,考虑树剖,往往树剖能将问题很好地平衡成菊花上的问题和链上的问题。

对于每个点的轻儿子,这是一个菊花图的问题,无区别地合并这些凸包得到钦定不匹配轻儿子和不限制的凸包,$t_{u,0},t_{u,1}$,但是直接合并复杂度不正确,可以像分治 NTT 做背包那样分治合并,这样合并层数就是 $\log$ 层的。

对于重链,这是一个链上(序列上)问题,我们建出形如线段树的分治结构,通过合并两段的信息求出更长的一段的信息,这样合并的层数也是 $\log$ 的,具体地,在每个点我们需要维护四个凸包:

$g_{0/1,0/1,x} $ 表示顶端,底端分别钦定不选或可选可不选,这段及其所有其它轻儿子总共选择 $x$ 条边的答案,这样我们可以合并两个段的 $g$,讨论中间的边是否选择即可。

每个点到根 $\log$ 条重链,每条只会被合并 $\log $ 次,复杂度 $O(n\log^2 n)$,卡不满。

LuoguP9499

首先有一个显然暴力的 dp:从前往后一个一个换,记录目前剩的货币的数量,$f_{i,j}$ 目前换完第换完第 $i$ 种货币,保留了 $j$ 张,可以赚到多少钱,转移如下:

兑换货币:

$$ f_{i,\lfloor\frac{j}{x_{i-1}}\rfloor+c_i}={f_{i-1,j}} $$

在这一步将 $k$ 张货币用于获取价值,剩下的保留换取更靠后的货币(完全背包):

$$ f_{i,j}=\max_{0\leq k\leq j}\{f_{i,j+k}+kv\} $$

和值域相关,需要优化,往往要把这种直接放在值域上的东西转化成分段离散的东西来维护,我们把 dp 拍平(滚动数组)观察转移是对 dp 数组进行怎样的操作:

  • 在开头放 $c$ 个 $0$,整体右移。
  • 从后往前,每个位置加上 $v$ 去更新前一个位置(完全背包)。
  • 将数组“放缩 $x$ 倍”,即只保留 $x,2x,\dots,kx$ 这些项。

这种形态的 dp 往往都不太好维护或是直接优化转移,分析可以发现 dp 值构成了一个上凸包:

  • 显然 dp 值是单调递减的,所以在开头插入一段 $0$ 不影响凸性。
  • 从后往前,每个位置加上 $v$ 去更新前一个位置,等价于与数组 $\{0,v,2v,....,kv\}$ 做一个闵可夫斯基和(只不过是差卷积的形态)。
  • 放缩 $x$ 倍后显然不改变凸性:其单减的差分数组上两段等长的区间,靠前那段一定和不会更小。

考虑维护这个上凸包,经典地,维护差分数组的分段函数。

前两种操作是很经典的,第一种操作直接在数组的前面插入一段 $0$,第二种操作相当于将前面一段斜率小于 $v$ 的值给弹出,换成斜率为 $v$ 的段。

第三种操作我们直接暴力完成(当 $x>1$ 时):

对于每个原来凸包上的拐点,找到其前后第一个横坐标为 $x$ 的倍数的点称作特殊点,然后将相邻两个特殊点连起来。

两个中间有拐点的特殊点在缩放后的横坐标差为 $1$,直接计算斜率,而中间没有拐点的特殊点直接将斜率扩大 $x$ 倍即可。

下面我们来证明直接暴力完成的复杂度是正确的:

  • 首先每次暴力操作会让所有点的坐标至少减半,然后两个凸包拐点的距离也会至少变成除二上取整。
  • 虽然每次放缩有可能会使凸包的点数翻倍(每个点前后各取一个),但是这些距离为 $1$ 的点对在下一次放缩时一定会每组消失至少一个。

所以复杂度为 $O(n\log \sum c_i)$,常数不大。


$\text{Slope Trick}.$

B面解锁(迫真)

Slope Trick 指的是通过使用堆来维护函数拐点的技巧,其解决的转移主要是形如与某个固定的数作闵可夫斯基和,相比于平衡树,其优点有很多:方便对两个函数求和,常熟小,好写等。但是往往不太方便进行两个凸包的闵可夫斯基和。

CF13C

在理解这个题对凸包的本质操作是取前缀$\min$、加绝对值函数后,我们回头寻找一个更优秀的维护方式:

维护这个凸包的所有拐点(可以重合),每个拐点处斜率 $+1$。

因为每次更新完后取了前缀 $\min$,将凸包末端斜率大于 $0$ 的段换成斜率为 $0$ 的,所以凸包初始斜率就是目前拐点的个数。

然后我们要做的就是在某个位置加入两个拐点,然后凸包末端弹出多余的拐点(斜率大于 $0$ 的部分)。所以操作本身就是维护拐点集合,支持插入和删除最大值。这只需要用堆即可完成。

对于凸包每个拐点具体纵坐标的值并不用维护,只需要维护任意一个点的坐标就可求出答案,常见的是维护凸包 $0$ 处的点值和凸包最低点的点值。

代码大概是这样

read(n) ;
rep(i,1,n) read(a[i]) , ans += a[i];
rep(i,1,n) {
    c.push(a[i]);
    c.push(a[i]);
    c.pop( );
}
while(c.size( )) {
    ans -= c.top( ) ; c.pop( ) ;
}

其中 c 为大根堆。

每次放入两个拐点,即绝对值函数的顶点,然后在末尾弹出斜率大于 $0$ 的段。

ans 为最终凸包在 $0$ 处的点值(全改成 $0$ 的代价),然后减去所有拐点的很坐标($-1\times\text{贡献}$)得到凸包最低处的答案。

read(n) ;
rep(i,1,n) read(a[i]) ;
rep(i,1,n) {
    c.push(a[i]);
    if(c.top( ) > a[i]) {
        ans += c.top( ) - a[i] ;
        c.pop( ) ;
        c.push(a[i]);
    }
}

这种写法是维护最低处的点值,可以自行理解。很多题解都这么写的,不那么好理解且难以拓展(如下方的 P3642 就难以使用)。

CF1534G

对凸包的操作为:

加绝对值函数、和 $\{0,...,0\} (c+1\text{个}0)$ 作闵可夫斯基和。

表现在凸包上就是将凸包斜率为 $0$ 的那段延长 $c$,因为这和这之后每个点一定都会成功地更新(取 $\min$)它之后的 $c$ 个点,表现出来就是将凸包的右半部分向右移动了 $c$ 距离,将最小值处拉长了 $c$ 距离。

考虑维护凸包的拐点(斜率+1),一个绝对值等于在其顶点处放两个拐点,而初始斜率就是拐点数量的一半的相反数(所有绝对值函数左半边的斜率为 $-1$),最小值就是在左右拐点数相同的位置斜率为 $0$。

拿一组对顶堆分别维护最小值左侧的拐点和最小值右侧的拐点,通过给右侧堆整体打 Tag 来实现整体右移,同样直接求出凸包在 $0$ 处的点值然后减去所有左侧拐点的横坐标得到最小值。

比较好理解的代码。

int n ;
pi pt[N] ;
struct heap1 {
    priority_queue < int , vector<int> , greater<int> > h;
    int del ;
    void emplace(int x) {
        h.emplace(x - del) ;
    }
    void shift(int x) {
        del += x ;
    }
    int top( ) {return h.top( ) + del; }
    void pop( ) { h.pop( ) ;}
    unsigned int size( ) {return h.size( ) ;}
} r ;
priority_queue <int> l ;
ll ans ;
bool cmp(pi x,pi y) {
    return x.first + x.second < y.first + y.second ;
}
signed main()
{
    read(n) ;
    rep(i,1,n) read(pt[i].first , pt[i].second),ans += pt[i].second;
    l.emplace(0) , r.emplace(0) ;
    sort(pt + 1 , pt + n + 1 ,cmp) ;
    rep(i,1,n) {
        r.shift(pt[i].first + pt[i].second - pt[i - 1].first - pt[i - 1].second) ;
        int v = pt[i].second ;
        if(v < l.top( )) {
            l.emplace(v) , l.emplace(v) ;
            r.emplace(l.top( )) ; l.pop( );
        }
        else if(v > r.top( )) {
            r.emplace(v) , r.emplace(v) ;
            l.emplace(r.top( )) ; r.pop( );
        } else {
            l.emplace(v) , r.emplace(v) ;
        }
       
    }
    rep(i,1,n + 1) ans -= l.top( ) , l.pop( ) ;
    cout << ans << '\n' ;
    return 0;
}



LuoguP3642

很适合在理解了 Slope trick 后拿来当练习题。

有暴力 dp: $f_{u,i}$ 为 $u$ 子树内全部 $i$ 时刻爆炸的代价。

$f_{u,i}=\sum_{v\text{ is son of } u} f'_{v,i} $

$f'_{u,i}=\min_{0\leq k \leq i} f_{u,i-k}+|k-dis|$ 其中 $dis$ 表示到父节点的引线的长度,将其调整为 $k$。

归纳证明已知所有儿子都是凸函数,凸函数求和还是凸函数,与凸函数 $f_k=|k-dis|$ 做闵可夫斯基和也是凸函数。

仍然用 Slope Trick 的方式维护函数斜率 $+1$ 的拐点,对一些函数求和就是直接将它们的拐点集合合并,考虑和 $f_k=|k-dis|$ 做闵可夫斯基和怎么做,$f_k=|k-dis|$ 的差分数组就是 $\{-1,-1,\dots -1,1,1,\dots\}$,共 $dis$ 个 $-1$,将它们插到对应的位置就是将凸包斜率为 $-1$ 的段延长 $dis$(可以比变得更小,就把斜率为 $0$ 的放在更后)然后将凸包末尾斜率大于 $1$ 的部分换成斜率为 $1$。

使用可并的大根堆维护凸包,因为所有凸包末尾斜率都为 $1$,所以将斜率为为 $-1$ 的段延长可以通过 pop 掉末两项,右移后重新 push 进去。 合并儿子的凸包就直接合并儿子对应的堆然后弹掉末尾斜率大于 $1$ 的部分即可。

复杂度 $O(n\log n)$。


$\text{End?}$

更新中,遇到有意义的凸优化题会加进来的。

谢谢你阅读到这里!


只不过大多是时候,我们都只能被那种由特定环境带来的镜子反射的光所照亮,很少有人能在 乏味的 拥挤的 阴郁的 环境里 也能保持觉知和观照的能力。

愿大家能找到前行的路!

还没有评论