「一本通 5.6 练习 5」锯木厂选址

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

以「上一个新厂位置 j」为决策写出一维 DP,前缀和展开后转移变成直线族在 pos_i 处取最小值;斜率与查询点双单调,单调队列维护下凸壳把 O(n^2) 降到 O(n)。

OJ: roj

题目 ID: 1614

难度:提高+/省选-

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

创建: 2026-09-30 22:06

更新: 2026-10-07 13:50

形式化题目

沿直线从山顶到山脚种着 nn 棵树,树 ii 重量为 wiw_i;did_i 是树 ii 与树 i+1i+1 的间距,dnd_n 是树 nn 到山脚的距离。记

pos1=0,posi=∑k<idk,posn+1=posn+dn (山脚). pos_1 = 0,\qquad pos_i = \sum_{k<i} d_k,\qquad pos_{n+1} = pos_n + d_n \ \text{(山脚)}.

山脚已有一个锯木厂,还要在山路上恰好新修两个。木材只能向山下运,每棵树由它下游方向最近的那个锯木厂承接;每公斤每米运费 1 分。求所有新厂位置中总运费的最小值。

样例(n=9n=9):

text
山顶
 │
 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 运到 pos=3pos=3 的厂得 1×3+2×1+3×0=51{\times}3+2{\times}1+3{\times}0=5,树 4…6 运到 pos=9pos=9 的厂得 1×3+3×2+1×0=91{\times}3+3{\times}2+1{\times}0=9,树 7…9 运到山脚 pos=19pos=19 得 2×4+1×3+1×1=122{\times}4+1{\times}3+1{\times}1=12,合计 5+9+12=265+9+12=26。

正解

思路

1. 方案的形状:归一化到树位置。 承接「最低一棵树 bb」的厂,其费用 ∑kwk(p−posk)\sum_k w_k(p-pos_k) 关于位置 pp 单调不减,而它必须放在 p⩾posbp \geqslant pos_b 处,所以放 p=posbp = pos_b(树 bb 处)最优——新厂只需考虑建在树下。又因为木材只能下行,每个厂承接的必然是一段连续的树:若两个新厂位于树 j<ij < i,则树 1..j1..j 运到厂 jj、树 j+1..ij{+}1..i 运到厂 ii、树 i+1..ni{+}1..n 运到山脚厂。

2. 朴素 DP。 记前缀和

sw[i]=∑k⩽iwk,swd[i]=∑k⩽iwk posk, sw[i]=\sum_{k\leqslant i} w_k,\qquad swd[i]=\sum_{k\leqslant i} w_k\,pos_k,

则三段运费分别是

C1(j)=sw[j] posj−swd[j],C2(j,i)=(sw[i]−sw[j]) posi−(swd[i]−swd[j]),C3(i)=(sw[n]−sw[i]) posn+1−(swd[n]−swd[i]). C_1(j)=sw[j]\,pos_j - swd[j],\quad C_2(j,i)=(sw[i]-sw[j])\,pos_i - (swd[i]-swd[j]),\quad C_3(i)=(sw[n]-sw[i])\,pos_{n+1}-(swd[n]-swd[i]).

固定较低的厂 ii,把「上一个厂 jj」作为决策,定义 f[i]=min⁡j<i{C1(j)+C2(j,i)}f[i]=\min_{j<i}\{C_1(j)+C_2(j,i)\},最后

ans=min⁡2⩽i⩽n{f[i]+C3(i)} \text{ans}=\min_{2\leqslant i\leqslant n}\big\{f[i]+C_3(i)\big\}

(i⩾2i\geqslant 2 因为较低的厂必须在较高的厂下游;i=ni=n 时尾段为空 C3=0C_3=0)。每个 f[i]f[i] 枚举 i−1i-1 个 jj,总 O(n2)O(n^2);n=2×105n=2\times10^5 时约 2×10102\times10^{10} 次转移,这是瓶颈。

3. 线性化:转移是直线族取最小。 把 C1(j)+C2(j,i)C_1(j)+C_2(j,i) 整体展开,按「含 jj / 不含 jj」分组(−swd[j]-swd[j] 与 +swd[j]+swd[j] 相消):

f[i]=sw[i] posi−swd[i]⏟只与 i 有关+min⁡j<i{(−sw[j])⏟斜率 mj⋅posi+sw[j] posj⏟截距 bj}. f[i]=\underbrace{sw[i]\,pos_i - swd[i]}_{\text{只与 } i \text{ 有关}} +\min_{j<i}\Big\{\underbrace{(-sw[j])}_{\text{斜率 } m_j}\cdot pos_i + \underbrace{sw[j]\,pos_j}_{\text{截距 } b_j}\Big\}.

