任务安排1

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

费用提前计算把二维分批 DP 压成一维断点转移,再把转移式改写成直线截距最小值,用下凸壳加单调队列做到 O(n)。

OJ: roj

题目 ID: 3158

难度:提高

标签:动态规划斜率优化

创建: 2026-10-01 21:44

更新: 2026-10-10 12:00

后置题目
roj 1614「一本通 5.6 练习 5」锯木厂选址B 的 main.py 用 hull_query/hull_add 照搬 A 教的「下凸壳 + 单调队列、队头队尾指针只前进」这一步,把 f[i]=min_j 的直线族查询压到均摊 O(1)roj 1611「一本通 5.6 练习 2」仓库建设B 复用 A 教的「先核对决策点横坐标与查询斜率都单调、再用单调队列维护下凸壳」这一判定步骤,在仓库建设的一维分段 DP 上落地roj 1612「一本通 5.6 练习 3」特别行动队A 教的「核对决策点 x_j 与查询斜率 k_i 都单调后用下凸壳+单调队列摊还 O(1)」正是 B 主解实际走的第五步:B 把 (S_i-S_j)² 展开成直线 l_j(u)=S_j·u+Y_j 后,同样靠 x_i≥1 使斜率单调增、a<0 使查询点单调增,再用双端队列弹队头弹队尾维护包络(main.py 的 hull 两个 while),只是把最小截距下凸壳换成最大上包络并把一维序列扩到 n=10^6,难度仅升一级。roj 1609「一本通 5.6 例 4」Cats TransportB 的 dp_layer 直接复用 A 教的「把 j 相关项拆成 (x_j,y_j)、用下凸壳+单调队列取最小截距」这一套实现,A 以点集形式给出、B 以直线族形式套用,B 另需叠加的是将猫压成 a_i=T_i-s[H_i] 并排序划分连续段。luogu P5785[SDOI2012] 任务安排两题同属任务安排:A 第二步用费用提前计算推出 dp[i]=dp[j]+(sum_t[i]-sum_t[j])(sum_c[n]-sum_c[j])+s(sum_c[n]-sum_c[j]),B 原文第 47 行给出完全相同的式子,再叠单调队列维护下凸壳,正是 A 教的费用提前计算加凸壳两步。luogu P2900[USACO08MAR] Land Acquisition GB 的 main.cpp 复用了 A 教的那一步「比较队首与第二点的边斜率、斜率不占优则永久弹出队首」:A 用它在斜率 k=t[i]+s 递增时淘汰队首,B 把同一判定搬到 x_i 递增的转移 dp[i]=min(dp[j]+x_i*y_{j+1}) 上(while(head<tail && slope(q[head],q[head+1])<=b[i].x) head++,队尾同样叉积判劣),只是在外面叠加了排序删被支配矩形、把分组化为连续分段 DP 的新流程。luogu P8632[蓝桥杯 2015 国 B] 居民集会B 沿用 A 的整理法:把转移里与 j 有关的项拆成 f[j]-k·c[j] 式直线,再在对 d_i 的直线 dp_prev[j]+pre_dw[j]-d_i·pre_w[j] 上做斜率优化luogu P5017[NOIP 2018 普及组] 摆渡车B 的斜率优化求解(main.cpp 中的 is_bad 弹尾与 line_value 弹首交叉相乘)就是 A 教的「把 j 相关部分拆成点、用下凸壳+单调队列在单调斜率查询」这一步的直接复用;额外叠加的是把题意平移成 a_i=t_i+m、按回程时刻设 dp[x] 并处理 y=x-m 才入队的新建模

形式化题目

把序列 T1,…,TNT_1,\dots,T_N 划分成若干段连续的批次。批次的执行按顺序进行:机器从时刻 00 开始,处理每批前先等待启动时间 SS,批内任务依次执行 TiT_i,一批的所有任务在整批结束时同时完成。任务 ii 的费用为「所在批的完成时刻 × Ci\times\, C_i」,求总费用的最小值。

正解

思路

第一步:朴素分批 DP。 设 f[i][j]f[i][j] 表示前 ii 个任务分成 jj 批的最小总费用。转移时枚举最后一批的开头 kk,需要知道这批的完成时刻,也就是前面 j−1j-1 批的总耗时。这样状态 O(n2)O(n^2)、转移 O(n)O(n),对 n=5000n=5000 太慢。更麻烦的是,费用依赖「完成时刻」这个绝对量,状态被迫把时间轴背在身上。

第二步:费用提前计算,砍掉一维。 关键观察:每开一批,启动时间 SS 会让这一批以及之后所有任务的完成时刻都延后 SS。那么在「决定新开一批」的时刻,它对总费用的全部影响就已经确定:S×S \times(尚未完成的任务的 CC 之和)$ = s\cdot(c[n]-c[j]),其中 $c 是 CC 的前缀和。于是批次数这一维毫无必要——未来的影响现在一次算清:

