[NOI2019] 回家路线 加强版

GitHub跳转原题关系图返回列表

把每个站点上的换乘 DP 写成关于发车时刻 p 的直线最小值查询,再按时间扫描并为每个站维护单调队列凸包。

OJ: luogu

题目 ID: P6302

难度:省选/NOI-

标签:动态规划斜率优化凸包优化按时间扫描

日期: 2026-06-21 07:23

题意

有很多班列车,每班车固定:

  • 在站点 x_i 的时刻 p_i 发车
  • 在站点 y_i 的时刻 q_i 到站

小猫从时刻 0 开始待在 1 号站,目标是最终到 n 号站。

等待 t 个时刻的代价是:

A t^2 + B t + C

最后到达终点时,还要再加上最终到达时刻。

思路

先看最直接的暴力转移:

cpp
#include <bits/stdc++.h>
using namespace std;

const long long INF = (1LL << 62);
const int MAXM = 5005;

struct Train {
    int x, y, p, q;
} tr[MAXM];

int n, m;
long long A, B, C;
long long dp[MAXM];

long long wait_cost(long long t) {
    return A * t * t + B * t + C;
}

bool cmp_train(const Train &lhs, const Train &rhs) {
    if (lhs.q != rhs.q) {
        return lhs.q < rhs.q;
    }
    if (lhs.p != rhs.p) {
        return lhs.p < rhs.p;
    }
    if (lhs.x != rhs.x) {
        return lhs.x < rhs.x;
    }
    return lhs.y < rhs.y;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // brute.cpp:小数据暴力 DP。
    // 按到达时间排序,枚举所有能接上的前一班车。
    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_train);

    long long answer = INF;
    for (int i = 1; i <= m; i++) {
        dp[i] = INF;
        if (tr[i].x == 1) {
            dp[i] = min(dp[i], wait_cost(tr[i].p));
        }
        for (int j = 1; j < i; j++) {
            if (dp[j] >= INF / 2) {
                continue;
            }
            if (tr[j].y == tr[i].x && tr[j].q <= tr[i].p) {
                dp[i] = min(dp[i], dp[j] + wait_cost(tr[i].p - tr[j].q));
            }
        }
        if (tr[i].y == n) {
            answer = min(answer, dp[i] + tr[i].q);
        }
    }

    cout << answer << '\n';
    return 0;
}

dp[i] 表示最后乘坐第 i 班列车时的最小烦躁值。

如果列车 j 能接到列车 i,那么:

dp[i] = min(dp[j] + A(p_i-q_j)^2 + B(p_i-q_j) + C)

若它是第一班车,还要考虑:

dp[i] = A p_i^2 + B p_i + C

把转移展开:

dp[i] = A p_i^2 + B p_i + C + min(dp[j] + A q_j^2 - B q_j - 2A q_j p_i)

对于固定的前驱 j,后半部分是关于 p_i 的一次函数。

所以可以对每个站点维护一个凸包:

  • 一条线对应一种“已经到达这个站”的方案
  • 之后若有列车从这个站发车,只要在 p_i 处查询最小值

又因为:

  • 查询时间 p_i 按扫描顺序单调不减
  • 插入时间 q_j 也单调不减,所以斜率单调

于是每个站点都可以用单调队列维护下凸壳。

主流程按时间从小到大扫描:

  1. 先处理当前时刻到达的列车,把它们对应的直线插入终点站
  2. 再处理当前时刻出发的列车,在起点站的凸壳上查询

这样自然支持 q_j = p_i 的零等待换乘。

DP 转移方程

核心状态:

dp[i] 为最后乘第 i 班车的最小烦躁值

核心转移:

dp[i]=A p_i^2+B p_i+C+min(dp[j]+Aq_j^2-Bq_j-2Aq_j p_i)

答案收束:

到终点列车 dp[i]+q_i 取最小

代码

cpp
#include <bits/stdc++.h>
using namespace std;

const long long INF = (1LL << 62);
const int MAXN = 100005;
const int MAXM = 1000005;