min⁡\min 里每个决策 jj 对应一条直线 yj(x)=mjx+bjy_j(x)=m_jx+b_j,问题变成「若干条固定直线在 x=posix=pos_i 处取最小值」,可用下凸壳维护。

4. 为什么单调队列就够(双单调)。 本题有两个单调性:

  • wk⩾1w_k\geqslant 1 ⇒ sw[j]sw[j] 严格递增 ⇒ 斜率 mj=−sw[j]m_j=-sw[j] 严格递减:新直线永远从壳尾进入,队尾只需判断末线是否被新线完全盖住;
  • di⩾0d_i\geqslant 0 ⇒ posipos_i 单调不减 ⇒ 查询点单调:队头某条线一旦输给下一条,之后不可能翻身,队头指针只前进。

两个条件同时成立,凸壳两端都只移动指针,每条直线入队、出队各一次,均摊 O(1)O(1),不需要二分或平衡树。判据用交点交叉相乘做整数比较,全程无浮点误差。

下面用样例展示每个 ii 的转移结果(f[6]f[6] 的候选值依次为 j=1..5j=1..5:41, 29, 14, 29, 3041,\ 29,\ 14,\ 29,\ 30):

ii f[i]=min⁡j{C1(j)+C2(j,i)}f[i]=\min_j\{C_1(j)+C_2(j,i)\} 最优 jj 尾段 C3(i)C_3(i) f[i]+C3(i)f[i]+C_3(i)
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

表中每行是「第二个厂建在树 ii」时的最优代价,列含义依次为:转移最小值、达到最小值的上一个厂 jj、树 i+1..ni{+}1..n 运到山脚的费用、二者之和。最小值 2626 出现在 i=6i=6、j=3j=3,正是形式化题目里 ★ 的方案;而 i=7,8i=7,8 时尾段更短但前两段运费更高,说明必须同时权衡三段,不能贪心地让某个厂独吞下游。

5. 实现要点。 边读入边推进 pospos(读完第 ii 行的 did_i 后 pos += d 恰好得到 posi+1pos_{i+1},读完最后一行即山脚 posn+1pos_{n+1});对每个 i⩾2i\geqslant 2 先用队头算 f[i]f[i],再入队直线 (−sw[i], sw[i]posi)(-sw[i],\ sw[i]pos_i),保证查询只用 j<ij<i;读完全部数据后再逐个加 C3(i)C_3(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 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++ 版(同一算法):

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

复杂度

  • 时间复杂度:O(n)O(n)。一次线性扫描,凸壳中每条直线最多入队、出队各一次,均摊 O(1)O(1)。对比朴素 DP 的 O(n2)O(n^2),n=2×105n=2\times10^5 时从约 2×10102\times10^{10} 次运算降到 2×1052\times10^5 次。
  • 空间复杂度:O(n)O(n)。sw、swd、f、tail 各一个长度 n+1n{+}1 的数组(n=2×105n=2\times10^5 时实测峰值约 85 MB,在 128 MB 内),凸壳最多存 nn 条直线。

总结

  • 运费模型的关键是连续三段:木材只能下行 ⇒ 每个厂承接一段连续的树 ⇒ 枚举「较低厂 ii + 上一个厂 jj」覆盖全部方案,得到以 jj 为决策的一维 DP。
  • 前缀和展开是线性化的前提:C1+C2C_1+C_2 中所有含 jj 的项恰好凑成 −sw[j] posi+sw[j] posj-sw[j]\,pos_i + sw[j]\,pos_j,即一条斜率为 −sw[j]-sw[j]、截距为 sw[j]posjsw[j]pos_j 的直线在 x=posix=pos_i 处的值。
  • 双单调(斜率递减、查询点不减)让下凸壳退化为单调队列,队头队尾各一个指针,均摊 O(1)O(1);判据用整数交叉相乘,无精度隐患。
  • 边界:n=2n=2 时唯一方案 j=1,i=2j=1,i=2;di=0d_i=0 时 pospos 非严格递增,仍满足查询单调;答案可达 101010^{10} 量级,需要 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

上半部分是建模与化简:先确定方案的三段形状,再用前缀和把转移拆成「只与 ii 有关」加「直线族在 posipos_i 处取最小」。下半部分是数据结构依据:双单调性让插入与查询都只移动指针,最终把 O(n2)O(n^2) 降到 O(n)O(n)。