[NOIP 2018 普及组] 摆渡车

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

把每位同学的到达时刻平移为“最早可被接走的回程时刻”,再设 dp[x] 表示最后一班车在时刻 x 返回时的最小等待和,并用斜率优化维护转移直线。

OJ: luogu

题目 ID: P5017

难度:提高+/省选-

标签:动态规划斜率优化前缀和优化

日期: 2026-06-21 06:36

题意

n 名同学要坐摆渡车。

i 位同学会在时刻 t_i 到达车站开始等车。

只有一辆车,容量无限。它每次从车站出发,把车上同学送到目的地,再回到车站,完整往返一次需要 m 分钟。

车回到车站后可以立刻再次出发。

要求安排每次发车时刻,使得所有同学等待时间之和最小。

思路

先看一个适合小数据验证的朴素搜索:

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

const int MAXN = 25;
const long long INF = (1LL << 60);

int n, m;
int t[MAXN];
map<pair<int, long long>, long long> memo;

long long dfs(int pos, long long last_depart) {
    if (pos > n) {
        return 0;
    }

    pair<int, long long> state = make_pair(pos, last_depart);
    map<pair<int, long long>, long long>::iterator it = memo.find(state);
    if (it != memo.end()) {
        return it->second;
    }

    long long best = INF;
    for (int j = pos; j <= n; j++) {
        long long depart_time = t[j];
        if (last_depart >= 0) {
            depart_time = max(depart_time, last_depart + m);
        }

        long long cost = 0;
        for (int k = pos; k <= j; k++) {
            cost += depart_time - t[k];
        }
        best = min(best, cost + dfs(j + 1, depart_time));
    }

    memo[state] = best;
    return best;
}

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

    // brute.cpp:直接枚举每一班车接走哪些连续同学。
    // 对一个固定分组,出发时刻就是“最后一位上车同学到达时刻”和“上一班返回时刻”两者的较大值。
    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        cin >> t[i];
    }

    sort(t + 1, t + n + 1);
    memo.clear();
    cout << dfs(1, -1) << '\n';
    return 0;
}

暴力会把同学按到达时刻排序,然后枚举每一班车接走哪一段连续同学。

因为同一班车的出发时刻只会取:

  • 这一班最后一位上车同学的到达时刻
  • 或上一班车返回时刻

两者的较大值,所以小数据可以直接搜。

正解的关键是换一个视角。

如果一辆车在时刻 x 返回车站,那么它最早要在时刻 x 再出发时,实际上能接走的是所有满足:

t_i + m <= x

的同学。

所以先把每个同学的时刻做一次平移:

a_i = t_i + m

这样以后所有发车/回程的讨论都可以统一落在“回程时刻”上。

设:

dp[x] 表示最后一班已经在时刻 x 回到车站,并且所有 a_i <= x 的同学都已经送走时,最小等待时间和。

如果上一班回程时刻是 y,那么下一班最早只能在 y + m 时刻之后再次回到车站,也就是:

x - y >= m

在这段转移里,所有满足:

y < a_i <= x

的同学都会被这一班车接走,并且他们都要等到时刻 x 才“完成这次运输”。

于是这批同学对答案的贡献就是:

count * x - sum

其中:

  • count 是区间 (y, x] 内的同学数
  • sum 是这些同学的 a_i 之和

整理一下就得到:

dp[x] = x * cnt[x] - sum[x] + min(dp[y] + sum[y] - x * cnt[y])

这里:

  • cnt[x] 表示 a_i <= x 的人数
  • sum[x] 表示这些 a_i 的总和

对固定的 y 来看,转移到 x 的部分是一条关于 x 的直线:

(dp[y] + sum[y]) - cnt[y] * x

所以我们要做的就是:

  1. 按时间 x 从小到大枚举
  2. y = x-m 可用时,把对应直线加入凸包
  3. 用单调队列维护下凸壳,查询当前最优直线

这就是标准的斜率优化 DP。

DP 转移方程

核心状态:

dp[x] 为回程时刻 x 时的最小等待和