f[i]=min⁡0⩽j<i { f[j]+t[i]⋅(c[i]−c[j])+s⋅(c[n]−c[j]) }f[i] = \min_{0 \leqslant j < i}\ \{\, f[j] + t[i]\cdot(c[i]-c[j]) + s\cdot(c[n]-c[j]) \,\}

其中 t[i]t[i] 是 TT 的前缀和,含义是:最后一批为 j+1…ij+1 \dots i,这批内所有任务都在时刻 t[i]t[i] 完成(贡献 t[i]⋅(c[i]−c[j])t[i]\cdot(c[i]-c[j])),同时把启动时间 SS 对后段的影响提前记为 s⋅(c[n]−c[j])s\cdot(c[n]-c[j])。样例的最优分批如下:

批次 任务 批耗时(含 S=1S{=}1) 完成时刻 批内 CC 之和 费用
1 1, 2 1+1+3=51+1+3=5 5 3+2=53+2=5 25
2 3 1+4=51+4=5 10 3 30
3 4, 5 1+2+1=41+2+1=4 14 3+4=73+4=7 98

合计 25+30+98=15325+30+98=153,与样例一致。

第三步:把转移看成几何问题。 把与 jj 有关的项整理成「斜率 × 横坐标 + 截距」的形状。固定 ii,令

k=t[i]+s,Pj=(xj, yj)=(c[j], f[j]),k = t[i]+s,\qquad P_j = (x_j,\, y_j) = (c[j],\, f[j]),

则

f[i]=t[i]⋅c[i]+s⋅c[n]+min⁡0⩽j<i { yj−k⋅xj }.f[i] = t[i]\cdot c[i] + s\cdot c[n] + \min_{0 \leqslant j < i}\ \{\, y_j - k\cdot x_j \,\}.

花括号里是过点 PjP_j、斜率为 kk 的直线的截距:min 取到时,PjP_j 恰好落在这些点构成的上边缘被斜率为 kk 的直线撑到的位置。等价地说,只需对每个 ii 在二维点集上求「斜率 kk 方向的极值」。

第四步:两个单调性 → 下凸壳 + 单调队列。

  • cc 非负,所以 xj=c[j]x_j = c[j] 随 jj 递增:新点只会从右侧插入,点集维持一个下凸壳即可(最小截距只会由凸壳上的点取到,凸壳内的点永远不优)。
  • tt 非负,所以查询斜率 k=t[i]+sk = t[i]+s 随 ii 递增:在凸壳上,若队首到第二点的边斜率 ⩽k\leqslant k,则第二点的截距不会更大,队首永久淘汰。队首指针、队尾插入都只前进,每个点至多进出队一次。

于是每个 ii 均 O(1)O(1) 摊还,总复杂度 O(n)O(n)。转移与查询的过程如下:

flowchart LR
    A["队列: P0 ... Pm<br/>下凸壳, x 递增"] --> B{"队首到下一点<br/>斜率 &le; k = t[i]+s ?"}
    B -- 是 --> C["队首已不可能更优<br/>永久弹出"]
    C --> B
    B -- 否 --> D["用队首 P_j 计算<br/>f[i] = y_j - k*x_j + t[i]*c[i] + s*c[n]"]
    D --> E["新点 P_i = (c[i], f[i])<br/>从队尾弹出被压住的点"]
    E --> F["P_i 入队, 处理 i+1"]
    F --> A

观察要点:队首判定是「斜率比较」而非「值比较」——一旦边斜率被 kk 超过,随着 kk 继续增大这条边永远不会再被选中,所以弹出是安全的;队尾判定保证队列中相邻两点连成的折线斜率严格递增,这正是下凸壳的形状条件。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-10 11:30
 * update_at: 2026-10-10 11:30
 */
#include <cstdio>
#include <deque>
using namespace std;

typedef long long ll;

const int MAXN = 5005;

ll task_time[MAXN]; // 第 i 个任务的执行时间 T_i
ll task_cost[MAXN]; // 第 i 个任务的费用系数 C_i
ll n, s;

deque<pair<ll, ll> > hull; // 下凸壳上的直线 (斜率 m, 截距 b),斜率随插入递减

// 把直线 y = m*x + b 压入凸壳尾部。
// 若队尾直线在新直线与次队尾直线的交点之上(三点不再构成下凸),它永远取不到最小值,弹掉。
void push_line(ll m, ll b) {
    while (hull.size() >= 2) {
        ll m1 = hull[hull.size() - 2].first, b1 = hull[hull.size() - 2].second;
        ll m2 = hull[hull.size() - 1].first, b2 = hull[hull.size() - 1].second;
        if ((b2 - b1) * (m2 - m) < (b - b2) * (m1 - m2)) break; // 队尾仍是凸壳顶点
        hull.pop_back();
    }
    hull.push_back(make_pair(m, b));
}

// 查询所有已插入直线在 x 处的最小值。
// 查询点 x 严格递增,最优直线沿凸壳单调右移,队首弹出即可均摊 O(1)。
ll min_line(ll x) {
    while (hull.size() >= 2
           && hull[0].first * x + hull[0].second >= hull[1].first * x + hull[1].second)
        hull.pop_front();
    return hull[0].first * x + hull[0].second;
}

