DP 凸优化
2025-07-07

袜,这篇文章一下子创建了三个新 tag。


什么是凸函数

定义

在信息学中研究的凸函数大抵是只在正整数域上有定义的,所以只能直接摒弃导函数的概念了。

若斜率递增,换言之,差分数组递增,认为函数是凸的(好反直觉)。

或者看看符号化的定义:若对于任意 \(\lambda\in[0,1]\),函数 \(f(x)\) 均满足:

\[ f(\lambda x+(1-\lambda) y)\le \lambda f(x)+(1-\lambda) f(y). \]

或等价地:

\[ f(x)-f(x - 1)\le f(x + 1) - f(x) \]

那么认为 \(f\) 是凸函数。

因为凹凸在性质上没区别,所以 下文施行暴政,将凹函数和凸函数统称为凸函数


判定

对于具体情境下的问题,怎么判断 DP 数组是否关于其某一维为凸函数?

  • 相信相信的力量(最常用)。
  • 对于分段问题,证明分出来的每一段满足四边形不等式。

    值得一提的是,数学上不认为满足四边形不等式能够推出函数为凸。但这是信息学.jpg
  • 其由两个凸函数线性相加得到:

    对于 \(\forall\, \lambda,\mu\ge 0\) 和凸函数 \(f,g\),若 \(h=\lambda f+\mu g\),则 \(h\) 为凸函数。

    此外,此时 \(h\) 的差分数组和 \(f,g\) 差分数组也满足上述关系。
  • 其是两个凸函数的 闽可夫斯基和
  • 其由两个凸函数线性 max 得到。

    对于两个凸函数的线性 min,一般不是凸函数,甚至可能是凹的。


性质

  • 最值为唯一极值。
  • 斜率 / 差分数组具有单调性。

在解题中主要用到的大概就是上面两条。


WQS 二分

主要任务

例:把 \(a_{1\sim n}\) 分为恰好 \(k\) 段,每段的代价为 \(sum^2+c\)\(c\) 为给定的常数),问最小代价。\(n\le 2\times 10^5,a_i\le 10^9\)

如果对段数没有要求,可以用斜优 \(O(n)\) 做。

加上了 \(k\) 的限制,DP 变成二维的,令 \(f_{i,j}\) 表示在 \(i\) 处分第 \(j\) 段,可以在第一维斜率优化,做到 \(O(nk)\);亦或发现 \((i,j)\) 有决策单调性,然后四边形不等式优化到 \(O(n^2)\)

我们发现 \(f\) 关于 \(j\) 这一维是凸的(而非上文的 \((i,j)\)),故将 \(f(n,j)\) 关于 \(j\) 的函数提取出来(记为 \(f_n(j)\))。发现只有最高点是可以一次斜优快速求出来的,故可以认为在当前复杂度限制下,整个函数除了最高点以外都是未知的(注意可以在 DP 时记录极值点的横坐标,即分了几段)。

