形式化题目

给定 nn 个边与坐标轴平行的闭矩形 Ri=[xi1,xi2]×[yi1,yi2]R_i = [x_{i1}, x_{i2}] \times [y_{i1}, y_{i2}], 求并集

U=⋃i=1nRi U = \bigcup_{i=1}^{n} R_i

的边界总长度(周长)。0⩽n<50000 \leqslant n < 5000,所有顶点坐标是绝对值不超过 1000010000 的整数。

并集的边界是一条分段水平或垂直的闭折线,所以周长可以拆成水平边总长与竖直边总长 两部分分别计算:

P=P横+P竖 P = P_{\text{横}} + P_{\text{竖}}

本题的规模要求 O(nlog⁡n)O(n \log n) 量级。

正解

关键观察

观察 1:边界可以按方向拆开。 边界上的每一小段要么水平、要么竖直,两者的长度只做加法, 互不干扰。所以只要能算清「所有水平边加起来多长」,把整个图形连同坐标轴一起转 90∘90^\circ 再算一次,就得到竖直边总长。下面只讨论水平边。

观察 2:把水平边按条带积分。 所有矩形的竖直边横坐标只有 O(n)O(n) 个不同取值。把它们 排序成 x0<x1<⋯<xmx_0 < x_1 < \dots < x_m,在任意开条带 (xi,xi+1)(x_i, x_{i+1}) 内部,没有任何矩形在此 开始或结束,因此竖直线 X=xX = x 与并集的交集结构在整个条带内完全不变。

记 cy(x)c_y(x) 为竖直线 X=xX = x 与 UU 相交所成的极大区间个数。取其中一个极大区间 [a,b][a, b],它有两个端点 (x,a)(x, a) 与 (x,b)(x, b);当 xx 在这个条带内移动时,这两个端点分别 扫出下端与上端的两条水平边,每条长度恰好是条带宽 xi+1−xix_{i+1} - x_i——因为条带内没有 竖直边插入,边不会中途断掉。于是

P横=∫2 cy(x) dx=∑条带2 cy⋅条带宽 P_{\text{横}} = \int 2\,c_y(x)\,\mathrm{d}x = \sum_{\text{条带}} 2\,c_y \cdot \text{条带宽}

积分退化成有限和,而且每个条带里的 cyc_y 是常数,只需在条带处查一次。

用一个十字形的例子看得更清楚:横条 [0,5]×[1,2][0,5] \times [1,2] 加上竖条 [2,3]×[0,4][2,3] \times [0,4]。

