「一本通 5.6 练习 1」玩具装箱

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

把分段 DP 的转移展开成直线族,用 T[i]=S[i]+i 带来的斜率与查询双单调性,在单调队列上维护下凸壳,O(N) 求出最小装箱费用。

OJ: roj

题目 ID: 1610

难度:提高

标签:动态规划斜率优化前缀和python

创建: 2026-09-30 21:39

更新: 2026-10-07 13:50

后置题目
roj 1614「一本通 5.6 练习 5」锯木厂选址B 直接复用 A 的下凸壳单调队列这一步:同样把决策 j 化成直线、同样靠斜率与查询双单调让队头队尾只移动指针,只是查询点从 T[i]-1-L 换成 pos_iroj 1611「一本通 5.6 练习 2」仓库建设A 教的「下凸壳可以用双端队列维护」这一具体步骤被 B 直接复用:B 的仓库建设转移同样化成直线族,靠查询点 X_i 与斜率 -W_j 双单调,用同一套队首弹线、队尾弹死线的单调队列凸壳把 O(N^2) 降到 O(N)。roj 1612「一本通 5.6 练习 3」特别行动队B 的斜率优化解法完整复用 A 教的这一步:同样把 dp 转移展开成 l_j(x)=m_j·x+b_j 直线族、同样利用斜率与查询双单调在双端队列上维护凸壳,队尾判死线也同样用交叉相乘(B 代码 slope_pq>=slope_qi 即 A 的 is_shadow),只是把取 min 的下凸壳镜像成取 max 的上包络,并叠加 a<0 的查询点单调与 n=10^6 的 array 存储;A 是提高级模板题,B 是提高+/省选-的加强应用,方向正确。roj 1609「一本通 5.6 例 4」Cats TransportA 教的「转移展开成直线族 + 斜率与查询双单调 ⇒ 双端队列维护下凸壳、交叉相乘判死线」正是 B 第四步每层 DP 用来替代枚举切点的同一具体步骤,B 只是在其上叠加排序压参与 P 层分段 DP。luogu P5785[SDOI2012] 任务安排B 把转移里随 j 变化的部分写成一次函数并逐决策点建线,直接复用 A 教的「双单调 ⇒ 单调队列维护下凸壳、弹队首查询」这一步,代码里 bad() 交叉相乘弹队尾与 better_front() 弹队首同 A 的 is_shadow()/value() 结构一致;只在此外层叠加「把每批启动时间 s 摊到 sum_c[n]-sum_c[j]」的前缀和建模。luogu P2120[ZJOI2007] 仓库建设B 的斜率优化解法直接复用 A 教的「双单调 ⇒ 单调队列维护下凸壳/凸包」这一步,把分段的二次代价换成前缀和写成的运输代价,模板完全一致luogu P2900[USACO08MAR] Land Acquisition GB 的主解正是复用 A 教的「斜率与查询双单调 ⇒ 单调队列维护下凸壳」这一模板:A 用它在 dp[j]+(T[i]-T[j]-1-L)^2 上 O(N) 转移,B 把它套到 x_i 递增、y_{j+1} 递减的 dp[j]+x_i*y_{j+1} 上(main.cpp 的 q 队列弹队首/弹队尾),两端点斜率与查询同样单调;B 额外叠加的只是排序删被支配矩形、把分组化为连续分段 DP 这一步,故为台阶式前置。luogu P8632[蓝桥杯 2015 国 B] 居民集会B 的 main.cpp 逐字复用了 A 教的「把 min 的 DP 转移展开成直线」这一步:都是把 dp[j] + cost(j+1,i) 整理成 i 的公共项加一条来自 j 的直线,再套 A 教的下凸壳队列(B 的 is_bad 与 A 的 is_shadow 同形,弹队头条件也同形),额外流程是把 1 层 dp 变成 dp[k][i] 的 3 层区间分段并按 d_i 递增查询。luogu P5017[NOIP 2018 普及组] 摆渡车B 的 main.cpp 把 A 教的「转移展开成直线族、斜率与查询同向单调 ⇒ 单调队列维护下凸壳(队首弹、队尾弹、交叉相乘判死线)」整套照搬:A 用在 dp[j]+(T[i]-T[j]-1-L)^2 上 O(N) 转移,B 用在 (dp[y]+sum[y])-cnt[y]·x 上,只是先多做一层「平移时刻 + 按回程时刻 DP + y=x-m 延迟入队」的建模,故属台阶式前置。