我们给每个 \(f_n(j)\) 加上 \(\alpha\cdot j\)(在本题中即将 \(c\gets c+k\)),勉强可以认为 \(\alpha\cdot j\) 是凸的,所以新函数 \(f'_n(j)\) 也是凸的。若 \(k>0\),可以感受到极值点向左偏移,且随着 \(k\) 的增大,极值点不会右移;\(k<0\) 时同理。

结合几何观点,在 \(f_n\) 上的极值点可以用斜率为 \(0\) 的直线切到;而加上 \(\alpha\cdot j\) 的过程,其实就是切线斜率减去 \(\alpha\) 的过程。相当于令切线斜率为 \(-\alpha\),看看能切到哪里。

故可以二分一个合适的 \(k\),使得极值点的横坐标为 \(k\)。此时 \(f'_n(k)-\alpha\cdot k\) 即为待求。


WQS 二分参与的问题一般以如下形式呈现:

给定序列,将其恰好分为 \(k\) 段 / 恰好选 \(k\) 个,求最大 / 最小代价。

题目要求最大 / 最小值一般能够反映函数的凹凸性,可以根据这一点来猜。


细节处理

考虑如下凸函数:

  • 显然对于『凸出来』的点(即图中除了 \(E,H\) 以外的点),能切到的直线斜率为一个范围;而位于直线上的点(途中 \(E,H\)),能切到的直线斜率更是定死了。所以这要求你的二分精度需要略高一点。
  • 对于 \(E\)(或 \(H\)),不能找到只过它一点的切线。根据 DP 的写法,记录的横坐标可能是最小的 \(D\),也可能是最大的 \(F\),此时你可以:

    • 同时记录最小和最大的横坐标,看看 \(k\) 在不在中间。
    • 题目要求最大代价时,记录最大横坐标,二分到一个极值点 \(k'\ge k\) 时,显然 \(\alpha'\ge \alpha\),用 \(f'_n(k')-\alpha'\cdot k\) 更新答案(是赋值而不是取最值);求最小代价时同理。

      主要是需要记住斜率是 \(\alpha\) 的相反数,实在搞不清对应关系可以都试一试


例:种树

https://www.luogu.com.cn/problem/P1484

首先,函数是凸的——感性理解即可。

接着,要求的是 \(\le k\) 时的最值。分为两种情况:\(k\) 是否右于原函数极值点。

  • \(k\) 右于原函数极值点,则原函数极值点为答案。
  • 否则,\(k\) 为答案。

好消息是整数域上一定有解(最后答案为整、倒推即可),所以不用打浮点数二分了。

#include <bits/stdc++.h>
const int lim = 1e6;
const long long inf = 1e18;
int main() {
#ifdef ONLINE_JUDGE
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr), std::cout.tie(nullptr);
#else
    std::freopen(".in", "r", stdin);
    std::freopen(".out", "w", stdout);
#endif
    int n, k;
    std::cin >> n >> k;
    std::vector<long long> a(n + 1);
    for (int i = 1; i <= n; ++i)
        std::cin >> a[i];
    auto calc = [&](int c) {
        std::vector<std::pair<long long, int> > f(n + 1);
        auto mx = f.front();
        auto upd = [&](auto &x, auto y) {
            if (y.first > x.first || (y.first == x.first && y.second < x.second))
                x = y;
            return;
        };
        for (int i = 1; i <= n; ++i) {
            f[i].first = mx.first + a[i] + c;
            f[i].second = mx.second + 1;
            upd(mx, f[i - 1]);
        }
        upd(mx, f[n]);
        return mx;
    };
    auto t(calc(0));
    if (t.second <= k) {
        std::cout << t.first << '\n';
        return 0;
    }
    auto res(-inf);
    for (int l = -lim, r = lim, mid; l <= r; ) {
        mid = (l + r) >> 1;
        auto t = calc(mid);
        if (t.second <= k)
            res = t.first - (long long)mid * k, l = mid + 1;
        else
            r = mid - 1;
    }
    std::cout << res << '\n';
    return 0;
}

例:Raper

https://www.luogu.com.cn/problem/P4694

给所有光盘附一个奖励 \(mid\),进行 WQS 二分。

然后内层就和 sequence 差得不是特别多,所以考虑反悔贪心。(?)

把路过的 \(-a_j\) 丢到大根堆里,尝试和 \(b_i\) 配对。如果 \(mid-a_i-b_i\ge 0\) 那么暂时配对,顺便把 \(b_i-mid\) 丢进去用于反悔,若 \(b_{i'}\) 选中了 \(b_i-mid\) 说明反悔,将 \(a_j\)\(b_{i'}\) 配对。

贪心的时候需要记录已经配的对数,优先队列中需要对 \(-a_j\)\(b_i-mid\) 打标记进行区分,匹配到前者时才增加成功对数。

发现整数域上仍然必有解(答案为整,倒推),不用打浮点数二分 neiher.

#include <bits/stdc++.h>
const long long inf = 2e9 + 5;
int main() {
#ifdef ONLINE_JUDGE
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr), std::cout.tie(nullptr);
#else
    std::freopen(".in", "r", stdin);
    std::freopen(".out", "w", stdout);
#endif
    int n, k;
    std::cin >> n >> k;
    std::vector<int> a(n + 1), b(n + 1);
    for (int i = 1; i <= n; ++i)
        std::cin >> a[i];
    for (int i = 1; i <= n; ++i)
        std::cin >> b[i];
    auto calc = [&](long long mid) {
        std::priority_queue<std::pair<long long, int> > q;
        int cnt = 0;
        auto res(0ll);
        for (int i = 1; i <= n; ++i) {
            q.emplace(-a[i], 1);
            auto [v, ty] = q.top();
            if (mid + v - b[i] >= 0) {
                q.pop(), cnt += ty;
                res += mid + v - b[i], q.emplace(b[i] - mid, 0);
            }
        }
        return std::make_pair(cnt, res);
    };
    auto res(inf);
    for (long long l = 1ll, r = inf, mid; l <= r; ) {
        mid = (l + r) >> 1;
        auto t(calc(mid));
        if (t.first >= k)
            r = mid - 1, res = -(t.second - k * mid);
        else
            l = mid + 1;
    }
    std::cout << res << '\n';
    return 0;
}

