分批任务的费用 DP:费用提前计算消掉绝对时刻,转移化为直线族后用单调队列维护下凸壳,O(n) 求最小总费用。
OJ: roj
题目 ID: 3159
难度:提高+/省选-
标签:动态规划斜率优化单调队列凸壳python
创建: 2026-10-01 21:44
更新: 2026-10-10 12:00
形式化题目
其中
任务
样例:
正解
思路
朴素做法的瓶颈:绝对时刻
设
问题在于"最后一批的费用"=
它取决于前面所有批次的历史。若照原式转移,就必须在状态里额外记录"机器现在跑到第几秒",
关键观察:换记账次序
第一步:把完成时刻拆成“任务耗时前缀 + 启动时间”。 设第
理由:
第二步:把
这一步是整个推导的关键:
前半段是“这段任务的耗时由这段任务的
答案是
注意
的含义:它是“把上述 记账截断到前 个任务”时的最小值,不等于 “前 个任务单独作为一道题的答案”(因为 用的是全局的 )。这不影响正确性: 对任何一个完整分批方案, 都恰好等于它的真实费用(第一步是恒等变形,第二步是 双重计数),而 时递推枚举了全部方案的这一记账值,所以 就是最小总费用。
以样例最优分批
| 批次 | 区间 | 小计 | |||||
|---|---|---|---|---|---|---|---|
| 1 | 4 | 5 | 15 | 35 | |||
| 2 | 10 | 6 | 10 | 70 | |||
| 3 | 11 | 4 | 4 | 48 | |||
| 合计 | 15 | 124 | 29 | 153 |
两个恒等式在这里对上了:
从转移式到直线族
把括号展开,并按
前两项
每个历史决策
对应平面中的一条直线 ; 求 就是问"所有已插入的直线在 处的最小值"。
这是个标准几何问题,且本题有两条单调性可以利用:
| 单调性 | 来源 | 作用 |
|---|---|---|
| 插入斜率 |
新直线只需与凸壳尾部维护凸性 | |
| 查询横坐标 |
最优点在凸壳上单调右移,队首可直接弹出 |
因此用 collections.deque 维护所有直线的下凸壳即可,插入与查询都均摊
样例推演
样例的前缀和(
| 1 | 2 | 3 | 4 | 5 | |
|---|---|---|---|---|---|
| 1 | 3 | 4 | 2 | 1 | |
| 3 | 2 | 3 | 3 | 4 | |
| 1 | 4 | 8 | 10 | 11 | |
| 3 | 5 | 8 | 11 | 15 |
下表每一行是算法的一轮。"各候选
| 各候选 |
最优 |
|||||
|---|---|---|---|---|---|---|
| 1 | 1 | 3 | 2 | 0 | 18 | |
| 2 | 4 | 5 | 5 | 0 | 35 | |
| 3 | 8 | 8 | 9 | 2 | 69 | |
| 4 | 10 | 11 | 11 | 2 | 105 | |
| 5 | 11 | 15 | 12 | 3 | 153 |
例如
表里能直接看到两条单调性:
代码
/**
* 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;
typedef __int128 lll; // 叉积可能超过 long long 范围,用 128 位
const int MAXN = 300005;
ll task_time[MAXN]; // 第 i 个任务的执行时间 T_i
ll task_cost[MAXN]; // 第 i 个任务的费用系数 C_i
ll n, s;
deque<pair<ll, ll> > hull; // 下凸壳上的直线 (斜率 m, 截距 b),斜率随插入递减
// 128 位乘法:参数直接声明成 128 位,靠隐式转换避免出现强制转换
lll mul(lll a, lll b) { return a * 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 (mul(b2 - b1, m2 - m) < mul(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;
}#!/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:44
# update_at: 2026-10-01 21:58
import sys
from collections import deque
def push_line(hull: deque[tuple[int, int]], m: int, b: int) -> None:
"""把直线 y = m*x + b 压入下凸壳的尾部(插入斜率 m 随插入递减)。
凸壳只保留"可能成为最小值"的直线:若队尾直线在新直线与次队尾直线的
交点之上(三点不再构成下凸),它永远取不到最小值,直接弹掉。
"""
while len(hull) >= 2:
(m1, b1), (m2, b2) = hull[-2], hull[-1]
if (b2 - b1) * (m2 - m) < (b - b2) * (m1 - m2):
break # 队尾仍是凸壳顶点
hull.pop()
hull.append((m, b))
def min_line(hull: deque[tuple[int, int]], x: int) -> int:
"""查询所有已插入直线在 x 处的最小值。
查询点 x = 前缀时间和 + S 严格递增,所以最优直线沿凸壳单调右移,
用队首指针(这里是 popleft)均摊 O(1) 即可,不必二分。
"""
while len(hull) >= 2 and hull[0][0] * x + hull[0][1] >= hull[1][0] * x + hull[1][1]:
hull.popleft()
m, b = hull[0]
return m * x + b
def solve() -> None:
read = sys.stdin.buffer.readline
n, S = int(read()), int(read()) # S 是每批任务开始前的启动时间
times: list[int] = [] # 第 i 个任务的执行时间 T_i
costs: list[int] = [] # 第 i 个任务的费用系数 C_i
for _ in range(n):
t, c = map(int, read().split())
times.append(t)
costs.append(c)
total_c = sum(costs)
# f[i]:前 i 个任务的最小总费用(已把启动时间折算给"它及之后所有任务")
# 转移展开后 f[i] = sc[i]*st[i] + S*total_c + min_j { -sc[j]*(st[i]+S) + f[j] }
# 每个决策 j 是一条斜率 -sc[j] 递减的直线,查询点 st[i]+S 递增 → 单调队列凸壳
hull: deque[tuple[int, int]] = deque()
dp = st = sc = 0 # dp = f[0] = 0
for t, c in zip(times, costs):
push_line(hull, -sc, dp) # 此刻 sc、dp 仍是第 i-1 轮的值
st += t
sc += c
dp = sc * st + S * total_c + min_line(hull, st + S)
print(dp)
if __name__ == "__main__":
solve()复杂度
- 时间复杂度:
。每个决策点入队、出队各至多一次,插入与查询均摊 ; 前缀和随读入滚动累加,也是 。对照朴素转移 , 时不可行。 - 空间复杂度:
,最坏情况下单调队列要存下全部 条直线(例如 很小、 凸壳几乎不弹尾时)。实测 、 时运行 0.25s、 峰值内存约 45MB(限时 1000ms / 128MB)。
总结
- 换记账方式是本题的分水岭。完成时刻是绝对量,直接 DP 需要多记一维时间;
用
把费用拆为 ,再用双重计数把 换成 ,全局的“第几批”就消失了。 - 改写后的物理含义很直白:第
批的贡献分两半——这段任务自己的耗时按自己的 支付, 而启动时间 由本批及其后所有还在等待的任务共同支付。 - 得到
后,展开并分离变量就变成 “每个 一条直线,问 处的最小值”,即下凸壳求切点。 - 两条单调性决定用单调队列而非二分:斜率
递减、查询点 递增, 两个方向都单调,于是队尾维护凸性、队首弹出失效直线,均摊 。 - 凸性判定务必用交叉相乘而不是除法:
与 的大小比较两边同乘正的分母即可,避免浮点误差。 - 若题目允许
或 ,两条单调性会退化(相等斜率 / 相等查询点), 那时需要改用二分查找切点或 Li Chao 树。
图示解析
下面这张图串起本题从建模到得到答案的主线,每个节点是一个推理结论:
N 个任务切成连续批次,同批同时完成
`- 直接 DP 的障碍:完成时刻是绝对量,状态要带一维时间
`- 拆 e_r = st[e_r] + S*r ,并把 S*Σ r*W_r 双重计数成 S*Σ(SC-sc[e_{r-1}])
`- 得到 O(N^2) 转移:f[i] = min_j { f[j] + st[i](sc[i]-sc[j]) + S(SC-sc[j]) }
`- 展开并分离变量:每个 j 是一条直线 y = (-sc[j])x + f[j]
`- 两条单调性:斜率递减 + 查询点递增
|- 队尾:交叉相乘判下凸,弹掉不再最优的直线
`- 队首:x 增大时直接弹掉被超越的直线
`- 均摊 O(1) 查询 -> 总复杂度 O(N)先看第 2 个节点——它解释了为什么不能照搬"最后一批"的朴素转移;再看第 3 个节点,
这是唯一需要一点代数技巧的地方,它把全局的批次序号换成了局部的区间端点。
从第 4 个节点往后,每一步都只是把同一个