形式化题目

给定正整数序列 C1,…,CNC_1,\ldots,C_N 与常数 LL。把下标 1…N1\ldots N 划分成若干连续段,每段 i…ji\ldots j(i⩽ji \leqslant j)装入一个容器:

  • 该段容器长度必须不小于 x=(j−i)+∑k=ijCkx = (j-i) + \sum_{k=i}^{j} C_k(即段内玩具之间放 j−ij-i 个单位填充物);
  • 长度为 xx 的容器制作费用为 (x−L)2(x-L)^2;
  • 容器数目与长度不受限制,超过 LL 也可以。

各段费用相加,求运走全部玩具的最小总费用。

样例 N=5, L=4, C=[3,4,2,1,4]N=5,\ L=4,\ C=[3,4,2,1,4] 的一种最优划分如下,总费用为 11:

分段 容器长度 xx 费用 (x−4)2(x-4)^2
{1}\{1\} 33 11
{2}\{2\} 44 00
{3,4}\{3,4\} 1+2+1=41+2+1=4 00
{5}\{5\} 44 00
合计 — 11

注意单件一段没有填充物(j−i=0j-i=0),所以 {1}\{1\} 的长度是 33 而不是 44;费用对每段独立计算后求和,不能把整段总长度代入一次公式。

正解

思路

第一步:分段 DP。 设 S[i]=∑k=1iCkS[i]=\sum_{k=1}^{i}C_k(S[0]=0S[0]=0),dp[i]dp[i] 为前 ii 件玩具的最小费用,dp[0]=0dp[0]=0。若最后一段是 j+1…ij{+}1\ldots i,该段长度为

x=(i−(j+1))+(S[i]−S[j])=i−j−1+S[i]−S[j].x = (i-(j+1)) + (S[i]-S[j]) = i - j - 1 + S[i] - S[j].

引入 T[i]=S[i]+iT[i] = S[i] + i,则 x=T[i]−T[j]−1x = T[i] - T[j] - 1,转移为

dp[i]=min⁡0⩽j<i{dp[j]+(T[i]−T[j]−1−L)2},dp[i] = \min_{0 \leqslant j < i} \left\{ dp[j] + \big(T[i] - T[j] - 1 - L\big)^2 \right\},

初始 dp[0]=0dp[0]=0,遍历顺序就是 ii 从 11 扫到 NN,答案为 dp[N]dp[N]。

对样例逐个 ii 枚举所有 jj 取最小,得到下表(xi=T[i]−1−Lx_i = T[i]-1-L 是第三步要用的查询点,每个 ii 标出取到最小值的 jj):

ii T[i]T[i] xix_i 最优 jj dp[i]dp[i]
0 0 — — 0
1 4 −1-1 0 1
2 9 4 1 1
3 12 7 2 5
4 14 9 2 1
5 19 14 4 1

要观察两点:dpdp 本身不单调(dp[4]=1<dp[3]=5dp[4]=1 < dp[3]=5),所以不能贪心地固定上一个断点;但最优 jj 序列 0,1,2,2,40,1,2,2,4 单调不减,这说明「哪个 jj 最优」随 ii 只会右移,有优化空间。朴素实现对每个 ii 扫一遍 jj,O(N2)O(N^2) 在 N=5×104N=5\times10^4 时约 2.5×1092.5\times10^9 次运算,必然超时。

第二步:把转移展开成直线族。 固定 ii 令 xi=T[i]−1−Lx_i = T[i]-1-L(只与 ii 有关),转移中随 jj 变化的部分是

dp[j]+(xi−T[j])2=(−2T[j])⏟mj⋅xi+(dp[j]+T[j]2)⏟bj+xi2.dp[j] + (x_i - T[j])^2 = \underbrace{(-2T[j])}_{m_j} \cdot x_i + \underbrace{\big(dp[j] + T[j]^2\big)}_{b_j} + x_i^2.

也就是说每个 jj 对应一条直线 ℓj: y=mjx+bj\ell_j:\ y = m_jx + b_j,而 dp[i]=min⁡jℓj(xi)+xi2dp[i] = \min_j \ell_j(x_i) + x_i^2。求 min⁡\min 只需要在直线族上查询。样例的直线族如下:

jj T[j]T[j] dp[j]dp[j] mj=−2T[j]m_j=-2T[j] bj=dp[j]+T[j]2b_j=dp[j]+T[j]^2
0 0 0 0 0
1 4 1 −8-8 17
2 9 1 −18-18 82
3 12 5 −24-24 149
4 14 1 −28-28 197

验证 i=3i=3(x3=7x_3=7):ℓ0(7)=0\ell_0(7)=0、ℓ1(7)=−39\ell_1(7)=-39、ℓ2(7)=−44\ell_2(7)=-44,最小 −44-44,加 x32=49x_3^2=49 得 dp[3]=5dp[3]=5,与 DP 表一致。

第三步:双单调 ⇒ 单调队列。 由 Ci⩾1C_i \geqslant 1 知 TT 严格递增,于是:

  • 斜率单调:mj=−2T[j]m_j = -2T[j] 随 jj 严格递减;
  • 查询单调:xi=T[i]−1−Lx_i = T[i]-1-L 随 ii 严格递增。

斜率与查询同向单调时,直线族的下凸壳可以用双端队列维护,每次查询只弹队首、每次插入只弹队尾:

  1. 队首(查询时):若 value(front) >= value(second) 就弹出队首。斜率递减的两条线只相交一次,首线在当前查询点被追平后,对一切更大的 xx 只会更差,可以永久丢弃——这就是 DP 表里「最优 jj 单调右移」的队列实现。
  2. 队尾(插入时):若 ℓa\ell_a 与 ℓc\ell_c 的交点 xacx_{ac} 不晚于 ℓa\ell_a 与队尾 ℓb\ell_b 的交点 xabx_{ab},则 ℓb\ell_b 在任何查询点都取不到最小值,弹出 ℓb\ell_b。交点 xab=bb−bama−mbx_{ab} = \dfrac{b_b-b_a}{m_a-m_b} 的分母同号,交叉相乘即可用整数精确比较,避免浮点误差。

每条直线进出队列各一次,均摊 O(1)O(1)。样例中 i=5i=5、x5=14x_5=14 连续越过交点 x23≈11.17x_{23}\approx11.17、x34=12x_{34}=12,把 ℓ2,ℓ3\ell_2,\ell_3 依次弹出,只剩 ℓ4\ell_4,算得 dp[5]=−195+142=1dp[5] = -195 + 14^2 = 1,即最终答案。

代码

下面的 min_cost() 就是上述单调队列:hull 存直线 (m,b)(m,b),value() 算直线在查询点的取值,is_shadow() 用交叉相乘判断队尾死线,t 即 T[i]T[i]。

Python 版:

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++ 版(同一算法):

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-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;
}

复杂度

  • 时间 O(N)O(N):每件玩具只做常数次队首、队尾比较,每个下标入队一次、出队一次。
  • 空间 O(N)O(N):CC 数组与直线队列(队列摊还长度远小于 NN)。
  • 与标准 C++ 斜率优化解法同阶;Python 侧无溢出风险(int 任意精度,最大答案 2183742228321837422283 也远小于 2632^{63},但段长上界对应费用可达 2.5×10232.5\times10^{23})。

总结

  • 先建模:dp[i]=min⁡j{dp[j]+(T[i]−T[j]−1−L)2}dp[i] = \min_j \{dp[j] + (T[i]-T[j]-1-L)^2\},其中 T[i]=S[i]+iT[i]=S[i]+i 把「前缀和 + 下标」合并成一个严格递增的量。
  • 再变形:转移对 jj 展开成 ℓj(xi)+xi2\ell_j(x_i) + x_i^2,把「枚举段首」变成「在直线族上取最小值」。
  • 最后利用 TT 严格递增带来的斜率、查询双单调,用单调队列维护下凸壳,O(N)O(N) 求出最小费用。
  • 易错点:填充物是「件数 −1-1 个」;费用按段独立求和;LL 只是费用参考点,段长可以小于 LL。
  • 边界:N=1N=1 时队列里只有初始直线 ℓ0\ell_0,dp[1]=(C1−L)2dp[1] = (C_1-L)^2;TT 严格递增保证斜率、查询都严格单调,无需退化分支。