区间分拆问题

目标:尝试将 \(a_{1\sim n}\) 分割成恰好 \(k\) 段,定义 \(w(i,j)\) 为将 \([i,j]\) 作为一段的代价,求代价最值。其中 \(w(i,j)\) 满足四边形不等式。

不妨设求最小代价。定义 \(f(i,j)\)\(i\) 处分第 \(j\) 段,那么有:

\[ f(i,j)=\min(f(i-1,p-1)+w(p,j)). \]

有定理:若 \(w(i,j)\) 满足四边形不等式,那么有 \(f(i-1,j)\le f(i,j)\le f(i,j+1)\),从 \(f\) 定义出发很好感性理解。

那么可以从这一点出发,\(p\) 这一维就可以被摊掉,做到 \(O(n(n+m))\)

有定理:若 \(w(i,j)\) 满足四边形不等式,那么 \(f_i(j)\) 是凸函数。

那很好了。直接冲刺 WQS 即可。


闽可夫斯基和

给定两个凸函数(当然是指 OI 中那种长得很直的函数),将它们的『边』拆下来过后,以某种方式拼在一起,使得最后得到一个新的凸函数。

按每条『边』的斜率归并一下就可以得到,显然答案存在且唯一,这个过程被称为闽可夫斯基和。可以发现这是一种最值 - 加卷积。

假设我们要求的 DP 数组是两个已知的子数组以类似的方法卷起来的,最后要求完整的 DP 数组(而非 WQS 中的单点值),就可以考虑闽可夫斯基和。

发现这个结构是很适合归并的,使用归并要求状态中含有版本维、因变量维。


例:It's Mooin' Time P

https://www.luogu.com.cn/problem/P11459

定义 \(w_i\) 为将 \([i,i+L)\) 改为 MOO 的代价,问题转化为选取 \(1\sim \frac nL\) 个不重区间的最小代价和。

尝试归并,每次将 \([l,mid]\)\((mid, r]\) 卷起来。但是会有一点问题:如果恰好有区间卡在中间呢?

可以令 \(f_{l,r,0/1/2,0/1/2}(i)\) 表示在 \([l,r]\) 内选了 \(i\)MOO,且两侧最远的被选点距离端点为 \(0/1/\ge 2\) 的 DP 值。然后可以进行相应的讨论、平移、闽可夫斯基和,最后将所有情况线性取 max 得到这一层的答案,容易发现得到的答案也是凸的。

#include <bits/stdc++.h>
const long long inf = 1e18;
int L;
int main() {
#ifdef ONLINE_JUDGE
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr), std::cout.tie(nullptr);
#else
    std::freopen(".in", "r", stdin);
    std::freopen(".out", "w", stdout);
