浅谈斜率优化

· · 算法·理论

浅谈斜率优化

更好的阅读体验

概论

列出状态转移方程,如果能化简为以下的形式:

dp[i]=\min/\max(c[i]+d[j]+C)

此时我们就可以利用单调队列优化从做 O(n^2)O(n) 的复杂度。

现在考虑更一般的情况,如果化简为以下形式:

dp[i]=\min/\max(a[i]\cdot b[j]+c[i]+d[j]+C)

此时单调队列优化就不再适用,就需要使用斜率优化

斜率优化的思想就是通过剔除一些不可能成为最优解的决策点,使剩下的决策点形成一个凸包,再使用单调队列、二分、平衡树、CDQ 分治或者李超线段树来快速转移。

理论讲解

斜率优化有两种理解方式:

代数法

设决策点 j_2 优于 j_1

则有:(< 对应 \max> 对应 \min

a[i]\cdot b[j_1]+c[i]+d[j_1]+C</>a[i]\cdot b[j_2]+c[i]+d[j_2]+C

化简得:

a[i]\cdot b[j_1]+d[j_1]</>a[i]\cdot b[j_2]+d[j_2]

Y(x)=-d[x]X(x)=b[x]k=a[i],再让 X(j_2)>X(j_1)

移项得(不等号要变号):

k>/<\frac{Y(j_2)-Y(j_1)}{X(j_2)-X(j_1)}

这就是说,将两个决策点在平面直角坐标系中用两点表示出来,如果两点间斜率小于(或大于)k,那么后面的点优于前面的点。

现在我们考虑有如下位置关系的三个点:

如图,k_2<k_1

下面我们讨论 \max 的情况。

综上,无论何种情况,C 都不可能最优,可以删去。

推广以上的性质,对于所有的决策点:

删去所有不可能为最优的决策点,发现可能为最优解的点会形成如下的一个下凸包

同理,对于 \min 的情况,会形成一个上凸包。

几何法

我们先讨论 \max 的情况:

dp[i]=\max(a[i]\cdot b[j]+c[i]+d[j]+C)

去掉 \max,再移项得:

-d[j]=a[i]\cdot b[j]+c[i]-dp[i]+C

我们可以将 -d[j] 看作纵坐标,a[i] 看作斜率,b[j] 看作横坐标,c[i]-dp[i]+C 看作常数。

那么此时就可以把状态转移方程看作一个形如 y=kx+b 的直线方程,其中对于同一个 i 斜率 k=a[i] 是不变的。

要使 dp[i] 最大,就要使方程的截距 b 尽可能小。

将所有决策点 (b[j],-d[j]) 在平面直角坐标系中表示出来:

寻找最优决策点的过程就可以看作平移一条斜率不变的直线,自下往上遇到的第一个点必然使截距最小,如图:

事实上,这个点就是和前面点斜率小于等于当前斜率,和后面点斜率大于等于当前斜率的点。

现在我们考虑改变直线斜率,发现可能是最优解的决策点当且仅当在下面的下凸包上:

不在下面的下凸包上的,无论斜率如何改变,都不可能是从下往上平移最小的点。

同理,对于 \min 的情况,会形成一个上凸包。

个人更喜欢几何法,比较直观,所以下面的例题都会采用几何法论证。

代码实现

对于不同的情况,我们有不同的插入决策点及查询最优解的方式(具体的可以看下面的例题):

决策点横坐标(b[j])严格增,直线方程斜率(a[i])严格增

决策点横坐标(b[j])严格增,直线方程斜率(a[i])不严格增

决策点横坐标(b[j])不严格增

由于横坐标都不严格增,也就是说可能加的点在两点之间,上面的方法就行不通了。

可以考虑利用动态维护凸包或者采用 CDQ 分治离线等方法,在此先不予陈述。

这里介绍一种思维含量低、代码量小、常数小的优秀算法:李超线段树

关于李超线段树本身的讲解可以看:浅谈李超线段树

事实上,李超线段树并不是维护动态凸包,而是利用本身的特性直接维护答案。

将一些不变量提出:

dp[i]=c[i]+C+\min/\max(b[j]\cdot a[i]+d[j])

我们将 \min/\max 中的式子看作一个直线方程 y=kx+b,其中 b[j] 是斜率,a[i] 是此时横坐标,d[j] 是截距。

发现此时就是要求若干个直线 y=b[j]\cdot x+d[j]x=a[i] 时的最值,而这恰好是李超线段树所维护的东西。

因此,每次我们只需将直线 y=b[j]\cdot x+d[j] 插入李超树,再查询所有直线在 x=a[i] 时的最值更新答案即可。

时间复杂度:O(n\log n)

例题

P5785 [SDOI2012] 任务安排 link

题目描述

机器上有 n 个需要处理的任务,它们构成了一个序列。这些任务被标号为 1n,因此序列的排列为 1 , 2 , 3 \cdots n。这 n 个任务被分成若干批,每批包含相邻的若干任务。从时刻 0 开始,这些任务被分批加工,第 i 个任务单独完成所需的时间是 T_i。在每批任务开始前,机器需要启动时间 s,而完成这批任务所需的时间是各个任务需要时间的总和。

注意,同一批任务将在同一时刻完成。每个任务的费用是它的完成时刻乘以一个费用系数 C_i

请确定一个分组方案,使得总费用最小。

数据范围

T1:1\leq n \leq 5000,0\leq S\leq 50,1\leq T_i,C_i \leq 100 T2:1\leq n \leq 300000,0\leq S\leq 512,1\leq T_i,C_i \leq 100 T3:1\leq n \leq 300000,0\leq S\leq 512,0\leq C_i \leq 512,-512\leq T_i \leq 512

解题思路

T1:

朴素 DP,不需要优化。

我们对 ct 做前缀和,用前缀和替代原来的数组。

时间复杂度:O(n^2)

T2:

因为 1\leq n \leq 300000O(n^2) 无法通过,发现状态转移方程符合斜率优化的条件,考虑斜率优化。

化简、移项得:

dp[j] = (t[i]+s)\cdot c[j]+dp[i]-t[i]\cdot c[j]-s\cdot c[n]

要使 dp[i] 更小,那么就要使截距尽可能小,因此决策点会形成一个下凸包。

由于 T_i,C_i \geq 1,因此前缀和严格增,所以横坐标 c[j] 严格增,截距 t[i]+s 严格增,应用第一种情况即可。

T3:

此时 -512\leq T_i \leq 512,所以截距不严格增,其他不变,在查询时二分找决策点即可。

编码技巧:在处理斜率时为避免误差,可以写作乘积的形式

代码

T1:
#include<bits/stdc++.h>
#define endl "\n"
#define ll long long
using namespace std;

const int N = 5010;

int n, s;
ll t[N], f[N];
ll dp[N];

int main()
{
    ios :: sync_with_stdio(0);
    cin.tie(0), cout.tie(0);
    cin >> n >> s;
    for (int i = 1; i <= n; i++)
    {
        cin >> t[i] >> f[i];
        t[i] = t[i - 1] + t[i];
        f[i] = f[i - 1] + f[i];
    }
    memset(dp, 0x7f, sizeof dp);
    dp[0] = 0;
    for (int i = 1; i <= n; i++)
    {
        for (int j = 0; j < i; j++)
        {
            dp[i] = min(dp[i], dp[j] + t[i] * (f[i] - f[j]) + s * (f[n] - f[j]));
        }
    }
    cout << dp[n] << endl;
    return 0;
}
T2:
#include<bits/stdc++.h>
#define endl "\n"
using namespace std;

const int N = 3e5 + 10;

int n, s;
int t[N], c[N];
int dp[N], q[N];

int main()
{
    ios :: sync_with_stdio(0);
    cin.tie(0), cout.tie(0);
    cin >> n >> s;
    for (int i = 1; i <= n; i++)
    {
        cin >> t[i] >> c[i];
        t[i] += t[i - 1];
        c[i] += c[i - 1];
    }
    int hh = 0, tt = 0;
    q[0] = 0;
    for (int i = 1; i <= n; i++)
    {
        while (hh < tt && (dp[q[hh + 1]] - dp[q[hh]]) <= (t[i] + s) * (c[q[hh + 1]] - c[q[hh]]))
        {
            hh++;
        }
        dp[i] = dp[q[hh]] + t[i] * (c[i] - c[q[hh]]) + s * (c[n] - c[q[hh]]);
        while (hh < tt && (dp[q[tt]] - dp[q[tt - 1]]) * (c[i] - c[q[tt]]) >= (dp[i] - dp[q[tt]]) * (c[q[tt]] - c[q[tt - 1]]))
        {
            tt--;
        }
        q[++tt] = i;
    }
    cout << dp[n] << endl;
    return 0;
}
T3:
#include<bits/stdc++.h>
#define endl "\n"
#define ll long long
using namespace std;

const int N = 3e5 + 10;

int n, s;
ll t[N], c[N];
ll dp[N], q[N];

int main()
{
    ios :: sync_with_stdio(0);
    cin.tie(0), cout.tie(0);
    cin >> n >> s;
    for (int i = 1; i <= n; i++)
    {
        cin >> t[i] >> c[i];
        t[i] += t[i - 1];
        c[i] += c[i - 1];
    }
    int hh = 0, tt = 0;
    q[0] = 0;
    for (int i = 1; i <= n; i++)
    {
        int l = hh, r = tt;
        while (l < r)
        {
            int mid = (l + r) >> 1;
            if (dp[q[mid + 1]] - dp[q[mid]] > (t[i] + s) * (c[q[mid + 1]] - c[q[mid]]))
            {
                r = mid;
            }
            else
            {
                l = mid + 1;
            }
        }
        dp[i] = dp[q[r]] + t[i] * (c[i] - c[q[r]]) + s * (c[n] - c[q[r]]);
        while (hh < tt && (dp[q[tt]] - dp[q[tt - 1]]) * (c[i] - c[q[tt]]) >= (dp[i] - dp[q[tt]]) * (c[q[tt]] - c[q[tt - 1]]))
        {
            tt--;
        }
        q[++tt] = i;
    }
    cout << dp[n] << endl;
    return 0;
}

P4655 [CEOI 2017] Building Bridges link

题目描述

n 根柱子依次排列,每根柱子都有一个高度。第 i 根柱子的高度为 h_i

现在想要建造若干座桥,如果一座桥架在第 i 根柱子和第 j 根柱子之间,那么需要 (h_i-h_j)^2​​ 的代价。

在造桥前,所有用不到的柱子都会被拆除,因为他们会干扰造桥进程。第 i 根柱子被拆除的代价为 w_i,注意 w_i 不一定非负,因为可能政府希望拆除某些柱子。

现在政府想要知道,通过桥梁把第 1 根柱子和第 n 根柱子连接的最小代价。注意桥梁不能在端点以外的任何地方相交。

数据范围

#### 解题思路 先用 $w$ 的前缀和替代原来的序列。 考虑先列出 $O(n^2)$ 的状态转移方程: - 状态表示:$dp[i]$ 表示用桥梁把前 $i$ 根柱子连接的最小代价。 - 初始化:$dp[1]=0

现在考虑斜率优化,移项:

dp[j]=2h[i]h[j]-h[j]^2-w[j]+dp[i]-h[i]^2+w[i-1]

发现由于原来的 h[i] 可以为 0,因此横坐标 h[j] 不严格增,只能用李超线段树优化。

将式子化为:

dp[i]=h[i]^2+w[i-1]+\min(-2h[j]h[i]+dp[j]+h[j]^2-w[j])

问题转化为:每次插入直线 y=-2h[j]\cdot x+dp[j]+h[j]^2-w[j],求在 x=h[i] 时的最小值。

用李超线段树维护即可,时间复杂度 O(n\log n)

代码

#include<bits/stdc++.h>
#define endl "\n"
using namespace std;

const int N = 1e5 + 10, M = 1e6 + 10;

int n;
long long h[N], w[N];
long long dp[N];

struct line
{
    long long k, b;

    inline long long calc(long long x)
    {
        return k * (x - 1) + b;
    }
} p[N];

struct SGT
{
    #define ls x << 1
    #define rs x << 1 | 1
    #define mid ((l + r) >> 1)
    int dat[M << 2];

    void update(int x, int l, int r, int k)
    {
        if (!dat[x])
        {
            dat[x] = k;
            return;
        }
        if (p[k].calc(mid) < p[dat[x]].calc(mid))
        {
            swap(k, dat[x]);
        }
        if (p[k].calc(l) < p[dat[x]].calc(l))
        {
            update(ls, l, mid, k);
        }
        if (p[k].calc(r) < p[dat[x]].calc(r))
        {
            update(rs, mid + 1, r, k);
        }
    }

    long long query(int x, int l, int r, int k)
    {
        if (r < k || l > k)
        {
            return 1e18;
        }
        long long res = p[dat[x]].calc(k);
        if (l == r)
        {
            return res;
        }
        return min(res, min(query(ls, l, mid, k), query(rs, mid + 1, r, k)));
    }
} t;

int main()
{
    ios :: sync_with_stdio(0);
    cin.tie(0), cout.tie(0);
    p[0].b = 1e18;
    cin >> n;
    for (int i = 1; i <= n; i++)
    {
        cin >> h[i];
    }
    for (int i = 1; i <= n; i++)
    {
        cin >> w[i];
        w[i] = w[i] + w[i - 1];
    }
    p[1].k = -2 * h[1], p[1].b = h[1] * h[1] - w[1];
    t.update(1, 1, M, 1);
    for (int i = 2; i <= n; i++)
    {
        dp[i] = h[i] * h[i] + w[i - 1] + t.query(1, 1, M, h[i] + 1);
        p[i].k = -2 * h[i], p[i].b = dp[i] + h[i] * h[i] - w[i];
        t.update(1, 1, M, i);
    }
    cout << dp[n] << endl;
    return 0;
}

P4072 [SDOI2016] 征途 link

题目描述

Pine 开始了从 S 地到 T 地的征途。

S 地到 T 地的路可以划分成 n 段,相邻两段路的分界点设有休息站。

Pine 计划用 m 天到达 T 地。除第 m 天外,每一天晚上 Pine 都必须在休息站过夜。所以,一段路必须在同一天中走完。

Pine 希望每一天走的路长度尽可能相近,所以他希望每一天走的路的长度的方差尽可能小。

帮助 Pine 求出最小方差是多少。

设方差是 v,可以证明,v\times m^2 是一个整数。为了避免精度误差,输出结果时输出 v\times m^2

数据范围

保证从 $S$ 到 $T$ 的总路程不超过 $3\times 10^4$。 $2 \leq m \leq n+1$,每段路的长度为不超过 $3 \times 10^4$ 的**正整数**。 #### 解题思路 考虑先化简最后答案的式子($a$ 表示输入的每段路的长度): $$s^2=\frac{(\bar a-a_1)^2+(\bar a-a_2)^2+\cdots+(\bar a-a_m)^2}{m}$$ $$s^2=\frac{m\bar a^2-2\bar a(a_1+a_2+\cdots+a_m)+{a_1}^2+{a_2}^2+\cdots+{a_m}^2}{m}$$ $$s^2=\frac{m(\frac{\sum_{i=1}^ma_i}{m})^2-2(\frac{\sum_{i=1}^ma_i}{m})(\sum_{i=1}^ma_i)+\sum_{i=1}^m{a_i}^2}{m}$$ $$s^2=-\frac{{(\sum_{i=1}^ma_i)}^2}{m^2}+\frac{\sum_{i=1}^m{a_i}^2}{m}$$ $$s^2\cdot m^2={-(\sum_{i=1}^ma_i)}^2+m\sum_{i=1}^m{a_i}^2$$ 发现右边第一项是定值,因此只要右边第二项最小即可,也就是问题转化为**求最小平方和**。 不妨用 $a$ 的前缀和替代原来的序列。 于是开始动态规划: - 状态表示:由于分成几段也会影响答案,因此要开两维表示,$dp[i][j]$ 表示前 $i$ 项分成 $j$ 段的最小平方和。 - 初始化:$dp[0][i]=0$。 - 状态转移:设最后一段为 $k+1\sim i$,则有: $$dp[i][j]=\min(dp[k][j-1]+(a[i]-a[k])^2)$$ - 答案:把 $dp[n][m]$ 代入上面的式子即可。 时间复杂度 $O(n^3)$,无法通过,考虑斜率优化。 将式子化为: $$dp[k][j-1]+a[k]^2=2a[i]a[k]+dp[i][j]-a[i]^2$$ 要使 $dp[i][j]$ 更小,则截距就要更小,所以最优决策点组成一个**下凸包**。 由于 $a[i]$ 严格增,所以**横坐标和斜率都严格增**,应用第一种情况即可。 比较值得注意的是,因为这道题是二维的,我们优化的是第一维,所以可以先枚举第二维,然后跑 $m$ 遍斜率优化。 时间复杂度 $O(m\log n)$。 #### 代码 ``` cpp #include<bits/stdc++.h> #define endl "\n" #define ll long long using namespace std; const int N = 3e3 + 10; ll n, m; ll a[N]; ll dp[N][N], q[N]; int main() { ios :: sync_with_stdio(0); cin.tie(0), cout.tie(0); cin >> n >> m; for (int i = 1; i <= n; i++) { cin >> a[i]; a[i] += a[i - 1]; dp[i][1] = a[i] * a[i]; } for (int j = 2; j <= m; j++) { int h = 0, t = 0; q[0] = 0; for (int i = 1; i <= n; i++) { while (h < t && (dp[q[h + 1]][j - 1] - dp[q[h]][j - 1] + a[q[h + 1]] * a[q[h + 1]] - a[q[h]] * a[q[h]]) <= 2 * a[i] * (a[q[h + 1]] - a[q[h]])) { h++; } dp[i][j] = dp[q[h]][j - 1] + (a[i] - a[q[h]]) * (a[i] - a[q[h]]); while (h < t && (dp[q[t]][j - 1] + a[q[t]] * a[q[t]] - dp[q[t - 1]][j - 1] - a[q[t - 1]] * a[q[t - 1]]) * (a[i] - a[q[t]]) >= (dp[i][j - 1] + a[i] * a[i] - dp[q[t]][j - 1] - a[q[t]] * a[q[t]]) * (a[q[t]] - a[q[t - 1]])) { t--; } q[++t] = i; } } cout << m * dp[n][m] - a[n] * a[n] << endl; return 0; } ``` ### P4027 [NOI2007] 货币兑换 [link](https://www.luogu.com.cn/problem/P4027) #### 题目描述 小 Y 最近在一家金券交易所工作。该金券交易所只发行交易两种金券:A 纪念券(以下简称 A 券)和 B 纪念券(以下简称 B 券)。每个持有金券的顾客都有一个自己的帐户。金券的数目可以是一个实数。 每天随着市场的起伏波动,两种金券都有自己当时的价值,即每一单位金券当天可以兑换的人民币数目。我们记录第 $K$ 天中 A 券和 B 券的价值分别为 $A_K$ 和 $B_K$(元/单位金券)。 为了方便顾客,金券交易所提供了一种非常方便的交易方式:比例交易法。 比例交易法分为两个方面: a) 卖出金券:顾客提供一个 $[0, 100]$ 内的实数 $OP$ 作为卖出比例,其意义为:将 $OP\%$ 的 A 券和 $OP\%$ 的 B 券以当时的价值兑换为人民币; b) 买入金券:顾客支付 $IP$ 元人民币,交易所将会兑换给用户总价值为 $IP$ 的金券,并且,满足提供给顾客的 A 券和 B 券的比例在第 $K$ 天恰好为 $\mathrm{Rate}_ K$; 例如,假定接下来 $3$ 天内的 $A_K,B_K,\mathrm{Rate}_ K$ 的变化分别为: | 时间 | $A_K$ | $B_K$ | $\mathrm{Rate}_ K$ | | ----- | ----- | ----- | ----- | | 第一天 | $1$ | $1$ | $1$ | | 第二天 | $1$ | $2$ | $2$ | | 第三天 | $2$ | $2$ | $3$ | 假定在第一天时,用户手中有 $100$ 元人民币但是没有任何金券。 用户可以执行以下的操作: | 时间 | 用户操作 | 人民币(元) | A 券的数量 | B 券的数量 | | ----- | ----- | ----- | ----- | ----- | | 开户 | 无 | $100$ | $0$ | $0$ | | 第一天 | 买入 $100$ 元 | $0$ | $50$ | $50$ | | 第二天 | 卖出 $50\%$ | $75$ | $25$ | $25$ | | 第二天 | 买入 $60$ 元 | $15$ | $55$ | $40$ | | 第三天 | 卖出 $100\%$ | $205$ | $0$ | $0$ | 注意到,同一天内可以进行多次操作。 小 Y 是一个很有经济头脑的员工,通过较长时间的运作和行情测算,他已经知道了未来 $N$ 天内的 A 券和 B 券的价值以及 $\mathrm{Rate}$。他还希望能够计算出来,如果开始时拥有 $S$ 元钱,那么 $N$ 天后最多能够获得多少元钱。 #### 数据范围 $N \le 10^5$,$0 < A_K \leq 10$,$0 < B_K\le 10$,$0 < \mathrm{Rate}_K \le 100$,$\mathrm{MaxProfit} \leq 10^9$。 必然存在一种最优的买卖方案满足: 每次买进操作使用完所有的人民币,每次卖出操作卖出所有的金券。 #### 解题思路 考虑 DP。 - 状态表示:$dp[i]$ 表示前 $i$ 天最多能获得多少钱。 - 初始化:$dp[0]=S$。 - 状态转移:设现在是第 $i$ 天,要使钱最多那么一定不买金券。 - 不卖金券:$dp[i]=dp[i-1]$。 - 卖金券:设现在卖出的金券是在第 $j$ 天买入的,如果卖出有利润,那么当时就应该花光所有钱买金券以获得最大收益。 设第 $i$ 天最多能获得 $x_i$ 张 $A$ 券, $y_i$ 张 $B$ 券,则有: $$x_i=dp_i\frac{R_i}{A_iR_i+B_i}$$ $$y_i=dp_i\frac{1}{A_iR_i+B_i}$$ 所以:$dp[i]=\max(x_jA_i+y_jB_i)$。 综上,状态转移方程为: $$dp[i]=\max(dp[i-1],\max(x_jA_i+y_jB_i))$$ - 答案:$dp[n]$。 现在来考虑斜率优化,我们把它化成斜率优化能处理的式子(先不考虑不卖,最后把它算上即可): $$y_j=-\frac{A_i}{B_i}x_j+\frac{1}{B_i}dp[i]$$ 发现 $x_j$ 没有单调性,因此考虑使用李超线段树。 变形得: $$dp[i]=b[i]\max(x_j\frac{a_i}{b_i}+y_j)$$ 问题转化为:每次插入直线 $y=x_j\cdot x+y_j$,求在 $x=\frac{a_i}{b_i}$ 时的最大值。 用李超线段树维护即可,时间复杂度 $O(n\log n)$。 值得注意的是,这道题需要查询实数位置的最小值,可以离散化,再将李超线段树的 `clac()` 函数改写即可。 #### 代码 ```cpp #include<bits/stdc++.h> #define endl "\n" using namespace std; const int N = 1e5 + 10; const double eps = 1e-10; int n; double a[N], b[N], c[N], d[N], r[N]; double ans; struct line { double k, b; int id; inline double calc(int x) { return k * c[x] + b; } } p[N]; struct SGT { #define ls x << 1 #define rs x << 1 | 1 #define mid ((l + r) >> 1) int dat[N << 2]; void update(int x, int l, int r, int k) { if (!dat[x]) { dat[x] = k; return; } if (p[k].calc(mid) - p[dat[x]].calc(mid) > eps) { swap(k, dat[x]); } if (p[k].calc(l) - p[dat[x]].calc(l) > eps || (p[k].calc(l) == p[dat[x]].calc(l) && k < dat[x])) { update(ls, l, mid, k); } if (p[k].calc(r) - p[dat[x]].calc(r) > eps || (p[k].calc(r) == p[dat[x]].calc(r) && k < dat[x])) { update(rs, mid + 1, r, k); } } double query(int x, int l, int r, int k) { if (r < k || l > k) { return 0; } double res = p[dat[x]].calc(k); if (l == r) { return res; } return max(res, max(query(ls, l, mid, k), query(rs, mid + 1, r, k))); } } t; int main() { ios :: sync_with_stdio(0); cin.tie(0), cout.tie(0); cin >> n >> ans; for (int i = 1; i <= n; i++) { cin >> a[i] >> b[i] >> r[i]; c[i] = d[i] = a[i] / b[i]; } sort(c + 1, c + n + 1); for (int i = 1; i <= n; i++) { int sum = lower_bound(c + 1, c + n + 1, d[i]) - c; ans = max(ans, b[i] * t.query(1, 1, n, sum)); p[i].k = ans * r[i] / (a[i] * r[i] + b[i]), p[i].b = ans / (a[i] * r[i] + b[i]); t.update(1, 1, n, i); } cout << fixed << setprecision(3) << ans << endl; return 0; } ``` ### P6302 [NOI2019] 回家路线 加强版 [link](https://www.luogu.com.cn/problem/P6302) #### 题目描述 猫国的铁路系统中有 $n$ 个站点,从 $1 - n$ 编号。小猫准备从 $1$ 号站点出发,乘坐列车回到猫窝所在的 $n$ 号站点。它查询了能够乘坐的列车,这些列车共 $m$ 班,从 $1 - m$ 编号。小猫将在 $0$ 时刻到达 $1$ 号站点。对于 $i$ 号列车,它将在时刻 $p_i$ 从站点 $x_i$ 出发,在时刻 $q_i$ 直达站点 $y_i$,小猫只能在时刻 $p_i$ 上 $i$ 号列车,也只能在时刻 $q_i$ 下 $i$ 号列车。小猫可以通过多次换乘到达 $n$ 号站点。一次换乘是指对于两班列车,假设分别为 $u$ 号与 $v$ 号列车,若 $y_u = x_v$ 并且 $q_u \leq p_v$,那么小猫可以乘坐完 $u$ 号列车后在 $y_u$ 号站点等待 $p_v - q_u$ 个时刻,并在时刻 $p_v$ 乘坐 $v$ 号列车。 小猫只想回到猫窝并且减少途中的麻烦,对此它用烦躁值来衡量。 - 小猫在站点等待时将增加烦躁值,对于一次 $t (t \geq 0)$ 个时刻的等待,烦躁值将增加 $At^2 + Bt + C$,其中 $A, B,C$ 是给定的常数。注意:小猫登上第一班列车前,即从 $0$ 时刻起停留在 $1$ 号站点的那些时刻也算作一次等待。 - 若小猫最终在时刻 $z$ 到达 $n$ 号站点,则烦躁值将再增加 $z$。 形式化地说,若小猫共乘坐了 $k$ 班列车,依次乘坐的列车编号可用序列 $s_1, s_2, \cdots , s_k$ 表示。该方案被称作一条可行的回家路线,当且仅当它满足下列两个条件: - $x_{s1} = 1,y_{sk} = n

对于该回家路线,小猫得到的烦躁值将为:

q_{s_k}+(A\times p_{s_1}^2+B\times p_{s_1}+C)+\sum_{j=1}^{k-1}(A(p_{s_{j+1}}-q_{s_j})^2+B(p_{s_{j+1}}-q_{s_j})+C)

小猫想让自己的烦躁值尽量小,请你帮它求出所有可行的回家路线中,能得到的最 小的烦躁值。题目保证至少存在一条可行的回家路线。

数据范围

对于所有的测试点,保证 2\le n\le 10^51\le m\le 10^60\le A\le 100\le B,C\le 10^71\le x_i,y_i\le nx_i\neq y_i0\le p_i<q_i\le 4\times 10^4

解题思路

考虑 DP。

首先我们可以先不考虑到达 n 号站点增加的烦躁值,最后把它算上即可,因此下文指的最小烦躁值都不包含这一项。

时间复杂度 O(m^2),考虑斜率优化:

dp[j]+A{q_j}^2-Bq_j=2Ap_iq_j+dp[i]-A{p_i}^2-Bp_i-C

我们先考虑两个限制如何处理:

发现对于第二个限制的处理方法,要求 p_i 不降,于是我们一开始便可以将 p_i 排序。

由于我们插入是按桶的顺序插入的,所以 q_j 也顺带着不降了,因此斜率和横坐标都不降。

要使 dp[i] 更小,就要使截距更小,因此最优决策点构成一个下凸包,应用第一种情况即可。

时间复杂度 O(m\log m)

代码

#include<bits/stdc++.h>
#define endl "\n"
#define ll long long
using namespace std;

const int N = 1e5 + 10, M = 1e6 + 10;

struct node
{
    ll x, y, p, q;
} tr[M];

ll n, m, a, b, c, lst = 0, ans = LONG_LONG_MAX;
ll dp[M];
vector<int> e[N];

struct que
{
    int h = 0, t = -1;
    vector<ll> q;

    inline ll X(int x)
    {
        return tr[x].q;
    }

    inline ll Y(int x)
    {
        return dp[x] + a * tr[x].q * tr[x].q - b * tr[x].q;
    }

    ll query(int x)
    {
        if (h > t)
        {
            return -1;
        }
        while (h < t && (Y(q[h + 1]) - Y(q[h])) <= 2 * a * tr[x].p * (X(q[h + 1]) - X(q[h])))
        {
            h++;
        }
        return q[h];
    }

    void insert(int x)
    {
        while (h < t && (Y(q[t]) - Y(q[t - 1])) * (X(x) - X(q[t])) >= (Y(x) - Y(q[t])) * (X(q[t]) - X(q[t - 1])))
        {
            q.pop_back();
            t--;
        }
        q.push_back(x);
        t++;
    }
} q[N];

bool cmp(node x, node y)
{
    return x.p < y.p;
}

int main()
{
    ios :: sync_with_stdio(0);
    cin.tie(0), cout.tie(0);
    cin >> n >> m >> a >> b >> c;
    for (int i = 1; i <= m; i++)
    {
        cin >> tr[i].x >> tr[i].y >> tr[i].p >> tr[i].q;
    }
    sort(tr + 1, tr + m + 1, cmp);
    q[1].insert(0);
    for (int i = 1; i <= m; i++)
    {
        for (; lst <= tr[i].p; lst++)
        {
            for (auto it : e[lst])
            {
                q[tr[it].y].insert(it);
            }
        }
        int j = q[tr[i].x].query(i);
        if (j == -1)
        {
            continue;
        }
        dp[i] = dp[j] + a * (tr[i].p - tr[j].q) * (tr[i].p - tr[j].q) + b * (tr[i].p - tr[j].q) + c;
        e[tr[i].q].push_back(i);
        if (tr[i].y == n)
        {
            ans = min(dp[i] + tr[i].q, ans);
        }
    }
    cout << ans << endl;
    return 0;
}

结语

看了这么多,恭喜你学会了斜率优化!!!

有没有人和你说过,你学 OI 的样子真的很帅。

如果没有?

那么,就在上一秒,我对你说了。