struct Train {
    int x, y, p, q;
} tr[MAXM];

struct Line {
    long long k, b;
};

int n, m;
long long A, B, C;
long long dp[MAXM];
vector<int> depart_at[40005];
vector<int> arrive_at[40005];

// 每个站维护一个下凸壳,表示已经到达该站的所有方案。
struct Hull {
    vector<Line> q;
    int head = 0;

    void clear() {
        q.clear();
        head = 0;
    }

    bool empty() const {
        return head >= (int) q.size();
    }

    long long value(const Line &line, long long x) const {
        return line.k * x + line.b;
    }

    // 下凸壳判劣。
    bool bad(const Line &a, const Line &b, const Line &c) const {
        __int128 left = (__int128) (b.b - a.b) * (b.k - c.k);
        __int128 right = (__int128) (c.b - b.b) * (a.k - b.k);
        return left >= right;
    }

    void add_line(long long k, long long b) {
        Line line;
        line.k = k;
        line.b = b;
        if (!q.empty() && q.back().k == line.k) {
            if (q.back().b <= line.b) {
                return;
            }
            q.pop_back();
            if (head > (int) q.size()) {
                head = (int) q.size();
            }
        }
        while ((int) q.size() - head >= 2 && bad(q[(int) q.size() - 2], q[(int) q.size() - 1], line)) {
            q.pop_back();
        }
        q.push_back(line);
    }

    long long query(long long x) {
        while ((int) q.size() - head >= 2 && value(q[head], x) >= value(q[head + 1], x)) {
            head++;
        }
        return value(q[head], x);
    }
};

Hull station_hull[MAXN];

long long wait_cost(long long t) {
    return A * t * t + B * t + C;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    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;
        depart_at[tr[i].p].push_back(i);
        arrive_at[tr[i].q].push_back(i);
    }

    // 起点站 1 在时刻 0 就已经可以开始等待。
    // 若下一班车在时刻 p 发车,那么代价就是:
    // A p^2 + B p + C
    station_hull[1].add_line(-2LL * A * 0, 0);

    long long answer = INF;

    for (int t = 0; t <= 40000; t++) {
        // 先处理这个时刻到达的列车,因为 q_u <= p_v,允许到站后立刻换乘同一时刻发车的车。
        for (unsigned int idx = 0; idx < arrive_at[t].size(); idx++) {
            int id = arrive_at[t][idx];
            if (dp[id] >= INF / 2) {
                continue;
            }
            int y = tr[id].y;
            int q = tr[id].q;

            // 若之后在时刻 p >= q 从这个站继续走,
            // 新增等待代价是 A(p-q)^2 + B(p-q) + C。
            // 展开后关于 p 的部分为:
            // A p^2 + B p + C + (-2Aq) * p + (dp + Aq^2 - Bq)
            // 所以插入一条斜率 -2Aq 的直线。
            long long k = -2LL * A * q;
            long long b = dp[id] + A * 1LL * q * q - B * 1LL * q;
            station_hull[y].add_line(k, b);
        }

        for (unsigned int idx = 0; idx < depart_at[t].size(); idx++) {
            int id = depart_at[t][idx];
            int x = tr[id].x;
            int p = tr[id].p;

            dp[id] = INF;
            if (station_hull[x].empty()) {
                continue;
            }

            long long best = station_hull[x].query(p);
            dp[id] = A * 1LL * p * p + B * 1LL * p + C + best;

            if (tr[id].y == n) {
                answer = min(answer, dp[id] + tr[id].q);
            }
        }
    }

    cout << answer << '\n';
    return 0;
}

复杂度

时间复杂度 O(m)O(m),空间复杂度 O(m+n)O(m + n)

总结

这题看起来是图上最短路,实际上更像“按站点分组的 DP”。

关键是把等待代价展开成:

  • 当前列车固定的二次项
  • 前驱列车形成的直线项

这样才能把枚举前驱优化成凸包查询。

一图流解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析