#endif
    int n;
    std::cin >> L >> n;
    std::vector<int> a(n + 1);
    std::vector<char> t(n + 1);
    std::vector<long long> w(n + 1);
    for (int i = 1; i <= n; ++i)
        std::cin >> t[i];
    for (int i = 1; i <= n; ++i)
        std::cin >> a[i];
    for (int i = 1; i <= n - L + 1; ++i) {
        w[i] = a[i] * (t[i] != 'M');
        for (int j = i + 1; j < i + L; ++j)
            w[i] += a[j] * (t[j] != 'O');
        // printf("[%d, %d]: %lld\n", i, i + L - 1, w[i]);
    }
    if (L == 1) {
        std::sort(w.begin() + 1, w.end());
        auto s(0ll);
        for (int i = 1; i <= n; ++i) {
            s += w[i];
            std::cout << s << '\n';
        }
        return 0;
    }
    using arr = std::vector<long long>;
    using brr = std::vector<arr>;
    using crr = std::vector<brr>;
    struct node {
        crr f;
        node(int len): f(L, brr(L, arr(len, inf))) {
            for (int i = 0; i < L; ++i)
                for (int j = 0; j < L; ++j)
                    f[i][j][0] = 0ll;
            return;
        }
        brr& operator[] (int q) { return f[q]; }
    };
    auto upd = [&](arr &x, arr y) {
        if (x.size() < y.size())
            x.resize(y.size(), inf);
        for (int i = 0; i < (int)y.size(); ++i)
            x[i] = std::min(x[i], y[i]);
        return;
    };
    auto merge = [&](arr l, arr r) {
        arr out((int)(l.size() + r.size()) - 1), dl(l.size()), dr(r.size());
        for (int i = 1; i < (int)l.size(); ++i)
            dl[i] = l[i] - l[i - 1];
        for (int i = 1; i < (int)r.size(); ++i)
            dr[i] = r[i] - r[i - 1];
        std::merge(dl.begin() + 1, dl.end(), dr.begin() + 1, dr.end(), out.begin() + 1);
        out[0] = l[0] + r[0];
        std::partial_sum(out.begin(), out.end(), out.begin());
        return out;
    };
    std::function<node(int, int)> calc = [&](int l, int r) {
        if (r - l + 1 < 2 * L) {
            node res(1);
            for (int ll = 0; ll < L; ++ll)
                for (int rr = 0; rr < L; ++rr)
                    if (l + ll <= r - rr - L + 1) {
                        res[ll][rr].resize(2, inf);
                        for (int i = l + ll; i <= r - rr - L + 1; ++i)
                            res[ll][rr][1] = std::min(res[ll][rr][1], w[i]);
                        // printf("[%d, %d]: (%d, %d) = %lld\n", l, r, ll, rr, res[ll][rr][1]);
                    }
            // for (int i = 0; i < L; ++i)
            //     if (l + i + L - 1 <= r) {
            //         printf("[%d, %d]: (%d, %d) = %lld\n", l, r, i, r - (l + i + L - 1), w[l + i]);
            //         res[i][r - (l + i + L - 1)].emplace_back(w[l + i]);
            //     }
            return res;
        }   
        int mid = (l + r) >> 1;
        node tl(calc(l, mid)), tr(calc(mid + 1, r)), res((r - l + 1) / L + 1);
        for (int ll = 0; ll < L; ++ll)
            for (int rr = 0; rr < L; ++rr)
                res[ll][rr] = merge(tl[ll][0], tr[0][rr]);
        for (int ll = 0; ll < L; ++ll)
            for (int rr = 0; rr < L; ++rr)
                for (int i = 1; i < L && i < mid; ++i)
                    upd(res[ll][rr], merge(merge(tl[ll][i], tr[L - i][rr]), { 0, w[mid - i + 1] }));
        for (int ll = 0; ll < L; ++ll)
            for (int rr = L - 1; rr; --rr)
                upd(res[ll][rr - 1], res[ll][rr]);
        for (int rr = 0; rr < L; ++rr)
            for (int ll = L - 1; ll; --ll)
                upd(res[ll - 1][rr], res[ll][rr]);
        // for (int i = 0; i < L; ++i)
        //     for (int j = 0; j < L; ++j) {
        //         printf("  (%d, %d): ", i, j);
        //         for (auto p : res[i][j])
        //             printf("%lld ", p);
        //         puts("");
        //     }
        return res;
    };
    auto res(calc(1, n));
    for (int i = 1; i <= n / L; ++i)
        std::cout << res[0][0][i] << '\n';
    return 0;
}