int main() {
    scanf("%lld%lld", &n, &s); // S 是每批任务开始前的启动时间
    ll total_cost = 0;
    for (ll i = 1; i <= n; i++) {
        scanf("%lld%lld", &task_time[i], &task_cost[i]);
        total_cost += task_cost[i];
    }
    // f[i]:前 i 个任务的最小总费用(已把启动时间折算给「它及之后所有任务」)
    // f[i] = sc[i]*st[i] + S*total_cost + min_j { -sc[j]*(st[i]+S) + f[j] }
    // 每个决策 j 是一条斜率 -sc[j] 递减的直线,查询点 st[i]+S 递增 → 单调队列凸壳
    ll dp = 0, st = 0, sc = 0; // dp = f[0] = 0
    for (ll i = 1; i <= n; i++) {
        push_line(-sc, dp); // 此刻 sc、dp 仍是第 i-1 轮的值
        st += task_time[i];
        sc += task_cost[i];
        dp = sc * st + s * total_cost + min_line(st + s);
    }
    printf("%lld\n", dp);
    return 0;
}
python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 21:45
# update_at: 2026-10-01 21:45

import sys
from collections import deque
from itertools import accumulate


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    n = next(data)
    s = next(data)

    # 每个任务给出一对 (T_i, C_i),先成对读入再分别求前缀和
    tc = [(next(data), next(data)) for _ in range(n)]
    t = [0, *accumulate(x for x, _ in tc)]  # t[i] = 前 i 个任务的时间之和
    c = [0, *accumulate(y for _, y in tc)]  # c[i] = 前 i 个任务的费用系数之和

    # f[i]:前 i 个任务全部完成的最小总费用(费用提前计算)。
    # f[i] = t[i]*(c[i]-c[j]) + s*(c[n]-c[j]) + f[j],
    # 整理成截距形式:f[i] = t[i]*c[i] + s*c[n] + min{ f[j] - (t[i]+s)*c[j] },
    # 花括号内即「点 (c[j], f[j]) 在斜率 k = t[i]+s 下的 y-kx 最小值」→ 下凸壳 + 单调队列。
    f = [0] * (n + 1)
    hull = deque([(0, 0)])  # 下凸壳上的点 (x, y) = (c[j], f[j]),x 随插入严格递增

    for i in range(1, n + 1):
        k = t[i] + s  # 查询斜率,随 i 单调递增

        # 队首不再最优:若到第二点的边斜率 \leqslant k,则第二点的截距更小(或相等)
        while len(hull) >= 2 and hull[1][1] - hull[0][1] <= k * (hull[1][0] - hull[0][0]):
            hull.popleft()

        best_x, best_y = hull[0]
        f[i] = best_y - k * best_x + t[i] * c[i] + s * c[n]

        # 插入新点 (c[i], f[i]):中间点落在连线上方(含共线)则破坏下凸性,弹出
        new = (c[i], f[i])
        while len(hull) >= 2 and (
            (hull[-1][1] - hull[-2][1]) * (new[0] - hull[-2][0])
            >= (new[1] - hull[-2][1]) * (hull[-1][0] - hull[-2][0])
        ):
            hull.pop()
        hull.append(new)

    print(f[n])


if __name__ == "__main__":
    solve()

复杂度

  • 时间 O(n)O(n):每个点至多入队、出队各一次。
  • 空间 O(n)O(n):前缀和、ff 数组与凸壳队列。
  • 费用上界约 1.3×10141.3\times 10^{14},超过 32 位整数范围(C++ 需 long long);Python 整数天然无溢出。

总结

  • 费用提前计算是处理「开始时间影响后续所有贡献」类分批问题的通用手法:在决策点把对未来的影响一次算清,用后缀和(这里是 c[n]−c[j]c[n]-c[j])代替对未来的模拟,状态从二维降到一维。
  • 斜率优化的识别信号:一维 DP 转移形如 f[i]=min⁡{f[j]+与i有关项×与j有关项+… }f[i]=\min\{f[j]+与 i 有关项 \times 与 j 有关项 + \dots\},把 jj 相关部分拆成 (xj,yj)(x_j, y_j) 与查询斜率 kik_i;再核对 xjx_j 与 kik_i 是否都单调,若单调就用下凸壳 + 单调队列做到均摊 O(1)O(1)。
  • 实现细节:队首/队尾判定全部用交叉乘避免除法;共线时(队首用 ⩽\leqslant、队尾用 ⩾\geqslant)保留更晚的点不影响正确性。
  • 本题是「任务安排」系列的第一题:N⩽5000N \leqslant 5000 时 O(n2)O(n^2) 也能过,但 O(n)O(n) 的斜率优化写法是后续加强版(N⩽3×105N \leqslant 3\times10^5,且 TiT_i、CiC_i 可为负导致单调性被破坏)的必经之路。