把建仓划分写成连续分段的线性 DP,用前缀和把转移拆成「只与 i 有关」加「若干直线在 X_i 处的最小值」,再用单调队列维护下凸壳做斜率优化,时间 O(n)。
OJ: roj
题目 ID: 1611
难度:提高+/省选-
标签:动态规划前缀和斜率优化凸包优化python
创建: 2026-09-30 21:52
更新: 2026-10-07 13:50
形式化题目
给定
(每件产品运送 1 个单位距离费用为 1。)求这个代价的最小值。
把
正解
思路
先明确"每个工厂的产品去哪"。因为产品只能往编号更大的方向运,所以工厂
设
用前缀和表示运费。记
则第
注意
下面这张表用题面样例(
| 候选 |
建仓费 |
运输费 |
||
|---|---|---|---|---|
| 1 | 0 | 10 | 0 | 10 |
| 1 | 取最小 | |||
| 2 | 0 | 100 | 25 | 125 |
| 2 | 1 | 100 | 0 | 110 |
| 2 | 取最小 | |||
| 3 | 0 | 10 | 57 | 67 |
| 3 | 1 | 10 | 12 | 32 |
| 3 | 2 | 10 | 0 | 120 |
| 3 | 取最小 |
表的最后一行正好复现题面样例说明的两种方案:
瓶颈:每个
线性化。把转移式里与
现在每个决策点
| 查询点 |
队头起的壳上直线 |
各线在 |
胜出 | ||
|---|---|---|---|---|---|
| 1 | 0 | 10 | |||
| 2 | 5 | 110 | |||
| 3 | 9 | 32 |
两条直线的交点在
为什么单调队列就够。本题有两个单调性:
- 题面保证各厂位置递增,
,所以查询点单调不减: 队头某条线一旦不优于第二条,之后更不可能翻身,队头指针只前进、不回头。 使 单调不减,所以插入的斜率 单调不增: 新线永远插在凸壳尾部,队尾只需检查"最后一条线的生效区间是否被新交点截空"。
两个条件都满足,于是不需要二分、不需要平衡树,凸壳用一条"队尾 append/pop、
队头用下标推进"的单调队列即可,每条直线最多进出一次,均摊
实现上还有两点:转移只依赖 (b2 - b1) * (m2 - m) >= (b - b2) * (m1 - m2);出现相等斜率时,同一判据恰好退化为
“保留截距较小的那条直线”,结论不变。整个过程不做除法,没有浮点误差。
代码
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-09-30 21:59
# update_at: 2026-09-30 21:59
import sys
from collections.abc import Iterator
def read_ints() -> Iterator[int]:
"""按块解码输入:一次 read() 全读再 split() 会同时留下 25 MB 字节和 300 万个 token 对象。"""
stream = sys.stdin.buffer
tail = b""
while block := stream.read(1 << 20):
body, _, tail = (tail + block).rpartition(b"\n") # 末行可能被切断,留给下一块拼
yield from map(int, body.split())
yield from map(int, tail.split())
def solve() -> None:
data = read_ints()
n = next(data)
# 凸壳用「队尾 list.append/list.pop + 队头指针 h」实现,两个数是斜率 m 和截距 b。
slope: list[int] = []
intercept: list[int] = []
head = 0
# 前缀量边读边更新:W = W[i-1] = sum_{t<i} P_t,D = D[i-1] = sum_{t<i} P_t*X_t;
# best = dp[i-1],即「前 i-1 个工厂处理完且第 i-1 个建仓」的最小费用。
W = D = best = 0
for _ in range(n):
x, p, c = next(data), next(data), next(data)
# dp[j] 对应的直线 y = -W[j]*x + dp[j] + D[j],新直线永远接在队尾
m, b = -W, best + D
while len(slope) - head >= 2:
# 两条旧直线的交点横坐标 x12 与末条、新直线的交点 x23:x12 >= x23 时末条永不为最小
x12 = (intercept[-1] - intercept[-2]) * (slope[-1] - m)
x23 = (b - intercept[-1]) * (slope[-2] - slope[-1])
if x12 < x23:
break
slope.pop()
intercept.pop()
slope.append(m)
intercept.append(b)
while len(slope) - head >= 2:
# 查询点 x 单调不减:队头一旦不优于第二条,之后也不会翻身
if slope[head] * x + intercept[head] < slope[head + 1] * x + intercept[head + 1]:
break
head += 1
best = c + x * W - D + slope[head] * x + intercept[head] # dp[i]
W += p
D += p * x
print(best)
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* 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-05 12:44
* update_at: 2026-10-05 12:50
*/
// ZJOI 2007 仓库建设:分段 DP + 斜率优化(单调队列维护下凸壳),O(N)。
// dp[i]:前 i 个工厂处理完且第 i 个建仓库的最小费用;产品只能往编号更大的方向运。
#include <cstdio>
typedef long long ll;
const int MAXN = 1000005;
// 每个工厂:位置、产品数、建仓费(N 最大 1e6,开全局数组避免爆栈)
ll x[MAXN];
ll p[MAXN];
ll c[MAXN];
// 下凸壳用数组模拟队列:slope/intercept 保存每条决策直线 y = m*x + b
// 决策点 j(dp[j],j 可为 0 哨兵)对应斜率 m = -W[j],截距 b = dp[j] + D[j]
ll q_slope[MAXN];
ll q_intercept[MAXN];
int head = 0; // 队头下标,只前进
int tail = 0; // 队尾下标(开区间),新直线插在 tail 处
ll w_sum[MAXN]; // w_sum[j] = P_1 + ... + P_j(前缀和,W[j])
ll d_sum[MAXN]; // d_sum[j] = P_1*X_1 + ... + P_j*X_j(前缀和,D[j])
ll dp[MAXN]; // dp[i]:第 i 个工厂一定建仓库时的最小费用,dp[0] = 0
// 把决策点(斜率 m、截距 b)加入队尾
// 队列内斜率严格递减:先消掉与末线平行的情形(P 可为 0,W 不严格递增),再弹被盖住的末线
void push_line(ll m, ll b) {
// 斜率相同时两条平行线只剩截距更小的一条
while (tail > head && q_slope[tail - 1] == m) {
if (b >= q_intercept[tail - 1]) return; // 新线永不更优,直接丢弃
tail--;
}
while (tail - head >= 2) {
// 末线被盖住 <=> 旧两线交点横坐标 x12 >= 末线与新线交点横坐标 x23
// x12 = (b_last - b_prev) / (m_prev - m_last),x23 = (b - b_last) / (m_last - m)
// 此时两个分母都为正(斜率严格递减),交叉相乘避免除法
if ((q_intercept[tail - 1] - q_intercept[tail - 2]) * (q_slope[tail - 1] - m)
>= (b - q_intercept[tail - 1]) * (q_slope[tail - 2] - q_slope[tail - 1]))
tail--;
else
break;
}
q_slope[tail] = m;
q_intercept[tail] = b;
tail++;
}
int main() {
int n;
if (scanf("%d", &n) != 1) return 0;
for (int i = 1; i <= n; i++) {
scanf("%lld %lld %lld", &x[i], &p[i], &c[i]);
}
// 哨兵决策点 j = 0:dp[0] = 0,W[0] = D[0] = 0,对应直线 y = 0(截距 0)
dp[0] = 0;
w_sum[0] = 0;
d_sum[0] = 0;
head = tail = 0;
push_line(0, 0);
for (int i = 1; i <= n; i++) {
// 转移:dp[i] = c[i] + x[i]*W[i-1] - D[i-1] + min_j { -W[j]*x[i] + dp[j] + D[j] }
// 推导:(j, i] 段运费 = x[i]*(W[i-1]-W[j]) - (D[i-1]-D[j]),i 自己的产品运费为 0
// 查询点 x[i] 单调递增:队头一旦不优于第二条,之后也不会翻身
while (tail - head >= 2
&& q_slope[head] * x[i] + q_intercept[head]
>= q_slope[head + 1] * x[i] + q_intercept[head + 1])
head++;
ll best = q_slope[head] * x[i] + q_intercept[head]; // min 部分取到的是 dp[j] + D[j]
dp[i] = c[i] + x[i] * w_sum[i - 1] - d_sum[i - 1] + best;
// 插入新决策点 j = i:W 单调不减 => 斜率 -W[i] 单调不增,永远插在队尾
w_sum[i] = w_sum[i - 1] + p[i]; // W[i] = sum_{t<=i} P_t
d_sum[i] = d_sum[i - 1] + p[i] * x[i]; // D[i] = sum_{t<=i} P_t*X_t
push_line(-w_sum[i], dp[i] + d_sum[i]);
}
// 题面保证产品运往山脚工厂 N,所以 S 必须包含 N,答案就是 dp[N]
printf("%lld\n", dp[n]);
return 0;
}复杂度
- 时间复杂度:
。外层扫描 次,凸壳中每条直线最多入队、出队各一次,均摊 。 - 空间复杂度:
,只用于两棵凸壳数组 slope/intercept;都是单个标量。 读入按 1 MiB 分块处理,避免一次性留下全部 token。
总结
- 关键第一步是看清方案的形状:产品只能往山下运,所以每个仓库承接的必然是一段连续工厂,
问题化为"把
切段、段内运到段尾"的一维线性 DP。 - 第二步是前缀和展开:把段运费写成
, 于是与 有关的只剩 。 - 第三步是斜率优化:
, 决策点变成直线,查询变成" 处取最小"。 - 因为
递增、 递增,查询点与斜率都单调,单调队列凸壳就能 解决; 判据用整数乘法,无精度问题。 - 本题必须建工厂
的仓库,这一点已经写进 的定义里; 时答案为 。
图示解析
把上面的推导压缩成一条路线,方便读完后复盘:
flowchart TD
A["朴素分段 DP:<br/>dp[i] = c_i + min_j(dp[j] + 段运费)"] --> B["前缀和展开段运费:<br/>W[k] = ΣP,D[k] = ΣP·X"]
B --> C["与 i 有关的项提到 min 外:<br/>dp[i] = c_i + X_i·W[i-1] - D[i-1]<br/>+ min_j ( -W[j]·X_i + dp[j] + D[j] )"]
C --> D["每个决策点 j 变成一条直线<br/>斜率 m_j = -W[j],截距 b_j = dp[j] + D[j]"]
D --> E["两个单调性:<br/>查询点 X_i 递增、斜率 -W[j] 不增"]
E --> F["查询:队头指针只前进<br/>均摊 O(1)"]
E --> G["插入:队尾弹掉被新线盖住的直线<br/>均摊 O(1)"]
F --> H["总复杂度 O(n)"]
G --> H
图中上半部分是建模与化简:先有分段 DP,再用前缀和把转移拆成"只与
