把分段 DP 的转移展开成直线族,用 T[i]=S[i]+i 带来的斜率与查询双单调性,在单调队列上维护下凸壳,O(N) 求出最小装箱费用。
OJ: roj
题目 ID: 1610
难度:提高
标签:动态规划斜率优化前缀和python
创建: 2026-09-30 21:39
更新: 2026-10-07 13:50
形式化题目
给定正整数序列
- 该段容器长度必须不小于
(即段内玩具之间放 个单位填充物); - 长度为
的容器制作费用为 ; - 容器数目与长度不受限制,超过
也可以。
各段费用相加,求运走全部玩具的最小总费用。
样例
| 分段 | 容器长度 |
费用 |
|---|---|---|
| 合计 | — |
注意单件一段没有填充物(
正解
思路
第一步:分段 DP。 设
引入
初始
对样例逐个
| 最优 |
||||
|---|---|---|---|---|
| 0 | 0 | — | — | 0 |
| 1 | 4 | 0 | 1 | |
| 2 | 9 | 4 | 1 | 1 |
| 3 | 12 | 7 | 2 | 5 |
| 4 | 14 | 9 | 2 | 1 |
| 5 | 19 | 14 | 4 | 1 |
要观察两点:
第二步:把转移展开成直线族。 固定
也就是说每个
| 0 | 0 | 0 | 0 | 0 |
| 1 | 4 | 1 | 17 | |
| 2 | 9 | 1 | 82 | |
| 3 | 12 | 5 | 149 | |
| 4 | 14 | 1 | 197 |
验证
第三步:双单调 ⇒ 单调队列。 由
- 斜率单调:
随 严格递减; - 查询单调:
随 严格递增。
斜率与查询同向单调时,直线族的下凸壳可以用双端队列维护,每次查询只弹队首、每次插入只弹队尾:
- 队首(查询时):若
value(front) >= value(second)就弹出队首。斜率递减的两条线只相交一次,首线在当前查询点被追平后,对一切更大的只会更差,可以永久丢弃——这就是 DP 表里「最优 单调右移」的队列实现。 - 队尾(插入时):若
与 的交点 不晚于 与队尾 的交点 ,则 在任何查询点都取不到最小值,弹出 。交点 的分母同号,交叉相乘即可用整数精确比较,避免浮点误差。
每条直线进出队列各一次,均摊
代码
下面的 min_cost() 就是上述单调队列:hull 存直线 value() 算直线在查询点的取值,is_shadow() 用交叉相乘判断队尾死线,t 即
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:43
# update_at: 2026-09-30 21:43
import sys
from collections import deque
Line = tuple[int, int] # (斜率, 截距):直线 y = m*x + b,公共的 x² 项统一后加
def value(line: Line, x: int) -> int:
"""直线在 x 处的取值(不含公共的 x²)。"""
m, b = line
return m * x + b
def is_shadow(a: Line, b: Line, c: Line) -> bool:
"""b 是否被 a、c 夹成死线:交点不前移,b 永远取不到最小值。"""
(ma, ba), (mb, bb), (mc, bc) = a, b, c
# 交点横坐标 (b_i - b_j) / (m_j - m_i),斜率严格递减使分母同号,交叉相乘避免浮点
return (bb - ba) * (mb - mc) >= (bc - bb) * (ma - mb)
def min_cost(costs: list[int], limit: int) -> int:
"""斜率优化 + 单调队列:依次推出 dp[i],返回最后一件玩具装完的最小费用。"""
hull: deque[Line] = deque()
hull.append((0, 0)) # j = 0:前 0 件玩具 T=0、dp=0 对应的直线
t = 0 # T[i] = 前缀和 S[i] + i,随 i 严格递增
dp = 0
for c in costs:
t += c + 1 # 每件玩具让前缀和与下标各 +1
x = t - 1 - limit # 以 j 分段时容器长度 = x - T[j]
while len(hull) > 1 and value(hull[0], x) >= value(hull[1], x):
hull.popleft() # x 随 i 递增:被追平的首线以后只会更差
m, b = hull[0]
dp = m * x + b + x * x # dp[i] = min_j 直线值 + x²
new = (-2 * t, dp + t * t) # dp[i] + (x - T[i])² 展开后的 (m, b)
while len(hull) > 1 and is_shadow(hull[-2], hull[-1], new):
hull.pop() # 维护下凸壳:交点左移的尾线整段多余
hull.append(new)
return dp
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, limit = next(data), next(data)
costs = [next(data) for _ in range(n)]
print(min_cost(costs, limit))
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:45
* update_at: 2026-10-05 12:45
*/
// 斜率优化 DP:转移展开成直线族 y = -2*T[j]*x + (dp[j]+T[j]^2),
// 斜率与查询点双单调,用单调队列维护下凸壳,O(N) 求最小装箱费用。
#include <iostream>
using namespace std;
typedef long long ll;
typedef __int128 lll; // 凸壳比较的中间乘积可达 1e35,64 位整数会溢出
const int MAXN = 50005;
int n;
ll limit;
ll T[MAXN]; // T[i] = 前缀和 S[i] + i,由 C[i] >= 1 知其严格递增
ll dp[MAXN]; // dp[i] 表示前 i 件玩具的最小装箱费用
int q[MAXN]; // 单调队列:存直线对应的下标 j,斜率 -2*T[j] 随下标严格递减
// 第 j 条直线:y = -2*T[j] * x + (dp[j] + T[j]^2),公共的 x^2 项统一后加
lll line_value(int j, ll x) {
return (lll)(-2 * T[j]) * x + (lll)dp[j] + (lll)T[j] * T[j];
}
// 队首 a 在当前查询点已不优于 b:两条直线只有一个交点,追平后 a 永远更差
bool is_worse_front(int a, int b, ll x) {
return line_value(a, x) >= line_value(b, x);
}
// b 被 a、c 夹成死线:交点 x_ab >= x_bc 时 b 取不到最小值,交叉相乘避免浮点
bool is_shadow(int a, int b, int c) {
lll ya = (lll)dp[a] + (lll)T[a] * T[a];
lll yb = (lll)dp[b] + (lll)T[b] * T[b];
lll yc = (lll)dp[c] + (lll)T[c] * T[c];
return (yb - ya) * (T[c] - T[b]) >= (yc - yb) * (T[b] - T[a]);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> limit;
for (int i = 1; i <= n; i++) {
ll c;
cin >> c;
T[i] = T[i - 1] + c + 1; // 前缀和与下标各加 1
}
int head = 1, tail = 1;
q[1] = 0; // j = 0:前 0 件玩具,T[0] = dp[0] = 0
for (int i = 1; i <= n; i++) {
ll x = T[i] - 1 - limit; // 断点为 j 时容器长度 = x - T[j]
while (head < tail && is_worse_front(q[head], q[head + 1], x)) {
head++; // x 随 i 递增,被追平的队首以后只会更差
}
int j = q[head];
ll len = x - T[j]; // 该段容器长度与 limit 的差
dp[i] = dp[j] + len * len; // 最优段的差恰为 dp[i]-dp[j],不会溢出
while (head < tail && is_shadow(q[tail - 1], q[tail], i)) {
tail--; // 维护下凸壳:交点左移的尾线永远取不到最小值
}
q[++tail] = i;
}
cout << dp[n] << '\n';
return 0;
}复杂度
- 时间
:每件玩具只做常数次队首、队尾比较,每个下标入队一次、出队一次。 - 空间
: 数组与直线队列(队列摊还长度远小于 )。 - 与标准 C++ 斜率优化解法同阶;Python 侧无溢出风险(
int任意精度,最大答案也远小于 ,但段长上界对应费用可达 )。
总结
- 先建模:
,其中 把「前缀和 + 下标」合并成一个严格递增的量。 - 再变形:转移对
展开成 ,把「枚举段首」变成「在直线族上取最小值」。 - 最后利用
严格递增带来的斜率、查询双单调,用单调队列维护下凸壳, 求出最小费用。 - 易错点:填充物是「件数
个」;费用按段独立求和; 只是费用参考点,段长可以小于 。 - 边界:
时队列里只有初始直线 , ; 严格递增保证斜率、查询都严格单调,无需退化分支。