题解:[NOIp 2018 PJ] 摆渡车(斜优)

不敢相信,这竟然是 T3!还好我当时没有考。

由于本人不太会 dp,只会套路,所以用了斜率优化。(放心,会讲的)
其他做法可参见这里

注:如无特殊说明,这里的单调递增为非严格的。单调递减同理。

前置任务

  • 线性 dp 及前缀和优化
  • 单调队列
  • 数学(代数式运算、函数、线性规划

题意抽象

这一步,对做几乎任何题都很重要。

给一个简易题意(表述若有问题还请指出):

有一条数轴,其上有 \(n(1\le n\le500)\) 个给定的点(可能重复),每个点对应 \(t_i(1\le i\le n, 0\le t_i\le4\times10^6)\) 这个数。
你要将数轴分成若干个左开右闭段,每段的长度 \(\ge m(1\le m\le100)\),覆盖每个 \(t_i\)。求所有点到各自所属段右端点距离之和的最小值。

第一个方程:Start

可以设 \(dp_i\) 表示对数轴上 \([0,i]\) 分段且最后一段的右边界是 \(i\) 时,位于 \([0,i]\) 内的点到各自所属段右端点距离之和的最小值。

设最后一段为 \((j, i]\)。由于它的长度不小于 \(m\),所以 \(j\le i-m\)。我们知道 \(j<t_k\le i\),意味着第 \(k\) 个点在这段中,它到右端的距离 \(=|i-t_k|=i-t_k\)(因 \(i\ge t_k\)),因而产生这些贡献。

累加 \(j\) 以前的最优答案 \(dp_j\),即可算出贡献。推出转移式:

\[dp_i = \min_{j\le i-m}\left\{dp_j + \sum_{k}^{j\le t_k\le i} (i-t_k)\right\} \]

不过,别忘了边界情况。这里要特判仅 \([0,i]\) 一段的情况,即 \(dp_i = \sum^{t_k\le i}(i-t_k) \)

但是,这种做法难以实现,复杂度还爆炸。先考虑较为无脑的优化。

第一步:前缀和优化

\(s_j = \sum_{i=1}^j t_i\)\(c_i\) 表示 \([0,i]\) 中点的个数,\(maxt=\max_{1\le i \le n}\{t_i\}\)

将原式拆开,得:

\[\begin{aligned} dp_i =& \min_{j\le i-m}\left\{dp_j + \sum_{k}^{j\le t_k\le i} i - \sum_{k}^{j\le t_k\le i}t_k\right\}\\ =& \min_{j\le i-m}\left\{dp_j + i(c_i - c_j) - (s_i - s_j)\right\}\\ \end{aligned}\]

显然,区间长度之和至少为 \(maxt\),因为需要覆盖到每个之前的点。而最劣情况就是区间起始点在 \(maxt\) 上,即区间覆盖了 \([0, maxt+m-1]\) 这一段。

所以,\(\min_i^{maxt\le i<m+maxt}\!\!\left\{dp_i\right\}\) 就是答案。

我们发现,时间复杂度\(O(t^2)\),可在 CCF 少爷机里拿下 \(50\text{ pts}\)

#include <bits/stdc++.h>
using ll = long long;

ll n, m, maxt, ans = 1e9, c[4000001], s[4000001], dp[4000001];
// 自动当我写了快速读入……

int main() {
    n = read(), m = read();
    for(int i=1; i<=n; ++i) {
        t = read();
        maxt = std::max(maxt, t);
        ++ c[t];
        s[t] += t; // 之后不将重复写输入输出、预处理和答案统计
    }
    for(int i=1; i < maxt+m; ++i) {
        c[i] += c[i-1], s[i] += s[i-1];
    }
    for(int i=0; i < maxt+m; ++i) {
        dp[i] = c[i] * i - s[i]; // 特判
        for (int j=0; j <= i-m; ++j) {
            dp[i] = std::min(dp[i], f[j] + (c[i] - c[j]) * i - s[i] + s[j]);
        }
    }
    for(int i=maxt; i < maxt+m; ++i) {
        ans = std::min(ans, f[i]);
    }
    cout << ans;
    return 0;
}

好像,够了?1= 稳了吧。

但是,力所能及的话,AC 才是最大的使命啊。

第 1.5 步:斜率优化原理(初步:\(k,x\) 均有单调性)

请注意,如果你才入门 dp,千万不要好高骛远抢着学高级优化。别到时候地基塌了什么都没了。

如果真想不借助斜率优化 AC 这题,请参见链接,查看其它 dp/search + 剪枝的题解。


(为什么只讨论最简单的都有单调性的情况?因为只用这个就行了。其他的更难。)

考虑这个问题:将 \(n\) 个物品分为连续的若干份(不限制份数),每分一次会产生一个代价,为该段物品的价格之和的平方。最大/最小化代价。

可以用线性 DP 来解决该问题。令 \(dp_i\) 表示选择前 \(i\) 个物品的最大/最小代价,则 \(dp_i=\min_i^{1\le j<i}\left\{dp_j+(s_i-s_j)^2\right\}\)

这个时候,由于 \((s_i-s_j)^2 = s_i^2+s_j^2+2s_is_j\) 中有一项 \(2s_is_j\),导致单调队列无法直接维护。所以,无计可施了?

理解一下斜率优化,这道题就很简单了。(

一种理解方式

我们要将这题的转移方程去掉大小判定后,化成 \(y=kx+b\) 的形式。(其中,\(k,b\) 只能与 \(i\) 或已知量有关,且 \(b\) 必须和 \(dp_i\) 有关;\(x,y\) 只能和决策点 \(j\) 有关,且 \(y\) 要与 \(dp_j\) 有关。)

以上题为例,化简得 \(dp_j + s_j^2 = (2s_i)(s_j) + (dp_i - s_i^2)\)。对应一下就可以了。

现在,我们按照上述的 \(x\)\(y\) 建立一个平面直角坐标系。因为 \(b\)\(dp_i\) 与已知量有关,则我们最小(大)化 \(dp_i\),就是最小(大)化该函数的截距(\(y=kx+b\) 中的 \(b\))。


\(k\) 只与 \(2s_i\) 有关,所以该函数图像的斜率一定。
这时我们可以把选择决策点的过程想象为一条斜率一定的直线在坐标系里向上平移的过程。而因为每个不同的 \(j\) 会带来不同的 \(x,y\) 的值,因此每个决策点都对应了平面直角坐标系里的一个点。

为了使我们这条函数图像的截距最小,我们在向上平移的过程中一定会选第一个切到的点。(最大化:即最大化截距,那么在向下平移的过程中选第一个切到的点即可)

三个点才可以确定两条直线。对于任意三个决策点 \(A(x_1,y_1),B(x_2,y_2),C(x_3,y_3)\),我们可设 \(x_1<x_2<x_3\)
我们发现 B 点可能成为最优决策点当且仅当:\(slope(A,B) < slope(B,C)\)。其中,\(slope(P,Q)\) 表示两点间的斜率。

所以我们应该维护“连接相邻两点的线段斜率”单调增(减)的一个下(上)凸壳,只有这个凸壳的顶点才有可能成为最优决策。

对于一条斜率为 \(k\) 的直线,在一个斜率具有单调性的凸包里,若某个顶点左侧线段的斜率比 \(k\) 小,右侧线段的斜率比 \(k\) 大,则该顶点就会被这条直线第一个切到。即只要找到了这样一个点,我们就得到了使截距最小化的那个决策 \(j\)。(最大化则反之)

放个高清大图,有助于理解:

一种做法:单调队列维护凸包

题中有两个隐含条件:

  • 横坐标 \(x\) 递增(因为 \(s_j\) 递增)
  • 斜率 \(k\) 递增(\(2s_i\) 递增)

所以,我们仍然可以用单调队列维护最值,即维护前文说到的“凸壳”。

对于每个状态变量 \(i\),步骤大约如下(根据实际情况进行调整):

  1. 弹出非法状态。检查队头的两个决策点 \(q_h\)\(q_{h+1}\),若这两点构成的斜率 \(slope(q_h,q_{h+1})<k\)(这里 \(k\) 由前面改写的方程得到),则将队头 \(q_h\) 弹出。
  2. 状态转移。直接取出队头 \(q_h\) 为最优决策 \(j\),计算出 \(dp_i\)
  3. 维护单调性。若三个决策点 \(q_{t-1}, q_t, i\) 不满足斜率的单调性(即不满足下(上)凸性),则直接弹出队尾,并继续检查新队尾。
  4. 产生新决策。把新决策 \(i\) 压入队尾。

由于每个元素最多入/出队一次,因此整个算法的时间复杂度为 \(\Theta(n)\)

第二步:斜优这题

化成 \(y=kx+b\) 的形式。

\[\begin{aligned} dp_i =& \min_{j\le i-m}\{dp_j + (c_i - c_j) * i - (s_i - s_j)\}\\ dp_i =&\ dp_j + ic_i - ic_j - s_i + s_j\\ -dp_j - s_j =& -ic_j + ic_i - dp_i - s_i\\ dp_j + s_j =&\ ic_j + ic_i - dp_i - s_i\\ \end{aligned}\]

其中:

  • \(y_j=dp_j+s_j\)
  • \(x_j=c_j\)
  • \(k_i=i\)
  • \(b_i=ic_i-dp_i-s_i\)

显然,\(k_i, x_j\) 单调不减,可直接用单调队列维护。

注意,这里要用到 \(i-m\) 这个状态求解,所以要先压状态进队列,再弹出非法状态。

核心代码

ll dq[4000001], h=1, t=0;
ll n, m, dp[4000001], s[4000001], c[4000001], maxt;

ll X(ll j){ return c[j]; }

ll Y(ll j){ return dp[j] + s[j]; }

long double K(ll i, ll j) { // 小心浮点数!!!
    return (long double)(Y(j) - Y(i)) / (X(j) == X(i) ? 1e-9 : X(j) - X(i));
}

int main(){
    // 读入,前缀和(省去)
    for(int i=0; i < maxt+m; ++i){
        if(i-m >= 0){
            while(h < t && K(dq[t-1], dq[t]) >= K(dq[t], i-m)){
                -- t; // 维护
            }
            dq[++ t] = i-m; // 把可能的最优解压入队列
        }
        while(h < t && K(dq[h], dq[h+1]) <= i){
            ++ h; // 维护
        }
        dp[i] = c[i] * i - s[i];
        if(h <= t) {
            int j = dq[h]; // 进行转移
            dp[i] = min(dp[i], dp[j] + (c[i] - c[j]) * i - (s[i] - s[j]));
        }
    }
    // 输出(省去)
    return 0;
}

最后

Q: 你说得对,但这才 T3。
A: 你说得对,但我那年没参加几乎不会 T4,当然要争这题。
Q: 你说得对,但是这是普及组。
A: 你说得对,但是多剪枝、大优化,能做出这题的都是好做法喵。
Q: 你说得对,但是这真的太难了。
A: 你说得对……钻研 >2h 应该可以会一点吧
但是……程序语法对初学者也是天书欸。

posted @ 2025-03-12 21:26  Newbie114  阅读(15)  评论(0)    收藏  举报