例:假人

http://222.180.160.110:61235/problem/47728

\(f_{i,j}\) 表示前 \(i\) 组选完占了 \(j\) 体积的最大价值。那么有 \(f_{i,j}=\max\{f_{i-1,k}+a_{i,j-k}\}\)

袜,这个东西好凸的样子!啊?居然不是凸的?

事实上,有结论:若干个体积为 \(0\sim 4\) 的物品做背包,可以凑出值域内任意 \(24\) 的倍数。所以先把体积全部 \(-1\),然后就可以得到体积为 \(0\sim 4\) 的物品。然后会发现他们按照模 \(12\) 的余数分别是凸的。

能感受到恶意了。然后就可以枚举体积,再闽可夫斯基和了。同样需要在状态中引入作为版本号的 \([l,r]\) 维,显然因变量是体积、版本号是组数。

#include <bits/stdc++.h>
const long long inf = 1e18;
int main() {
#ifdef ONLINE_JUDGE
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr), std::cout.tie(nullptr);
#else
    std::freopen("fake2.in", "r", stdin);
    std::freopen(".out", "w", stdout);
#endif
    int n, m = 0;
    std::cin >> n;
    std::vector<int> siz(n + 1);
    std::vector<std::vector<int> > a(n + 1);
    for (int i = 1; i <= n; ++i) {
        std::cin >> siz[i], m += siz[i];
        a[i].resize(siz[i] + 1);
        for (int j = 1; j <= siz[i]; ++j)
            std::cin >> a[i][j];
    }
    using arr = std::vector<long long>;
    struct node {
        std::vector<arr> f;
        node(): f(12) {}
        arr &operator[] (int q) {
            return f[q];
        }
    };
    auto merge = [&](arr &p, arr &q) {
        arr res((int)(p.size() + q.size()) - 1), dp(p.size()), dq(q.size());
        std::adjacent_difference(p.begin(), p.end(), dp.begin());
        std::adjacent_difference(q.begin(), q.end(), dq.begin());
        std::merge(dp.begin() + 1, dp.end(), dq.begin() + 1, dq.end(), res.begin() + 1, std::greater<long long> ());
        res[0] = p[0] + q[0];
        std::partial_sum(res.begin(), res.end(), res.begin());
        return res;
    };
    auto upd = [&](arr y, arr &x, int tag) {
        if (tag)
            y.insert(y.begin(), -inf);
        if (y.size() > x.size())
            x.resize(y.size(), -inf);
        for (int i = 0; i < (int)y.size(); ++i)
            x[i] = std::max(x[i], y[i]);
        return;
    };
    std::function<node(int, int)> calc = [&](int l, int r) {
        if (l == r) {
            node res;
            for (int i = 1; i <= siz[l]; ++i)
                res[i - 1].push_back(a[l][i]);
            return res;
        }
        int mid = (l + r) >> 1;
        node tl(calc(l, mid)), tr(calc(mid + 1, r)), res;
        for (int p = 0; p < 12; ++p)
            for (int q = 0; q < 12; ++q)
                if (!tl[p].empty() && !tr[q].empty())
                    upd(merge(tl[p], tr[q]), res[(p + q) % 12], p + q >= 12);
        return res;
    };
    auto res(calc(1, n));
    for (int i = 0; i <= m - n; ++i)
        std::cout << res[i % 12][i / 12] << ' ';
    std::cout << '\n';
    return 0;
}