text
并集轮廓(# = 被覆盖的单位格,y 轴向上):
  y= 3 ..#..
  y= 2 ..#..
  y= 1 #####
  y= 0 ..#..
       01234  <- x

沿 xx 方向扫描,三个条带的截面各有 1 个极大区间:

条带 宽 截面极大区间数 贡献 2×区间数×宽2 \times \text{区间数} \times \text{宽}
[0,2)[0,2) 2 1 4
[2,3)[2,3) 1 1 2
[3,5)[3,5) 2 1 4
水平边合计 10

这 10 正好是 y=1y=1 与 y=2y=2 处的各 4(横条上下边界,被竖条切掉中间一段),加上 y=0y=0 与 y=4y=4 处的各 1。把图形转 90∘90^\circ 再扫一次得到 8,就是四条竖直边 (x=0x=0、x=5x=5 各 1,x=2x=2、x=3x=3 各 3)。10+8=1810 + 8 = 18,与直接在单位格上数边界一致。

观察 3:cyc_y 是可以合并的。 观察 2 把一个二维问题压成了一维:维护一个布尔区间集合, 支持「区间加 1 / 区间减 1」,并随时回答极大区间个数。这个量可以自底向上合并。 设节点被中点切成左右两半,左半有 segL\text{seg}_L 个极大区间、其最右元区间是否被覆盖记为 rightL\text{right}_L;右半对称地有 segR\text{seg}_R、leftR\text{left}_R,则

seg=segL+segR−[ rightL 且 leftR ] \text{seg} = \text{seg}_L + \text{seg}_R - [\,\text{right}_L \ \text{且}\ \text{left}_R\,]

减掉的这一项是必要的:只有当两半在交界处都被覆盖时,它们才其实是同一个极大区间, 必须去重。比如把元区间 [0,0][0,0] 和 [1,1][1,1] 都覆盖上,答案是 1 个区间而不是 2 个。

注意这不是「被覆盖的总长度」。 一个常见的错误写法是维护被覆盖总长 len,用水平边 =∑∣Δlen∣= \sum |\Delta \text{len}| 来算。它对本类问题是有陷阱的:同一个横坐标上可能既有矩形 开始又有矩形结束,|Δlen| 的算法必须先加后删,否则会算错。例如矩形 (0,0,1,2)(0,0,1,2)、(1,0,2,2)(1,0,2,2)、(1,1,2,3)(1,1,2,3) 构成的 T 形并集周长是 10,先删后加的写法会给出 14。 本解法用条带区间计数,同一坐标上的事件顺序完全不影响结果,不必关心这个排序陷阱。

思路

线段树节点。 用线段树维护四个量:

量 含义
cover 整段被多少个矩形完全盖住
seg 该节点范围内「被盖住的极大区间」个数
left_on 最左边那个元区间是否被盖住
right_on 最右边那个元区间是否被盖住

pull 分三种情况:cover > 0 时整段被盖住,seg = 1、两个端标志都为真, 不必看孩子;是叶子(且没被盖住)时 seg = 0;否则按观察 3 的式子合并。 cover 只加在完整覆盖的高层节点上,不需要下传——这是本题不必写 push 的原因, 也是 Python 能跑得动的前提。

建树与查询。 把「下端 / 上端」坐标排序去重,相邻两个坐标之间叫一个元区间, 共有 leaves = len(coords) - 1 个。每个矩形的 [y1,y2][y_1, y_2] 对应元区间下标区间 [index(y1),index(y2)−1][\text{index}(y_1), \text{index}(y_2) - 1]。区间的覆盖次数用 cover 加减, 树根上的 seg 就是当前的 cyc_y。

扫描。 每个矩形在起点处产生一个 +1+1 事件、终点处产生一个 −1-1 事件,按位置排序后 线性扫过:

text
total = 0 ; last = events[0].pos
for (pos, lo, hi, val) in events:
    total += 2 * seg[1] * (pos - last)   # 先按当前截面结算条带 (last, pos)
    last = pos
    update(1, 0, leaves-1, lo, hi, val)  # 再让 pos 处的事件生效

顺序不能反:结算条带时必须还是条带内部的状态,而事件要到条带右端点才起作用。 第一个事件开出的是宽度为 0 的空条带,天然贡献 0;同一位置上的多个事件除第一个外, 条带宽也是 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 11:30
 * update_at: 2026-10-10 11:30
 */
// 周长:沿一个坐标扫描,线段树维护截面上「被盖住的极大区间个数」。
#include <cstdio>
#include <vector>
#include <algorithm>

typedef long long ll;

struct Rect {
    ll start, end; // 沿扫描方向的起点 / 终点
    ll lo, hi;     // 垂直于扫描方向的区间 [lo, hi]
};

struct Event {
    ll pos;
    ll lo, hi;
    ll val;
};

std::vector<ll> COVER;    // 每个节点被整段盖住的次数:只记在最高的大节点上,不下传
std::vector<ll> SEGMENT;  // 该节点内部「被盖住的极大区间」个数
std::vector<ll> LEFT_ON;  // 最左元区间是否被盖住
std::vector<ll> RIGHT_ON; // 最右元区间是否被盖住

ll leaves;

// 按压缩坐标建一棵空树,返回元区间(相邻两个坐标之间)的个数
ll build(std::vector<ll>& coords) {
    leaves = (ll)coords.size() - 1;
    ll size = 4 * leaves + 4;
    COVER.assign(size, 0);
    SEGMENT.assign(size, 0);
    LEFT_ON.assign(size, 0);
    RIGHT_ON.assign(size, 0);
    return leaves;
}

// 由两个孩子的覆盖信息重算 node 的区间个数与左右端标志
void pull(ll node, ll nl, ll nr) {
    if (COVER[node]) { // 整段被盖住,内部必然只剩一个极大区间
        SEGMENT[node] = 1;
        LEFT_ON[node] = RIGHT_ON[node] = 1;
    } else if (nl == nr) { // 叶子且没被盖住
        SEGMENT[node] = 0;
        LEFT_ON[node] = RIGHT_ON[node] = 0;
    } else {
        ll left = node << 1, right = node << 1 | 1;
        // 两孩子在交界处都被盖住时,它们其实属于同一个极大区间,要减去这一次重复
        SEGMENT[node] = SEGMENT[left] + SEGMENT[right] - (RIGHT_ON[left] & LEFT_ON[right]);
        LEFT_ON[node] = LEFT_ON[left];
        RIGHT_ON[node] = RIGHT_ON[right];
    }
}

// 把元区间 [ql, qr] 的覆盖次数加上 val
void update(ll node, ll nl, ll nr, ll ql, ll qr, ll val) {
    if (ql <= nl && nr <= qr) {
        COVER[node] += val;
        pull(node, nl, nr);
        return;
    }
    ll mid = (nl + nr) >> 1;
    if (ql <= mid) update(node << 1, nl, mid, ql, qr, val);
    if (qr > mid) update(node << 1 | 1, mid + 1, nr, ql, qr, val);
    pull(node, nl, nr);
}

bool event_less(const Event& a, const Event& b) {
    if (a.pos != b.pos) return a.pos < b.pos;
    if (a.lo != b.lo) return a.lo < b.lo;
    if (a.hi != b.hi) return a.hi < b.hi;
    return a.val < b.val;
}

// 沿第一个坐标扫描,返回平行于扫描方向的那些边的总长
ll sweep(std::vector<Rect>& rects) {
    std::vector<ll> coords;
    for (size_t i = 0; i < rects.size(); i++) {
        coords.push_back(rects[i].lo);
        coords.push_back(rects[i].hi);
    }
    std::sort(coords.begin(), coords.end());
    coords.erase(std::unique(coords.begin(), coords.end()), coords.end());
    build(coords);
    if (leaves <= 0) return 0; // 连一个元区间都没有,也就没有边

    std::vector<Event> events;
    for (size_t i = 0; i < rects.size(); i++) {
        ll lo = std::lower_bound(coords.begin(), coords.end(), rects[i].lo) - coords.begin();
        ll hi = std::lower_bound(coords.begin(), coords.end(), rects[i].hi) - coords.begin();
        Event e;
        e.pos = rects[i].start; e.lo = lo; e.hi = hi - 1; e.val = 1;  events.push_back(e);
        e.pos = rects[i].end;   e.lo = lo; e.hi = hi - 1; e.val = -1; events.push_back(e);
    }
    std::sort(events.begin(), events.end(), event_less);

    ll total = 0;
    ll last = events[0].pos; // 第一个事件开出空条带,(first, first) 宽度为 0
    for (size_t i = 0; i < events.size(); i++) {
        ll width = events[i].pos - last; // 同一位置上的多个事件之间宽度为 0
        // 截面上每个极大区间贡献上下两条平行于扫描方向的边
        total += 2 * SEGMENT[1] * width;
        last = events[i].pos;
        update(1, 0, leaves - 1, events[i].lo, events[i].hi, events[i].val);
    }
    return total;
}

int main() {
    ll n;
    if (scanf("%lld", &n) != 1) return 0; // 空输入安全返回

    std::vector<Rect> rects(n);
    std::vector<Rect> vertical(n);
    for (ll i = 0; i < n; i++) {
        ll x1, y1, x2, y2;
        scanf("%lld %lld %lld %lld", &x1, &y1, &x2, &y2);
        if (x1 > x2) { ll t = x1; x1 = x2; x2 = t; }
        if (y1 > y2) { ll t = y1; y1 = y2; y2 = t; }
        rects[i].start = x1; rects[i].end = x2; rects[i].lo = y1; rects[i].hi = y2;
        // 竖直边的总长 = 把矩形转 90° 后再照同一套扫描线求一次
        vertical[i].start = y1; vertical[i].end = y2; vertical[i].lo = x1; vertical[i].hi = x2;
    }

    printf("%lld\n", sweep(rects) + sweep(vertical));
    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 18:45
# update_at: 2026-10-01 18:45

import sys

COVER: list[int] = []       # 每个节点被整段盖住的次数:只记在最高的大节点上,不下传
SEGMENT: list[int] = []     # 该节点内部「被盖住的极大区间」个数
LEFT_ON: list[int] = []     # 最左元区间是否被盖住
RIGHT_ON: list[int] = []    # 最右元区间是否被盖住


def build(coords: list[int]) -> int:
    """按压缩坐标建一棵空树,返回元区间(相邻两个坐标之间)的个数。"""
    global COVER, SEGMENT, LEFT_ON, RIGHT_ON
    leaves = len(coords) - 1
    size = 4 * leaves + 4
    COVER = [0] * size
    SEGMENT = [0] * size
    LEFT_ON = [0] * size
    RIGHT_ON = [0] * size
    return leaves


def pull(node: int, nl: int, nr: int) -> None:
    """由两个孩子的覆盖信息重算 node 的区间个数与左右端标志。"""
    if COVER[node]:                      # 整段被盖住,内部必然只剩一个极大区间
        SEGMENT[node] = 1
        LEFT_ON[node] = RIGHT_ON[node] = 1
    elif nl == nr:                       # 叶子且没被盖住
        SEGMENT[node] = 0
        LEFT_ON[node] = RIGHT_ON[node] = 0
    else:
        left, right = node << 1, node << 1 | 1
        # 两孩子在交界处都被盖住时,它们其实属于同一个极大区间,要减去这一次重复
        SEGMENT[node] = SEGMENT[left] + SEGMENT[right] - (RIGHT_ON[left] & LEFT_ON[right])
        LEFT_ON[node] = LEFT_ON[left]
        RIGHT_ON[node] = RIGHT_ON[right]


def update(node: int, nl: int, nr: int, ql: int, qr: int, val: int) -> None:
    """把元区间 [ql, qr] 的覆盖次数加上 val:val = 1 加矩形,val = -1 删矩形。"""
    if ql <= nl and nr <= qr:
        COVER[node] += val
        pull(node, nl, nr)
        return
    mid = (nl + nr) >> 1
    if ql <= mid:
        update(node << 1, nl, mid, ql, qr, val)
    if qr > mid:
        update(node << 1 | 1, mid + 1, nr, ql, qr, val)
    pull(node, nl, nr)


def sweep(rects: list[tuple[int, int, int, int]]) -> int:
    """沿第一个坐标扫描,返回平行于扫描方向的那些边的总长。

    rects 每项是 (起点, 终点, 区间下端, 区间上端)。相邻两个扫描位置之间截面是不变的,
    该条带上截面里每个极大区间都会有两个端点各扫出一条平行于扫描方向的边,
    所以贡献是 2 * 极大区间个数 * 条带宽;把每个条带加起来就是对 2 * 极大区间个数 求积分。
    """
    coords = sorted({v for r in rects for v in r[2:]})
    leaves = build(coords)
    if leaves <= 0:                      # 连一个元区间都没有,也就没有边
        return 0
    index_of = {v: i for i, v in enumerate(coords)}
    events = sorted(
        [(r[0], index_of[r[2]], index_of[r[3]] - 1, 1) for r in rects]
        + [(r[1], index_of[r[2]], index_of[r[3]] - 1, -1) for r in rects]
    )

    total = 0
    last = events[0][0]                  # 第一个事件开出一条空条带,(first, first) 宽度为 0
    for pos, lo, hi, val in events:
        width = pos - last               # 同一位置上的多个事件之间宽度为 0,不影响结果
        total += 2 * SEGMENT[1] * width  # 截面上每个极大区间贡献上下两条平行于扫描方向的边
        last = pos
        update(1, 0, leaves - 1, lo, hi, val)
    return total


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

    # 题面给的 (x1, y1, x2, y2) 是左下角 / 右上角,这里整理成 (起点, 终点, 下端, 上端)
    rects: list[tuple[int, int, int, int]] = []
    for _ in range(n):
        x1, y1, x2, y2 = next(data), next(data), next(data), next(data)
        rects.append((min(x1, x2), max(x1, x2), min(y1, y2), max(y1, y2)))

    # 竖直边的总长 = 把矩形转 90°(交换 x / y 两个方向)后再照同一套扫描线求一次
    vertical = [(lo, hi, start, end) for start, end, lo, hi in rects]
    print(sweep(rects) + sweep(vertical))


if __name__ == "__main__":
    solve()

实现说明:

  • COVER、SEGMENT、LEFT_ON、RIGHT_ON 是模块级数组,build 用 global 一次性重建,避免把大数组当参数在递归里反复传递。
  • 数组长度取 4 * leaves + 4;leaves = 1 时子节点下标也不会越界。
  • leaves <= 0 提前返回 0,覆盖了 n=0n = 0、n=1n = 1 和「所有矩形退化到同一条竖线」等情形, 顺带避免了 events[0] 越界。
  • 读入时用 min / max 归一化,题面保证 (x1,y1)(x_1, y_1) 是左下角,这样写不增加成本但更稳。
  • 第二趟扫描直接把每个四元组重排成 (lo, hi, start, end),复用同一个 sweep。

复杂度

  • 时间:坐标排序 O(nlog⁡n)O(n \log n);2n2n 个事件各做一次 O(log⁡n)O(\log n) 的区间更新,共 O(nlog⁡n)O(n \log n)。 两趟扫描同阶,总计 O(nlog⁡n)O(n \log n)。
  • 空间:线段树四个数组与坐标表、事件表都是 O(n)O(n)。
  • 实测:题面样例(7 个矩形)输出 228,与标准答案一致;5 组真实数据(最大 n=4912n = 4912) 全部一致,总耗时 0.17 s、峰值内存 18.5 MB,在 1000 ms / 128 MB 限制内。

总结

  • 求并集周长时,先把边界按方向拆成水平、竖直两组;每组都能写成对截面极大区间个数的积分, 再转 90∘90^\circ 复用同一段代码。
  • 关键在于「条带内截面不变」:它把积分变成有限和,也让扫描线上的线段树只需回答 「截面上覆盖了多少段」这一个问题。
  • 「极大区间个数」必须靠 cover / seg / left_on / right_on 四个量维护, 合并时要减掉交界处的重复;它和「覆盖总长」是两回事,后者会引入同坐标事件排序的陷阱。
  • cover 不需要下传,单次更新 O(log⁡n)O(\log n),这是 Python 能过掉本题的原因。

图示解析

从题面到答案的完整路线:

text
   题目要求: 并集边界的总长度
                    |
                    v
   边界按方向拆开:  水平边 P_横   +   竖直边 P_竖   (转 90 度复用同一代码)
                    |
                    v
   条带积分:  P_横 = sum over 条带 [ 2 * c_y * 条带宽 ]
                    |                      ^
                    |                      |
                    |            c_y = 截面上被覆盖的极大区间个数
                    v
   一维问题: 区间加 1 / 减 1, 随时问「极大区间个数」
                    |
                    v
   线段树 4 个量: cover / seg / left_on / right_on
        |- cover > 0        -> seg = 1, 两端都为真, 不看孩子
        |- 叶子且无覆盖      -> seg = 0
        `- 否则             -> seg = 左.seg + 右.seg - (左.right_on 且 右.left_on)
                    |
                    v
   扫描顺序: 先结算条带 (last, pos), 再执行 pos 处的事件 (顺序不能反)

图中第一层是目标;第二层是唯一的建模决定——按方向拆解,让问题降一维;第三层是贡献公式, 它把二维几何变成对 cyc_y 的求和;第四层是 cyc_y 的合并式,减号那一项处理交界去重; 第五层是线段树节点设计;最下面一层是扫描的实现要点,也是唯一容易写反的地方。