以「上一个新厂位置 j」为决策写出一维 DP,前缀和展开后转移变成直线族在 pos_i 处取最小值;斜率与查询点双单调,单调队列维护下凸壳把 O(n^2) 降到 O(n)。
OJ: roj
题目 ID: 1614
难度:提高+/省选-
标签:动态规划斜率优化凸包优化前缀和python
创建: 2026-09-30 22:06
更新: 2026-10-07 13:50
形式化题目
沿直线从山顶到山脚种着
山脚已有一个锯木厂,还要在山路上恰好新修两个。木材只能向山下运,每棵树由它下游方向最近的那个锯木厂承接;每公斤每米运费 1 分。求所有新厂位置中总运费的最小值。
样例(
山顶
│
1(1)─2─2(2)─1─3(3)★─3─4(1)─1─5(3)─2─6(1)★─6─7(2)─1─8(1)─2─9(1)─1→山脚锯木厂图中 树号(重量),连线上的数字是间距,★ 是最优的两个新厂。运费分三段:树 1…3 运到
正解
思路
1. 方案的形状:归一化到树位置。 承接「最低一棵树
2. 朴素 DP。 记前缀和
则三段运费分别是
固定较低的厂
(
3. 线性化:转移是直线族取最小。 把
4. 为什么单调队列就够(双单调)。 本题有两个单调性:
⇒ 严格递增 ⇒ 斜率 严格递减:新直线永远从壳尾进入,队尾只需判断末线是否被新线完全盖住; ⇒ 单调不减 ⇒ 查询点单调:队头某条线一旦输给下一条,之后不可能翻身,队头指针只前进。
两个条件同时成立,凸壳两端都只移动指针,每条直线入队、出队各一次,均摊
下面用样例展示每个
| 最优 |
尾段 |
|||
|---|---|---|---|---|
| 2 | 0 | 1 | 119 | 119 |
| 3 | 2 | 1 | 71 | 73 |
| 4 | 5 | 3 | 58 | 63 |
| 5 | 6 | 3 | 22 | 28 |
| 6 | 14 | 3 | 12 | 26 |
| 7 | 36 | 5 | 4 | 40 |
| 8 | 39 | 5 | 1 | 40 |
| 9 | 47 | 5 | 0 | 47 |
表中每行是「第二个厂建在树
5. 实现要点。 边读入边推进 pos += d 恰好得到
代码
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 22:40
# update_at: 2026-09-30 22:40
import sys
from collections import deque
def hull_add(hull: deque, slope: int, intercept: int) -> None:
"""把直线 y = slope*x + intercept 并入下凸壳(直线按斜率递减顺序入队)。
若「尾前直线与尾直线」的交点不小于「尾直线与新直线」的交点,尾直线
在所有位置都被新直线压住,出队。交点横坐标之比用交叉相乘判断,避免除法。
"""
while len(hull) >= 2:
m1, c1 = hull[-2]
m2, c2 = hull[-1]
# 交点 x(l1,l2) = (c2-c1)/(m1-m2),两条分母都为正,可直接交叉相乘
if (c2 - c1) * (m2 - slope) >= (intercept - c2) * (m1 - m2):
hull.pop()
else:
break
hull.append((slope, intercept))
def hull_query(hull: deque, x: int) -> int:
"""查询凸壳在横坐标 x 处的最小值:查询点单调不减,最优直线只向队尾移动。"""
while len(hull) >= 2 and hull[0][0] * x + hull[0][1] >= hull[1][0] * x + hull[1][1]:
hull.popleft()
return hull[0][0] * x + hull[0][1]
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
# sw[i] = 前 i 棵树总重量;swd[i] = 前 i 棵树的 Σ w_k·pos_k;
# f[i] = 两个新锯木厂中较高的在树 j<i、较低的建在树 i 时,树 1..i 的最小运费
sw: list[int] = [0] * (n + 1)
swd: list[int] = [0] * (n + 1)
f: list[int] = [0] * (n + 1)
hull: deque = deque() # 直线 y = -sw[j]·x + sw[j]·pos_j,斜率随 j 递减
pos = 0 # 当前树到山顶的距离 pos_i,山顶处 pos_1 = 0
for i in range(1, n + 1):
w, d = next(data), next(data)
sw[i], swd[i] = sw[i - 1] + w, swd[i - 1] + w * pos
# f[i] = sw[i]·pos_i - swd[i] + min_j ( sw[j]·pos_j - sw[j]·pos_i )
# min 部分恰是候选直线族在 x = pos_i 处的最小值,用凸壳 O(1) 取到
if i > 1:
f[i] = sw[i] * pos - swd[i] + hull_query(hull, pos)
hull_add(hull, -sw[i], sw[i] * pos) # 树 i 成为「较高锯木厂」的候选
pos += d # 推进到下一棵树,读完 d_n 后即山脚
# 第二个锯木厂在树 i 时,树 i+1..n 全部运到山脚锯木厂的费用
tail = [(sw[n] - sw[i]) * pos - (swd[n] - swd[i]) for i in range(n + 1)]
print(min(f[i] + tail[i] for i in range(2, n + 1)))
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 23:02
* update_at: 2026-10-05 23:03
*/
#include <iostream>
using namespace std;
const int MAXN = 200005;
typedef long long ll;
ll n;
ll sw[MAXN]; // sw[i] = 前 i 棵树的总重量
ll swd[MAXN]; // swd[i] = 前 i 棵树的 Σ w_k·pos_k
ll f[MAXN]; // f[i] = 较低的厂建在树 i 时,树 1..i 的最小运费(较高的厂在其上游)
// 下凸壳,维护若干条直线 y = m·x + b,按斜率递减的顺序存放
ll hull_m[MAXN]; // 候选直线的斜率 m_j = -sw[j]
ll hull_b[MAXN]; // 候选直线的截距 b_j = sw[j]·pos_j
int head_pos; // 队头指针:查询点单调不减,只前进
int tail_pos; // 队尾指针:新直线从队尾进入
// 把直线 y = s·x + b 加入下凸壳(斜率 s 比壳内所有直线都小)
void hull_add(ll s, ll b) {
// 若「尾前直线 l1 与尾直线 l2」的交点不低于「尾直线 l2 与新直线 l3」的交点,
// 则 l2 被完全压住,弹出。交点比较用交叉相乘,避免除法与浮点。
while (tail_pos - head_pos >= 2) {
ll m1 = hull_m[tail_pos - 2], c1 = hull_b[tail_pos - 2];
ll m2 = hull_m[tail_pos - 1], c2 = hull_b[tail_pos - 1];
// x(l1,l2) = (c2-c1)/(m1-m2),x(l2,l3) = (b-c2)/(m2-s),两条分母都为正
if ((c2 - c1) * (m2 - s) >= (b - c2) * (m1 - m2)) {
tail_pos--;
} else {
break;
}
}
hull_m[tail_pos] = s;
hull_b[tail_pos] = b;
tail_pos++;
}
// 查询凸壳在横坐标 x 处的最小值
ll hull_query(ll x) {
while (tail_pos - head_pos >= 2 &&
hull_m[head_pos] * x + hull_b[head_pos] >=
hull_m[head_pos + 1] * x + hull_b[head_pos + 1]) {
head_pos++;
}
return hull_m[head_pos] * x + hull_b[head_pos];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
ll pos = 0; // 当前树到山顶的距离 pos_i,山顶处 pos_1 = 0
for (ll i = 1; i <= n; i++) {
ll w, d;
cin >> w >> d;
sw[i] = sw[i - 1] + w;
swd[i] = swd[i - 1] + w * pos;
// f[i] = sw[i]·pos_i - swd[i] + min_{j<i} ( sw[j]·pos_j - sw[j]·pos_i )
// 后一项是候选直线族在 x = pos_i 处的最小值,用下凸壳 O(1) 取得
if (i > 1) {
f[i] = sw[i] * pos - swd[i] + hull_query(pos);
}
hull_add(-sw[i], sw[i] * pos); // 树 i 成为「较高的厂」的候选
pos += d; // 推进到下一棵树,读完 d_n 后即山脚
}
// 循环结束时 pos = pos_{n+1}(山脚)。尾段:树 i+1..n 全部运到山脚的费用
ll ans = -1;
for (ll i = 2; i <= n; i++) {
ll tail_cost = (sw[n] - sw[i]) * pos - (swd[n] - swd[i]);
ll total = f[i] + tail_cost;
if (ans < 0 || total < ans) {
ans = total;
}
}
cout << ans << "\n";
return 0;
}复杂度
- 时间复杂度:
。一次线性扫描,凸壳中每条直线最多入队、出队各一次,均摊 。对比朴素 DP 的 , 时从约 次运算降到 次。 - 空间复杂度:
。 sw、swd、f、tail各一个长度的数组( 时实测峰值约 85 MB,在 128 MB 内),凸壳最多存 条直线。
总结
- 运费模型的关键是连续三段:木材只能下行 ⇒ 每个厂承接一段连续的树 ⇒ 枚举「较低厂
+ 上一个厂 」覆盖全部方案,得到以 为决策的一维 DP。 - 前缀和展开是线性化的前提:
中所有含 的项恰好凑成 ,即一条斜率为 、截距为 的直线在 处的值。 - 双单调(斜率递减、查询点不减)让下凸壳退化为单调队列,队头队尾各一个指针,均摊
;判据用整数交叉相乘,无精度隐患。 - 边界:
时唯一方案 ; 时 非严格递增,仍满足查询单调;答案可达 量级,需要 64 位整数(Python 天然支持)。
图示解析
把全文推导压缩成一条路线,方便读完后复盘:
flowchart TD
A["方案归一化:<br/>新厂只考虑建在树位置,<br/>方案 = 三段连续的树"] --> B["朴素 DP:<br/>f[i] = min_j (C1(j) + C2(j,i))<br/>O(n^2),瓶颈在枚举 j"]
B --> C["前缀和展开:<br/>f[i] = sw[i]·pos_i - swd[i]<br/>+ min_j ( -sw[j]·pos_i + sw[j]·pos_j )"]
C --> D["决策 j 变成一条直线:<br/>斜率 m_j = -sw[j],截距 b_j = sw[j]·pos_j"]
D --> E["两个单调性:<br/>斜率严格递减(w≥1)<br/>查询点 pos_i 不减(d≥0)"]
E --> F["查询:队头指针只前进<br/>均摊 O(1)"]
E --> G["插入:队尾交叉相乘弹线<br/>均摊 O(1)"]
F --> H["ans = min_i (f[i] + 尾段 C3(i))<br/>总复杂度 O(n)"]
G --> H
上半部分是建模与化简:先确定方案的三段形状,再用前缀和把转移拆成「只与