slope trick

对于一个 OI 中的凸函数,它是由一些斜率单调的线段拼接成的图形。

在这个前提下,发现可以用整个函数的左 / 右端点值(截距)和 \((n-1)\) 个斜率 / 差分信息描述函数。而这有时比直接维护 \(n\) 个点值有着更优美的性质,唤其为 slope trick。

例如,关于斜率序列,如果定义域不大,可以直接维护;如果它的变化次数不多,可以利用它的单调性质,用单调数据结构维护;如果它的值域很小,可以直接维护拐点。

注意前两种方法一般关注横坐标对应的函数值,所以自变量一般是和操作次数一类有关的;而这里关注函数最值而弱化了具体横坐标,操作次数一般会作为待最优化的因变量,例如:

  • 我想要将数列通过若干次单点修改使序列单调,但不关心序列最终形态,问最小操作次数。
  • 我想要让树上所有叶子到根的路径边权和相等,但不关心这个值是多少,问最小操作次数。

下面依次解决上述问题。


例:序列 sequence

https://www.luogu.com.cn/problem/P4597

暴力地,令 \(f_{i,j}\) 表示 \(a_i\) 最终被修改为 \(j\),容易写出 DP 式子 \(f_{i,j}=\min\limits_{k\le j}\{f_{i-1,k}\}+|a_i-j|\)

然后就会发现它的定义域实在是太大了!那如果关注斜率的变化情况呢?

\(f_{i-1}(k)\) 的极值点为 \(k_m\),分类讨论拆绝对值和 min:

  • \(j\le a_i\) 时,\(f_i(j)=\min\limits_{k\le j}\{f_{i-1}(k)\}+a_i - j\)

    • \(j < k_m\) 时,\(f_i(j)=f_{i-1}(j)+a_i-j\),斜率相对于 \(f_{i-1}(j)\) 整体减 \(1\)我就说导数没白学吧
    • \(j \ge k_m\) 时,\(f_i(j)=f_{i-1}(k_m)+a_i-j\),斜率为 \(-1\)
  • \(j > a_i\) 时,\(f_i(j)=\min\limits_{k\le j}\{f_{i-1}(k)\}+j-a_i\)

    • \(j < k_m\) 时,\(f_i(j)=f_{i-1}(j)+j-a_i\),斜率相对于 \(f_{i-1}(j)\) 整体加 \(1\)
    • \(j\ge k_m\) 时,\(f_i(j) = f_{i-1}(k_m)+j-a_i\),斜率为 \(1\)

如果现在我们掏一个支持区间加斜率、区间推平斜率且维护区间函数最值点的数据结构,那就太逆天了。一个经典的解决方案是用堆维护线段斜率的差分(也就是差分的差分)。

发现整体修改次数是 \(O(n)\) 的,且如果只看比 \(k_m\) 更左的 \(j\)(更靠右的已经与极值无关所以不用维护),那么就只需要维护区间减法。

如果将一次区间前缀减法看作一次前缀右端点入队,显然左侧某点 \(a\) 的实际被减次数为 \(a\) 入队的次数 + 所有右于 \(a\) 点的元素的入队次数。用大根堆维护这些元素,就可以快速维护整个斜率序列。例如 3 2 2 1,代表 \(3\) 处斜率为 \(-1\)\(2\) 处斜率为 \(-3\)\(1\) 处斜率为 \(-4\)

  • \(a_i\ge k_m\) 时(容易发现它会成为新的 \(k_m\)),直接把 \(a_i\) 加入大根堆,就可以把 \(\le a_i\) 的所有位置斜率减去 \(1\)

    什么你问在 \(k_m\)\(a_i\) 之间的推平怎么办?由于唯一右于它们的拐点 \(a_i\) 入了一次队,所以中间的元素斜率自然是 \(-1\) 啊。
  • \(a_i<k_m\) 时,我们需要:

    1. \(a_i\) 以左的位置斜率减去 \(1\),直接将 \(a_i\) 入队即可。
    2. \(a_i\)\(k_m\) 之间的位置斜率加上 \(1\),把 \(k_m\) 弹出(太好了就在队首)再把 \(a_i\) 加入即可。由于 \(k_m\) 显然是一个拐点,所以删除 \(k_m\) 并非虚空索敌,但注意 \(k_m\) 可能会变化成其后继。

