「Fence Obstacle Course」 围栏障碍训练场

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

以奶牛站立的围栏端点作为状态,用线段树区间赋值加单点查询求出正下方最近的围栏,自底向上 O(N log C) 递推最小水平距离。

OJ: roj

题目 ID: 3176

难度:提高

标签:动态规划线段树区间赋值坐标平移python

创建: 2026-10-01 23:10

更新: 2026-10-10 13:00

形式化题目

平面上有 NN 条水平线段(围栏),第 ii 条位于直线 y=iy = i 上,横跨闭区间 [Ai,Bi][A_i, B_i]。出口是原点 (0,0)(0, 0),起点是 (S,N)(S, N),且保证 AN⩽S⩽BNA_N \leqslant S \leqslant B_N。

一个点从起点出发,反复执行:

  1. 沿竖直方向下落,横坐标不变;
  2. 一旦落在某条围栏上,就沿这条围栏水平移动到它的左端点或右端点;
  3. 从端点继续下落,直到落在 y=0y = 0 的地面上,再沿地面走到原点。

竖直方向的距离不计。求水平方向走过的总距离的最小值。

正解

思路

第一步:竖直下落不花钱,状态只需要记录"站在哪条围栏的哪个端点上"。

奶牛在空中时不能横向移动,所以"在空中"这个瞬态没有选择余地,不必作为状态。真正要做决策的时刻,是它落到一条围栏之后的下一步:向左端走还是向右端走。而一条围栏 [Ai,Bi][A_i, B_i] 上任意一点 pp 的后续选择也只有这两个,代价分别是

∣p−Ai∣+di[0],∣p−Bi∣+di[1] |p - A_i| + d_i[0], \qquad |p - B_i| + d_i[1]

其中 di[0],di[1]d_i[0], d_i[1] 表示第 ii 条围栏左右端点到地面的最少水平距离。于是状态总数只有 2N+22N + 2 个:每条围栏两个端点,再加地面上两个端点(都取原点,d0[0]=d0[1]=0d_0[0] = d_0[1] = 0)。

第二步:写清转移,找出真正的瓶颈。

设 g(x,i)g(x, i) 为"在横坐标 xx、第 ii 层开始下落时,最先接住奶牛的围栏编号",也就是满足 j<ij < i、Aj⩽x⩽BjA_j \leqslant x \leqslant B_j 的最大 jj;不存在时记 g(x,i)=0g(x, i) = 0(地面)。于是

di[0]=min⁡(∣Af−Ai∣+df[0], ∣Bf−Ai∣+df[1]),f=g(Ai,i) d_i[0] = \min\bigl(|A_{f} - A_i| + d_{f}[0],\ |B_{f} - A_i| + d_{f}[1]\bigr), \qquad f = g(A_i, i)

di[1]d_i[1] 把 AiA_i 换成 BiB_i 即可;最后答案把 xx 换成 SS、令 i=N+1i = N + 1,用同一个式子求出。

因为 f<if < i 恒成立,按 i=1,2,…,Ni = 1, 2, \ldots, N 自底向上递推就不会有后效性。

第三步:瓶颈是查询 gg。

状态数已经被压到 O(N)O(N),剩下的唯一开销是求 g(x,i)g(x, i)。朴素做法从第 i−1i - 1 层往下逐条判断区间是否包含 xx,单次 O(N)O(N),总复杂度 O(N2)O(N^2);N=30000N = 30000 时约 9×1089 \times 10^8 次判断,超时。

注意 gg 有一个很强的单调性质:处理顺序就是层号从小到大,而"层号越大的围栏越晚出现"。如果把每条围栏的区间 [Ai,Bi][A_i, B_i] 依次"刷"到横坐标轴上,那么后刷的一定盖住先刷的,正下方最近的围栏就是 xx 处最后被刷上的颜色。

这正是"区间赋值 + 单点查询",用线段树可以做到 O(log⁡C)O(\log C),其中 C=200001C = 200001 是横坐标的值域大小。

区间只有 NN 个、值域也只有 2×1052 \times 10^5,坐标整体加 100001100001 平移到 [1,200001][1, 200001] 后,叶子可以直接用横坐标的值当下标,不需要离散化。

第四步:线段树上不写 pushdown。

本题的赋值是"整段覆盖",而且赋值顺序就是层号递增,因此可以只在 O(log⁡C)O(\log C) 个"整段命中"的节点上记下 (层号, 颜色),查询时沿根到叶的路径取层号最大的那个标记:

  • 修改:把 [Ai,Bi][A_i, B_i] 拆成 O(log⁡C)O(\log C) 个整段节点,写入 (i, i);
  • 查询 xx:从叶子走到根,取时间戳最大的节点;都没有则返回地面 00。

父节点的标记时间一定不晚于子节点,所以"路径上时间戳最大者"就是最后一次覆盖。省掉 pushdown 后既不用递归,常数也小。

第五步:实现顺序不能颠倒。