核心转移:

dp[x]=x*cnt[x]-sum[x]+min(dp[y]+sum[y]-x*cnt[y])

答案收束:

覆盖所有同学后的最小 dp[x]

代码

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

const int MAXN = 505;
const long long INF = (1LL << 60);

int n, round_time;
int t[MAXN];

long long *dp;
unsigned short *cnt_prefix;
int *sum_prefix;
int *freq_cnt;
int *que;

long long line_value(int idx, int x) {
    return dp[idx] + sum_prefix[idx] - 1LL * cnt_prefix[idx] * x;
}

bool is_bad(int a, int b, int c) {
    long long k1 = -1LL * cnt_prefix[a];
    long long k2 = -1LL * cnt_prefix[b];
    long long k3 = -1LL * cnt_prefix[c];
    long long b1 = dp[a] + sum_prefix[a];
    long long b2 = dp[b] + sum_prefix[b];
    long long b3 = dp[c] + sum_prefix[c];

    return (__int128)(b2 - b1) * (k2 - k3) >= (__int128)(b3 - b2) * (k1 - k2);
}

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

    cin >> n >> round_time;
    for (int i = 1; i <= n; i++) {
        cin >> t[i];
    }

    sort(t + 1, t + n + 1);
    for (int i = 1; i <= n; i++) {
        t[i] += round_time;
    }

    int max_time = t[n] + n * round_time;

    dp = new long long[max_time + 1];
    cnt_prefix = new unsigned short[max_time + 1];
    sum_prefix = new int[max_time + 1];
    freq_cnt = new int[max_time + 1];
    que = new int[max_time + 1];

    for (int i = 0; i <= max_time; i++) {
        dp[i] = INF;
        cnt_prefix[i] = 0;
        sum_prefix[i] = 0;
        freq_cnt[i] = 0;
    }

    for (int i = 1; i <= n; i++) {
        freq_cnt[t[i]]++;
    }

    for (int x = 1; x <= max_time; x++) {
        cnt_prefix[x] = cnt_prefix[x - 1] + freq_cnt[x];
        sum_prefix[x] = sum_prefix[x - 1] + freq_cnt[x] * x;
    }

    dp[0] = 0;

    int head = 1, tail = 1;
    que[1] = 0;

    for (int x = 1; x <= max_time; x++) {
        int y = x - round_time;
        if (y >= 0 && dp[y] < INF / 2) {
            // 插入一条来自状态 y 的直线。
            while (head <= tail && cnt_prefix[que[tail]] == cnt_prefix[y]) {
                if (dp[que[tail]] + sum_prefix[que[tail]] <= dp[y] + sum_prefix[y]) {
                    y = -1;
                    break;
                }
                tail--;
            }
            if (y >= 0) {
                while (head < tail && is_bad(que[tail - 1], que[tail], y)) {
                    tail--;
                }
                que[++tail] = y;
            }
        }

        while (head < tail && line_value(que[head], x) >= line_value(que[head + 1], x)) {
            head++;
        }

        dp[x] = 1LL * x * cnt_prefix[x] - sum_prefix[x] + line_value(que[head], x);
    }

    long long ans = INF;
    for (int x = t[n]; x <= max_time; x++) {
        if (cnt_prefix[x] == n && dp[x] < ans) {
            ans = dp[x];
        }
    }

    cout << ans << '\n';

    delete[] dp;
    delete[] cnt_prefix;
    delete[] sum_prefix;
    delete[] freq_cnt;
    delete[] que;
    return 0;
}

复杂度

设最大的回程时刻上界为 T

每个状态只会进出队一次,所以时间复杂度 O(T)O(T),空间复杂度 O(T)O(T)

在本题数据范围下,T <= max(t_i) + n*m,可以接受。

总结

这题最难的地方不是斜率优化本身,而是把题意改写成“按回程时刻做 DP”。

一旦把等待代价整理成:

  • 一个只和 x 有关的项
  • 加上一批关于 y 的直线

后面的凸包优化就很自然了。

一图流解析

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

一图流解析