其实就会发现还有一些有趣的小性质,比如 \(k_m\) 与队首等价(用于实现);\(k_m\) 右侧斜率全部为 \(1\),也是唯一可以取到的正斜率。

袜写起来好简单。

#include <bits/stdc++.h>
int main() {
#ifdef ONLINE_JUDGE
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr), std::cout.tie(nullptr);
#else
    std::freopen(".in", "r", stdin);
    std::freopen(".out", "w", stdout);
#endif
    int n;
    std::cin >> n;
    std::vector<int> a(n + 1);
    long long res = 0ll;
    std::priority_queue<int> q;
    for (int i = 1; i <= n; ++i) {
        std::cin >> a[i];
        if (i == 1 || a[i] >= q.top())
            q.push(a[i]);
        else {
            res += q.top() - a[i];
            q.pop(), q.push(a[i]), q.push(a[i]);
        }
    }
    std::cout << res << '\n';
    return 0;
}

例:烟花表演

https://www.luogu.com.cn/problem/P3642

\(f_{u,d}\) 表示在 \(u\) 子树中所有叶子距根均为 \(d\) 的最小代价。那么有:

\[ f_{u,d}=\sum\limits_{(v, w)\in u}\min\limits_{0\le w'\le d}\{f_{v,d-w'}+|w-w'|\}. \]

可以归纳证明 \(f_{u,d}\) 为凸;和上一题相似地,考虑用 \(f_v\) 的堆得到 \(f_u\) 的堆。

首先最外层是一个线性加,这其实就是把差分数组 / 斜率加起来,放到我们这个堆里面就是合并两个堆。虽然你可能和我一样不认识可并堆,但是你可以和我一样不会 pd_ds。

再看内层,发现和上一题长得像但其实不一样,呜呜呜。发现 \(|w-w'|\) 是一个左右斜率分别为 \(-1,1\) 的分段函数,而左边类比可得斜率最大为 \(1\)

由闽可夫斯基和,内层函数斜率 \(<-1\) 的部分来自 \(f_{v,d-w'}\)\(=-1\) 的部分来自 \(f_{v,d-w'}\)\(|w-w'|\) 的左半边(感谢 \(w'\) 是有下确界的);\(=0\) 的部分来自 \(f_{v,d-w'}\)\(>0\) 的部分我们不关心。

最终函数图像的变化:水平部分的左端点右移了 \(w\)。找到 \(f_{v,d}\) 中的水平线段和斜率为 \(1\) 的线段,令其在堆中的值 \(+w\) 就能达到平移的目的。

但需要注意,此时仍然需要 保留内层函数中斜率为 \(1\) 的部分,但斜率 \(>1\) 的部分被删除。原因如下:

  • 绝对值函数与内层函数定义域相同,作闽可夫斯基和后,斜率为 \(1\) 的右侧线段会将 \(f_{v,d-w'}\) 中原有的 \(>1\) 的部分『挤』出定义域,故可以将其直接删除。
  • 斜率为 \(1\) 的部分,虽然对内层函数中极值点无贡献,但在外层函数中极值点可能向右偏移,此时会对其产生影响,需要维护。

容易发现由于 \(w'\) 的取值上限是 \(d\),但 \(d\) 没有上界,所以每个内层函数的右侧都一定是一条斜率为 \(1\) 的射线;也就是说,每个外层函数一定有(儿子个数)个拐点(堆中元素),弹出这么多个就可以删除外层函数中斜率 \(>1\) 的线段,顺便找到斜率为 \(0\)\(1\) 的线段。

#include <bits/stdc++.h>
#include <ext/pb_ds/priority_queue.hpp>
const int inf= 0x3f3f3f3f;
int main() {
#ifdef ONLINE_JUDGE
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr), std::cout.tie(nullptr);
#else
    std::freopen(".in", "r", stdin);
    std::freopen(".out", "w", stdout);
#endif
    int n, m;
    std::cin >> n >> m, n += m;
    std::vector<std::vector<std::pair<int, int> > > g(n + 1);
    auto res(0ll);
    for (int i = 2, fa, w; i <= n; ++i) {
        std::cin >> fa >> w, res += w;
        g[fa].emplace_back(i, w);
    }
    std::vector<__gnu_pbds::priority_queue<long long> > q(n + 1);
    std::function<void(int)> DFS = [&](int x) {
        if (g[x].empty())
            q[x].push(0), q[x].push(0);
        else
            for (auto [i, w] : g[x]) {
                DFS(i);
                for (int j = 1; j < (int)g[i].size(); ++j)
                    q[i].pop();
                long long t1 = q[i].top(), t0;
                q[i].pop(), t0 = q[i].top(), q[i].pop();
                q[i].push(t1 + w), q[i].push(t0 + w);
                q[x].join(q[i]);
            }
        // fprintf(stderr, "size(%d) = %d\n", x, (int)q[x].size());
        return;
    };
    DFS(1);
    for (int i = 1; i <= (int)g[1].size(); ++i)
        q[1].pop();
    for (; !q[1].empty(); q[1].pop())
        res -= q[1].top();
    std::cout << res << '\n';
    return 0;
}

例:Buy Low Sell High

https://codeforces.com/problemset/problem/865/D

\(f_{i,j}\) 表示在第 \(i\) 天结束后持有 \(j\) 支股票的最大收益。则 \(f_{i,j}=\max\{f_{i-1,j},f_{i-1,j-1}-a_i,f_{i-1,j+1}+a_i\}\)

(由于这里和 max 有关所以需要精细描述凹凸了)发现就是把三个(可能)经过平移的(可能)凹的函数做线性 max。虽然实际上的结论『函数在整点上是凹的』不算太显然,但是可以猜是凹的。

线性 max 在 slope trick 中是不太能体现的,但我们发现这里是同一个凹函数向左上、右下平移同一个向量,希望得到新的凹函数不管是从差分角度还是图象,都能得到新函数由左上部分斜率 \(>-a_i\) 的线段、两段斜率 \(=-a_i\) 的线段、右下部分斜率 \(<-a_i\) 的线段组成。此外,由于向左上平移的函数有一处离开了定义域,还需将其剔除。

值得注意的是,当 \(-a_i\) 比最小斜率还要小时会有变化,可以画图看一下。

发现定义域是相对小的(\(0\le j\le n\)),故用优先队列维护整个斜率序列就可以快速插入两个 \(k=-a_i\) 的线段。容易发现顶点在 \(j=0\) 处取得(从图象和定义都易知),最右侧是只买不卖的最劣方案,可以倒推。

#include <bits/stdc++.h>
int main() {
#ifdef ONLINE_JUDGE
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr), std::cout.tie(nullptr);
#else
    std::freopen(".in", "r", stdin);
    std::freopen(".out", "w", stdout);
#endif
    int n;
    std::cin >> n;
    std::vector<int> a(n + 1);
    auto res = 0ll;
    for (int i = 1; i <= n; ++i)
        std::cin >> a[i], res -= a[i];
    std::priority_queue<int> q;
    q.push(-a[1]);
    for (int i = 2; i <= n; ++i)
        if (a[i] < -q.top())
            q.push(-a[i]);
        else
            q.pop(), q.push(-a[i]), q.push(-a[i]);
    for (; !q.empty(); res -= q.top(), q.pop());
    std::cout << res << '\n';
    return 0;
}

言论