对第 ii 层要先查询两端点、再覆盖本层区间。若先覆盖,端点会查到自己,f=if = i 会让递推自我循环。

下面的表格用题面样例(N=4N = 4,S=0S = 0)复算了整个递推过程。列的含义依次是:层号、该层围栏区间、左右端点正下方的围栏编号(00 表示地面)、以及该端点下落到地面的最少水平距离。

层号 区间 左端点下方 d[左]d[左] 右端点下方 d[右]d[右]
1 [−2,1][-2, 1] 0 2 0 1
2 [−1,2][-1, 2] 1 3 0 2
3 [−3,0][-3, 0] 0 3 2 4
4 [−2,1][-2, 1] 3 4 2 3

读者应重点观察第 3、4 行:第 4 层的左端点 (−2)(-2) 正下方不是"包含它的最低围栏"第 1 层,而是层号最大的那一条第 3 层;先落到第 3 层再换乘,才得到 4 这个最小值。起点 S=0S = 0 正下方是第 4 层,答案为 min⁡(0+4, 1+3)=4\min(0 + 4,\ 1 + 3) = 4。

下面这张图展示样例中"落到哪条围栏 → 走到哪个端点"的两条候选路线,以及为什么最终答案是 4。

flowchart TD
    start["起点 (0, 4)<br/>正下方 = 围栏 4"] --> l4["围栏 4 左端 (-2, 4)<br/>d = 4"]
    start --> r4["围栏 4 右端 (1, 4)<br/>d = 3"]
    l4 --> |"下落到围栏 3"| l3["围栏 3 左端 (-3, 3)<br/>再下落到地面"]
    r4 --> |"下落到围栏 2"| r2["围栏 2 右端 (2, 2)<br/>再下落到地面"]
    l3 --> ans["取较小值:4"]
    r2 --> ans

两条路线分别对应 DP 表格里第 4 行的两个候选值 44 和 33,加上起点到两个端点的水平距离 22 与 11,得到 66 与 44,取 min⁡\min 即为答案。

代码

代码就是上面五步的直译:query 是 gg,assign 是"把本层刷上去",ground_cost 是那个 min⁡\min 式子,endpoint_costs 按 i=1…Ni = 1 \ldots N 递推出一张 di[0],di[1]d_i[0], d_i[1] 表,fences[0] 是当作地面用的虚拟围栏。

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-10 13:00
 * update_at: 2026-10-10 13:00
 */

// 围栏:线段树区间覆盖(记时间戳)+ 端点自底向上递推
#include <cstdio>
#include <cstdlib>
using namespace std;

typedef long long ll;

const int ORIGIN = 100001; // 坐标整体平移量
const int SIZE = 1 << 18;   // 线段树叶子数
const int GROUND = 0;       // 地面编号

int stamp[SIZE * 2]; // 节点整段被覆盖的层号(越大表示覆盖越晚)
int who[SIZE * 2];   // 节点整段被哪条围栏覆盖,0 表示地面

int f_left[300005], f_right[300005]; // 围栏端点(编号即层号 y)
int down[300005][2];                 // 每条围栏左右端点到地面的最少水平距离

// 把横坐标区间 [left,right] 整段覆盖成围栏 value
void assign_range(int left, int right, int value) {
    left += SIZE;
    right += SIZE + 1;
    while (left < right) {
        if (left & 1) {
            stamp[left] = who[left] = value;
            left++;
        }
        if (right & 1) {
            right--;
            stamp[right] = who[right] = value;
        }
        left >>= 1;
        right >>= 1;
    }
}

// 横坐标 x 正上方最近的一条围栏编号(没有围栏时是地面)
int query(int x) {
    int node = x + SIZE;
    int latest = 0, res = GROUND;
    while (node) {
        if (stamp[node] > latest) { // 越晚的覆盖越贴近上方
            latest = stamp[node];
            res = who[node];
        }
        node >>= 1;
    }
    return res;
}

// 从 (x, 某个高度) 竖直下落,再沿接住它的围栏走到端点下到地面
int ground_cost(int x) {
    int below = query(x);
    int a = f_left[below], b = f_right[below];
    int da = (a > x ? a - x : x - a) + down[below][0];
    int db = (b > x ? b - x : x - b) + down[below][1];
    return da < db ? da : db;
}

int main() {
    int n, s;
    if (scanf("%d %d", &n, &s) != 2) {
        return 0;
    }
    s += ORIGIN;
    f_left[GROUND] = f_right[GROUND] = ORIGIN; // 地面:两端点都取原点
    for (int i = 1; i <= n; i++) {
        int a, b;
        scanf("%d %d", &a, &b);
        f_left[i] = a + ORIGIN;
        f_right[i] = b + ORIGIN;
    }

    // 自底向上:查两端点脚下的围栏(此时树里只有更低的层),再把本层覆盖上去
    for (int level = 1; level <= n; level++) {
        down[level][0] = ground_cost(f_left[level]);
        down[level][1] = ground_cost(f_right[level]);
        assign_range(f_left[level], f_right[level], level);
    }
    printf("%d\n", ground_cost(s));
    return 0;
}
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-10-01 23:35
# update_at: 2026-10-01 23:35

import sys

ORIGIN = 100_001      # 坐标整体平移量:原坐标 [-100000, 100000] 变成 [1, 200001]
SIZE = 1 << 18        # 线段树的叶子数,取 2 的幂且大于 200001
GROUND = 0            # 地面编号;它的两个端点都取原点,落在上面等于已经走出场地

STAMP: list[int] = [0] * (SIZE * 2)     # 节点整段被覆盖的层号;层号越大表示覆盖越晚
WHO: list[int] = [0] * (SIZE * 2)       # 节点整段被哪条围栏覆盖,0 表示地面


def assign(left: int, right: int, value: int) -> None:
    """把横坐标区间 [left, right] 整段覆盖成围栏 value。

    只写在能拼出这个区间的 O(log SIZE) 个节点上,不往叶子推;覆盖时间直接用层号
    value(层号随处理顺序递增),点查询时沿根到叶取时间戳最大的节点就是最后一次覆盖。
    """
    left += SIZE
    right += SIZE + 1
    while left < right:
        if left & 1:
            STAMP[left], WHO[left] = value, value
            left += 1
        if right & 1:
            right -= 1
            STAMP[right], WHO[right] = value, value
        left >>= 1
        right >>= 1


def query(x: int) -> int:
    """横坐标 x 正上方最近的一条围栏编号(它上方没有围栏时是地面 GROUND)。"""
    node = x + SIZE
    latest, who = 0, GROUND
    while node:
        if STAMP[node] > latest:        # 越晚的覆盖越贴近上方,会遮住更早的覆盖
            latest, who = STAMP[node], WHO[node]
        node >>= 1
    return who


def ground_cost(fences: list[tuple[int, int]], down: list[tuple[int, int]], x: int) -> int:
    """从 (x, 某个高度) 竖直下落,再沿着接住它的那条围栏走到端点下到地面。

    自由下落途中不做水平移动,所以接住奶牛的那条围栏是唯一的;站上去以后,
    最优走法只取决于站在哪个端点,于是取"走左端点"和"走右端点"的较小值。
    """
    below = query(x)
    (left, right), (dl, dr) = fences[below], down[below]
    return min(abs(left - x) + dl, abs(right - x) + dr)


def endpoint_costs(fences: list[tuple[int, int]]) -> list[tuple[int, int]]:
    """自底向上算出每条围栏左右端点到地面所需的最少水平距离。

    第 0 项是当作地面用的虚拟围栏,两个端点都在原点,所以距离是 (0, 0)。
    每层先查两端点脚下的围栏(此时树里只有更低的层),再把本层覆盖上去——
    顺序颠倒会让端点查到自己。
    """
    down: list[tuple[int, int]] = [(0, 0)] * len(fences)
    for level in range(1, len(fences)):
        left, right = fences[level]
        down[level] = (ground_cost(fences, down, left), ground_cost(fences, down, right))
        assign(left, right, level)
    return down


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    n, s = next(data), next(data) + ORIGIN

    # 围栏编号就是层号 y;GROUND 代表地面,它的端点取原点,落到它上面等于已经出场地
    fences: list[tuple[int, int]] = [(ORIGIN, ORIGIN)] + [
        (next(data) + ORIGIN, next(data) + ORIGIN) for _ in range(n)
    ]

    print(ground_cost(fences, endpoint_costs(fences), s))


if __name__ == "__main__":
    solve()

复杂度

  • 时间复杂度:每条围栏做 2 次单点查询和 1 次区间赋值,最后再做 1 次查询,每次 O(log⁡C)O(\log C);读入 O(N)O(N)。总时间复杂度 O(Nlog⁡C)O(N \log C),其中 C=200001C = 200001。N=30000N = 30000 时约 1.6×1061.6 \times 10^6 次基本操作,实测单测例约 0.130.13 秒。
  • 空间复杂度:线段树的两个标记数组(STAMP、WHO)各 2×SIZE=2192 \times \texttt{SIZE} = 2^{19} 项,加上围栏端点数组和 DP 数组各 O(N)O(N),总空间 O(C+N)O(C + N),实测峰值约 34 MB,低于 128 MB 限制。

总结

本题的难点不在 DP 方程本身,而在"谁挡住了下落路径"这个几何查询。把状态从"围栏上的任意点"压缩到"两个端点"之后,递推式就只剩下 O(N)O(N) 个状态,瓶颈集中在 O(N)O(N) 次"求正下方最近的围栏"。这个查询恰好可以看成在一维坐标轴上依次刷颜色,于是用支持区间赋值、单点查询的线段树把每次查询降到 O(log⁡C)O(\log C)。因为赋值顺序与层号顺序一致,线段树不必下传懒标记,沿根到叶取时间戳最大的标记即可。